From 0d341ac6166b23b638c2b88cde11e1ddf3235570 Mon Sep 17 00:00:00 2001 From: Greg May Date: Wed, 13 Nov 2024 20:36:26 -0800 Subject: [PATCH] feat(MPC-2011): add mTLS client cert auth support --- .env.example | 23 ++++- README.md | 38 +++++++- src/provider.ts | 139 +++++++++++++++------------- src/tests/utils.ts | 51 ++++++---- src/tests/web3/contractCall.test.ts | 2 +- src/tests/web3/sign.test.ts | 1 + src/tests/web3/transfer.test.ts | 8 +- src/types.ts | 36 ++++++- 8 files changed, 199 insertions(+), 99 deletions(-) diff --git a/.env.example b/.env.example index e8c47dd..6c85d31 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,23 @@ BLOCKDAEMON_RPC_URL="https://svc.blockdaemon.com/native/v1/ethereum/holesky?apiKey=zpka_..." +BUILDERVAULT_PLAYER_COUNT=3 # Count of BuilderVault MPC Node Players BUILDERVAULT_PLAYER0_URL="http://localhost:8500" -BUILDERVAULT_PLAYER0_APIKEY="..." +BUILDERVAULT_PLAYER0_MPCPUBLICKEY="MFkwEwYHKoZIzj0....gO+224X8T0J9eMg=" # Base64 encoding of MPC Player public key. Used in Dynamic communications with broker setups BUILDERVAULT_PLAYER1_URL="http://localhost:8501" -BUILDERVAULT_PLAYER1_APIKEY="..." -BUILDERVAULT_MASTERKEY_ID="Ap7..." +BUILDERVAULT_PLAYER1_MPCPUBLICKEY="MFkwEwYHKoZIzj0....gO+224X8T0J9eMg=" # Base64 encoding of MPC Player public key. Used in Dynamic communications with broker setups + +BUILDERVAULT_MASTERKEY_ID="Ap7..." BUILDERVAULT_ACCOUNT_ID=0 -BUILDERVAULT_ADDRESS_INDEX=0 \ No newline at end of file +BUILDERVAULT_ADDRESS_INDEX=0 + +BUILDERVAULT_PLAYER0_APIKEY="..." +BUILDERVAULT_PLAYER1_APIKEY="..." + +## mTLS-based client certificate authentication key pair paths +# BUILDERVAULT_PLAYER0_CLIENT_CERT="./client.crt" +# BUILDERVAULT_PLAYER0_CLIENT_KEY="./client.key" +# BUILDERVAULT_PLAYER1_CLIENT_CERT="./client.crt" +# BUILDERVAULT_PLAYER1_CLIENT_KEY="./client.key" + +## Optional mTLS-based server certificate pinning +# BUILDERVAULT_PLAYER0_MTLSPUBLICKEY="-----BEGIN CERTIFICATE-----\nMIICMTCCAdegAwIBAg...iABMV+KTXJxA==\n-----END CERTIFICATE-----" +# BUILDERVAULT_PLAYER1_MTLSPUBLICKEY="-----BEGIN CERTIFICATE-----\nMIICMjCCAdegAwIBAg...srtGsDhLOe8O8=\n-----END CERTIFICATE-----" diff --git a/README.md b/README.md index 9ef3b9f..3e29dfc 100644 --- a/README.md +++ b/README.md @@ -55,22 +55,37 @@ const walletClient = createWalletClient({ ### BuildervaultProviderConfig ```ts -type BuildervaultProviderConfig = { +export type BuildervaultProviderConfig = { // ------------- Mandatory fields ------------- /** * Set the RPC API URL endpoint for JSON-RPC over HTTP access to the blockchain data */ rpcUrl?: string, /** - * Set the URL of the BuilderVault player0 and player1 endpoints + * Set the numnber of the BuilderVault players + */ + playerCount?: number + /** + * Set the URL of each BuilderVault player endpoint */ player0Url?: string, player1Url?: string, - /** - * Set the BuilderVault TSM API keys + player2Url?: string, + /** + * Set the BuilderVault TSM API keys or use Client Certificate authentication */ player0ApiKey?: string, player1ApiKey?: string, + player2ApiKey?: string, + /** + * Set the BuilderVault TSM mTLS Client Authentication Certficate key pair or use API Key authentication + */ + player0ClientCert?: string, + player0ClientKey?: string, + player1ClientCert?: string, + player1ClientKey?: string, + player2ClientCert?: string, + player2ClientKey?: string, /** * BuilderVault Master Key ID. This ID represents all the private Master Key shares and must be generated outside if the web3 provider using the BuilderVault SDK */ @@ -88,12 +103,25 @@ type BuildervaultProviderConfig = { addressIndex?: number, // ------------- Optional fields -------------- + /** + * Set the MPC publickey of each BuilderVault player. This is required for Dynamic communication between nodes such as through a broker and not static communication + */ + player0MPCpublicKey?: string, + player1MPCpublicKey?: string, + player2MPCpublicKey?: string, + /** + /** + * Set the TLS publickey of each BuilderVault player endpoint. This is used for mTLS server certificate pinning. + */ + player0mTLSpublicKey?: string, + player1mTLSpublicKey?: string, + player2mTLSpublicKey?: string, /** * Default: false * By setting to true, every request and response processed by the provider will be logged to the console * Same as setting env var `DEBUG=buildervault-web3-provider:req_res` */ - logRequestsAndResponses?: boolean + logRequestsAndResponses?: boolean, } ``` diff --git a/src/provider.ts b/src/provider.ts index 22706a8..ecd2e3e 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -4,6 +4,7 @@ import asn1 from "asn1.js"; import { keccak256, toHex, hexToBytes, toChecksumAddress, hexToString, hexToNumber } from 'web3-utils'; import { encodeParameters } from 'web3-eth-abi'; import { FeeMarketEIP1559Transaction, hashMessage } from 'web3-eth-accounts'; +import crypto from "crypto"; import util from "util"; import { promiseToFunction } from "./utils"; import { AccountAddresses, BuildervaultProviderConfig, EthereumSignature, ProviderRpcError, RequestArguments } from "./types"; @@ -16,12 +17,13 @@ const logRequestsAndResponses = Debug(DEBUG_NAMESPACE_REQUESTS_AND_RESPONSES); export class BuildervaultWeb3Provider extends HttpProvider { private config: BuildervaultProviderConfig; - private headers: { name: string, value: string }[] = []; private accountsAddresses: AccountAddresses = {}; private accountId: number; private accountsPopulatedPromise: () => Promise; private chainIdPopulatedPromise: () => Promise; private requestCounter = 0; + private TSMClients: TSMClient[] = []; + private sessionConfig: SessionConfig = []; constructor(config: BuildervaultProviderConfig) { if (!config.rpcUrl) { @@ -34,8 +36,6 @@ export class BuildervaultWeb3Provider extends HttpProvider { } Debug.enable(debugNamespaces.join(',')) - const headers: { name: string, value: string }[] = [] - super(config.rpcUrl) this.config = config @@ -45,14 +45,72 @@ export class BuildervaultWeb3Provider extends HttpProvider { this.chainPath = new Uint32Array([44, 60, this.accountId, 0, this.addressIndex]); this.accountsAddresses[this.accountId] = {}; - this.headers = headers; - - this.note = 'Created by BuilderVault Web3 Provider' - this.chainIdPopulatedPromise = promiseToFunction(async () => { if (!this.chainId) return await this.populateChainId() }) this.accountsPopulatedPromise = promiseToFunction(async () => { return await this.populateAccounts() }) } + private async initializeTSMClients() { + + // For each player create an authenticated TSMClient + if (this.config.playerCount) { + for (let i = 0; i < this.config.playerCount; i++) { + const playerUrlConfigKey = `player${i}Url`; + const playerApiKeyConfigKey = `player${i}ApiKey`; + const playerClientCertConfigKey = `player${i}ClientCert`; + const playerClientKeyConfigKey = `player${i}ClientKey`; + const playerMTLSpublicKeyConfigKey = `player${i}mTLSpublicKey`; + + if (playerUrlConfigKey in this.config) { + const playerConfig = await new Configuration((this.config as { [key: string]: any })[playerUrlConfigKey]); + + if (playerApiKeyConfigKey in this.config) { + await playerConfig.withAPIKeyAuthentication((this.config as { [key: string]: any })[playerApiKeyConfigKey]); + } else if (playerClientCertConfigKey in this.config && playerClientKeyConfigKey in this.config && playerMTLSpublicKeyConfigKey in this.config) { + const cert = new crypto.X509Certificate((this.config as { [key: string]: any })[playerMTLSpublicKeyConfigKey]); + await playerConfig.withMTLSAuthentication( + (this.config as { [key: string]: any })[playerClientKeyConfigKey], + (this.config as { [key: string]: any })[playerClientCertConfigKey], + cert.publicKey.export({ type: "spki", format: "der" }) + ); + } else { + throw new Error(`player${i} authentication credentials are required`); + } + + this.TSMClients.push(await TSMClient.withConfiguration(playerConfig)); + } else { + throw new Error(`${playerUrlConfigKey} not found`); + } + + } + + // If player MPC publickeys are defined construct new Dynamic SessionConfig + if (this.config.player0MPCpublicKey) { + const playerPubkeys = []; + const playerIds = new Uint32Array(Array(this.TSMClients.length).fill(0).map((_, i) => i)); + for (let i = 0; i < this.config.playerCount; i++) { + const playerMPCpublicKeyConfigKey = Buffer.from( + (this.config as { [key: string]: any })[`player${i}MPCpublicKey`], "base64" + ) + playerPubkeys.push(playerMPCpublicKeyConfigKey) + } + this.sessionConfig = await SessionConfig.newSessionConfig( + await SessionConfig.GenerateSessionID(), + playerIds, + playerPubkeys + ); + // If player MPC publickeys are not defined construct new Static SessionConfig + } else { + this.sessionConfig = await SessionConfig.newStaticSessionConfig( + await SessionConfig.GenerateSessionID(), + this.TSMClients.length + ); + } + + } else { + throw new Error('playerCount is required'); + } + + } private async populateChainId() { const chainId = (await util.promisify(super.send).bind(this)(formatJsonRpcRequest('eth_chainId', []))).result @@ -64,27 +122,7 @@ export class BuildervaultWeb3Provider extends HttpProvider { if (this.accountsAddresses[0]?.[0] !== undefined) { throw this.createError({ message: "Accounts already populated" }) } - - let player0config - if (this.config.player0ApiKey) { - player0config = await new Configuration(this.config.player0Url); - await player0config.withAPIKeyAuthentication(this.config.player0ApiKey); - } else { - throw new Error('player0ApiKey is required'); - } - - let player1config - if (this.config.player1ApiKey) { - player1config = await new Configuration(this.config.player1Url); - await player1config.withAPIKeyAuthentication(this.config.player1ApiKey); - } else { - throw new Error('player1ApiKey is required'); - } - - const TSMClients: TSMClient[] = [ - await TSMClient.withConfiguration(player0config), - await TSMClient.withConfiguration(player1config) - ]; + await this.initializeTSMClients(); // ToDo: include this.addressIndex in loop when outside 0-5 for (let i = 0; i < 5; i++) { @@ -92,7 +130,7 @@ export class BuildervaultWeb3Provider extends HttpProvider { let chainPath = new Uint32Array([44, 60, this.accountId, 0, i]); const pkixPublicKeys: Uint8Array[] = []; - for (const [_, client] of TSMClients.entries()) { + for (const [_, client] of await this.TSMClients.entries()) { const ecdsaApi = client.ECDSA(); pkixPublicKeys.push( await ecdsaApi.publicKey(this.masterKeyId, chainPath) @@ -109,7 +147,7 @@ export class BuildervaultWeb3Provider extends HttpProvider { const pkixPublicKey = pkixPublicKeys[0]; // Convert the public key into an Ethereum address - const utils = TSMClients[0].Utils(); + const utils = this.TSMClients[0].Utils(); const publicKeyBytes = await utils.pkixPublicKeyToUncompressedPoint( pkixPublicKey ); @@ -300,7 +338,7 @@ export class BuildervaultWeb3Provider extends HttpProvider { const {r,s,v} = await this.signTx(unsignedTxHash, this.masterKeyId, this.chainPath); - const signedTransaction = unsignedTx._processSignature(v.valueOf(), hexToBytes(r), hexToBytes(s)); + const signedTransaction = unsignedTx._processSignature(BigInt(v), hexToBytes(r), hexToBytes(s)); const serializeTx = FeeMarketEIP1559Transaction.fromTxData(signedTransaction).serialize(); console.log('Broadcasting signed transaction:', toHex(serializeTx)); @@ -447,45 +485,18 @@ export class BuildervaultWeb3Provider extends HttpProvider { chainPath: Uint32Array ): Promise { - console.log(`Builder Vault signing transaction hash...`); - - let player0config - if (this.config.player0ApiKey) { - player0config = await new Configuration(this.config.player0Url); - await player0config.withAPIKeyAuthentication(this.config.player0ApiKey); - } else { - throw new Error('player0ApiKey is required'); - } - - let player1config - if (this.config.player1ApiKey) { - player1config = await new Configuration(this.config.player1Url); - await player1config.withAPIKeyAuthentication(this.config.player1ApiKey); - } else { - throw new Error('player1ApiKey is required'); - } - - const clients: TSMClient[] = [ - await TSMClient.withConfiguration(player0config), - await TSMClient.withConfiguration(player1config) - ]; - - const sessionConfig = await SessionConfig.newStaticSessionConfig( - await SessionConfig.GenerateSessionID(), - clients.length - ); - + const partialSignatures: Uint8Array[] = []; const partialSignaturePromises: Promise[] = []; - for (const [_, client] of clients.entries()) { + for (const [_, client] of this.TSMClients.entries()) { const func = async (): Promise => { const ecdsaApi = client.ECDSA(); console.log(`Creating partialSignature with MPC player ${_}...`); const partialSignResult = await ecdsaApi.sign( - sessionConfig, + this.sessionConfig, masterKeyId, chainPath, messageToSign @@ -499,7 +510,7 @@ export class BuildervaultWeb3Provider extends HttpProvider { await Promise.all(partialSignaturePromises); - const ecdsaApi = clients[0].ECDSA(); + const ecdsaApi = this.TSMClients[0].ECDSA(); const signature = await ecdsaApi.finalizeSignature( messageToSign, @@ -519,8 +530,8 @@ export class BuildervaultWeb3Provider extends HttpProvider { return { r: "0x" + decodedSignature.r.toString(16), s: "0x" + decodedSignature.s.toString(16), - v: BigInt(signature.recoveryID! + 27), // Type 2 transaction with ._processSignature subtracts 27 Post EIP-155 should be: chainId * 2 + 35 + signature.recoveryID; + v: signature.recoveryID+27 // Type 2 transaction with ._processSignature subtracts 27 Post EIP-155 should be: chainId * 2 + 35 + signature.recoveryID; }; } -} \ No newline at end of file +} diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 54e78f3..4eb0e73 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -5,32 +5,51 @@ import { BuildervaultWeb3Provider } from ".." import Web3 from "web3"; export function getBuildervaultProviderForTesting(extraConfiguration?: any) { - if (!process.env.BLOCKDAEMON_RPC_URL || - !process.env.BUILDERVAULT_PLAYER0_URL || - !process.env.BUILDERVAULT_PLAYER0_APIKEY || - !process.env.BUILDERVAULT_PLAYER1_URL || - !process.env.BUILDERVAULT_PLAYER1_APIKEY || - !process.env.BUILDERVAULT_MASTERKEY_ID) { - throw new Error("Environment variables BLOCKDAEMON_RPC_URL, BUILDERVAULT_PLAYER0_URL, BUILDERVAULT_PLAYER0_APIKEY must be set") - } - const providerConfig = { + let providerConfig: { [key: string]: string } = { rpcUrl: process.env.BLOCKDAEMON_RPC_URL, - player0Url: process.env.BUILDERVAULT_PLAYER0_URL, - player0ApiKey: process.env.BUILDERVAULT_PLAYER0_APIKEY, - player1Url: process.env.BUILDERVAULT_PLAYER1_URL, - player1ApiKey: process.env.BUILDERVAULT_PLAYER1_APIKEY, + playerCount: process.env.BUILDERVAULT_PLAYER_COUNT, masterKeyId: process.env.BUILDERVAULT_MASTERKEY_ID, accountId: process.env.BUILDERVAULT_ACCOUNT_ID, addressIndex: process.env.BUILDERVAULT_ADDRESS_INDEX, logRequestsAndResponses: true, // Verbose logging ...extraConfiguration - }; + }; + + // Todo: dynamically determine number of players and loop through + if (process.env.BUILDERVAULT_PLAYER_COUNT){ + + for (let i = 0; i < Number(process.env.BUILDERVAULT_PLAYER_COUNT); i++) { + if (!process.env[`BUILDERVAULT_PLAYER${i}_URL`]){ + throw new Error(`BUILDERVAULT_PLAYER${i}_URL is required`) + } else { + providerConfig[`player${i}Url`] = process.env[`BUILDERVAULT_PLAYER${i}_URL`] as string - const provider = new BuildervaultWeb3Provider(providerConfig) + if (process.env[`BUILDERVAULT_PLAYER${i}_MPCPUBLICKEY`]){ + providerConfig[`player${i}MPCpublicKey`] = process.env[`BUILDERVAULT_PLAYER${i}_MPCPUBLICKEY`] as string + }; + + if (process.env[`BUILDERVAULT_PLAYER${i}_APIKEY`]){ + providerConfig[`player${i}ApiKey`] = process.env[`BUILDERVAULT_PLAYER${i}_APIKEY`] as string + }; + + if (process.env[`BUILDERVAULT_PLAYER${i}_MTLSPUBLICKEY`]){ + providerConfig[`player${i}mTLSpublicKey`] = process.env[`BUILDERVAULT_PLAYER${i}_MTLSPUBLICKEY`] as string + } + if (process.env[`BUILDERVAULT_PLAYER${i}_CLIENT_CERT`]){ + providerConfig[`player${i}ClientCert`] = process.env[`BUILDERVAULT_PLAYER${i}_CLIENT_CERT`] as string + providerConfig[`player${i}ClientKey`] = process.env[`BUILDERVAULT_PLAYER${i}_CLIENT_KEY`] as string + } + } + } + + return new BuildervaultWeb3Provider(providerConfig) + + } else { + throw new Error(`BUILDERVAULT_PLAYER_COUNT is required`) + } - return provider } export function getEthersBuildervaultProviderForTesting(extraConfiguration?: any) { diff --git a/src/tests/web3/contractCall.test.ts b/src/tests/web3/contractCall.test.ts index e2420f5..c56b10d 100644 --- a/src/tests/web3/contractCall.test.ts +++ b/src/tests/web3/contractCall.test.ts @@ -56,7 +56,7 @@ describe("Web3: Should be able to call a contract method", () => { const receipt = await greeterContract.methods.setGreeting(greeting).send({ from: await getFirstAddressWithBalance() }) expect(receipt.transactionHash).to.be.not.undefined - }) + }, 30000) it("greet() after", async () => { const currentGreeting = await greeterContract.methods.greet().call() diff --git a/src/tests/web3/sign.test.ts b/src/tests/web3/sign.test.ts index 71977cc..42e5e67 100644 --- a/src/tests/web3/sign.test.ts +++ b/src/tests/web3/sign.test.ts @@ -83,6 +83,7 @@ describe("Web3: Should be able to sign using BuilderVault", () => { // @ts-ignore delete types.EIP712Domain const recoveredAddress = ethers.utils.verifyTypedData(domain, types, message, signature as any); + console.log(signature) expect(recoveredAddress).to.be.equals(signerAddress) }) diff --git a/src/tests/web3/transfer.test.ts b/src/tests/web3/transfer.test.ts index 14b0169..9580532 100644 --- a/src/tests/web3/transfer.test.ts +++ b/src/tests/web3/transfer.test.ts @@ -11,7 +11,7 @@ async function getFirstAddressWithBalance() { for (const address of addresses) { const balance = await web3.eth.getBalance(address) if (BigInt(balance) > BigInt(minAmount)) { - return address.toLowerCase() + return address } } @@ -23,8 +23,7 @@ describe("Web3: Should be able to transfer ETH", async function () { it("Transfer", async function () { const addresses = await web3.eth.getAccounts() - //const fromAddress = await getFirstAddressWithBalance() - const fromAddress = (await web3.eth.getAccounts())[0] + const fromAddress = await getFirstAddressWithBalance() const toAddress = addresses.find(x => x != fromAddress) if (!toAddress) { @@ -39,7 +38,6 @@ describe("Web3: Should be able to transfer ETH", async function () { from: fromAddress, to: toAddress, value: transferAmount, - //gasLimit: 21000, maxFeePerGas: feeData.maxFeePerGas, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas, }) @@ -47,5 +45,5 @@ describe("Web3: Should be able to transfer ETH", async function () { const toAddressEndingBalance = await web3.eth.getBalance(toAddress) expect(BigInt(toAddressEndingBalance) == (BigInt(toAddressStartingBalance) - BigInt(transferAmount))) - }) + }, 30000) }) diff --git a/src/types.ts b/src/types.ts index 82ccfd7..662605c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,15 +6,30 @@ export type BuildervaultProviderConfig = { */ rpcUrl?: string, /** - * Set the URL of the BuilderVault player0 and player1 endpoints + * Set the numnber of the BuilderVault players + */ + playerCount?: number + /** + * Set the URL of each BuilderVault player endpoint */ player0Url?: string, player1Url?: string, - /** - * Set the BuilderVault TSM API keys + player2Url?: string, + /** + * Set the BuilderVault TSM API keys or use Client Certificate authentication */ player0ApiKey?: string, player1ApiKey?: string, + player2ApiKey?: string, + /** + * Set the BuilderVault TSM mTLS Client Authentication Certficate key pair or use API Key authentication + */ + player0ClientCert?: string, + player0ClientKey?: string, + player1ClientCert?: string, + player1ClientKey?: string, + player2ClientCert?: string, + player2ClientKey?: string, /** * BuilderVault Master Key ID. This ID represents all the private Master Key shares and must be generated outside if the web3 provider using the BuilderVault SDK */ @@ -32,6 +47,19 @@ export type BuildervaultProviderConfig = { addressIndex?: number, // ------------- Optional fields -------------- + /** + * Set the MPC publickey of each BuilderVault player. This is required for Dynamic communication between nodes such as through a broker and not static communication + */ + player0MPCpublicKey?: string, + player1MPCpublicKey?: string, + player2MPCpublicKey?: string, + /** + /** + * Set the TLS publickey of each BuilderVault player endpoint. This is used for mTLS server certificate pinning. + */ + player0mTLSpublicKey?: string, + player1mTLSpublicKey?: string, + player2mTLSpublicKey?: string, /** * Default: false * By setting to true, every request and response processed by the provider will be logged to the console @@ -43,7 +71,7 @@ export type BuildervaultProviderConfig = { export type EthereumSignature = { r: string, s: string, - v: BigInt, + v: number, }; export interface AccountAddresses {