Skip to content

Commit 077e837

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

5 files changed

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

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)