From 9c7bd6e2199b3476593cd7aba7e1a205bb2eca27 Mon Sep 17 00:00:00 2001 From: mrdanish26 Date: Thu, 11 Jun 2026 12:15:50 -0700 Subject: [PATCH] fix(sdk-core): enforce recipient verification in EdDSA TSS signing TICKET: WCN-196 --- .../src/bitgo/utils/tss/eddsa/eddsa.ts | 8 + .../src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 4 +- .../src/bitgo/utils/tss/recipientUtils.ts | 9 + .../unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts | 176 ++++++++++++++++++ .../unit/bitgo/utils/tss/recipientUtils.ts | 3 + 5 files changed, 199 insertions(+), 1 deletion(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index 5cad5b6b82..16da87f15a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -37,6 +37,7 @@ import { isV2Envelope, } from '../baseTypes'; import { InvalidTransactionError } from '../../../errors'; +import { resolveEffectiveTxParams } from '../recipientUtils'; import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, YShare } from './types'; import baseTSSUtils from '../baseTSSUtils'; import { BaseEddsaUtils } from './base'; @@ -690,6 +691,13 @@ export class EddsaUtils extends baseTSSUtils { ); unsignedTx = apiVersion === 'full' ? txRequestResolved.transactions![0].unsignedTx : txRequestResolved.unsignedTxs[0]; + + await this.baseCoin.verifyTransaction({ + txPrebuild: { txHex: unsignedTx.serializedTxHex ?? unsignedTx.signableHex }, + txParams: resolveEffectiveTxParams(txRequestResolved, params.txParams), + wallet: this.wallet, + walletType: this.wallet.multisigType(), + }); } else if (requestType === RequestType.message) { assert(txRequestResolved.messages?.length, 'Unable to find messages in txRequest for message signing'); const message = txRequestResolved.messages[0]; 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 540f789f20..299a4cf312 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -42,6 +42,7 @@ import { TxRequest, isV2Envelope, } from '../baseTypes'; +import { resolveEffectiveTxParams } from '../recipientUtils'; import { EncryptionVersion } from '../../../../api'; import { BitGoBase } from '../../../bitgoBase'; import { BaseEddsaUtils } from './base'; @@ -446,9 +447,10 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils { assert(txOrMessageToSign, 'Missing signableHex in unsignedTx'); derivationPath = unsignedTx.derivationPath; bufferContent = Buffer.from(txOrMessageToSign, 'hex'); + await this.baseCoin.verifyTransaction({ txPrebuild: { txHex: unsignedTx.serializedTxHex ?? txOrMessageToSign }, - txParams: params.txParams || { recipients: [] }, + txParams: resolveEffectiveTxParams(txRequest, params.txParams), wallet: this.wallet, walletType: this.wallet.multisigType(), }); diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index e83728151b..2a44051587 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -57,6 +57,15 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'transferOfferWithdrawn', 'cantonCommand', 'pledge', + + // SOL token account management + 'closeAssociatedTokenAccount', + + // ADA governance + 'voteDelegation', + + // CANTON multi-step transfer lifecycle + 'transferAcknowledge', ]); /** 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 3c8c892a37..e8df1e697b 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 @@ -1835,3 +1835,179 @@ describe('signRecoveryEddsaMPCv2', () => { ); }); }); + +describe('EdDSA MPCv2 signRequestBase recipient verification', () => { + let eddsaMPCv2Utils: EddsaMPCv2Utils; + let verifyTransactionStub: sinon.SinonStub; + + const walletId = 'wallet-verify-test'; + const signableHex = 'deadbeef'; + const serializedTxHex = 'cafebabe'; + const derivationPath = 'm/0'; + // Dummy key — tests only verify that verifyTransaction is called before MPC signing starts. + // Real DKG key generation is avoided to prevent WASM SIGSEGV on Node 22 CI. + const dummyPrv = randomBytes(64).toString('base64'); + + beforeEach(async () => { + verifyTransactionStub = sinon.stub().resolves(true); + + const mockBitgo = { + getEnv: sinon.stub().returns('test'), + setRequestTracer: sinon.stub(), + url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com${path}`), + post: sinon.stub().returns({ + send: sinon.stub().returnsThis(), + set: sinon.stub().returnsThis(), + result: sinon.stub().rejects(new Error('mock: HTTP not available')), + }), + } as unknown as BitGoBase; + + const mockCoin = { + getMPCAlgorithm: sinon.stub().returns('eddsa'), + verifyTransaction: verifyTransactionStub, + } as unknown as IBaseCoin; + + const mockWallet = { + id: sinon.stub().returns(walletId), + multisigType: sinon.stub().returns('tss'), + multisigTypeVersion: sinon.stub().returns('MPCv2'), + } as unknown as IWallet; + + eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin, mockWallet); + sinon + .stub(eddsaMPCv2Utils as any, 'pickBitgoPubGpgKeyForSigning') + .resolves(await pgp.readKey({ armoredKey: (await generateGPGKeyPair('ed25519')).publicKey })); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should call verifyTransaction with resolveEffectiveTxParams output', async () => { + const txRequest: TxRequest = { + txRequestId: 'txreq-verify-1', + walletId, + apiVersion: 'full', + transactions: [ + { + unsignedTx: { signableHex, serializedTxHex, derivationPath }, + signatureShares: [], + }, + ], + intent: { + intentType: 'payment', + recipients: [{ address: { address: 'solAddr1' }, amount: { value: '5000000', symbol: 'tsol' } }], + }, + unsignedTxs: [], + } as unknown as TxRequest; + + try { + await eddsaMPCv2Utils.signTxRequest({ + txRequest, + txParams: { recipients: [{ address: 'solAddr1', amount: '5000000' }] }, + prv: dummyPrv, + reqId: new RequestTracer(), + }); + } catch { + // Expected to fail at MPC signing rounds — we only care about verifyTransaction + } + + sinon.assert.calledOnce(verifyTransactionStub); + const call = verifyTransactionStub.getCall(0); + assert.strictEqual(call.args[0].txPrebuild.txHex, serializedTxHex); + assert.deepStrictEqual(call.args[0].txParams.recipients, [{ address: 'solAddr1', amount: '5000000' }]); + }); + + it('should resolve recipients from intent when txParams has none', async () => { + const txRequest: TxRequest = { + txRequestId: 'txreq-verify-2', + walletId, + apiVersion: 'full', + transactions: [ + { + unsignedTx: { signableHex, serializedTxHex, derivationPath }, + signatureShares: [], + }, + ], + intent: { + intentType: 'payment', + recipients: [{ address: { address: 'solAddr2' }, amount: { value: '1000', symbol: 'tsol' } }], + }, + unsignedTxs: [], + } as unknown as TxRequest; + + try { + await eddsaMPCv2Utils.signTxRequest({ + txRequest, + prv: dummyPrv, + reqId: new RequestTracer(), + }); + } catch { + // Expected to fail at MPC signing rounds + } + + sinon.assert.calledOnce(verifyTransactionStub); + const call = verifyTransactionStub.getCall(0); + assert.strictEqual(call.args[0].txParams.recipients[0].address, 'solAddr2'); + assert.strictEqual(call.args[0].txParams.recipients[0].amount, '1000'); + }); + + it('should not call verifyTransaction for message signing', async () => { + const txRequest: TxRequest = { + txRequestId: 'txreq-verify-msg', + walletId, + apiVersion: 'full', + messages: [ + { + messageEncoded: 'deadbeef', + derivationPath: 'm/0', + }, + ], + unsignedTxs: [], + } as unknown as TxRequest; + + try { + await eddsaMPCv2Utils.signTxRequestForMessage({ + txRequest, + prv: dummyPrv, + reqId: new RequestTracer(), + messageRaw: 'test message', + bufferToSign: Buffer.from('deadbeef', 'hex'), + }); + } catch { + // Expected to fail at MPC signing rounds + } + + sinon.assert.notCalled(verifyTransactionStub); + }); + + it('should use signableHex as fallback when serializedTxHex is missing', async () => { + const txRequest: TxRequest = { + txRequestId: 'txreq-verify-fallback', + walletId, + apiVersion: 'full', + transactions: [ + { + unsignedTx: { signableHex, derivationPath }, + signatureShares: [], + }, + ], + intent: { intentType: 'consolidate' }, + unsignedTxs: [], + } as unknown as TxRequest; + + try { + await eddsaMPCv2Utils.signTxRequest({ + txRequest, + prv: dummyPrv, + reqId: new RequestTracer(), + }); + } catch { + // Expected to fail at MPC signing rounds + } + + sinon.assert.calledOnce(verifyTransactionStub); + const call = verifyTransactionStub.getCall(0); + assert.strictEqual(call.args[0].txPrebuild.txHex, signableHex); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 1df5ccfd95..9858384615 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -49,6 +49,9 @@ describe('recipientUtils', function () { 'transferOfferWithdrawn', 'cantonCommand', 'pledge', + 'closeAssociatedTokenAccount', + 'voteDelegation', + 'transferAcknowledge', ]; expected.forEach((t) => assert.ok(NO_RECIPIENT_TX_TYPES.has(t), `${t} should be in NO_RECIPIENT_TX_TYPES`)); assert.strictEqual(NO_RECIPIENT_TX_TYPES.size, expected.length);