diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts b/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts deleted file mode 100644 index 395476735a..0000000000 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/btc.ts +++ /dev/null @@ -1,260 +0,0 @@ -import 'should'; -import assert from 'assert'; - -import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; -import { BitGoAPI, encrypt } from '@bitgo/sdk-api'; -import * as utxolib from '@bitgo/utxo-lib'; -import { Wallet } from '@bitgo/sdk-core'; - -import { Tbtc } from '../../../../../src/impl/btc'; - -import { btcBackupKey } from './fixtures'; - -describe('BTC:', () => { - let bitgo: TestBitGoAPI; - - before(() => { - bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgo.safeRegister('tbtc', Tbtc.createInstance); - bitgo.initializeTestVars(); - }); - - describe('Address validation:', () => { - let coin: Tbtc; - before(() => { - coin = bitgo.coin('tbtc') as Tbtc; - }); - - it('should validate a base58 address', () => { - const validBase58Address = '2Mv1fGp8gHSqsiXYG7WqcYmHZdurDGVtUbn'; - coin.isValidAddress(validBase58Address).should.be.true(); - const invalidBase58Address = '2MV1FGP8GHSQSSXYG7WQCYMHZDURDGVTUBN'; - coin.isValidAddress(invalidBase58Address).should.be.false(); - }); - - it('should validate a bech32 address', () => { - const validBech32Address = 'tb1qtxxqmkkdx4n4lcp0nt2cct89uh3h3dlcu940kw9fcqyyq36peh0st94hfp'; - coin.isValidAddress(validBech32Address).should.be.true(); - coin.isValidAddress(validBech32Address.toUpperCase()).should.be.false(); - }); - - it('should validate a bech32m address', () => { - // https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki#Test_vectors_for_Bech32m - const validBech32mAddress = 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7'; - coin.isValidAddress(validBech32mAddress).should.be.true(); - coin.isValidAddress(validBech32mAddress.toUpperCase()).should.be.false(); - }); - }); - - describe('Post Build Validation', () => { - let coin: Tbtc; - before(() => { - coin = bitgo.coin('tbtc') as Tbtc; - }); - - it('should not modify locktime on postProcessPrebuild', async () => { - const txHex = - '0100000001a8ec78f09f7acb0d344622ed3082c1a98e51ba1b1ab65406044f6e0a801609020100000000ffffffff02a0860100000000001976a9149f9a7abd600c0caa03983a77c8c3df8e062cb2fa88acfbf2150000000000220020b922cc1e737e679d24ff2d2b18cfa9fff4e35a733b4fba94282eaa1b7cfe56d200000000'; - const blockHeight = 100; - const preBuild = { txHex, blockHeight }; - const postProcessBuilt = await coin.postProcessPrebuild(preBuild); - const transaction = utxolib.bitgo.createTransactionFromHex( - postProcessBuilt.txHex as string, - utxolib.networks.bitcoin - ); - - transaction.locktime.should.equal(0); - const inputs = transaction.ins; - for (const input of inputs) { - input.sequence.should.equal(0xffffffff); - } - }); - }); - - describe('Audit Key', () => { - const { key } = btcBackupKey; - let coin: Tbtc; - before(() => { - coin = bitgo.coin('tbtc') as Tbtc; - }); - - it('should return for valid inputs', () => { - coin.assertIsValidKey({ - encryptedPrv: key, - walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', - }); - }); - - it('should throw error if the walletPassphrase is incorrect', () => { - assert.throws( - () => - coin.assertIsValidKey({ - encryptedPrv: key, - walletPassphrase: 'foo', - }), - { message: "failed to decrypt prv: ccm: tag doesn't match" } - ); - }); - - it('should return throw if the key is altered', () => { - const alteredKey = key.replace(/[0-9]/g, '0'); - assert.throws( - () => - coin.assertIsValidKey({ - encryptedPrv: alteredKey, - walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', - }), - { message: 'failed to decrypt prv: json decrypt: invalid parameters' } - ); - }); - }); - - describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', () => { - let coin: Tbtc; - let bitgoTest: TestBitGoAPI; - before(() => { - bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgoTest.safeRegister('tbtc', Tbtc.createInstance); - bitgoTest.initializeTestVars(); - coin = bitgoTest.coin('tbtc') as Tbtc; - }); - - it('should detect hex spoofing in BUILD_SIGN_SEND', async (): Promise => { - const keyTriple = utxolib.testutil.getKeyTriple('default'); - const rootWalletKey = new utxolib.bitgo.RootWalletKeys(keyTriple); - const [user] = keyTriple; - - const wallet = new Wallet(bitgoTest, coin, { - id: '5b34252f1bf349930e34020a', - coin: 'tbtc', - keys: ['user', 'backup', 'bitgo'], - }); - - const originalPsbt = utxolib.testutil.constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const - ); - utxolib.bitgo.addXpubsToPsbt(originalPsbt, rootWalletKey); - const spoofedPsbt = utxolib.testutil.constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const - ); - utxolib.bitgo.addXpubsToPsbt(spoofedPsbt, rootWalletKey); - const spoofedHex: string = spoofedPsbt.toHex(); - - const bgUrl: string = (bitgoTest as any)._baseUrl; - const nock = require('nock'); - - nock(bgUrl) - .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/consolidateUnspents`) - .reply(200, { txHex: spoofedHex, consolidateId: 'test' }); - - nock(bgUrl) - .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) - .reply((requestBody: any) => { - if (requestBody?.txHex === spoofedHex) { - throw new Error('Spoofed transaction was sent: spoofing protection failed'); - } - return [200, { txid: 'test-txid-123', status: 'signed' }]; - }); - - const pubs = keyTriple.map((k) => k.neutered().toBase58()); - const responses = [ - { pub: pubs[0], encryptedPrv: encrypt('pass', user.toBase58()) }, - { pub: pubs[1] }, - { pub: pubs[2] }, - ]; - wallet - .keyIds() - .forEach((id, i) => nock(bgUrl).get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i])); - - await assert.rejects( - wallet.consolidateUnspents({ walletPassphrase: 'pass' }), - (e: any) => - typeof e?.message === 'string' && - e.message.includes('prebuild attempts to spend to unintended external recipients') - ); - }); - }); - - describe('Unspent management spoofability - Fanout (BUILD_SIGN_SEND)', () => { - let coin: Tbtc; - let bitgoTest: TestBitGoAPI; - before(() => { - bitgoTest = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgoTest.safeRegister('tbtc', Tbtc.createInstance); - bitgoTest.initializeTestVars(); - coin = bitgoTest.coin('tbtc') as Tbtc; - }); - - it('should detect hex spoofing in fanout BUILD_SIGN_SEND', async (): Promise => { - const keyTriple = utxolib.testutil.getKeyTriple('default'); - const rootWalletKey = new utxolib.bitgo.RootWalletKeys(keyTriple); - const [user] = keyTriple; - - const wallet = new Wallet(bitgoTest, coin, { - id: '5b34252f1bf349930e34020a', - coin: 'tbtc', - keys: ['user', 'backup', 'bitgo'], - }); - - const originalPsbt = utxolib.testutil.constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const - ); - utxolib.bitgo.addXpubsToPsbt(originalPsbt, rootWalletKey); - - const spoofedPsbt = utxolib.testutil.constructPsbt( - [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], - [{ address: 'tb1pjgg9ty3s2ztp60v6lhgrw76f7hxydzuk9t9mjsndh3p2gf2ah7gs4850kn', value: BigInt(9000) }], - coin.network, - rootWalletKey, - 'unsigned' as const - ); - utxolib.bitgo.addXpubsToPsbt(spoofedPsbt, rootWalletKey); - const spoofedHex: string = spoofedPsbt.toHex(); - - const bgUrl: string = (bitgoTest as any)._baseUrl; - const nock = require('nock'); - - nock(bgUrl) - .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/fanoutUnspents`) - .reply(200, { txHex: spoofedHex, fanoutId: 'test' }); - - nock(bgUrl) - .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) - .reply((requestBody: any) => { - if (requestBody?.txHex === spoofedHex) { - throw new Error('Spoofed transaction was sent: spoofing protection failed'); - } - return [200, { txid: 'test-txid-123', status: 'signed' }]; - }); - - const pubs = keyTriple.map((k) => k.neutered().toBase58()); - const responses = [ - { pub: pubs[0], encryptedPrv: encrypt('pass', user.toBase58()) }, - { pub: pubs[1] }, - { pub: pubs[2] }, - ]; - wallet - .keyIds() - .forEach((id, i) => nock(bgUrl).get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i])); - - await assert.rejects( - wallet.fanoutUnspents({ walletPassphrase: 'pass' }), - (e: any) => - typeof e?.message === 'string' && - e.message.includes('prebuild attempts to spend to unintended external recipients') - ); - }); - }); -}); diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts b/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts deleted file mode 100644 index 2d461c704b..0000000000 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/btcBackupKey.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const btcBackupKey = { - key: - '{"iv":"JgqqE4W45/tKBSMSYqD+qg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"' + - ':"ccm","adata":"","cipher":"aes","salt":"kiLPf8VSdI0=","ct":"zUh4Oko/06g02E' + - 'wnqOfzJbTwtE2p3b19jDk8Tum07Jv3N/RP7Bo0w/ObLBO1uIJFossO3nJ1JS+7t/vPQhdCtN8oD' + - '6YrZnEZYrRwN6JQkL1uYPnZ1PoWbYI9navK5CLU1KQwDTO9YEN46++OrzFH+CjpQVLblaw="}', -}; diff --git a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts b/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts deleted file mode 100644 index 2cccced5fe..0000000000 --- a/modules/abstract-utxo/test/unit/impl/btc/unit/fixtures/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './btcBackupKey'; diff --git a/modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts b/modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts deleted file mode 100644 index 43d0cc1266..0000000000 --- a/modules/abstract-utxo/test/unit/impl/ltc/unit/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import 'should'; - -import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; -import { BitGoAPI } from '@bitgo/sdk-api'; - -import { Ltc, Tltc } from '../../../../../src/impl/ltc'; - -describe('Litecoin:', function () { - const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' }); - bitgo.initializeTestVars(); - bitgo.safeRegister('ltc', Ltc.createInstance); - bitgo.safeRegister('tltc', Tltc.createInstance); - - const ltc = bitgo.coin('ltc') as Ltc; - const tltc = bitgo.coin('tltc') as Tltc; - - describe('should validate addresses', () => { - it('should validate base58 addresses', () => { - // known valid main and testnet base58 address are valid - ltc.isValidAddress('MH6J1PzpsAfapZek7QGHv2mheUxnP8Kdek').should.be.true(); - tltc.isValidAddress('QWC1miKKHFikbwg2iyt8KZBGsTSEBKr21i').should.be.true(); - // malformed base58 addresses are invalid - ltc.isValidAddress('MH6J1PzpsAfapZek7QGHv2mheUxnP8Kder').should.be.false(); - tltc.isValidAddress('QWC1miKKHFikbwg2iyt8KZBGsTSEBKr21l').should.be.false(); - }); - it('should validate bech32 addresses', () => { - // all lower case is valid - ltc.isValidAddress('ltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3wspfryg7').should.be.true(); - tltc.isValidAddress('tltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3ws2x86ht').should.be.true(); - // all upper case is valid - ltc.isValidAddress('LTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WSPFRYG7').should.be.false(); - tltc.isValidAddress('TLTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WS2X86HT').should.be.false(); - // mixed case is invalid - ltc.isValidAddress('LTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WSPFRYg7').should.be.false(); - tltc.isValidAddress('TLTC1QQ7FZT3EK5EGE3V92WH0Q6WZCJR39PQSWLPE36MU28F6YUFARK3WS2X86Ht').should.be.false(); - // malformed addresses are invalid - ltc.isValidAddress('ltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3wspfryg9').should.be.false(); - tltc.isValidAddress('tltc1qq7fzt3ek5ege3v92wh0q6wzcjr39pqswlpe36mu28f6yufark3ws2x86hl').should.be.false(); - }); - }); -}); diff --git a/modules/abstract-utxo/test/unit/keychains.ts b/modules/abstract-utxo/test/unit/keychains.ts index 321288c37b..74a37b07b5 100644 --- a/modules/abstract-utxo/test/unit/keychains.ts +++ b/modules/abstract-utxo/test/unit/keychains.ts @@ -1,7 +1,11 @@ import * as assert from 'assert'; import 'should'; +import { type TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test'; +import { BitGoAPI } from '@bitgo/sdk-api'; + import { AbstractUtxoCoin } from '../../src'; +import { Tbtc } from '../../src/impl/btc'; import { utxoCoins } from './util'; @@ -16,3 +20,54 @@ function run(coin: AbstractUtxoCoin) { } utxoCoins.forEach((c) => run(c)); + +describe('Audit Key', function () { + // Encrypted backup key fixture for testing assertIsValidKey + const btcBackupKey = { + key: + '{"iv":"JgqqE4W45/tKBSMSYqD+qg==","v":1,"iter":10000,"ks":256,"ts":64,"mode"' + + ':"ccm","adata":"","cipher":"aes","salt":"kiLPf8VSdI0=","ct":"zUh4Oko/06g02E' + + 'wnqOfzJbTwtE2p3b19jDk8Tum07Jv3N/RP7Bo0w/ObLBO1uIJFossO3nJ1JS+7t/vPQhdCtN8oD' + + '6YrZnEZYrRwN6JQkL1uYPnZ1PoWbYI9navK5CLU1KQwDTO9YEN46++OrzFH+CjpQVLblaw="}', + }; + + let bitgo: TestBitGoAPI; + let coin: Tbtc; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); + bitgo.safeRegister('tbtc', Tbtc.createInstance); + bitgo.initializeTestVars(); + coin = bitgo.coin('tbtc') as Tbtc; + }); + + it('should return for valid inputs', function () { + coin.assertIsValidKey({ + encryptedPrv: btcBackupKey.key, + walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', + }); + }); + + it('should throw error if the walletPassphrase is incorrect', function () { + assert.throws( + () => + coin.assertIsValidKey({ + encryptedPrv: btcBackupKey.key, + walletPassphrase: 'foo', + }), + { message: "failed to decrypt prv: ccm: tag doesn't match" } + ); + }); + + it('should throw if the key is altered', function () { + const alteredKey = btcBackupKey.key.replace(/[0-9]/g, '0'); + assert.throws( + () => + coin.assertIsValidKey({ + encryptedPrv: alteredKey, + walletPassphrase: 'kAm[EFQ6o=SxlcLFDw%,', + }), + { message: 'failed to decrypt prv: json decrypt: invalid parameters' } + ); + }); +}); diff --git a/modules/abstract-utxo/test/unit/postProcessPrebuild.ts b/modules/abstract-utxo/test/unit/postProcessPrebuild.ts new file mode 100644 index 0000000000..f827fda821 --- /dev/null +++ b/modules/abstract-utxo/test/unit/postProcessPrebuild.ts @@ -0,0 +1,42 @@ +import 'should'; + +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; +import * as testutils from '@bitgo/wasm-utxo/testutils'; + +import { constructPsbt, getWalletAddress, getUtxoCoin } from './util'; + +const { BitGoPsbt } = fixedScriptWallet; + +describe('Post Build Validation', function () { + const coin = getUtxoCoin('tbtc'); + + it('should not modify locktime on postProcessPrebuild', async function () { + const walletKeys = testutils.getDefaultWalletKeys(); + const walletAddress = getWalletAddress('tbtc', walletKeys); + + // Create a PSBT with lockTime=0 and sequence=0xffffffff + const psbt = constructPsbt( + [{ scriptType: 'p2wsh' as const, value: BigInt(100000) }], + [{ address: walletAddress, value: BigInt(90000) }], + 'tbtc', + walletKeys, + { lockTime: 0, sequence: 0xffffffff } + ); + + const txHex = Buffer.from(psbt.serialize()).toString('hex'); + const blockHeight = 100; + const preBuild = { txHex, blockHeight }; + const postProcessBuilt = await coin.postProcessPrebuild(preBuild); + + // Parse result as PSBT + const resultPsbt = BitGoPsbt.fromBytes(Buffer.from(postProcessBuilt.txHex as string, 'hex'), 'tbtc'); + + resultPsbt.lockTime.should.equal(0); + + // Check sequences via parseTransactionWithWalletKeys + const parsed = resultPsbt.parseTransactionWithWalletKeys(walletKeys, { publicKeys: [] }); + for (const input of parsed.inputs) { + input.sequence.should.equal(0xffffffff); + } + }); +}); diff --git a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts index 5a6b5244ce..84bd20f74b 100644 --- a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts @@ -8,11 +8,11 @@ import { BIP32, ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin, backupKeyRecoveryWithWalletUnspents } from '../../../src'; import type { WalletUnspent } from '../../../src/unspent'; import { + createWasmWalletKeys, getDefaultWasmWalletKeys, getFixture, getNormalTestnetCoin, getWalletAddress, - getWalletKeys, toUnspent, utxoCoins, } from '../util'; @@ -48,7 +48,7 @@ function run( const defaultFeeRateSatB = 100; describe(`Backup Key Recovery PSBT [${[coin.getChain(), ...tags].join(',')}]`, function () { - const externalWallet = getWalletKeys('external'); + const { walletKeys: externalWallet } = createWasmWalletKeys('external'); const recoveryDestination = getWalletAddress(coin.name, externalWallet); const fixtureCoin = getNormalTestnetCoin(coin); // Get xpubs from wallet keys diff --git a/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts b/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts index a8e76817ea..6d915f1086 100644 --- a/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts +++ b/modules/abstract-utxo/test/unit/recovery/backupKeyRecoveryUnspentGathering.ts @@ -13,12 +13,12 @@ import { } from '../../../src'; import type { Unspent } from '../../../src/unspent'; import { + createWasmWalletKeys, defaultBitGo, encryptKeychain, getDefaultWasmWalletKeys, getMinUtxoCoins, getWalletAddress, - getWalletKeys, keychainsBase58, toUnspentWithPrevTx, WalletUnspentWithPrevTx, @@ -79,7 +79,7 @@ describe('Backup Key Recovery - Unspent Gathering', function () { getMinUtxoCoins().forEach((coin) => { describe(`Unspent Gathering [${coin.getChain()}]`, function () { - const externalWallet = getWalletKeys('external'); + const { walletKeys: externalWallet } = createWasmWalletKeys('external'); const recoveryDestination = getWalletAddress(coin.name, externalWallet); before('mock', function () { diff --git a/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts b/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts index 26d526382a..6b783624e7 100644 --- a/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts +++ b/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts @@ -10,10 +10,10 @@ import { } from '../../../src'; import type { WalletUnspent } from '../../../src/unspent'; import { + createWasmWalletKeys, getDefaultWasmWalletKeys, getFixture, getWalletAddress, - getWalletKeys, shouldEqualJSON, toUnspent, utxoCoins, @@ -52,7 +52,7 @@ function clonePsbt(psbt: fixedScriptWallet.BitGoPsbt): fixedScriptWallet.BitGoPs } describe('formatBackupKeyRecoveryResult', function () { - const externalWallet = getWalletKeys('external'); + const { walletKeys: externalWallet } = createWasmWalletKeys('external'); const recoveryDestination = getWalletAddress(coin.name, externalWallet); const feeRateSatVB = 100; diff --git a/modules/abstract-utxo/test/unit/testSpoofTransaction.ts b/modules/abstract-utxo/test/unit/testSpoofTransaction.ts new file mode 100644 index 0000000000..ac51335553 --- /dev/null +++ b/modules/abstract-utxo/test/unit/testSpoofTransaction.ts @@ -0,0 +1,106 @@ +import assert from 'assert'; + +import * as testutils from '@bitgo/wasm-utxo/testutils'; +import { Wallet } from '@bitgo/sdk-core'; + +import { constructPsbt, getWalletAddress, getUtxoCoin, defaultBitGo, nockBitGo, nockWalletKeys } from './util'; + +describe('Transaction Spoofability Tests', function () { + const coin = getUtxoCoin('tbtc'); + + describe('Unspent management spoofability - Consolidation (BUILD_SIGN_SEND)', function () { + it('should detect spoofed consolidation to attacker address', async function (): Promise { + const keyTriple = testutils.getKeyTriple('default'); + const walletKeys = testutils.getDefaultWalletKeys(); + const attackerKeys = testutils.getWalletKeysForSeed('attacker'); + + const wallet = new Wallet(defaultBitGo, coin, { + id: '5b34252f1bf349930e34020a', + coin: 'tbtc', + keys: ['user', 'backup', 'bitgo'], + }); + + // The attacker replaces the legitimate wallet address with their own + const attackerAddress = getWalletAddress('tbtc', attackerKeys); + const spoofedPsbt = constructPsbt( + [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], + [{ address: attackerAddress, value: BigInt(9000) }], + 'tbtc', + walletKeys // Input uses wallet keys (the funds being stolen) + ); + const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); + + nockBitGo() + .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/consolidateUnspents`) + .reply(200, { txHex: spoofedHex, consolidateId: 'test' }); + + nockBitGo() + .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .reply((requestBody: any) => { + if (requestBody?.txHex === spoofedHex) { + throw new Error('Spoofed transaction was sent: spoofing protection failed'); + } + return [200, { txid: 'test-txid-123', status: 'signed' }]; + }); + + nockWalletKeys(wallet, keyTriple, 'pass'); + + await assert.rejects( + wallet.consolidateUnspents({ walletPassphrase: 'pass' }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => + typeof e?.message === 'string' && + e.message.includes('prebuild attempts to spend to unintended external recipients') + ); + }); + }); + + describe('Unspent management spoofability - Fanout (BUILD_SIGN_SEND)', function () { + it('should detect spoofed fanout to attacker address', async function (): Promise { + const keyTriple = testutils.getKeyTriple('default'); + const walletKeys = testutils.getDefaultWalletKeys(); + const attackerKeys = testutils.getWalletKeysForSeed('attacker'); + + const wallet = new Wallet(defaultBitGo, coin, { + id: '5b34252f1bf349930e34020a', + coin: 'tbtc', + keys: ['user', 'backup', 'bitgo'], + }); + + // The attacker replaces the legitimate wallet address with their own + const attackerAddress = getWalletAddress('tbtc', attackerKeys); + const spoofedPsbt = constructPsbt( + [{ scriptType: 'p2wsh' as const, value: BigInt(10000) }], + [{ address: attackerAddress, value: BigInt(9000) }], + 'tbtc', + walletKeys // Input uses wallet keys (the funds being stolen) + ); + const spoofedHex: string = Buffer.from(spoofedPsbt.serialize()).toString('hex'); + + nockBitGo() + .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/fanoutUnspents`) + .reply(200, { txHex: spoofedHex, fanoutId: 'test' }); + + nockBitGo() + .post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/send`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .reply((requestBody: any) => { + if (requestBody?.txHex === spoofedHex) { + throw new Error('Spoofed transaction was sent: spoofing protection failed'); + } + return [200, { txid: 'test-txid-123', status: 'signed' }]; + }); + + nockWalletKeys(wallet, keyTriple, 'pass'); + + await assert.rejects( + wallet.fanoutUnspents({ walletPassphrase: 'pass' }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: any) => + typeof e?.message === 'string' && + e.message.includes('prebuild attempts to spend to unintended external recipients') + ); + }); + }); +}); diff --git a/modules/abstract-utxo/test/unit/util/address.ts b/modules/abstract-utxo/test/unit/util/address.ts new file mode 100644 index 0000000000..21cabb1c4a --- /dev/null +++ b/modules/abstract-utxo/test/unit/util/address.ts @@ -0,0 +1,24 @@ +import * as utxolib from '@bitgo/utxo-lib'; +import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; + +const { ChainCode } = fixedScriptWallet; + +type UtxolibRootWalletKeys = utxolib.bitgo.RootWalletKeys; +type WasmRootWalletKeys = fixedScriptWallet.RootWalletKeys; +type RootWalletKeys = UtxolibRootWalletKeys | WasmRootWalletKeys; + +const defaultChain = ChainCode.value('p2sh', 'external'); + +/** + * Generate a wallet address from RootWalletKeys. + * Supports both utxolib and wasm-utxo RootWalletKeys. + * Utxolib keys are converted to wasm-utxo keys for address generation. + */ +export function getWalletAddress( + coinName: CoinName, + walletKeys: RootWalletKeys, + chain = defaultChain, + index = 0 +): string { + return fixedScriptWallet.address(walletKeys, chain, index, coinName); +} diff --git a/modules/abstract-utxo/test/unit/util/index.ts b/modules/abstract-utxo/test/unit/util/index.ts index 6063354ca6..e4e8f85b6d 100644 --- a/modules/abstract-utxo/test/unit/util/index.ts +++ b/modules/abstract-utxo/test/unit/util/index.ts @@ -4,3 +4,6 @@ export * from './keychains'; export * from './wallet'; export * from './unspents'; export * from './transaction'; +export * from './psbt'; +export * from './address'; +export * from './nockBitGo'; diff --git a/modules/abstract-utxo/test/unit/util/keychains.ts b/modules/abstract-utxo/test/unit/util/keychains.ts index b55636e025..a7f9ad5ee4 100644 --- a/modules/abstract-utxo/test/unit/util/keychains.ts +++ b/modules/abstract-utxo/test/unit/util/keychains.ts @@ -1,7 +1,7 @@ import { Triple } from '@bitgo/sdk-core'; import { encrypt } from '@bitgo/sdk-api'; import { getSeed } from '@bitgo/sdk-test'; -import { bip32, BIP32Interface, bitgo, testutil } from '@bitgo/utxo-lib'; +import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib'; import { BIP32 as WasmBIP32, fixedScriptWallet } from '@bitgo/wasm-utxo'; type RootWalletKeys = bitgo.RootWalletKeys; @@ -93,13 +93,16 @@ export function getWalletKeys(seed: string): RootWalletKeys { /** * Create test wallet keys from a seed string. - * Returns xpubs and xprivs as base58 strings. + * Uses the same seed generation as getWalletKeys (getSeed('seed/i')) + * to ensure compatibility with existing test fixtures. */ export function createTestWalletKeys(seed: string): { xpubs: Triple; xprivs: Triple; } { - const keys = testutil.getKeyTriple(seed); + const keys = Array.from({ length: 3 }).map((_, i) => + bip32.fromSeed(getSeed(`${seed}/${i}`)) + ) as Triple; return { xpubs: keys.map((k) => k.neutered().toBase58()) as Triple, xprivs: keys.map((k) => k.toBase58()) as Triple, diff --git a/modules/abstract-utxo/test/unit/util/nockBitGo.ts b/modules/abstract-utxo/test/unit/util/nockBitGo.ts index cd4cbd7983..eec60d50d5 100644 --- a/modules/abstract-utxo/test/unit/util/nockBitGo.ts +++ b/modules/abstract-utxo/test/unit/util/nockBitGo.ts @@ -1,5 +1,7 @@ import nock = require('nock'); -import { Environment, Environments } from '@bitgo/sdk-core'; +import { encrypt } from '@bitgo/sdk-api'; +import { Environment, Environments, Wallet } from '@bitgo/sdk-core'; +import { BIP32, Triple } from '@bitgo/wasm-utxo'; import { defaultBitGo } from './utxoCoins'; @@ -7,3 +9,18 @@ export function nockBitGo(bitgo = defaultBitGo): nock.Scope { const env = Environments[bitgo.getEnv()] as Environment; return nock(env.uri); } + +/** + * Mock the key fetching endpoints for a wallet. + * Sets up nock to return the key triple with the user key encrypted. + */ +export function nockWalletKeys(wallet: Wallet, keyTriple: Triple, userPassphrase: string): void { + const [user] = keyTriple; + const pubs = keyTriple.map((k) => k.neutered().toBase58()); + const responses = [ + { pub: pubs[0], encryptedPrv: encrypt(userPassphrase, user.toBase58()) }, + { pub: pubs[1] }, + { pub: pubs[2] }, + ]; + wallet.keyIds().forEach((id, i) => nockBitGo().get(`/api/v2/${wallet.coin()}/key/${id}`).reply(200, responses[i])); +} diff --git a/modules/abstract-utxo/test/unit/util/psbt.ts b/modules/abstract-utxo/test/unit/util/psbt.ts new file mode 100644 index 0000000000..a50aed7d1f --- /dev/null +++ b/modules/abstract-utxo/test/unit/util/psbt.ts @@ -0,0 +1,36 @@ +import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; + +const { BitGoPsbt, ChainCode } = fixedScriptWallet; + +export type PsbtInput = { scriptType: fixedScriptWallet.OutputScriptType; value: bigint }; +export type PsbtOutput = { address: string; value: bigint }; +export type PsbtOptions = { lockTime?: number; sequence?: number }; + +/** + * Construct a test PSBT with the given inputs and outputs. + * Uses dummy txids (all zeros) for inputs. + */ +export function constructPsbt( + inputs: PsbtInput[], + outputs: PsbtOutput[], + network: CoinName, + walletKeys: fixedScriptWallet.RootWalletKeys, + options?: PsbtOptions +): fixedScriptWallet.BitGoPsbt { + const psbt = BitGoPsbt.createEmpty(network, walletKeys, { lockTime: options?.lockTime }); + + inputs.forEach((input, index) => { + const chain = ChainCode.value(input.scriptType, 'external'); + psbt.addWalletInput( + { txid: '00'.repeat(32), vout: index, value: input.value, sequence: options?.sequence }, + walletKeys, + { scriptId: { chain, index }, signPath: { signer: 'user', cosigner: 'bitgo' } } + ); + }); + + outputs.forEach((output) => { + psbt.addOutput(output.address, output.value); + }); + + return psbt; +} diff --git a/modules/abstract-utxo/test/unit/util/unspents.ts b/modules/abstract-utxo/test/unit/util/unspents.ts index c02734cd9a..c303a90139 100644 --- a/modules/abstract-utxo/test/unit/util/unspents.ts +++ b/modules/abstract-utxo/test/unit/util/unspents.ts @@ -6,7 +6,9 @@ import { getReplayProtectionAddresses } from '../../../src'; import { getCoinName, isUtxoCoinName, type UtxoCoinName } from '../../../src/names'; import type { Unspent, UnspentWithPrevTx, WalletUnspent } from '../../../src/unspent'; -const { scriptTypeForChain, chainCodesP2sh, getExternalChainCode, getInternalChainCode } = utxolib.bitgo; +import { getWalletAddress } from './address'; + +const { chainCodesP2sh, getExternalChainCode, getInternalChainCode } = utxolib.bitgo; export type UtxolibRootWalletKeys = utxolib.bitgo.RootWalletKeys; export type WasmRootWalletKeys = wasmUtxo.fixedScriptWallet.RootWalletKeys; @@ -38,41 +40,6 @@ export type Input = { const defaultChain: ChainCode = getExternalChainCode(chainCodesP2sh); -/** - * Check if walletKeys is wasm-utxo RootWalletKeys. - */ -function isWasmRootWalletKeys(walletKeys: RootWalletKeys): walletKeys is WasmRootWalletKeys { - return walletKeys instanceof wasmUtxo.fixedScriptWallet.RootWalletKeys; -} - -export function getOutputScript( - walletKeys: UtxolibRootWalletKeys, - chain = defaultChain, - index = 0 -): utxolib.bitgo.outputScripts.SpendableScript { - return utxolib.bitgo.outputScripts.createOutputScript2of3( - walletKeys.deriveForChainAndIndex(chain, index).publicKeys, - scriptTypeForChain(chain) - ); -} - -export function getWalletAddress( - network: NetworkArg, - walletKeys: RootWalletKeys, - chain = defaultChain, - index = 0 -): string { - const coinName = toCoinName(network); - - // Use wasm-utxo address generation for wasm-utxo RootWalletKeys - if (isWasmRootWalletKeys(walletKeys)) { - return wasmUtxo.fixedScriptWallet.address(walletKeys, chain, index, coinName); - } - - // For utxolib RootWalletKeys, generate address from output script - return wasmUtxo.address.fromOutputScriptWithCoin(getOutputScript(walletKeys, chain, index).scriptPubKey, coinName); -} - function mockOutputIdForAddress(address: string) { return getSeed(address).toString('hex') + ':1'; } @@ -88,7 +55,7 @@ export function mockWalletUnspent( if (chain === undefined) { throw new Error(`unspent chain must be set`); } - const deriveAddress = getWalletAddress(network, walletKeys, chain, index); + const deriveAddress = getWalletAddress(toCoinName(network), walletKeys, chain, index); if (address) { if (address !== deriveAddress) { throw new Error(`derivedAddress mismatch: ${address} derived=${deriveAddress}`); @@ -288,7 +255,7 @@ export function toUnspentWithPrevTx( const { prevTx, txid } = createMockPrevTx(0, outputScript, input.value); // Get the wallet address - const address = getWalletAddress(network, rootWalletKeys, chain, index); + const address = getWalletAddress(toCoinName(network), rootWalletKeys, chain, index); // Use the actual txid from the prevTx in the unspent id return {