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' + ); + }); +});