From 3266e351162bd23ae60d78d071bca22de12c7b00 Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Tue, 12 May 2026 11:06:44 +0000 Subject: [PATCH] feat(sdk-core): implement EdDSA MPCv2 external signer orchestrator Implement signEddsaMPCv2TssUsingExternalSigner in EddsaMPCv2Utils, replacing the stub in baseTSSUtils. Also wire up the wallet.ts dispatch block and private signTransactionTssExternalSignerEdDSAMPCv2 method so the calling code and implementation land together. - EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner: full 3-round orchestration using caller-supplied generator functions, pickBitgo- PubGpgKeyForSigning with isEddsaMpcv2=true, and armored BitGo GPG key passed to round 2/3 generators. Asserts signatureShares after round 2, mirrors ECDSA counterpart. - wallet.ts: adds dispatch block for customEddsaMPCv2Signing* params before existing ECDSA MPCv2 block; adds private signTransactionTssExternalSignerEdDSAMPCv2 method. - Unit tests: 5 cases covering happy path, string txRequestId resolution, missing round-2 signatureShares assert, message-signing rejection, and armored key delivery to generators. Ticket: WCI-374 Co-Authored-By: Claude Opus 4.7 Session-Id: 68742945-d54f-4fb6-a866-47a802ad253e Task-Id: bb6a6972-cbe9-43f2-a9c3-74eefc045a44 --- .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 112 ++++++- modules/sdk-core/src/bitgo/wallet/wallet.ts | 62 ++++ .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 290 +++++++++++++++++- 3 files changed, 462 insertions(+), 2 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index afde0fce9a..f1d534e286 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -26,7 +26,18 @@ import { } from '../../../tss/eddsa/eddsaMPCv2'; import { generateGPGKeyPair } from '../../opengpgUtils'; import { MPCv2PartiesEnum } from '../ecdsa/typesMPCv2'; -import { RequestType, SignatureShareType, TSSParamsForMessageWithPrv, TSSParamsWithPrv, TxRequest } from '../baseTypes'; +import { + CustomEddsaMPCv2SigningRound1GeneratingFunction, + CustomEddsaMPCv2SigningRound2GeneratingFunction, + CustomEddsaMPCv2SigningRound3GeneratingFunction, + RequestType, + SignatureShareType, + TSSParams, + TSSParamsForMessage, + TSSParamsForMessageWithPrv, + TSSParamsWithPrv, + TxRequest, +} from '../baseTypes'; import { BaseEddsaUtils } from './base'; import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender'; @@ -515,4 +526,103 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { } // #endregion + + // #region external signer + /** @inheritdoc */ + async signEddsaMPCv2TssUsingExternalSigner( + params: TSSParams | TSSParamsForMessage, + externalSignerEddsaMPCv2SigningRound1Generator: CustomEddsaMPCv2SigningRound1GeneratingFunction, + externalSignerEddsaMPCv2SigningRound2Generator: CustomEddsaMPCv2SigningRound2GeneratingFunction, + externalSignerEddsaMPCv2SigningRound3Generator: CustomEddsaMPCv2SigningRound3GeneratingFunction, + requestType: RequestType = RequestType.tx + ): Promise { + const { txRequest, reqId } = params; + + // TODO(WP-2176): Add support for message signing + assert( + requestType === RequestType.tx, + 'Only transaction signing is supported for external signer, got: ' + requestType + ); + + let txRequestResolved: TxRequest; + if (typeof txRequest === 'string') { + txRequestResolved = await getTxRequest(this.bitgo, this.wallet.id(), txRequest, reqId); + } else { + txRequestResolved = txRequest; + } + + const bitgoPublicGpgKey = await this.pickBitgoPubGpgKeyForSigning( + true, + reqId, + txRequestResolved.enterpriseId, + true + ); + + if (!bitgoPublicGpgKey) { + throw new Error('Missing BitGo GPG key for MPCv2'); + } + + // round 1 + const { signatureShareRound1, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey } = + await externalSignerEddsaMPCv2SigningRound1Generator({ txRequest: txRequestResolved }); + const round1TxRequest = await sendSignatureShareV2( + this.bitgo, + txRequestResolved.walletId, + txRequestResolved.txRequestId, + [signatureShareRound1], + requestType, + this.baseCoin.getMPCAlgorithm(), + userGpgPubKey, + undefined, + this.wallet.multisigTypeVersion(), + reqId + ); + + // round 2 + const { signatureShareRound2, encryptedRound2Session } = await externalSignerEddsaMPCv2SigningRound2Generator({ + txRequest: round1TxRequest, + encryptedRound1Session, + encryptedUserGpgPrvKey, + bitgoPublicGpgKey: bitgoPublicGpgKey.armor(), + }); + const round2TxRequest = await sendSignatureShareV2( + this.bitgo, + txRequestResolved.walletId, + txRequestResolved.txRequestId, + [signatureShareRound2], + requestType, + this.baseCoin.getMPCAlgorithm(), + userGpgPubKey, + undefined, + this.wallet.multisigTypeVersion(), + reqId + ); + assert( + round2TxRequest.transactions && round2TxRequest.transactions[0].signatureShares, + 'Missing signature shares in round 2 txRequest' + ); + + // round 3 + const { signatureShareRound3 } = await externalSignerEddsaMPCv2SigningRound3Generator({ + txRequest: round2TxRequest, + encryptedRound2Session, + encryptedUserGpgPrvKey, + bitgoPublicGpgKey: bitgoPublicGpgKey.armor(), + }); + await sendSignatureShareV2( + this.bitgo, + txRequestResolved.walletId, + txRequestResolved.txRequestId, + [signatureShareRound3], + requestType, + this.baseCoin.getMPCAlgorithm(), + userGpgPubKey, + undefined, + this.wallet.multisigTypeVersion(), + reqId + ); + + return sendTxRequest(this.bitgo, txRequestResolved.walletId, txRequestResolved.txRequestId, requestType, reqId); + } + // #endregion } diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index d05427cff5..aff15d1d3d 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2165,6 +2165,15 @@ export class Wallet implements IWallet { return this.signTransactionTssExternalSignerECDSA(this.baseCoin, params); } + if ( + _.isFunction(params.customEddsaMPCv2SigningRound1GenerationFunction) && + _.isFunction(params.customEddsaMPCv2SigningRound2GenerationFunction) && + _.isFunction(params.customEddsaMPCv2SigningRound3GenerationFunction) + ) { + // invoke external signer TSS for EdDSA MPCv2 workflow + return this.signTransactionTssExternalSignerEdDSAMPCv2(this.baseCoin, params); + } + if ( _.isFunction(params.customMPCv2SigningRound1GenerationFunction) && _.isFunction(params.customMPCv2SigningRound2GenerationFunction) && @@ -4335,6 +4344,59 @@ export class Wallet implements IWallet { } } + /** + * Signs a transaction from a TSS EdDSA MPCv2 wallet using external signer. + * + * @param params signing options + */ + private async signTransactionTssExternalSignerEdDSAMPCv2( + coin: IBaseCoin, + params: WalletSignTransactionOptions = {} + ): Promise { + let txRequestId = ''; + if (params.txRequestId) { + txRequestId = params.txRequestId; + } else if (params.txPrebuild && params.txPrebuild.txRequestId) { + txRequestId = params.txPrebuild.txRequestId; + } else { + throw new Error('TxRequestId required to sign TSS transactions with External Signer.'); + } + + if (!params.customEddsaMPCv2SigningRound1GenerationFunction) { + throw new Error( + 'Generator function for EdDSA MPCv2 Round 1 share required to sign transactions with External Signer.' + ); + } + + if (!params.customEddsaMPCv2SigningRound2GenerationFunction) { + throw new Error( + 'Generator function for EdDSA MPCv2 Round 2 share required to sign transactions with External Signer.' + ); + } + + if (!params.customEddsaMPCv2SigningRound3GenerationFunction) { + throw new Error( + 'Generator function for EdDSA MPCv2 Round 3 share required to sign transactions with External Signer.' + ); + } + + try { + assert(this.tssUtils, 'tssUtils must be defined'); + const signedTxRequest = await this.tssUtils.signEddsaMPCv2TssUsingExternalSigner( + { + txRequest: txRequestId, + reqId: params.reqId || new RequestTracer(), + }, + params.customEddsaMPCv2SigningRound1GenerationFunction, + params.customEddsaMPCv2SigningRound2GenerationFunction, + params.customEddsaMPCv2SigningRound3GenerationFunction + ); + return signedTxRequest; + } catch (e) { + throw new Error('failed to sign transaction ' + e); + } + } + /** * Signs a transaction from a TSS ECDSA wallet using external signer. * diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 6b0c1964cb..65b4d2bcb2 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -1,4 +1,5 @@ import * as assert from 'assert'; +import * as sinon from 'sinon'; import * as pgp from 'openpgp'; import { EddsaMPSDsg, MPSComms, MPSUtil } from '@bitgo/sdk-lib-mpc'; import { @@ -8,7 +9,20 @@ import { EddsaMPCv2SignatureShareRound2Output, EddsaMPCv2SignatureShareRound3Input, } from '@bitgo/public-types'; -import { SignatureShareRecord, SignatureShareType } from '../../../../../../src'; +import { + BitGoBase, + BitGoRequest, + CustomEddsaMPCv2SigningRound1GeneratingFunction, + CustomEddsaMPCv2SigningRound2GeneratingFunction, + CustomEddsaMPCv2SigningRound3GeneratingFunction, + EddsaMPCv2Utils, + IBaseCoin, + IWallet, + RequestTracer, + SignatureShareRecord, + SignatureShareType, + TxRequest, +} from '../../../../../../src'; import { getSignatureShareRoundOne, getSignatureShareRoundTwo, @@ -323,3 +337,277 @@ describe('EdDSA MPS DSG helper functions', async () => { assert.ok(parsed.data.msg3.signature, 'msg3.signature should be set'); }); }); + +describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => { + let sandbox: sinon.SinonSandbox; + let eddsaMPCv2Utils: EddsaMPCv2Utils; + let mockBitgo: BitGoBase; + let bitgoGpgKeyPair: pgp.SerializedKeyPair; + let bitgoGpgPubKey: pgp.Key; + + const walletId = 'abc123wallet'; + const txRequestId = 'txreq-001'; + const enterpriseId = 'ent-001'; + + const mockTxRequest: TxRequest = { + txRequestId, + walletId, + enterpriseId, + apiVersion: 'full', + transactions: [ + { + unsignedTx: { + signableHex: 'deadbeef', + derivationPath: 'm/0', + serializedTxHex: 'deadbeef', + }, + signatureShares: [ + { + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify({ type: 'round1Output', data: {} }), + }, + ], + }, + ], + intent: { intentType: 'payment' }, + unsignedTxs: [], + } as unknown as TxRequest; + + const mockTxRequestRound2: TxRequest = { + ...mockTxRequest, + transactions: [ + { + ...mockTxRequest.transactions![0], + signatureShares: [ + { + from: SignatureShareType.BITGO, + to: SignatureShareType.USER, + share: JSON.stringify({ type: 'round2Output', data: {} }), + }, + ], + }, + ], + }; + + const dummyShare: SignatureShareRecord = { + from: SignatureShareType.USER, + to: SignatureShareType.BITGO, + share: JSON.stringify({ type: 'round1Input', data: {} }), + }; + + // Returns a chain compatible with: bitgo.post(url).send(body).result() + const makePostChain = (response: TxRequest): BitGoRequest => + ({ send: () => ({ result: sinon.stub().resolves(response) }) } as unknown as BitGoRequest); + + // Returns a chain compatible with: bitgo.get(url).query(params).retry(n).result() + const makeGetChain = (txRequests: TxRequest[]): BitGoRequest<{ txRequests: TxRequest[] }> => + ({ + query: () => ({ + retry: () => ({ result: sinon.stub().resolves({ txRequests }) }), + }), + } as unknown as BitGoRequest<{ txRequests: TxRequest[] }>); + + before(async () => { + bitgoGpgKeyPair = await generateGPGKeyPair('ed25519'); + bitgoGpgPubKey = await pgp.readKey({ armoredKey: bitgoGpgKeyPair.publicKey }); + }); + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + + // Full mock of the BitGo HTTP client (consistent with other sdk-core tests such as + // tokenApproval.ts and walletsEvmKeyring.ts). Module-level stubs on tssCommon functions + // do not work under tsx 4.x (ESM live bindings), so we mock at the bitgo object level. + mockBitgo = { + getEnv: sinon.stub().returns('test'), + setRequestTracer: sinon.stub(), + url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com${path}`), + post: sinon.stub(), + get: sinon.stub(), + } as unknown as BitGoBase; + + const mockCoin = { + getMPCAlgorithm: sinon.stub().returns('eddsa'), + } as unknown as IBaseCoin; + + const mockWallet = { + id: sinon.stub().returns(walletId), + keyIds: sinon.stub().returns(['userKeyId', 'backupKeyId', 'bitgoKeyId']), + multisigTypeVersion: sinon.stub().returns('MPCv2'), + } as unknown as IWallet; + + eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin, mockWallet); + + sandbox.stub(eddsaMPCv2Utils, 'pickBitgoPubGpgKeyForSigning').resolves(bitgoGpgPubKey); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should call all 3 generators and return the final tx request', async () => { + const finalTxRequest = { ...mockTxRequest, txRequestId }; + + // sendSignatureShareV2 is called 3 times (one per round), sendTxRequest once — all use bitgo.post + (mockBitgo.post as sinon.SinonStub) + .onCall(0) + .returns(makePostChain(mockTxRequest)) // round 1 sign + .onCall(1) + .returns(makePostChain(mockTxRequestRound2)) // round 2 sign + .onCall(2) + .returns(makePostChain(mockTxRequestRound2)) // round 3 sign + .onCall(3) + .returns(makePostChain(finalTxRequest)); // sendTxRequest (send) + + const encryptedRound1Session = 'encrypted-r1-session'; + const encryptedRound2Session = 'encrypted-r2-session'; + const encryptedUserGpgPrvKey = 'encrypted-gpg-key'; + const userGpgPubKey = bitgoGpgKeyPair.publicKey; + + const round1Share: SignatureShareRecord = { ...dummyShare }; + const round2Share: SignatureShareRecord = { ...dummyShare, share: JSON.stringify({ type: 'round2Input' }) }; + const round3Share: SignatureShareRecord = { ...dummyShare, share: JSON.stringify({ type: 'round3Input' }) }; + + const round1Generator = sinon + .stub() + .resolves({ signatureShareRound1: round1Share, userGpgPubKey, encryptedRound1Session, encryptedUserGpgPrvKey }); + const round2Generator = sinon.stub().resolves({ signatureShareRound2: round2Share, encryptedRound2Session }); + const round3Generator = sinon.stub().resolves({ signatureShareRound3: round3Share }); + + const result = await eddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner( + { txRequest: mockTxRequest, reqId: new RequestTracer() }, + round1Generator as unknown as CustomEddsaMPCv2SigningRound1GeneratingFunction, + round2Generator as unknown as CustomEddsaMPCv2SigningRound2GeneratingFunction, + round3Generator as unknown as CustomEddsaMPCv2SigningRound3GeneratingFunction + ); + + assert.deepStrictEqual(result, finalTxRequest); + + sinon.assert.calledOnce(round1Generator); + sinon.assert.calledWith(round1Generator, { txRequest: mockTxRequest }); + + sinon.assert.calledOnce(round2Generator); + const round2Call = round2Generator.getCall(0); + assert.strictEqual(round2Call.args[0].txRequest, mockTxRequest); + assert.strictEqual(round2Call.args[0].encryptedRound1Session, encryptedRound1Session); + assert.strictEqual(round2Call.args[0].encryptedUserGpgPrvKey, encryptedUserGpgPrvKey); + assert.strictEqual(round2Call.args[0].bitgoPublicGpgKey, bitgoGpgPubKey.armor()); + + sinon.assert.calledOnce(round3Generator); + const round3Call = round3Generator.getCall(0); + assert.strictEqual(round3Call.args[0].txRequest, mockTxRequestRound2); + assert.strictEqual(round3Call.args[0].encryptedRound2Session, encryptedRound2Session); + assert.strictEqual(round3Call.args[0].encryptedUserGpgPrvKey, encryptedUserGpgPrvKey); + assert.strictEqual(round3Call.args[0].bitgoPublicGpgKey, bitgoGpgPubKey.armor()); + + // 3 sendSignatureShareV2 calls + 1 sendTxRequest = 4 POST calls total + assert.strictEqual((mockBitgo.post as sinon.SinonStub).callCount, 4); + }); + + it('should resolve txRequest by ID string using getTxRequest', async () => { + // getTxRequest uses bitgo.get; sendSignatureShareV2 (×3) + sendTxRequest use bitgo.post + (mockBitgo.get as sinon.SinonStub).returns(makeGetChain([mockTxRequest])); + (mockBitgo.post as sinon.SinonStub) + .onCall(0) + .returns(makePostChain(mockTxRequest)) + .onCall(1) + .returns(makePostChain(mockTxRequestRound2)) + .onCall(2) + .returns(makePostChain(mockTxRequestRound2)) + .onCall(3) + .returns(makePostChain(mockTxRequest)); + + const round1Generator = sinon.stub().resolves({ + signatureShareRound1: dummyShare, + userGpgPubKey: bitgoGpgKeyPair.publicKey, + encryptedRound1Session: 'r1', + encryptedUserGpgPrvKey: 'key', + }); + const round2Generator = sinon.stub().resolves({ signatureShareRound2: dummyShare, encryptedRound2Session: 'r2' }); + const round3Generator = sinon.stub().resolves({ signatureShareRound3: dummyShare }); + + await eddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner( + { txRequest: txRequestId, reqId: new RequestTracer() }, + round1Generator as unknown as CustomEddsaMPCv2SigningRound1GeneratingFunction, + round2Generator as unknown as CustomEddsaMPCv2SigningRound2GeneratingFunction, + round3Generator as unknown as CustomEddsaMPCv2SigningRound3GeneratingFunction + ); + + sinon.assert.calledOnce(mockBitgo.get as sinon.SinonStub); + sinon.assert.calledWith(round1Generator, { txRequest: mockTxRequest }); + }); + + it('should throw when round 2 txRequest is missing signatureShares', async () => { + const round2NoShares: TxRequest = { + ...mockTxRequest, + transactions: [{ ...mockTxRequest.transactions![0], signatureShares: undefined as unknown as [] }], + }; + + (mockBitgo.post as sinon.SinonStub) + .onCall(0) + .returns(makePostChain(mockTxRequest)) + .onCall(1) + .returns(makePostChain(round2NoShares)); + + const round1Generator = sinon.stub().resolves({ + signatureShareRound1: dummyShare, + userGpgPubKey: bitgoGpgKeyPair.publicKey, + encryptedRound1Session: 'r1', + encryptedUserGpgPrvKey: 'key', + }); + const round2Generator = sinon.stub().resolves({ signatureShareRound2: dummyShare, encryptedRound2Session: 'r2' }); + const round3Generator = sinon.stub().resolves({ signatureShareRound3: dummyShare }); + + await assert.rejects( + () => + eddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner( + { txRequest: mockTxRequest, reqId: new RequestTracer() }, + round1Generator as unknown as CustomEddsaMPCv2SigningRound1GeneratingFunction, + round2Generator as unknown as CustomEddsaMPCv2SigningRound2GeneratingFunction, + round3Generator as unknown as CustomEddsaMPCv2SigningRound3GeneratingFunction + ), + /Missing signature shares in round 2 txRequest/ + ); + }); + + it('should pass armored BitGo public GPG key to round 2 and round 3 generators', async () => { + (mockBitgo.post as sinon.SinonStub) + .onCall(0) + .returns(makePostChain(mockTxRequest)) + .onCall(1) + .returns(makePostChain(mockTxRequestRound2)) + .onCall(2) + .returns(makePostChain(mockTxRequestRound2)) + .onCall(3) + .returns(makePostChain(mockTxRequest)); + + const round1Generator = sinon.stub().resolves({ + signatureShareRound1: dummyShare, + userGpgPubKey: bitgoGpgKeyPair.publicKey, + encryptedRound1Session: 'r1', + encryptedUserGpgPrvKey: 'key', + }); + const round2Generator = sinon.stub().resolves({ signatureShareRound2: dummyShare, encryptedRound2Session: 'r2' }); + const round3Generator = sinon.stub().resolves({ signatureShareRound3: dummyShare }); + + await eddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner( + { txRequest: mockTxRequest, reqId: new RequestTracer() }, + round1Generator as unknown as CustomEddsaMPCv2SigningRound1GeneratingFunction, + round2Generator as unknown as CustomEddsaMPCv2SigningRound2GeneratingFunction, + round3Generator as unknown as CustomEddsaMPCv2SigningRound3GeneratingFunction + ); + + const armoredKey = bitgoGpgPubKey.armor(); + assert.strictEqual( + round2Generator.getCall(0).args[0].bitgoPublicGpgKey, + armoredKey, + 'round 2 should receive armored BitGo GPG key' + ); + assert.strictEqual( + round3Generator.getCall(0).args[0].bitgoPublicGpgKey, + armoredKey, + 'round 3 should receive armored BitGo GPG key' + ); + }); +});