Skip to content

Commit 6c4ab26

Browse files
committed
feat(abstract-utxo): add safe signing helpers for external PSBTs
Add signExternalPsbt and assertExternalPsbtSighashPolicy to the recovery module. signExternalPsbt signs an externally supplied (untrusted) PSBT with a single key under the SIGHASH_ALL-only policy: it enforces the wasm-utxo sighash policy before signing, verifies the output set is byte-identical to a pre-signing snapshot, re-checks the policy on the re-parsed serialized result, and determines the signed inputs by verifying the signer's signature on each input (BitGoPsbt.sign reports attempted inputs, so a key matching no input still returns indexes). assertExternalPsbtSighashPolicy runs the policy check alone, for callers that hand the PSBT to a different signer (e.g. the SDK signer). Why: wallet recovery tooling signs externally pasted PSBTs with user keys, and a foreign PSBT can request SIGHASH_NONE/SINGLE/ANYONECANPAY per input so the user signature does not bind to the recipient output - the output-swap drain from WCN-1994. Consumers (wallet-recovery-wizard) get the complete policy from the SDK instead of reimplementing it, with network-aware rules (BCH FORKID, Taproot SIGHASH_DEFAULT) owned by wasm-utxo. Requires the wasm-utxo sighash policy primitives; the yarn.lock bump to the published wasm-utxo release follows after the BitGoWASM PR lands. Ticket: WCN-1994 Session-Id: 5c05e047-31d0-43fa-91ab-f1c595b37850 Task-Id: f7cdbdd7-f9f0-4164-a69b-0c7e58e609a8
1 parent fea1c02 commit 6c4ab26

3 files changed

Lines changed: 365 additions & 0 deletions

File tree

‎modules/abstract-utxo/src/recovery/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './coingeckoApi';
55
export * from './crossChainRecovery';
66
export * from './mempoolApi';
77
export * from './safeRecovery';
8+
export * from './signExternalPsbt';
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { BIP32, bip32, fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo';
2+
3+
import { toWasmUtxoCoinName, type UtxoCoinName } from '../names';
4+
5+
/**
6+
* The network an externally supplied PSBT is signed on: a wasm-utxo coin
7+
* name or an SDK UTXO coin name (normalized with toWasmUtxoCoinName).
8+
*/
9+
export type ExternalPsbtCoinName = CoinName | UtxoCoinName;
10+
11+
/** The result of signing an externally supplied PSBT. */
12+
export type SignedExternalPsbt = {
13+
/** The signed PSBT, serialized as hex. */
14+
psbtHex: string;
15+
/** The input indexes that carry a valid signature by the signer key. */
16+
signedInputIndexes: number[];
17+
};
18+
19+
type PsbtOutput = { script: Uint8Array; value: bigint };
20+
21+
/**
22+
* A key that can sign an externally supplied PSBT: a base58-encoded xprv, a
23+
* wasm-utxo BIP32/WasmBIP32 instance, or a BIP32Interface-compatible
24+
* (utxolib) key. Normalized with BIP32.from.
25+
*/
26+
export type ExternalPsbtSigner = bip32.BIP32Arg;
27+
28+
function snapshotOutputs(psbt: fixedScriptWallet.BitGoPsbt): PsbtOutput[] {
29+
return psbt.getOutputs().map(({ script, value }) => ({ script: Buffer.from(script), value }));
30+
}
31+
32+
function assertOutputsUnchanged(expected: PsbtOutput[], actual: PsbtOutput[]): void {
33+
if (
34+
expected.length !== actual.length ||
35+
expected.some(
36+
(output, index) =>
37+
output.value !== actual[index].value || !Buffer.from(output.script).equals(Buffer.from(actual[index].script))
38+
)
39+
) {
40+
throw new Error('PSBT outputs changed after signing');
41+
}
42+
}
43+
44+
/**
45+
* Asserts the sighash policy for an externally supplied PSBT before handing it
46+
* to a signer.
47+
*
48+
* Every input must commit to the entire transaction: the declared per-input
49+
* sighash type (BIP-174 PSBT_IN_SIGHASH_TYPE) and every signature already
50+
* present in the PSBT must be SIGHASH_ALL (or the network's full-commitment
51+
* equivalent: SIGHASH_ALL|SIGHASH_FORKID on BCH-family coins, SIGHASH_DEFAULT
52+
* or SIGHASH_ALL on Taproot inputs). An absent sighash type is accepted and
53+
* uses the signer default. SIGHASH_NONE, SIGHASH_SINGLE, SIGHASH_ANYONECANPAY,
54+
* and combinations thereof are rejected because signatures produced under
55+
* them do not bind the signer to the transaction outputs — see WCN-1994.
56+
*
57+
* @param psbtHex - The externally supplied PSBT, hex-encoded
58+
* @param coinName - The network the PSBT is signed on
59+
* @throws Error naming the offending input if the PSBT violates the policy
60+
*/
61+
export function assertExternalPsbtSighashPolicy(psbtHex: string, coinName: ExternalPsbtCoinName): void {
62+
fixedScriptWallet.BitGoPsbt.fromBytes(
63+
Buffer.from(psbtHex, 'hex'),
64+
toWasmUtxoCoinName(coinName)
65+
).assertSighashAllPolicy();
66+
}
67+
68+
/**
69+
* Signs an externally supplied (untrusted) PSBT with a single signer key under
70+
* the SIGHASH_ALL-only policy.
71+
*
72+
* A foreign PSBT controls its own per-input sighash types, so signing it
73+
* blindly lets the PSBT author request SIGHASH_NONE/SINGLE/ANYONECANPAY and
74+
* produce a signature that does not bind the signer to the outputs — the
75+
* output-swap drain demonstrated in WCN-1994. This helper closes that hole
76+
* by enforcing, in order:
77+
*
78+
* 1. the sighash policy before signing (see {@link assertExternalPsbtSighashPolicy});
79+
* 2. that signing leaves the output set byte-identical to the snapshot taken
80+
* before signing;
81+
* 3. the sighash policy again on the re-parsed serialized result, so every
82+
* signature in the exported PSBT commits to the entire transaction;
83+
* 4. that the signer's signature cryptographically validates on the re-parsed
84+
* result for at least one input (BitGoPsbt.sign reports every attempted
85+
* input, including inputs the key does not match, so signed inputs are
86+
* determined by verifying the signatures).
87+
*
88+
* @param psbtHex - The externally supplied PSBT, hex-encoded
89+
* @param coinName - The network the PSBT is signed on
90+
* @param signer - The signer key (xprv)
91+
* @returns The signed PSBT hex and the input indexes that carry a valid
92+
* signature by the signer key
93+
* @throws Error if the PSBT violates the sighash policy, the outputs changed
94+
* during signing, or the signer key produced no valid signature
95+
*/
96+
export function signExternalPsbt(
97+
psbtHex: string,
98+
coinName: ExternalPsbtCoinName,
99+
signer: ExternalPsbtSigner
100+
): SignedExternalPsbt {
101+
const wasmCoinName = toWasmUtxoCoinName(coinName);
102+
const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(Buffer.from(psbtHex, 'hex'), wasmCoinName);
103+
psbt.assertSighashAllPolicy();
104+
105+
const signerBIP32 = BIP32.from(signer);
106+
const expectedOutputs = snapshotOutputs(psbt);
107+
108+
psbt.sign(signerBIP32);
109+
110+
const signedPsbtHex = Buffer.from(psbt.serialize()).toString('hex');
111+
// Re-parse the serialized bytes so the checks below run against exactly
112+
// what callers will export.
113+
const signedPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(Buffer.from(signedPsbtHex, 'hex'), wasmCoinName);
114+
115+
signedPsbt.assertSighashAllPolicy();
116+
assertOutputsUnchanged(expectedOutputs, signedPsbt.getOutputs());
117+
118+
const signerXpub = signerBIP32.neutered();
119+
const signedInputIndexes: number[] = [];
120+
for (let inputIndex = 0; inputIndex < signedPsbt.inputCount(); inputIndex++) {
121+
try {
122+
if (signedPsbt.verifySignature(inputIndex, signerXpub)) {
123+
signedInputIndexes.push(inputIndex);
124+
}
125+
} catch {
126+
// a malformed or non-matching signature counts as not signed
127+
}
128+
}
129+
if (signedInputIndexes.length === 0) {
130+
throw new Error('No PSBT inputs were signed with the signer key');
131+
}
132+
133+
return { psbtHex: signedPsbtHex, signedInputIndexes };
134+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import 'mocha';
2+
import assert from 'node:assert/strict';
3+
4+
import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo';
5+
import * as testutils from '@bitgo/wasm-utxo/testutils';
6+
7+
import { assertExternalPsbtSighashPolicy, signExternalPsbt } from '../../../src/recovery/signExternalPsbt';
8+
9+
const { BitGoPsbt, ChainCode } = fixedScriptWallet;
10+
const { getKeyTriple } = testutils;
11+
12+
const INPUT_VALUE = 100_000n;
13+
const RECIPIENT = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4';
14+
15+
const keychain = getKeyTriple('signExternalPsbt');
16+
const walletKeys = fixedScriptWallet.RootWalletKeys.from({
17+
triple: keychain,
18+
derivationPrefixes: ['m/0/0', 'm/0/0', 'm/0/0'],
19+
});
20+
const userKey = keychain[0];
21+
const userXprv = keychain[0].toBase58();
22+
23+
function createWalletPsbtHex(coinName: CoinName, inputCount: number, scriptType: 'p2sh' | 'p2wsh'): string {
24+
const psbt = BitGoPsbt.createEmpty(coinName, walletKeys, { version: 2, lockTime: 0 });
25+
const chain = ChainCode.value(scriptType, 'external');
26+
for (let inputIndex = 0; inputIndex < inputCount; inputIndex++) {
27+
psbt.addWalletInput(
28+
{
29+
txid: inputIndex.toString(16).padStart(2, '0').repeat(32),
30+
vout: inputIndex,
31+
value: INPUT_VALUE,
32+
},
33+
walletKeys,
34+
{
35+
scriptId: { chain, index: inputIndex },
36+
signPath: { signer: 'user', cosigner: 'bitgo' },
37+
}
38+
);
39+
}
40+
return Buffer.from(psbt.serialize()).toString('hex');
41+
}
42+
43+
function readCompactSize(bytes: Buffer, offset: number): [number, number] {
44+
const prefix = bytes[offset];
45+
if (prefix < 0xfd) return [prefix, offset + 1];
46+
if (prefix === 0xfd) return [bytes.readUInt16LE(offset + 1), offset + 3];
47+
if (prefix === 0xfe) return [bytes.readUInt32LE(offset + 1), offset + 5];
48+
return [Number(bytes.readBigUInt64LE(offset + 1)), offset + 9];
49+
}
50+
51+
/**
52+
* Rewrite (or remove) the BIP-174 PSBT_IN_SIGHASH_TYPE value of a single
53+
* input in serialized PSBT bytes, simulating a foreign PSBT crafted with an
54+
* attacker-chosen sighash type.
55+
*/
56+
function rewriteInputSighashType(psbtHex: string, inputIndex: number, sighashType: number | undefined): string {
57+
const bytes = Buffer.from(psbtHex, 'hex');
58+
let offset = 5;
59+
60+
function readMap(targetInput: boolean): Buffer | undefined {
61+
while (offset < bytes.length) {
62+
const entryStart = offset;
63+
const [keyLength, keyStart] = readCompactSize(bytes, offset);
64+
offset = keyStart;
65+
if (keyLength === 0) return undefined;
66+
67+
const keyType = keyLength === 1 ? bytes[offset] : -1;
68+
offset += keyLength;
69+
const [valueLength, valueStart] = readCompactSize(bytes, offset);
70+
offset = valueStart;
71+
const valueEnd = valueStart + valueLength;
72+
73+
if (targetInput && keyType === 0x03) {
74+
if (sighashType === undefined) {
75+
return Buffer.concat([bytes.subarray(0, entryStart), bytes.subarray(valueEnd)]);
76+
}
77+
if (valueLength !== 4) {
78+
throw new Error('Expected a four-byte PSBT sighash value');
79+
}
80+
bytes.writeUInt32LE(sighashType, valueStart);
81+
return bytes;
82+
}
83+
offset = valueEnd;
84+
}
85+
return undefined;
86+
}
87+
88+
readMap(false); // global map
89+
for (let index = 0; index <= inputIndex; index++) {
90+
const rewritten = readMap(index === inputIndex);
91+
if (rewritten) return rewritten.toString('hex');
92+
}
93+
throw new Error(`No sighash type found for input ${inputIndex}`);
94+
}
95+
96+
/**
97+
* Rewrite the sighash byte (the trailing byte) of the first PSBT_IN_PARTIAL_SIG
98+
* value of a single input, simulating a foreign PSBT that already carries a
99+
* signature under an unsafe sighash type.
100+
*/
101+
function rewritePartialSigSighashByte(psbtHex: string, inputIndex: number, sighashByte: number): string {
102+
const bytes = Buffer.from(psbtHex, 'hex');
103+
let offset = 5;
104+
105+
function readMap(targetInput: boolean): boolean {
106+
while (offset < bytes.length) {
107+
const [keyLength, keyStart] = readCompactSize(bytes, offset);
108+
offset = keyStart;
109+
if (keyLength === 0) return false;
110+
111+
const keyType = keyLength > 1 ? bytes[offset] : -1;
112+
offset += keyLength;
113+
const [valueLength, valueStart] = readCompactSize(bytes, offset);
114+
offset = valueStart;
115+
const valueEnd = valueStart + valueLength;
116+
117+
if (targetInput && keyType === 0x02) {
118+
bytes[valueEnd - 1] = sighashByte;
119+
return true;
120+
}
121+
offset = valueEnd;
122+
}
123+
return false;
124+
}
125+
126+
readMap(false); // global map
127+
for (let index = 0; index <= inputIndex; index++) {
128+
if (readMap(index === inputIndex)) {
129+
return bytes.toString('hex');
130+
}
131+
}
132+
throw new Error(`No partial signature found for input ${inputIndex}`);
133+
}
134+
135+
describe('signExternalPsbt', function () {
136+
it('signs every input and returns verified SIGHASH_ALL signatures', function () {
137+
const unsignedPsbtHex = createWalletPsbtHex('btc', 2, 'p2wsh');
138+
const { psbtHex, signedInputIndexes } = signExternalPsbt(unsignedPsbtHex, 'btc', userXprv);
139+
140+
assert.deepStrictEqual(signedInputIndexes, [0, 1]);
141+
142+
const signedPsbt = BitGoPsbt.fromBytes(Buffer.from(psbtHex, 'hex'), 'btc');
143+
for (const inputIndex of signedInputIndexes) {
144+
const partialSigs = signedPsbt
145+
.getInputKeyValues(inputIndex)
146+
.filter((keyValue) => keyValue.type === 'known' && keyValue.key === 'PSBT_IN_PARTIAL_SIG');
147+
assert.strictEqual(partialSigs.length, 1);
148+
assert.strictEqual(partialSigs[0].value[partialSigs[0].value.length - 1], 0x01);
149+
assert(signedPsbt.verifySignature(inputIndex, userKey.neutered()));
150+
}
151+
});
152+
153+
it('accepts BIP32 instances as the signer key', function () {
154+
const { signedInputIndexes } = signExternalPsbt(createWalletPsbtHex('btc', 1, 'p2wsh'), 'btc', userKey);
155+
assert.deepStrictEqual(signedInputIndexes, [0]);
156+
});
157+
158+
const unsafeSighashModes = [
159+
['SIGHASH_NONE', 0x02],
160+
['SIGHASH_SINGLE', 0x03],
161+
['SIGHASH_ANYONECANPAY', 0x80],
162+
['SIGHASH_ALL|ANYONECANPAY', 0x81],
163+
['SIGHASH_NONE|ANYONECANPAY', 0x82],
164+
['SIGHASH_SINGLE|ANYONECANPAY', 0x83],
165+
] as const;
166+
167+
for (const [name, sighashType] of unsafeSighashModes) {
168+
it(`rejects ${name} before signing`, function () {
169+
const psbtHex = rewriteInputSighashType(createWalletPsbtHex('btc', 1, 'p2wsh'), 0, sighashType);
170+
171+
assert.throws(() => assertExternalPsbtSighashPolicy(psbtHex, 'btc'), /Only SIGHASH_ALL/);
172+
assert.throws(() => signExternalPsbt(psbtHex, 'btc', userXprv), /Only SIGHASH_ALL/);
173+
});
174+
}
175+
176+
it('rejects an unsafe sighash type on a later input and names it', function () {
177+
const psbtHex = rewriteInputSighashType(createWalletPsbtHex('btc', 2, 'p2wsh'), 1, 0x03);
178+
assert.throws(() => signExternalPsbt(psbtHex, 'btc', userXprv), /Input 1 .*Only SIGHASH_ALL/);
179+
});
180+
181+
it('accepts an omitted sighash type, which uses the signer default', function () {
182+
const psbtHex = rewriteInputSighashType(createWalletPsbtHex('btc', 1, 'p2wsh'), 0, undefined);
183+
const { signedInputIndexes } = signExternalPsbt(psbtHex, 'btc', userXprv);
184+
assert.deepStrictEqual(signedInputIndexes, [0]);
185+
});
186+
187+
it('rejects a signer key that matches no input', function () {
188+
const unrelatedKey = testutils.getKey('signExternalPsbt.unrelated');
189+
assert.throws(
190+
() => signExternalPsbt(createWalletPsbtHex('btc', 1, 'p2wsh'), 'btc', unrelatedKey.toBase58()),
191+
/No PSBT inputs were signed/
192+
);
193+
});
194+
195+
it('requires the FORKID form of SIGHASH_ALL on BCH-family coins', function () {
196+
// addWalletInput stamps SIGHASH_ALL|FORKID for BCH
197+
const bchPsbtHex = createWalletPsbtHex('bch', 1, 'p2sh');
198+
assert.doesNotThrow(() => assertExternalPsbtSighashPolicy(bchPsbtHex, 'bch'));
199+
const { signedInputIndexes } = signExternalPsbt(bchPsbtHex, 'bch', userXprv);
200+
assert.deepStrictEqual(signedInputIndexes, [0]);
201+
202+
assert.throws(
203+
() => signExternalPsbt(rewriteInputSighashType(bchPsbtHex, 0, 0x01), 'bch', userXprv),
204+
/Only SIGHASH_ALL/
205+
);
206+
});
207+
208+
it('rejects a PSBT that already carries a non-SIGHASH_ALL signature', function () {
209+
const { psbtHex } = signExternalPsbt(createWalletPsbtHex('btc', 1, 'p2wsh'), 'btc', userXprv);
210+
const tampered = rewritePartialSigSighashByte(psbtHex, 0, 0x02);
211+
212+
assert.throws(() => assertExternalPsbtSighashPolicy(tampered, 'btc'), /Only SIGHASH_ALL/);
213+
assert.throws(() => signExternalPsbt(tampered, 'btc', userXprv), /Only SIGHASH_ALL/);
214+
});
215+
216+
it('does not mutate the outputs it was given', function () {
217+
const unsignedPsbtHex = createWalletPsbtHex('btc', 1, 'p2wsh');
218+
const unsignedPsbt = BitGoPsbt.fromBytes(Buffer.from(unsignedPsbtHex, 'hex'), 'btc');
219+
unsignedPsbt.addOutput(RECIPIENT, 90_000n);
220+
const withOutputHex = Buffer.from(unsignedPsbt.serialize()).toString('hex');
221+
222+
const { psbtHex } = signExternalPsbt(withOutputHex, 'btc', userXprv);
223+
const signedPsbt = BitGoPsbt.fromBytes(Buffer.from(psbtHex, 'hex'), 'btc');
224+
assert.strictEqual(signedPsbt.outputCount(), 1);
225+
assert.deepStrictEqual(
226+
signedPsbt.getOutputs().map((output) => output.value),
227+
[90_000n]
228+
);
229+
});
230+
});

0 commit comments

Comments
 (0)