Skip to content

Commit e190bc0

Browse files
authored
Merge pull request #7020 from BitGo/WP-5945/sdk-unspent-management-validation
feat(sdk-core): add transaction validation for (consolidate) unspent management
2 parents 11a986e + 91cf215 commit e190bc0

File tree

4 files changed

+176
-3
lines changed

4 files changed

+176
-3
lines changed

modules/bitgo/test/v2/unit/unspents.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ describe('Verify string type is used for value of unspent', function () {
6060
);
6161

6262
sinon.stub(wallet, 'signTransaction').resolves({});
63+
sinon.stub(wallet.baseCoin, 'verifyTransaction').resolves();
6364

6465
const sendScope = nock(bgUrl)
6566
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`, { type: manageUnspentType })

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,6 +1589,7 @@ describe('V2 Wallet:', function () {
15891589
'unsigned'
15901590
)
15911591
);
1592+
psbts.forEach((psbt) => utxoLib.bitgo.addXpubsToPsbt(psbt, rootWalletKey));
15921593
const txHexes = psbts.map((psbt) => ({ txHex: psbt.toHex() }));
15931594

15941595
const nocks: nock.Scope[] = [];
@@ -1627,6 +1628,7 @@ describe('V2 Wallet:', function () {
16271628
rootWalletKey,
16281629
'unsigned'
16291630
);
1631+
utxoLib.bitgo.addXpubsToPsbt(psbt, rootWalletKey);
16301632

16311633
const nocks: nock.Scope[] = [];
16321634
nocks.push(

modules/sdk-coin-btc/test/unit/btc.ts

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { btcBackupKey } from './fixtures';
55
import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test';
66

77
import { Tbtc } from '../../src';
8-
import { BitGoAPI } from '@bitgo/sdk-api';
8+
import { BitGoAPI, encrypt } from '@bitgo/sdk-api';
99
import * as utxolib from '@bitgo/utxo-lib';
1010

11+
import { Wallet } from '@bitgo/sdk-core';
12+
1113
describe('BTC:', () => {
1214
let bitgo: TestBitGoAPI;
1315

@@ -106,4 +108,153 @@ describe('BTC:', () => {
106108
);
107109
});
108110
});
111+
112+
describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', () => {
113+
let coin: Tbtc;
114+
let bitgoTest: TestBitGoAPI;
115+
before(() => {
116+
bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' });
117+
bitgoTest.safeRegister('tbtc', Tbtc.createInstance);
118+
bitgoTest.initializeTestVars();
119+
coin = bitgoTest.coin('tbtc') as Tbtc;
120+
});
121+
122+
it('should detect hex spoofing in BUILD_SIGN_SEND', async (): Promise<void> => {
123+
const keyTriple = utxolib.testutil.getKeyTriple('default');
124+
const rootWalletKey = new utxolib.bitgo.RootWalletKeys(keyTriple);
125+
const [user] = keyTriple;
126+
127+
const wallet = new Wallet(bitgoTest, coin, {
128+
id: '5b34252f1bf349930e34020a',
129+
coin: 'tbtc',
130+
keys: ['user', 'backup', 'bitgo'],
131+
});
132+
133+
const originalPsbt = utxolib.testutil.constructPsbt(
134+
[{ scriptType: 'p2wsh' as const, value: BigInt(10000) }],
135+
[{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }],
136+
coin.network,
137+
rootWalletKey,
138+
'unsigned' as const
139+
);
140+
utxolib.bitgo.addXpubsToPsbt(originalPsbt, rootWalletKey);
141+
const spoofedPsbt = utxolib.testutil.constructPsbt(
142+
[{ scriptType: 'p2wsh' as const, value: BigInt(10000) }],
143+
[{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }],
144+
coin.network,
145+
rootWalletKey,
146+
'unsigned' as const
147+
);
148+
utxolib.bitgo.addXpubsToPsbt(spoofedPsbt, rootWalletKey);
149+
const spoofedHex: string = spoofedPsbt.toHex();
150+
151+
const bgUrl: string = (bitgoTest as any)._baseUrl;
152+
const nock = require('nock');
153+
154+
nock(bgUrl)
155+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/consolidateUnspents`)
156+
.reply(200, { txHex: spoofedHex, consolidateId: 'test' });
157+
158+
nock(bgUrl)
159+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`)
160+
.reply((requestBody: any) => {
161+
if (requestBody?.txHex === spoofedHex) {
162+
throw new Error('Spoofed transaction was sent: spoofing protection failed');
163+
}
164+
return [200, { txid: 'test-txid-123', status: 'signed' }];
165+
});
166+
167+
const pubs = keyTriple.map((k) => k.neutered().toBase58());
168+
const responses = [
169+
{ pub: pubs[0], encryptedPrv: encrypt('pass', user.toBase58()) },
170+
{ pub: pubs[1] },
171+
{ pub: pubs[2] },
172+
];
173+
wallet
174+
.keyIds()
175+
.forEach((id, i) => nock(bgUrl).get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i]));
176+
177+
await assert.rejects(
178+
wallet.consolidateUnspents({ walletPassphrase: 'pass' }),
179+
(e: any) =>
180+
typeof e?.message === 'string' &&
181+
e.message.includes('prebuild attempts to spend to unintended external recipients')
182+
);
183+
});
184+
});
185+
186+
describe('Unspent management spoofability - Fanout (BUILD_SIGN_SEND)', () => {
187+
let coin: Tbtc;
188+
let bitgoTest: TestBitGoAPI;
189+
before(() => {
190+
bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' });
191+
bitgoTest.safeRegister('tbtc', Tbtc.createInstance);
192+
bitgoTest.initializeTestVars();
193+
coin = bitgoTest.coin('tbtc') as Tbtc;
194+
});
195+
196+
it('should detect hex spoofing in fanout BUILD_SIGN_SEND', async (): Promise<void> => {
197+
const keyTriple = utxolib.testutil.getKeyTriple('default');
198+
const rootWalletKey = new utxolib.bitgo.RootWalletKeys(keyTriple);
199+
const [user] = keyTriple;
200+
201+
const wallet = new Wallet(bitgoTest, coin, {
202+
id: '5b34252f1bf349930e34020a',
203+
coin: 'tbtc',
204+
keys: ['user', 'backup', 'bitgo'],
205+
});
206+
207+
const originalPsbt = utxolib.testutil.constructPsbt(
208+
[{ scriptType: 'p2wsh' as const, value: BigInt(10000) }],
209+
[{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }],
210+
coin.network,
211+
rootWalletKey,
212+
'unsigned' as const
213+
);
214+
utxolib.bitgo.addXpubsToPsbt(originalPsbt, rootWalletKey);
215+
216+
const spoofedPsbt = utxolib.testutil.constructPsbt(
217+
[{ scriptType: 'p2wsh' as const, value: BigInt(10000) }],
218+
[{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }],
219+
coin.network,
220+
rootWalletKey,
221+
'unsigned' as const
222+
);
223+
utxolib.bitgo.addXpubsToPsbt(spoofedPsbt, rootWalletKey);
224+
const spoofedHex: string = spoofedPsbt.toHex();
225+
226+
const bgUrl: string = (bitgoTest as any)._baseUrl;
227+
const nock = require('nock');
228+
229+
nock(bgUrl)
230+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/fanoutUnspents`)
231+
.reply(200, { txHex: spoofedHex, fanoutId: 'test' });
232+
233+
nock(bgUrl)
234+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`)
235+
.reply((requestBody: any) => {
236+
if (requestBody?.txHex === spoofedHex) {
237+
throw new Error('Spoofed transaction was sent: spoofing protection failed');
238+
}
239+
return [200, { txid: 'test-txid-123', status: 'signed' }];
240+
});
241+
242+
const pubs = keyTriple.map((k) => k.neutered().toBase58());
243+
const responses = [
244+
{ pub: pubs[0], encryptedPrv: encrypt('pass', user.toBase58()) },
245+
{ pub: pubs[1] },
246+
{ pub: pubs[2] },
247+
];
248+
wallet
249+
.keyIds()
250+
.forEach((id, i) => nock(bgUrl).get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i]));
251+
252+
await assert.rejects(
253+
wallet.fanoutUnspents({ walletPassphrase: 'pass' }),
254+
(e: any) =>
255+
typeof e?.message === 'string' &&
256+
e.message.includes('prebuild attempts to spend to unintended external recipients')
257+
);
258+
});
259+
});
109260
});

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,27 @@ export class Wallet implements IWallet {
739739
.keychains()
740740
.getKeysForSigning({ wallet: this, reqId })) as unknown as Keychain[];
741741

742+
// Validate that the platform-built transaction matches user parameters
743+
const txPrebuilds = Array.isArray(buildResponse) ? buildResponse : [buildResponse];
744+
await Promise.all(
745+
txPrebuilds.map((txPrebuild) =>
746+
this.baseCoin.verifyTransaction({
747+
txParams: params,
748+
txPrebuild,
749+
wallet: this,
750+
verification: {
751+
...(params.verification ?? {}),
752+
keychains: {
753+
user: keychains[0],
754+
backup: keychains[1],
755+
bitgo: keychains[2],
756+
},
757+
},
758+
reqId,
759+
})
760+
)
761+
);
762+
742763
const transactionParams = {
743764
...params,
744765
keychain: keychains[0],
@@ -751,8 +772,6 @@ export class Wallet implements IWallet {
751772
allowNonSegwitSigningWithoutPrevTx: !!params.bulk,
752773
};
753774

754-
const txPrebuilds = Array.isArray(buildResponse) ? buildResponse : [buildResponse];
755-
756775
const selectParams = _.pick(params, ['comment', 'otp', 'bulk']);
757776

758777
const response = await Promise.all(

0 commit comments

Comments
 (0)