diff --git a/src/__tests__/api/master/eddsaMpcV2KeyGenCallbacks.test.ts b/src/__tests__/api/master/eddsaMpcV2KeyGenCallbacks.test.ts new file mode 100644 index 00000000..54de8229 --- /dev/null +++ b/src/__tests__/api/master/eddsaMpcV2KeyGenCallbacks.test.ts @@ -0,0 +1,338 @@ +import 'should'; +import assert from 'assert'; +import * as sinon from 'sinon'; +import { AdvancedWalletManagerClient } from '../../../masterBitgoExpress/clients/advancedWalletManagerClient'; +import { createEddsaMPCv2KeyGenCallbacks } from '../../../masterBitgoExpress/handlers/walletGenerationCallbacks'; + +describe('createEddsaMPCv2KeyGenCallbacks', () => { + const bitgoGpgPub = 'bitgo-gpg-pub'; + const signedMessage = { + message: 'bWVzc2FnZQ==', + signature: '-----BEGIN PGP SIGNATURE-----\n\ntest\n-----END PGP SIGNATURE-----\n', + }; + + let awmUserClient: AdvancedWalletManagerClient; + let awmBackupClient: AdvancedWalletManagerClient; + let userInitStub: sinon.SinonStub; + let backupInitStub: sinon.SinonStub; + let userRoundStub: sinon.SinonStub; + let backupRoundStub: sinon.SinonStub; + let userFinalizeStub: sinon.SinonStub; + let backupFinalizeStub: sinon.SinonStub; + + const initResponse = { + gpgPub: 'test-gpg-pub', + encryptedData: 'init-encrypted-data', + encryptedDataKey: 'init-encrypted-data-key', + }; + const backupInitResponse = { + gpgPub: 'backup-gpg-pub', + encryptedData: 'backup-init-data', + encryptedDataKey: 'backup-init-key', + }; + const round1Response = { + encryptedData: 'round1-encrypted-data', + encryptedDataKey: 'round1-encrypted-data-key', + round: 2, + signedMsg: signedMessage, + }; + const backupRound1Response = { + encryptedData: 'backup-round1-data', + encryptedDataKey: 'backup-round1-key', + round: 2, + signedMsg: signedMessage, + }; + const round2Response = { + encryptedData: 'round2-encrypted-data', + encryptedDataKey: 'round2-encrypted-data-key', + round: 3, + signedMsg: signedMessage, + }; + const backupRound2Response = { + encryptedData: 'backup-round2-data', + encryptedDataKey: 'backup-round2-key', + round: 3, + signedMsg: signedMessage, + }; + const finalizeResponse = { commonKeychain: 'common-keychain', source: 'user' }; + const backupFinalizeResponse = { commonKeychain: 'common-keychain', source: 'backup' }; + + beforeEach(() => { + awmUserClient = new AdvancedWalletManagerClient( + { + advancedWalletManagerUrl: 'http://user-awm.invalid', + tlsMode: 0, + clientCertAllowSelfSigned: true, + } as never, + 'tsol', + ); + awmBackupClient = new AdvancedWalletManagerClient( + { + advancedWalletManagerUrl: 'http://backup-awm.invalid', + tlsMode: 0, + clientCertAllowSelfSigned: true, + } as never, + 'tsol', + ); + userInitStub = sinon.stub(awmUserClient, 'initEcdsaMpcV2KeyGenMpcV2'); + backupInitStub = sinon.stub(awmBackupClient, 'initEcdsaMpcV2KeyGenMpcV2'); + userRoundStub = sinon.stub(awmUserClient, 'roundEcdsaMPCv2KeyGen'); + backupRoundStub = sinon.stub(awmBackupClient, 'roundEcdsaMPCv2KeyGen'); + userFinalizeStub = sinon.stub(awmUserClient, 'finalizeEcdsaMPCv2KeyGen'); + backupFinalizeStub = sinon.stub(awmBackupClient, 'finalizeEcdsaMPCv2KeyGen'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('initializeCallback calls both AWMs via initEcdsaMpcV2KeyGenMpcV2 and returns pubs and state', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + const result = await callbacks.initializeCallback({ + enterprise: 'test-ent', + bitgoPublicGpgKey: bitgoGpgPub, + }); + + result.userGpgPublicKey.should.equal('test-gpg-pub'); + result.backupGpgPublicKey.should.equal('backup-gpg-pub'); + result.userState.encryptedData.should.equal('init-encrypted-data'); + result.backupState.encryptedData.should.equal('backup-init-data'); + sinon.assert.calledWith(userInitStub, { source: 'user' }); + sinon.assert.calledWith(backupInitStub, { source: 'backup' }); + }); + + it('round1Callback calls roundEcdsaMPCv2KeyGen with round 1 and GPG pubs, returns signed msg1s', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + userRoundStub.resolves(round1Response); + backupRoundStub.resolves(backupRound1Response); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + const initResult = await callbacks.initializeCallback({ + enterprise: 'test-ent', + bitgoPublicGpgKey: bitgoGpgPub, + }); + + const round1Result = await callbacks.round1Callback({ + bitgoPublicGpgKey: bitgoGpgPub, + userGpgPublicKey: initResult.userGpgPublicKey, + backupGpgPublicKey: initResult.backupGpgPublicKey, + userState: initResult.userState, + backupState: initResult.backupState, + }); + + round1Result.userSignedMsg1.should.deepEqual(signedMessage); + round1Result.backupSignedMsg1.should.deepEqual(signedMessage); + + sinon.assert.calledWith( + userRoundStub, + sinon.match({ + source: 'user', + round: 1, + bitgoGpgPub: bitgoGpgPub, + counterPartyGpgPub: 'backup-gpg-pub', + }), + ); + sinon.assert.calledWith( + backupRoundStub, + sinon.match({ + source: 'backup', + round: 1, + bitgoGpgPub: bitgoGpgPub, + counterPartyGpgPub: 'test-gpg-pub', + }), + ); + }); + + it('round2Callback calls roundEcdsaMPCv2KeyGen with round 2 and counterparty msg1', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + userRoundStub.onFirstCall().resolves(round1Response); + backupRoundStub.onFirstCall().resolves(backupRound1Response); + userRoundStub.onSecondCall().resolves(round2Response); + backupRoundStub.onSecondCall().resolves(backupRound2Response); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + const initResult = await callbacks.initializeCallback({ + enterprise: 'test-ent', + bitgoPublicGpgKey: bitgoGpgPub, + }); + const round1Result = await callbacks.round1Callback({ + bitgoPublicGpgKey: bitgoGpgPub, + userGpgPublicKey: initResult.userGpgPublicKey, + backupGpgPublicKey: initResult.backupGpgPublicKey, + userState: initResult.userState, + backupState: initResult.backupState, + }); + + const round2Result = await callbacks.round2Callback({ + bitgoMsg1: signedMessage, + userSignedMsg1: round1Result.userSignedMsg1, + backupSignedMsg1: round1Result.backupSignedMsg1, + userState: round1Result.userState, + backupState: round1Result.backupState, + }); + + round2Result.userSignedMsg2.should.deepEqual(signedMessage); + round2Result.backupSignedMsg2.should.deepEqual(signedMessage); + + sinon.assert.calledWith( + userRoundStub, + sinon.match({ + source: 'user', + round: 2, + bitgoMsg: signedMessage, + counterPartyMsg: signedMessage, + }), + ); + }); + + it('finalizeCallback bridges counterparty msg2 from closure and asserts commonKeychain match', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + userRoundStub.onFirstCall().resolves(round1Response); + backupRoundStub.onFirstCall().resolves(backupRound1Response); + userRoundStub.onSecondCall().resolves(round2Response); + backupRoundStub.onSecondCall().resolves(backupRound2Response); + userFinalizeStub.resolves(finalizeResponse); + backupFinalizeStub.resolves(backupFinalizeResponse); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + const initResult = await callbacks.initializeCallback({ + enterprise: 'test-ent', + bitgoPublicGpgKey: bitgoGpgPub, + }); + const round1Result = await callbacks.round1Callback({ + bitgoPublicGpgKey: bitgoGpgPub, + userGpgPublicKey: initResult.userGpgPublicKey, + backupGpgPublicKey: initResult.backupGpgPublicKey, + userState: initResult.userState, + backupState: initResult.backupState, + }); + const round2Result = await callbacks.round2Callback({ + bitgoMsg1: signedMessage, + userSignedMsg1: round1Result.userSignedMsg1, + backupSignedMsg1: round1Result.backupSignedMsg1, + userState: round1Result.userState, + backupState: round1Result.backupState, + }); + + const finalizeResult = await callbacks.finalizeCallback({ + bitgoMsg2: signedMessage, + bitgoCommonKeychain: 'common-keychain', + userState: round2Result.userState, + backupState: round2Result.backupState, + }); + + finalizeResult.commonKeychain.should.equal('common-keychain'); + + sinon.assert.calledWith( + userFinalizeStub, + sinon.match({ + source: 'user', + bitgoMsg: signedMessage, + counterPartyMsg: signedMessage, + bitgoCommonKeychain: 'common-keychain', + }), + ); + sinon.assert.calledWith( + backupFinalizeStub, + sinon.match({ + source: 'backup', + bitgoMsg: signedMessage, + counterPartyMsg: signedMessage, + bitgoCommonKeychain: 'common-keychain', + }), + ); + }); + + it('finalizeCallback rejects when commonKeychains do not match', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + userRoundStub.onFirstCall().resolves(round1Response); + backupRoundStub.onFirstCall().resolves(backupRound1Response); + userRoundStub.onSecondCall().resolves(round2Response); + backupRoundStub.onSecondCall().resolves(backupRound2Response); + userFinalizeStub.resolves({ ...finalizeResponse, commonKeychain: 'different-keychain' }); + backupFinalizeStub.resolves(backupFinalizeResponse); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + const initResult = await callbacks.initializeCallback({ + enterprise: 'test-ent', + bitgoPublicGpgKey: bitgoGpgPub, + }); + const round1Result = await callbacks.round1Callback({ + bitgoPublicGpgKey: bitgoGpgPub, + userGpgPublicKey: initResult.userGpgPublicKey, + backupGpgPublicKey: initResult.backupGpgPublicKey, + userState: initResult.userState, + backupState: initResult.backupState, + }); + const round2Result = await callbacks.round2Callback({ + bitgoMsg1: signedMessage, + userSignedMsg1: round1Result.userSignedMsg1, + backupSignedMsg1: round1Result.backupSignedMsg1, + userState: round1Result.userState, + backupState: round1Result.backupState, + }); + + await assert.rejects( + () => + callbacks.finalizeCallback({ + bitgoMsg2: signedMessage, + bitgoCommonKeychain: 'common-keychain', + userState: round2Result.userState, + backupState: round2Result.backupState, + }), + /common keychains do not match/, + ); + }); + + it('initializeCallback rejects when called more than once', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + await callbacks.initializeCallback({ enterprise: 'test-ent', bitgoPublicGpgKey: bitgoGpgPub }); + + await assert.rejects( + () => + callbacks.initializeCallback({ enterprise: 'test-ent', bitgoPublicGpgKey: bitgoGpgPub }), + /initializeCallback called more than once/, + ); + }); + + it('finalizeCallback rejects when called before round2Callback', async () => { + userInitStub.resolves(initResponse); + backupInitStub.resolves(backupInitResponse); + userRoundStub.onFirstCall().resolves(round1Response); + backupRoundStub.onFirstCall().resolves(backupRound1Response); + + const callbacks = createEddsaMPCv2KeyGenCallbacks(awmUserClient, awmBackupClient); + const initResult = await callbacks.initializeCallback({ + enterprise: 'test-ent', + bitgoPublicGpgKey: bitgoGpgPub, + }); + const round1Result = await callbacks.round1Callback({ + bitgoPublicGpgKey: bitgoGpgPub, + userGpgPublicKey: initResult.userGpgPublicKey, + backupGpgPublicKey: initResult.backupGpgPublicKey, + userState: initResult.userState, + backupState: initResult.backupState, + }); + + await assert.rejects( + () => + callbacks.finalizeCallback({ + bitgoMsg2: signedMessage, + bitgoCommonKeychain: 'common-keychain', + userState: round1Result.userState, + backupState: round1Result.backupState, + }), + /finalizeCallback called before round2Callback/, + ); + }); +}); diff --git a/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts b/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts index fdd46145..779a7cf5 100644 --- a/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts +++ b/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts @@ -4,6 +4,7 @@ import { CreateKeychainCallback, EcdsaMPCv2KeyGenCallbacks, EddsaKeyGenCallbacks, + EddsaMPCv2KeyGenCallbacks, } from '@bitgo-beta/sdk-core'; import { KeySource } from '../../shared/types'; import { @@ -415,3 +416,152 @@ export function createEddsaKeyGenCallbacks( }, }; } + +/** + * Creates EdDSA MPCv2 key generation callbacks for the SDK external signer flow. + * + * Each callback drives both the user and the backup AWM through the existing + * v1.mpcv2.initialize / v1.mpcv2.round / v1.mpcv2.finalize routes (the AWM handler + * branches on curve type). The SDK relays messages to and from BitGo, while the + * counterparty round-2 messages — which finalize needs but the SDK does not pass + * into finalizeCallback — are carried in this closure. + */ +export function createEddsaMPCv2KeyGenCallbacks( + awmUserClient: AdvancedWalletManagerClient, + awmBackupClient: AdvancedWalletManagerClient, +): EddsaMPCv2KeyGenCallbacks { + let initialized = false; + let userSignedMsg2: { message: string; signature: string } | undefined; + let backupSignedMsg2: { message: string; signature: string } | undefined; + + return { + initializeCallback: async () => { + assert(!initialized, 'initializeCallback called more than once'); + initialized = true; + const [userInit, backupInit] = await Promise.all([ + awmUserClient.initEcdsaMpcV2KeyGenMpcV2({ source: KeySource.USER }), + awmBackupClient.initEcdsaMpcV2KeyGenMpcV2({ source: KeySource.BACKUP }), + ]); + return { + userGpgPublicKey: userInit.gpgPub, + backupGpgPublicKey: backupInit.gpgPub, + userState: { + encryptedData: userInit.encryptedData, + encryptedDataKey: userInit.encryptedDataKey, + }, + backupState: { + encryptedData: backupInit.encryptedData, + encryptedDataKey: backupInit.encryptedDataKey, + }, + }; + }, + + round1Callback: async ({ + bitgoPublicGpgKey, + userGpgPublicKey, + backupGpgPublicKey, + userState, + backupState, + }) => { + const [userRound1, backupRound1] = await Promise.all([ + awmUserClient.roundEcdsaMPCv2KeyGen({ + source: KeySource.USER, + ...userState, + round: 1, + bitgoGpgPub: bitgoPublicGpgKey, + counterPartyGpgPub: backupGpgPublicKey, + }), + awmBackupClient.roundEcdsaMPCv2KeyGen({ + source: KeySource.BACKUP, + ...backupState, + round: 1, + bitgoGpgPub: bitgoPublicGpgKey, + counterPartyGpgPub: userGpgPublicKey, + }), + ]); + assert(userRound1.signedMsg, 'Missing signed message in user round 1 response'); + assert(backupRound1.signedMsg, 'Missing signed message in backup round 1 response'); + return { + userSignedMsg1: userRound1.signedMsg, + backupSignedMsg1: backupRound1.signedMsg, + userState: { + encryptedData: userRound1.encryptedData, + encryptedDataKey: userRound1.encryptedDataKey, + }, + backupState: { + encryptedData: backupRound1.encryptedData, + encryptedDataKey: backupRound1.encryptedDataKey, + }, + }; + }, + + round2Callback: async ({ + bitgoMsg1, + userSignedMsg1, + backupSignedMsg1, + userState, + backupState, + }) => { + const [userRound2, backupRound2] = await Promise.all([ + awmUserClient.roundEcdsaMPCv2KeyGen({ + source: KeySource.USER, + ...userState, + round: 2, + bitgoMsg: bitgoMsg1, + counterPartyMsg: backupSignedMsg1, + }), + awmBackupClient.roundEcdsaMPCv2KeyGen({ + source: KeySource.BACKUP, + ...backupState, + round: 2, + bitgoMsg: bitgoMsg1, + counterPartyMsg: userSignedMsg1, + }), + ]); + assert(userRound2.signedMsg, 'Missing signed message in user round 2 response'); + assert(backupRound2.signedMsg, 'Missing signed message in backup round 2 response'); + userSignedMsg2 = userRound2.signedMsg; + backupSignedMsg2 = backupRound2.signedMsg; + return { + userSignedMsg2, + backupSignedMsg2, + userState: { + encryptedData: userRound2.encryptedData, + encryptedDataKey: userRound2.encryptedDataKey, + }, + backupState: { + encryptedData: backupRound2.encryptedData, + encryptedDataKey: backupRound2.encryptedDataKey, + }, + }; + }, + + finalizeCallback: async ({ bitgoMsg2, bitgoCommonKeychain, userState, backupState }) => { + assert( + userSignedMsg2 && backupSignedMsg2, + 'finalizeCallback called before round2Callback completed', + ); + const [userFinalize, backupFinalize] = await Promise.all([ + awmUserClient.finalizeEcdsaMPCv2KeyGen({ + source: KeySource.USER, + ...userState, + bitgoMsg: bitgoMsg2, + counterPartyMsg: backupSignedMsg2, + bitgoCommonKeychain, + }), + awmBackupClient.finalizeEcdsaMPCv2KeyGen({ + source: KeySource.BACKUP, + ...backupState, + bitgoMsg: bitgoMsg2, + counterPartyMsg: userSignedMsg2, + bitgoCommonKeychain, + }), + ]); + assert( + userFinalize.commonKeychain === backupFinalize.commonKeychain, + 'User and backup common keychains do not match', + ); + return { commonKeychain: userFinalize.commonKeychain }; + }, + }; +}