diff --git a/src/__tests__/api/advancedWalletManager/postMpcV2Key.test.ts b/src/__tests__/api/advancedWalletManager/postMpcV2Key.test.ts index 634aeeca..c86684bb 100644 --- a/src/__tests__/api/advancedWalletManager/postMpcV2Key.test.ts +++ b/src/__tests__/api/advancedWalletManager/postMpcV2Key.test.ts @@ -21,7 +21,8 @@ describe('postMpcV2Key', () => { const coin = 'hteth'; const accessToken = 'test-token'; - // sinon stubs + // sinon sandbox + const sandbox = sinon.createSandbox(); let configStub: sinon.SinonStub; before(() => { @@ -42,7 +43,7 @@ describe('postMpcV2Key', () => { clientCertAllowSelfSigned: true, }; - configStub = sinon.stub(configModule, 'initConfig').returns(cfg); + configStub = sandbox.stub(configModule, 'initConfig').returns(cfg); // app setup app = advancedWalletManagerApp(cfg); @@ -93,7 +94,7 @@ describe('postMpcV2Key', () => { }); after(() => { - configStub.restore(); + sandbox.restore(); }); it('should be able to create a new MPC V2 wallet', async () => { diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts index 93939f0a..971ac2a8 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts @@ -142,6 +142,85 @@ describe('recoveryMpc', () => { }); }); + describe('EdDSA dual-KMS recovery', () => { + it('should route backup key retrieval to backup KMS when configured', async () => { + const primaryKmsUrl = 'http://primary-kms.invalid'; + const backupKmsUrl = 'http://backup-kms.invalid'; + + const commonKeychain = + 'b6f5fb808f538a32735a89609e98fab75690a2c79b26f50a54c4cbf0fbca287138b733783f1590e12b4916ef0f6053b22044860117274bda44bd5d711855f174'; + + const mockKmsUserResponse = { + prv: '{"uShare":{"i":1,"t":2,"n":3,"y":"85aa6462d927329418f70f6d0863cf6cf33e7da2934f935e5927f1b13062d779","seed":"2f55c80fd6b5583dcde8037b2ee461d2e7d445a4d3e7a9b2a0d3d00b5f534169","chaincode":"66e80f2bf41a5706608352d51ceb07a5aa1729cab6c6993c124d5731546ed9a1"},"bitgoYShare":{"i":1,"j":3,"y":"483e53b72de3aa893df698d0b20b20777fb3d2716cc8483a9e9797174fd52b16","v":"e70696459e46434a2a12cc988e3ae714a61fe96da8a6764d058b849cab50d6dc","u":"49abf8144d265a77cf6d098eff784d6ce56ec77a182f6b39f47d5d8e28f2a802","chaincode":"797348468202f1d7fede0a7851f80162b02e7da306e65075dd864b6789b9bc5b"},"backupYShare":{"i":1,"j":2,"y":"249a9798d0064a989a16cd8f479edf09ffaee73f4175d2ac555ba90ff41b89da","v":"98e31d2b643e40060ba344c6a41fc096ea7e39a1ae879f65e4af645870e90ee0","u":"ac047b1bceab2e1a42d97ab540b39176e545d9c0af4a192aee8e1dae91a4240b","chaincode":"585bdc05c8f84802cbe7b9a1a07d4aa9c5fede93597a622854e9bad83a2d5b78"}}', + pub: commonKeychain, + source: 'user', + type: 'tss', + }; + + const mockKmsBackupResponse = { + prv: '{"uShare":{"i":2,"t":2,"n":3,"y":"249a9798d0064a989a16cd8f479edf09ffaee73f4175d2ac555ba90ff41b89da","seed":"abab5be2b32d07cf39b2a162af0f78bad8325b2fbdc89d14fd8b4e5767b74097","chaincode":"585bdc05c8f84802cbe7b9a1a07d4aa9c5fede93597a622854e9bad83a2d5b78"},"bitgoYShare":{"i":2,"j":3,"y":"483e53b72de3aa893df698d0b20b20777fb3d2716cc8483a9e9797174fd52b16","v":"e70696459e46434a2a12cc988e3ae714a61fe96da8a6764d058b849cab50d6dc","u":"eb54da28da3da22eb3d61797a02a96264be8940b7115aefbb90b9dd044db7f06","chaincode":"797348468202f1d7fede0a7851f80162b02e7da306e65075dd864b6789b9bc5b"},"userYShare":{"i":2,"j":1,"y":"85aa6462d927329418f70f6d0863cf6cf33e7da2934f935e5927f1b13062d779","v":"76cfdcbf0f769f21c64e0faf0072ebccbcc3aaa844522336af27f8e50ed7ca5f","u":"6ce814af82683423c8d8befd13f6eeeb0cd3f7274d1ebfdd5807fd2e4eaadb08","chaincode":"66e80f2bf41a5706608352d51ceb07a5aa1729cab6c6993c124d5731546ed9a1"}}', + pub: commonKeychain, + source: 'backup', + type: 'tss', + }; + + const dualCfg: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + keyProviderUrl: primaryKmsUrl, + backupKmsUrl, + httpLoggerFile: '', + tlsMode: TlsMode.DISABLED, + recoveryMode: true, + }; + + const dualApp = expressApp(dualCfg); + const dualAgent = request.agent(dualApp); + + // User key served from primary KMS + const userKmsNock = nock(primaryKmsUrl) + .get(`/key/${commonKeychain}`) + .query({ source: 'user' }) + .reply(200, mockKmsUserResponse) + .persist(); + + // Backup key served from backup KMS + const backupKmsNock = nock(backupKmsUrl) + .get(`/key/${commonKeychain}`) + .query({ source: 'backup' }) + .reply(200, mockKmsBackupResponse) + .persist(); + + const input = { + commonKeychain, + unsignedSweepPrebuildTx: { + txRequests: [ + { + unsignedTx: '', + signableHex: + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAECvoOqYkvCPusjYyhX4GdUtzSeVIcx6GkwdpSk8SkU0/cAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQtFGO2YBsrubq15CKqJLwXG3VEF1aEs36Rao6EaJDLAQECAAAMAgAAALhJxgAAAAAA', + derivationPath: 'm/0', + }, + ], + }, + }; + + const response = await dualAgent + .post(`/api/${sol}/mpc/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send(input); + + response.status.should.equal(200); + response.body.should.have.property('txHex'); + + userKmsNock.isDone().should.be.true(); + backupKmsNock.isDone().should.be.true(); + }); + }); + describe('ECDSA sui recovery', () => { it('should successfully generate MPC sui transactions', async () => { const mockKeyProviderUserResponse = { diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts index 797d8ea0..78128c11 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts @@ -9,7 +9,7 @@ import * as sinon from 'sinon'; import * as configModule from '../../../initConfig'; import { DklsTypes, DklsUtils } from '@bitgo-beta/sdk-lib-mpc'; -describe('recoveryMpcV2', async () => { +describe('recoveryMpcV2', () => { let cfg: AdvancedWalletManagerConfig; let app: express.Application; let agent: request.SuperAgentTest; @@ -20,35 +20,44 @@ describe('recoveryMpcV2', async () => { const cosmosLikeCoin = 'tsei'; const accessToken = 'test-token'; - // sinon stubs + // sinon sandbox + const sandbox = sinon.createSandbox(); let configStub: sinon.SinonStub; // key provider nocks setup - const [userShare, backupShare] = await DklsUtils.generateDKGKeyShares(); - const userKeyShare = userShare.getKeyShare().toString('base64'); - const backupKeyShare = backupShare.getKeyShare().toString('base64'); - const commonKeychain = DklsTypes.getCommonKeychain(userShare.getKeyShare()); - - const mockKeyProviderUserResponse = { - prv: JSON.stringify(userKeyShare), - pub: commonKeychain, - source: 'user', - type: 'tss', - }; - - const mockKeyProviderBackupResponse = { - prv: JSON.stringify(backupKeyShare), - pub: commonKeychain, - source: 'backup', - type: 'tss', - }; - const input = { - txHex: - '02f6824268018502540be4008504a817c80083030d409443442e403d64d29c4f64065d0c1a0e8edc03d6c88801550f7dca700000823078c0', - pub: commonKeychain, - }; + let userKeyShare: string; + let backupKeyShare: string; + let commonKeychain: string; + let mockKeyProviderUserResponse: { prv: string; pub: string; source: string; type: string }; + let mockKeyProviderBackupResponse: { prv: string; pub: string; source: string; type: string }; + let input: { txHex: string; pub: string }; before(async () => { + const [userShare, backupShare] = await DklsUtils.generateDKGKeyShares(); + userKeyShare = userShare.getKeyShare().toString('base64'); + backupKeyShare = backupShare.getKeyShare().toString('base64'); + commonKeychain = DklsTypes.getCommonKeychain(userShare.getKeyShare()); + + mockKeyProviderUserResponse = { + prv: JSON.stringify(userKeyShare), + pub: commonKeychain, + source: 'user', + type: 'tss', + }; + + mockKeyProviderBackupResponse = { + prv: JSON.stringify(backupKeyShare), + pub: commonKeychain, + source: 'backup', + type: 'tss', + }; + + input = { + txHex: + '02f6824268018502540be4008504a817c80083030d409443442e403d64d29c4f64065d0c1a0e8edc03d6c88801550f7dca700000823078c0', + pub: commonKeychain, + }; + // nock config nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); @@ -67,7 +76,7 @@ describe('recoveryMpcV2', async () => { recoveryMode: true, }; - configStub = sinon.stub(configModule, 'initConfig').returns(cfg); + configStub = sandbox.stub(configModule, 'initConfig').returns(cfg); // app setup app = advancedWalletManagerApp(cfg); @@ -79,7 +88,7 @@ describe('recoveryMpcV2', async () => { }); after(() => { - configStub.restore(); + sandbox.restore(); }); // happy path test @@ -132,6 +141,61 @@ describe('recoveryMpcV2', async () => { backupKeyProviderNock.isDone().should.be.true(); }); + it('should route backup key retrieval to backup KMS when configured', async () => { + const kmsUrl = 'http://kms.invalid'; + const backupKmsUrl = 'http://backup-kms.invalid'; + + const mockKmsUserResponse = { + prv: JSON.stringify(userKeyShare), + pub: commonKeychain, + source: 'user', + type: 'tss', + }; + + const mockKmsBackupResponse = { + prv: JSON.stringify(backupKeyShare), + pub: commonKeychain, + source: 'backup', + type: 'tss', + }; + + // Reconfigure app with backup KMS URL + const dualCfg: AdvancedWalletManagerConfig = { + ...cfg, + keyProviderUrl: kmsUrl, + backupKmsUrl, + }; + configStub.returns(dualCfg); + const dualApp = advancedWalletManagerApp(dualCfg); + const dualAgent = request.agent(dualApp); + + // User key served from primary KMS + const userKmsNock = nock(kmsUrl) + .get(`/key/${input.pub}`) + .query({ source: 'user' }) + .reply(200, mockKmsUserResponse) + .persist(); + + // Backup key served from backup KMS + const backupKmsNock = nock(backupKmsUrl) + .get(`/key/${input.pub}`) + .query({ source: 'backup' }) + .reply(200, mockKmsBackupResponse) + .persist(); + + const response = await dualAgent + .post(`/api/${ethLikeCoin}/mpcv2/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send(input); + + response.status.should.equal(200); + response.body.should.have.property('txHex'); + response.body.should.have.property('stringifiedSignature'); + + userKmsNock.isDone().should.be.true(); + backupKmsNock.isDone().should.be.true(); + }); + // failure test case it('should throw 400 Bad Request if failed to construct eth transaction from message hex', async () => { const input = { diff --git a/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts index a9436ddc..b2b4ff6c 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts @@ -22,7 +22,8 @@ describe('recoveryMultisigTransaction', () => { const coin = 'hteth'; const accessToken = 'test-token'; - // sinon stubs + // sinon sandbox + const sandbox = sinon.createSandbox(); let configStub: sinon.SinonStub; before(() => { @@ -44,7 +45,7 @@ describe('recoveryMultisigTransaction', () => { recoveryMode: true, }; - configStub = sinon.stub(configModule, 'initConfig').returns(cfg); + configStub = sandbox.stub(configModule, 'initConfig').returns(cfg); // app setup app = advancedWalletManagerApp(cfg); @@ -56,7 +57,7 @@ describe('recoveryMultisigTransaction', () => { }); after(() => { - configStub.restore(); + sandbox.restore(); }); it('should generate a successful txHex from unsigned sweep prebuild data', async () => { @@ -106,6 +107,67 @@ describe('recoveryMultisigTransaction', () => { keyProviderNockBackup.done(); }); + it('should route backup key retrieval to backup KMS when configured', async () => { + const kmsUrl = 'http://kms.invalid'; + const backupKmsUrl = 'http://backup-kms.invalid'; + const { userPub, backupPub, walletContractAddress, userPrv, backupPrv, txHexResult } = awmData; + const unsignedSweepPrebuildTx = unsignedSweepRecJSON as unknown as any; + + // Reconfigure app with backup KMS URL + const dualCfg: AdvancedWalletManagerConfig = { + ...cfg, + keyProviderUrl: kmsUrl, + backupKmsUrl, + }; + configStub.returns(dualCfg); + const dualApp = advancedWalletManagerApp(dualCfg); + const dualAgent = request.agent(dualApp); + + const mockKmsUserResponse = { + prv: userPrv, + pub: userPub, + source: 'user', + type: 'independent', + }; + + const mockKmsBackupResponse = { + prv: backupPrv, + pub: backupPub, + source: 'backup', + type: 'independent', + }; + + // User key from primary KMS + const kmsNockUser = nock(kmsUrl) + .get(`/key/${userPub}`) + .query({ source: 'user' }) + .reply(200, mockKmsUserResponse); + + // Backup key from backup KMS + const kmsNockBackup = nock(backupKmsUrl) + .get(`/key/${backupPub}`) + .query({ source: 'backup' }) + .reply(200, mockKmsBackupResponse); + + const response = await dualAgent + .post(`/api/${coin}/multisig/recovery`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + userPub, + backupPub, + apiKey: 'etherscan-api-token', + unsignedSweepPrebuildTx, + walletContractAddress, + coinSpecificParams: undefined, + }); + + response.status.should.equal(200); + response.body.should.have.property('txHex', txHexResult); + + kmsNockUser.done(); + kmsNockBackup.done(); + }); + it('should fail when prv keys non related to pub keys', async () => { const { userPub, backupPub, walletContractAddress } = awmData; const unsignedSweepPrebuildTx = unsignedSweepRecJSON as unknown as any; diff --git a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts index af957421..28ca6056 100644 --- a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts +++ b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts @@ -52,11 +52,14 @@ export async function ecdsaMPCv2Recovery( ); } - // setup clients and retreive the keys - // TODO: this needs to be segerated if the EBE instance cannot retrieve both keys - const keyProvider = new KeyProviderClient(req.config); - const { prv: userPrv } = await keyProvider.getKey({ pub, source: 'user' }); - const { prv: backupPrv } = await keyProvider.getKey({ pub, source: 'backup' }); + // setup clients and retrieve the keys + const userKeyProvider = new KeyProviderClient(req.config); + const backupCfg = req.config.backupKmsUrl + ? { ...req.config, keyProviderUrl: req.config.backupKmsUrl } + : req.config; + const backupKeyProvider = new KeyProviderClient(backupCfg); + const { prv: userPrv } = await userKeyProvider.getKey({ pub, source: 'user' }); + const { prv: backupPrv } = await backupKeyProvider.getKey({ pub, source: 'backup' }); // construct tx builder const txHash = await getMessageHash(coin, txHex); diff --git a/src/advancedWalletManager/handlers/utils/utils.ts b/src/advancedWalletManager/handlers/utils/utils.ts index c95ffdc3..34c61a29 100644 --- a/src/advancedWalletManager/handlers/utils/utils.ts +++ b/src/advancedWalletManager/handlers/utils/utils.ts @@ -16,7 +16,9 @@ export async function retrieveKeyProviderPrvKey({ source: string; cfg: AdvancedWalletManagerConfig; }): Promise { - const keyProvider = new KeyProviderClient(cfg); + const effectiveCfg = + source === 'backup' && cfg.backupKmsUrl ? { ...cfg, keyProviderUrl: cfg.backupKmsUrl } : cfg; + const keyProvider = new KeyProviderClient(effectiveCfg); // Retrieve the private key from key provider let prv: string; try { diff --git a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts index 0b84ace0..3a07cb7a 100644 --- a/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts +++ b/src/advancedWalletManager/routers/advancedWalletManagerApiSpec.ts @@ -72,6 +72,11 @@ const RecoveryMultisigRequest = { bitgoPub: optional(t.string), unsignedSweepPrebuildTx: t.any, walletContractAddress: optional(t.string), + // When set, only sign with the specified key (user half-sign or backup full-sign). + // When omitted, the endpoint signs with both keys (default single-AWM behavior). + keyToSign: optional(t.union([t.literal('user'), t.literal('backup')])), + // Required when keyToSign is 'backup': the half-signed transaction from the user-key phase. + halfSignedTransaction: optional(t.any), }; // Response type for /multisig/recovery endpoint diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index 10352446..ff39ca61 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -99,6 +99,10 @@ interface RecoveryMultisigOptions { | MPCTx | RecoveryTransaction; walletContractAddress: string; + // When set, only sign with the specified key (user half-sign or backup full-sign). + keyToSign?: 'user' | 'backup'; + // Required when keyToSign is 'backup': the half-signed transaction from the user-key phase. + halfSignedTransaction?: any; } interface SignMpcCommitmentParams { diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d07d20da..3f8550b3 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -40,6 +40,16 @@ export interface AdvancedWalletManagerConfig extends BaseConfig { keyProviderClientTlsCert?: string; keyProviderServerCertAllowSelfSigned?: boolean; + // Backup KMS settings (separate HSM for backup key) + backupKmsUrl?: string; + backupKmsServerCaCertPath?: string; + backupKmsServerCaCert?: string; + backupKmsClientTlsKeyPath?: string; + backupKmsClientTlsCertPath?: string; + backupKmsClientTlsKey?: string; + backupKmsClientTlsCert?: string; + backupKmsServerCertAllowSelfSigned?: boolean; + // mTLS server settings serverTlsKeyPath?: string; serverTlsCertPath?: string;