From 849d1b310730a1b0cd3ba0810d9b9314bd6e61a3 Mon Sep 17 00:00:00 2001 From: Marzooqa Naeema Kather Date: Fri, 23 Oct 2026 19:22:19 +0530 Subject: [PATCH] feat(mbe): add EdDSA MPCv2 key generation callback factory Rename the MPCv2 AWM client methods to curve-neutral names and let them carry the EdDSA signed messages, then add createEddsaMPCv2KeyGenCallbacks, which drives the user and backup AWMs through the shared v1.mpcv2.* routes for the SDK external signer flow. Ticket: WCI-893, WCI-895 Co-Authored-By: Claude --- .../master/eddsaMpcV2KeyGenCallbacks.test.ts | 338 ++++++++++++++++++ .../api/master/mpcV2KeyGenClient.test.ts | 111 ++++++ .../clients/advancedWalletManagerClient.ts | 23 +- .../handlers/walletGenerationCallbacks.ts | 175 ++++++++- 4 files changed, 625 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/api/master/eddsaMpcV2KeyGenCallbacks.test.ts create mode 100644 src/__tests__/api/master/mpcV2KeyGenClient.test.ts diff --git a/src/__tests__/api/master/eddsaMpcV2KeyGenCallbacks.test.ts b/src/__tests__/api/master/eddsaMpcV2KeyGenCallbacks.test.ts new file mode 100644 index 00000000..2c623419 --- /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, 'initMpcV2KeyGen'); + backupInitStub = sinon.stub(awmBackupClient, 'initMpcV2KeyGen'); + userRoundStub = sinon.stub(awmUserClient, 'roundMpcV2KeyGen'); + backupRoundStub = sinon.stub(awmBackupClient, 'roundMpcV2KeyGen'); + userFinalizeStub = sinon.stub(awmUserClient, 'finalizeMpcV2KeyGen'); + backupFinalizeStub = sinon.stub(awmBackupClient, 'finalizeMpcV2KeyGen'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('initializeCallback calls both AWMs via initMpcV2KeyGen 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 roundMpcV2KeyGen 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 roundMpcV2KeyGen 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/__tests__/api/master/mpcV2KeyGenClient.test.ts b/src/__tests__/api/master/mpcV2KeyGenClient.test.ts new file mode 100644 index 00000000..af635013 --- /dev/null +++ b/src/__tests__/api/master/mpcV2KeyGenClient.test.ts @@ -0,0 +1,111 @@ +import 'should'; +import nock from 'nock'; +import { AdvancedWalletManagerClient } from '../../../masterBitgoExpress/clients/advancedWalletManagerClient'; + +describe('AdvancedWalletManagerClient MPCv2 key generation', () => { + const awmUrl = 'http://awm.invalid'; + const signedMessage = { message: 'bWVzc2FnZQ==', signature: 'sig' }; + let client: AdvancedWalletManagerClient; + + before(() => { + nock.disableNetConnect(); + }); + + beforeEach(() => { + client = new AdvancedWalletManagerClient( + { advancedWalletManagerUrl: awmUrl, tlsMode: 0, clientCertAllowSelfSigned: true } as never, + 'tsol', + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('initMpcV2KeyGen posts the source to /mpcv2/initialize', async () => { + const scope = nock(awmUrl) + .post('/api/tsol/mpcv2/initialize', { source: 'user' }) + .reply(200, { gpgPub: 'pub', encryptedData: 'data', encryptedDataKey: 'key' }); + + const res = await client.initMpcV2KeyGen({ source: 'user' }); + res.should.deepEqual({ gpgPub: 'pub', encryptedData: 'data', encryptedDataKey: 'key' }); + scope.isDone().should.be.true(); + }); + + it('roundMpcV2KeyGen sends EdDSA round 1 GPG keys and returns the signed message', async () => { + const body = { + source: 'backup', + encryptedData: 'data', + encryptedDataKey: 'key', + round: 1, + bitgoGpgPub: 'bitgo-pub', + counterPartyGpgPub: 'user-pub', + }; + const scope = nock(awmUrl).post('/api/tsol/mpcv2/round', body).reply(200, { + encryptedData: 'd2', + encryptedDataKey: 'k2', + round: 2, + signedMsg: signedMessage, + }); + + const res = await client.roundMpcV2KeyGen({ ...body, source: 'backup' }); + res.signedMsg?.should.deepEqual(signedMessage); + scope.isDone().should.be.true(); + }); + + it('roundMpcV2KeyGen sends EdDSA round 2 signed messages', async () => { + const body = { + source: 'user', + encryptedData: 'data', + encryptedDataKey: 'key', + round: 2, + bitgoMsg: signedMessage, + counterPartyMsg: signedMessage, + }; + const scope = nock(awmUrl).post('/api/tsol/mpcv2/round', body).reply(200, { + encryptedData: 'd3', + encryptedDataKey: 'k3', + round: 3, + signedMsg: signedMessage, + }); + + const res = await client.roundMpcV2KeyGen({ ...body, source: 'user' }); + res.round.should.equal(3); + scope.isDone().should.be.true(); + }); + + it('finalizeMpcV2KeyGen sends EdDSA signed messages and no broadcastMessages', async () => { + const body = { + source: 'user', + encryptedData: 'data', + encryptedDataKey: 'key', + bitgoCommonKeychain: 'common', + bitgoMsg: signedMessage, + counterPartyMsg: signedMessage, + }; + const scope = nock(awmUrl) + .post('/api/tsol/mpcv2/finalize', body) + .reply(200, { source: 'user', commonKeychain: 'common' }); + + const res = await client.finalizeMpcV2KeyGen({ ...body, source: 'user' }); + res.commonKeychain.should.equal('common'); + scope.isDone().should.be.true(); + }); + + it('roundMpcV2KeyGen still sends the ECDSA broadcast and p2p messages', async () => { + const body = { + source: 'user', + encryptedData: 'data', + encryptedDataKey: 'key', + round: 2, + broadcastMessages: { bitgo: 'b', counterParty: 'c' }, + p2pMessages: { bitgo: 'p', counterParty: 'q' }, + }; + const scope = nock(awmUrl) + .post('/api/tsol/mpcv2/round', body) + .reply(200, { encryptedData: 'd', encryptedDataKey: 'k', round: 3 }); + + await client.roundMpcV2KeyGen({ ...body, source: 'user' }); + scope.isDone().should.be.true(); + }); +}); diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index a43100d5..27859a4c 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -1,3 +1,4 @@ +import { MPSTypes } from '@bitgo-beta/sdk-lib-mpc'; import { z } from 'zod'; import assert from 'assert'; import https from 'https'; @@ -629,7 +630,7 @@ export class AdvancedWalletManagerClient { /** * Initialize MPCv2 key generation */ - async initEcdsaMpcV2KeyGenMpcV2(params: { + async initMpcV2KeyGen(params: { source: 'user' | 'backup'; }): Promise { if (!this.coin) { @@ -639,7 +640,7 @@ export class AdvancedWalletManagerClient { try { let request = this.apiClient['v1.mpcv2.initialize'].post({ coin: this.coin, - source: params.source, + ...params, }); if (this.tlsMode === TlsMode.MTLS) { @@ -658,17 +659,19 @@ export class AdvancedWalletManagerClient { } /** - * Execute a round in the MPCv2 protocol + * Execute a round in the MPCv2 protocol. */ - async roundEcdsaMPCv2KeyGen(params: { + async roundMpcV2KeyGen(params: { source: 'user' | 'backup'; encryptedData: string; encryptedDataKey: string; round: number; bitgoGpgPub?: string; counterPartyGpgPub?: string; - broadcastMessages?: { bitgo: any; counterParty: any }; - p2pMessages?: { bitgo: any; counterParty: any }; + broadcastMessages?: { bitgo: unknown; counterParty: unknown }; + p2pMessages?: { bitgo: unknown; counterParty: unknown }; + bitgoMsg?: MPSTypes.MPSSignedMessage; + counterPartyMsg?: MPSTypes.MPSSignedMessage; }): Promise { if (!this.coin) { throw new Error('Coin must be specified for MPCv2 round'); @@ -696,14 +699,16 @@ export class AdvancedWalletManagerClient { } /** - * Finalize MPCv2 key generation + * Finalize MPCv2 key generation. */ - async finalizeEcdsaMPCv2KeyGen(params: { + async finalizeMpcV2KeyGen(params: { source: 'user' | 'backup'; encryptedData: string; encryptedDataKey: string; - broadcastMessages: { bitgo: any; counterParty: any }; + broadcastMessages?: { bitgo: unknown; counterParty: unknown }; bitgoCommonKeychain: string; + bitgoMsg?: MPSTypes.MPSSignedMessage; + counterPartyMsg?: MPSTypes.MPSSignedMessage; }): Promise { if (!this.coin) { throw new Error('Coin must be specified to finalize MPCv2 key generation'); diff --git a/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts b/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts index fdd46145..4e0dbcc2 100644 --- a/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts +++ b/src/masterBitgoExpress/handlers/walletGenerationCallbacks.ts @@ -1,9 +1,10 @@ import assert from 'assert'; -import { DklsTypes } from '@bitgo-beta/sdk-lib-mpc'; +import { DklsTypes, MPSTypes } from '@bitgo-beta/sdk-lib-mpc'; import { CreateKeychainCallback, EcdsaMPCv2KeyGenCallbacks, EddsaKeyGenCallbacks, + EddsaMPCv2KeyGenCallbacks, } from '@bitgo-beta/sdk-core'; import { KeySource } from '../../shared/types'; import { @@ -98,13 +99,13 @@ export function createEcdsaMPCv2KeyGenCallbacks( return { initializeCallback: async ({ bitgoPublicGpgKey }) => { assert(!userRound1Broadcast, 'initializeCallback called more than once'); - const userInit = await awmUserClient.initEcdsaMpcV2KeyGenMpcV2({ source: KeySource.USER }); - const backupInit = await awmBackupClient.initEcdsaMpcV2KeyGenMpcV2({ + const userInit = await awmUserClient.initMpcV2KeyGen({ source: KeySource.USER }); + const backupInit = await awmBackupClient.initMpcV2KeyGen({ source: KeySource.BACKUP, }); const [userRound1, backupRound1] = await Promise.all([ - awmUserClient.roundEcdsaMPCv2KeyGen({ + awmUserClient.roundMpcV2KeyGen({ source: KeySource.USER, encryptedData: userInit.encryptedData, encryptedDataKey: userInit.encryptedDataKey, @@ -112,7 +113,7 @@ export function createEcdsaMPCv2KeyGenCallbacks( bitgoGpgPub: bitgoPublicGpgKey, counterPartyGpgPub: backupInit.gpgPub, }), - awmBackupClient.roundEcdsaMPCv2KeyGen({ + awmBackupClient.roundMpcV2KeyGen({ source: KeySource.BACKUP, encryptedData: backupInit.encryptedData, encryptedDataKey: backupInit.encryptedDataKey, @@ -150,13 +151,13 @@ export function createEcdsaMPCv2KeyGenCallbacks( assert(bitgoMsg1.from === PARTY.BITGO, 'bitgoMsg1 is not from BitGo'); const bitgoBroadcast = formatBroadcastMessage(bitgoMsg1); const [userRound2, backupRound2] = await Promise.all([ - awmUserClient.roundEcdsaMPCv2KeyGen({ + awmUserClient.roundMpcV2KeyGen({ source: KeySource.USER, ...userState, round: 2, broadcastMessages: { bitgo: bitgoBroadcast, counterParty: backupRound1Broadcast }, }), - awmBackupClient.roundEcdsaMPCv2KeyGen({ + awmBackupClient.roundMpcV2KeyGen({ source: KeySource.BACKUP, ...backupState, round: 2, @@ -185,7 +186,7 @@ export function createEcdsaMPCv2KeyGenCallbacks( assert(bitgoToBackupMsg2.to === PARTY.BACKUP, 'bitgoToBackupMsg2 is not addressed to backup'); const [userRound3, backupRound3] = await Promise.all([ - awmUserClient.roundEcdsaMPCv2KeyGen({ + awmUserClient.roundMpcV2KeyGen({ source: KeySource.USER, ...toMpcState(userRound2), round: 3, @@ -194,7 +195,7 @@ export function createEcdsaMPCv2KeyGenCallbacks( counterParty: backupRound2CounterPartyP2p, }, }), - awmBackupClient.roundEcdsaMPCv2KeyGen({ + awmBackupClient.roundMpcV2KeyGen({ source: KeySource.BACKUP, ...toMpcState(backupRound2), round: 3, @@ -248,7 +249,7 @@ export function createEcdsaMPCv2KeyGenCallbacks( assert(bitgoToBackupMsg3.from === PARTY.BITGO, 'bitgoToBackupMsg3 is not from BitGo'); assert(bitgoToBackupMsg3.to === PARTY.BACKUP, 'bitgoToBackupMsg3 is not addressed to backup'); const [userRound4, backupRound4] = await Promise.all([ - awmUserClient.roundEcdsaMPCv2KeyGen({ + awmUserClient.roundMpcV2KeyGen({ source: KeySource.USER, ...userState, round: 4, @@ -257,7 +258,7 @@ export function createEcdsaMPCv2KeyGenCallbacks( counterParty: backupRound3CounterPartyP2p, }, }), - awmBackupClient.roundEcdsaMPCv2KeyGen({ + awmBackupClient.roundMpcV2KeyGen({ source: KeySource.BACKUP, ...backupState, round: 4, @@ -288,13 +289,13 @@ export function createEcdsaMPCv2KeyGenCallbacks( assert(bitgoMsg4.from === PARTY.BITGO, 'bitgoMsg4 is not from BitGo'); const bitgoBroadcast = formatBroadcastMessage(bitgoMsg4); const [userFinalize, backupFinalize] = await Promise.all([ - awmUserClient.finalizeEcdsaMPCv2KeyGen({ + awmUserClient.finalizeMpcV2KeyGen({ source: KeySource.USER, ...userState, broadcastMessages: { bitgo: bitgoBroadcast, counterParty: backupRound4Broadcast }, bitgoCommonKeychain, }), - awmBackupClient.finalizeEcdsaMPCv2KeyGen({ + awmBackupClient.finalizeMpcV2KeyGen({ source: KeySource.BACKUP, ...backupState, broadcastMessages: { bitgo: bitgoBroadcast, counterParty: userRound4Broadcast }, @@ -415,3 +416,151 @@ 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 shared + * v1.mpcv2 routes (the AWM route handlers dispatch on curve type). The counterparty + * round-2 signed 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: MPSTypes.MPSSignedMessage | undefined; + let backupSignedMsg2: MPSTypes.MPSSignedMessage | undefined; + + return { + initializeCallback: async () => { + assert(!initialized, 'initializeCallback called more than once'); + initialized = true; + const [userInit, backupInit] = await Promise.all([ + awmUserClient.initMpcV2KeyGen({ source: KeySource.USER }), + awmBackupClient.initMpcV2KeyGen({ 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.roundMpcV2KeyGen({ + source: KeySource.USER, + ...userState, + round: 1, + bitgoGpgPub: bitgoPublicGpgKey, + counterPartyGpgPub: backupGpgPublicKey, + }), + awmBackupClient.roundMpcV2KeyGen({ + 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.roundMpcV2KeyGen({ + source: KeySource.USER, + ...userState, + round: 2, + bitgoMsg: bitgoMsg1, + counterPartyMsg: backupSignedMsg1, + }), + awmBackupClient.roundMpcV2KeyGen({ + 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.finalizeMpcV2KeyGen({ + source: KeySource.USER, + ...userState, + bitgoMsg: bitgoMsg2, + counterPartyMsg: backupSignedMsg2, + bitgoCommonKeychain, + }), + awmBackupClient.finalizeMpcV2KeyGen({ + 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 }; + }, + }; +}