Skip to content

Commit 9c7bd6e

Browse files
committed
fix(sdk-core): enforce recipient verification in EdDSA TSS signing
TICKET: WCN-196
1 parent 1afb671 commit 9c7bd6e

5 files changed

Lines changed: 199 additions & 1 deletion

File tree

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
isV2Envelope,
3838
} from '../baseTypes';
3939
import { InvalidTransactionError } from '../../../errors';
40+
import { resolveEffectiveTxParams } from '../recipientUtils';
4041
import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, YShare } from './types';
4142
import baseTSSUtils from '../baseTSSUtils';
4243
import { BaseEddsaUtils } from './base';
@@ -690,6 +691,13 @@ export class EddsaUtils extends baseTSSUtils<KeyShare> {
690691
);
691692
unsignedTx =
692693
apiVersion === 'full' ? txRequestResolved.transactions![0].unsignedTx : txRequestResolved.unsignedTxs[0];
694+
695+
await this.baseCoin.verifyTransaction({
696+
txPrebuild: { txHex: unsignedTx.serializedTxHex ?? unsignedTx.signableHex },
697+
txParams: resolveEffectiveTxParams(txRequestResolved, params.txParams),
698+
wallet: this.wallet,
699+
walletType: this.wallet.multisigType(),
700+
});
693701
} else if (requestType === RequestType.message) {
694702
assert(txRequestResolved.messages?.length, 'Unable to find messages in txRequest for message signing');
695703
const message = txRequestResolved.messages[0];

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
TxRequest,
4343
isV2Envelope,
4444
} from '../baseTypes';
45+
import { resolveEffectiveTxParams } from '../recipientUtils';
4546
import { EncryptionVersion } from '../../../../api';
4647
import { BitGoBase } from '../../../bitgoBase';
4748
import { BaseEddsaUtils } from './base';
@@ -446,9 +447,10 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
446447
assert(txOrMessageToSign, 'Missing signableHex in unsignedTx');
447448
derivationPath = unsignedTx.derivationPath;
448449
bufferContent = Buffer.from(txOrMessageToSign, 'hex');
450+
449451
await this.baseCoin.verifyTransaction({
450452
txPrebuild: { txHex: unsignedTx.serializedTxHex ?? txOrMessageToSign },
451-
txParams: params.txParams || { recipients: [] },
453+
txParams: resolveEffectiveTxParams(txRequest, params.txParams),
452454
wallet: this.wallet,
453455
walletType: this.wallet.multisigType(),
454456
});

modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
5757
'transferOfferWithdrawn',
5858
'cantonCommand',
5959
'pledge',
60+
61+
// SOL token account management
62+
'closeAssociatedTokenAccount',
63+
64+
// ADA governance
65+
'voteDelegation',
66+
67+
// CANTON multi-step transfer lifecycle
68+
'transferAcknowledge',
6069
]);
6170

6271
/**

modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,3 +1835,179 @@ describe('signRecoveryEddsaMPCv2', () => {
18351835
);
18361836
});
18371837
});
1838+
1839+
describe('EdDSA MPCv2 signRequestBase recipient verification', () => {
1840+
let eddsaMPCv2Utils: EddsaMPCv2Utils;
1841+
let verifyTransactionStub: sinon.SinonStub;
1842+
1843+
const walletId = 'wallet-verify-test';
1844+
const signableHex = 'deadbeef';
1845+
const serializedTxHex = 'cafebabe';
1846+
const derivationPath = 'm/0';
1847+
// Dummy key — tests only verify that verifyTransaction is called before MPC signing starts.
1848+
// Real DKG key generation is avoided to prevent WASM SIGSEGV on Node 22 CI.
1849+
const dummyPrv = randomBytes(64).toString('base64');
1850+
1851+
beforeEach(async () => {
1852+
verifyTransactionStub = sinon.stub().resolves(true);
1853+
1854+
const mockBitgo = {
1855+
getEnv: sinon.stub().returns('test'),
1856+
setRequestTracer: sinon.stub(),
1857+
url: sinon.stub().callsFake((path: string) => `https://test.bitgo.com${path}`),
1858+
post: sinon.stub().returns({
1859+
send: sinon.stub().returnsThis(),
1860+
set: sinon.stub().returnsThis(),
1861+
result: sinon.stub().rejects(new Error('mock: HTTP not available')),
1862+
}),
1863+
} as unknown as BitGoBase;
1864+
1865+
const mockCoin = {
1866+
getMPCAlgorithm: sinon.stub().returns('eddsa'),
1867+
verifyTransaction: verifyTransactionStub,
1868+
} as unknown as IBaseCoin;
1869+
1870+
const mockWallet = {
1871+
id: sinon.stub().returns(walletId),
1872+
multisigType: sinon.stub().returns('tss'),
1873+
multisigTypeVersion: sinon.stub().returns('MPCv2'),
1874+
} as unknown as IWallet;
1875+
1876+
eddsaMPCv2Utils = new EddsaMPCv2Utils(mockBitgo, mockCoin, mockWallet);
1877+
sinon
1878+
.stub(eddsaMPCv2Utils as any, 'pickBitgoPubGpgKeyForSigning')
1879+
.resolves(await pgp.readKey({ armoredKey: (await generateGPGKeyPair('ed25519')).publicKey }));
1880+
});
1881+
1882+
afterEach(() => {
1883+
sinon.restore();
1884+
});
1885+
1886+
it('should call verifyTransaction with resolveEffectiveTxParams output', async () => {
1887+
const txRequest: TxRequest = {
1888+
txRequestId: 'txreq-verify-1',
1889+
walletId,
1890+
apiVersion: 'full',
1891+
transactions: [
1892+
{
1893+
unsignedTx: { signableHex, serializedTxHex, derivationPath },
1894+
signatureShares: [],
1895+
},
1896+
],
1897+
intent: {
1898+
intentType: 'payment',
1899+
recipients: [{ address: { address: 'solAddr1' }, amount: { value: '5000000', symbol: 'tsol' } }],
1900+
},
1901+
unsignedTxs: [],
1902+
} as unknown as TxRequest;
1903+
1904+
try {
1905+
await eddsaMPCv2Utils.signTxRequest({
1906+
txRequest,
1907+
txParams: { recipients: [{ address: 'solAddr1', amount: '5000000' }] },
1908+
prv: dummyPrv,
1909+
reqId: new RequestTracer(),
1910+
});
1911+
} catch {
1912+
// Expected to fail at MPC signing rounds — we only care about verifyTransaction
1913+
}
1914+
1915+
sinon.assert.calledOnce(verifyTransactionStub);
1916+
const call = verifyTransactionStub.getCall(0);
1917+
assert.strictEqual(call.args[0].txPrebuild.txHex, serializedTxHex);
1918+
assert.deepStrictEqual(call.args[0].txParams.recipients, [{ address: 'solAddr1', amount: '5000000' }]);
1919+
});
1920+
1921+
it('should resolve recipients from intent when txParams has none', async () => {
1922+
const txRequest: TxRequest = {
1923+
txRequestId: 'txreq-verify-2',
1924+
walletId,
1925+
apiVersion: 'full',
1926+
transactions: [
1927+
{
1928+
unsignedTx: { signableHex, serializedTxHex, derivationPath },
1929+
signatureShares: [],
1930+
},
1931+
],
1932+
intent: {
1933+
intentType: 'payment',
1934+
recipients: [{ address: { address: 'solAddr2' }, amount: { value: '1000', symbol: 'tsol' } }],
1935+
},
1936+
unsignedTxs: [],
1937+
} as unknown as TxRequest;
1938+
1939+
try {
1940+
await eddsaMPCv2Utils.signTxRequest({
1941+
txRequest,
1942+
prv: dummyPrv,
1943+
reqId: new RequestTracer(),
1944+
});
1945+
} catch {
1946+
// Expected to fail at MPC signing rounds
1947+
}
1948+
1949+
sinon.assert.calledOnce(verifyTransactionStub);
1950+
const call = verifyTransactionStub.getCall(0);
1951+
assert.strictEqual(call.args[0].txParams.recipients[0].address, 'solAddr2');
1952+
assert.strictEqual(call.args[0].txParams.recipients[0].amount, '1000');
1953+
});
1954+
1955+
it('should not call verifyTransaction for message signing', async () => {
1956+
const txRequest: TxRequest = {
1957+
txRequestId: 'txreq-verify-msg',
1958+
walletId,
1959+
apiVersion: 'full',
1960+
messages: [
1961+
{
1962+
messageEncoded: 'deadbeef',
1963+
derivationPath: 'm/0',
1964+
},
1965+
],
1966+
unsignedTxs: [],
1967+
} as unknown as TxRequest;
1968+
1969+
try {
1970+
await eddsaMPCv2Utils.signTxRequestForMessage({
1971+
txRequest,
1972+
prv: dummyPrv,
1973+
reqId: new RequestTracer(),
1974+
messageRaw: 'test message',
1975+
bufferToSign: Buffer.from('deadbeef', 'hex'),
1976+
});
1977+
} catch {
1978+
// Expected to fail at MPC signing rounds
1979+
}
1980+
1981+
sinon.assert.notCalled(verifyTransactionStub);
1982+
});
1983+
1984+
it('should use signableHex as fallback when serializedTxHex is missing', async () => {
1985+
const txRequest: TxRequest = {
1986+
txRequestId: 'txreq-verify-fallback',
1987+
walletId,
1988+
apiVersion: 'full',
1989+
transactions: [
1990+
{
1991+
unsignedTx: { signableHex, derivationPath },
1992+
signatureShares: [],
1993+
},
1994+
],
1995+
intent: { intentType: 'consolidate' },
1996+
unsignedTxs: [],
1997+
} as unknown as TxRequest;
1998+
1999+
try {
2000+
await eddsaMPCv2Utils.signTxRequest({
2001+
txRequest,
2002+
prv: dummyPrv,
2003+
reqId: new RequestTracer(),
2004+
});
2005+
} catch {
2006+
// Expected to fail at MPC signing rounds
2007+
}
2008+
2009+
sinon.assert.calledOnce(verifyTransactionStub);
2010+
const call = verifyTransactionStub.getCall(0);
2011+
assert.strictEqual(call.args[0].txPrebuild.txHex, signableHex);
2012+
});
2013+
});

modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ describe('recipientUtils', function () {
4949
'transferOfferWithdrawn',
5050
'cantonCommand',
5151
'pledge',
52+
'closeAssociatedTokenAccount',
53+
'voteDelegation',
54+
'transferAcknowledge',
5255
];
5356
expected.forEach((t) => assert.ok(NO_RECIPIENT_TX_TYPES.has(t), `${t} should be in NO_RECIPIENT_TX_TYPES`));
5457
assert.strictEqual(NO_RECIPIENT_TX_TYPES.size, expected.length);

0 commit comments

Comments
 (0)