Skip to content

Commit f71aa51

Browse files
feat(utxo-core): add BIP322 signature verification
Add functions to verify BIP322 message signatures. This includes verification of both fully signed transactions and PSBTs. The verification checks that: 1. The transaction follows BIP322 format requirements 2. Each input correctly references the expected message 3. Signatures are valid for the corresponding public keys BTC-2375 Co-authored-by: llm-git <[email protected]> TICKET: BTC-2375
1 parent 1eb5eae commit f71aa51

File tree

4 files changed

+538
-2
lines changed

4 files changed

+538
-2
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './toSpend';
22
export * from './toSign';
33
export * from './utils';
4+
export * from './verify';

modules/utxo-core/src/bip322/toSign.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ export const MAX_NUM_BIP322_INPUTS = 200;
1515
* Create the base PSBT for the to_sign transaction for BIP322 signing.
1616
* There will be ever 1 output.
1717
*/
18-
export function createBaseToSignPsbt(rootWalletKeys?: bitgo.RootWalletKeys): bitgo.UtxoPsbt {
18+
export function createBaseToSignPsbt(
19+
rootWalletKeys?: bitgo.RootWalletKeys,
20+
network = networks.bitcoin
21+
): bitgo.UtxoPsbt {
1922
// Create PSBT object for constructing the transaction
20-
const psbt = bitgo.createPsbtForNetwork({ network: networks.bitcoin });
23+
const psbt = bitgo.createPsbtForNetwork({ network });
2124
// Set default value for nVersion and nLockTime
2225
psbt.setVersion(0); // nVersion = 0
2326
psbt.setLocktime(0); // nLockTime = 0
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import * as assert from 'assert';
2+
3+
import * as utxolib from '@bitgo/utxo-lib';
4+
5+
import { buildToSpendTransaction } from './toSpend';
6+
7+
export type MessageInfo = {
8+
address: string;
9+
message: string;
10+
// Hex encoded pubkeys
11+
pubkeys: string[];
12+
scriptType: utxolib.bitgo.outputScripts.ScriptType2Of3;
13+
};
14+
15+
export function assertBaseTx(tx: utxolib.bitgo.UtxoTransaction<bigint>): void {
16+
assert.deepStrictEqual(tx.version, 0, 'Transaction version must be 0.');
17+
assert.deepStrictEqual(tx.locktime, 0, 'Transaction locktime must be 0.');
18+
assert.deepStrictEqual(tx.outs.length, 1, 'Transaction must have exactly 1 output.');
19+
assert.deepStrictEqual(tx.outs[0].value, BigInt(0), 'Transaction output value must be 0.');
20+
assert.deepStrictEqual(tx.outs[0].script.toString('hex'), '6a', 'Transaction output script must be OP_RETURN.');
21+
}
22+
23+
export function assertTxInput(
24+
tx: utxolib.bitgo.UtxoTransaction<bigint>,
25+
inputIndex: number,
26+
prevOuts: utxolib.TxOutput<bigint>[],
27+
info: MessageInfo,
28+
checkSignature: boolean
29+
): void {
30+
assert.ok(
31+
inputIndex < tx.ins.length,
32+
`inputIndex ${inputIndex} is out of range for tx with ${tx.ins.length} inputs.`
33+
);
34+
const input = tx.ins[inputIndex];
35+
assert.deepStrictEqual(input.index, 0, `transaction input ${inputIndex} must have index=0.`);
36+
assert.deepStrictEqual(input.sequence, 0, `transaction input ${inputIndex} sequence must be 0.`);
37+
38+
// Make sure that the message is correctly encoded into the input of the transaction and
39+
// verify that the message info corresponds
40+
const scriptPubKey = utxolib.bitgo.outputScripts.createOutputScript2of3(
41+
info.pubkeys.map((pubkey) => Buffer.from(pubkey, 'hex')),
42+
info.scriptType,
43+
tx.network
44+
).scriptPubKey;
45+
assert.deepStrictEqual(
46+
info.address,
47+
utxolib.address.fromOutputScript(scriptPubKey, tx.network).toString(),
48+
`Address does not match derived scriptPubKey for input ${inputIndex}.`
49+
);
50+
51+
const txid = utxolib.bitgo.getOutputIdForInput(input).txid;
52+
const toSpendTx = buildToSpendTransaction(scriptPubKey, info.message);
53+
assert.deepStrictEqual(
54+
txid,
55+
toSpendTx.getId(),
56+
`Input ${inputIndex} derived to_spend transaction is not encoded in the input.`
57+
);
58+
59+
if (checkSignature) {
60+
const signatureScript = utxolib.bitgo.parseSignatureScript2Of3(input);
61+
const scriptType =
62+
signatureScript.scriptType === 'taprootKeyPathSpend'
63+
? 'p2trMusig2'
64+
: signatureScript.scriptType === 'taprootScriptPathSpend'
65+
? 'p2tr'
66+
: signatureScript.scriptType;
67+
assert.deepStrictEqual(scriptType, info.scriptType, 'Script type does not match.');
68+
utxolib.bitgo.verifySignatureWithPublicKeys(
69+
tx,
70+
inputIndex,
71+
prevOuts,
72+
info.pubkeys.map((pubkey) => Buffer.from(pubkey, 'hex'))
73+
);
74+
}
75+
}
76+
77+
export function assertBip322TxProof(tx: utxolib.bitgo.UtxoTransaction<bigint>, messageInfo: MessageInfo[]): void {
78+
assertBaseTx(tx);
79+
assert.deepStrictEqual(
80+
tx.ins.length,
81+
messageInfo.length,
82+
'Transaction must have the same number of inputs as messageInfo entries.'
83+
);
84+
const prevOuts = messageInfo.map((info) => {
85+
return {
86+
value: 0n,
87+
script: utxolib.bitgo.outputScripts.createOutputScript2of3(
88+
info.pubkeys.map((pubkey) => Buffer.from(pubkey, 'hex')),
89+
info.scriptType,
90+
tx.network
91+
).scriptPubKey,
92+
};
93+
});
94+
tx.ins.forEach((input, inputIndex) => assertTxInput(tx, inputIndex, prevOuts, messageInfo[inputIndex], true));
95+
}
96+
97+
export function assertBip322PsbtProof(psbt: utxolib.bitgo.UtxoPsbt, messageInfo: MessageInfo[]): void {
98+
const unsignedTx = psbt.getUnsignedTx();
99+
100+
assertBaseTx(unsignedTx);
101+
assert.deepStrictEqual(
102+
psbt.data.inputs.length,
103+
messageInfo.length,
104+
'PSBT must have the same number of inputs as messageInfo entries.'
105+
);
106+
107+
const prevOuts = psbt.data.inputs.map((input, inputIndex) => {
108+
assert.ok(input.witnessUtxo, `PSBT input ${inputIndex} is missing witnessUtxo`);
109+
return input.witnessUtxo;
110+
});
111+
112+
psbt.data.inputs.forEach((input, inputIndex) => {
113+
// Check that the metadata in the PSBT matches the messageInfo, then check the input data
114+
const info = messageInfo[inputIndex];
115+
116+
// Check that the to_spend transaction is encoded in the nonWitnessUtxo
117+
assert.ok(input.nonWitnessUtxo, `PSBT input ${inputIndex} is missing nonWitnessUtxo`);
118+
const toSpendTx = buildToSpendTransaction(prevOuts[inputIndex].script, info.message);
119+
assert.deepStrictEqual(input.nonWitnessUtxo.toString('hex'), toSpendTx.toHex());
120+
121+
if (input.bip32Derivation) {
122+
input.bip32Derivation.forEach((b) => {
123+
const pubkey = b.pubkey.toString('hex');
124+
assert.ok(
125+
info.pubkeys.includes(pubkey),
126+
`PSBT input ${inputIndex} has a pubkey in (tap)bip32Derivation that is not in messageInfo`
127+
);
128+
});
129+
} else if (!input.tapBip32Derivation) {
130+
throw new Error(`PSBT input ${inputIndex} is missing (tap)bip32Derivation when it should have it.`);
131+
}
132+
133+
// Verify the signature on the input
134+
assert.ok(psbt.validateSignaturesOfInputCommon(inputIndex), `PSBT input ${inputIndex} has an invalid signature.`);
135+
136+
// Do not check the signature when using the PSBT, the signature is not there. We are going
137+
// to signatures in the PSBT.
138+
assertTxInput(unsignedTx, inputIndex, prevOuts, info, false);
139+
});
140+
}

0 commit comments

Comments
 (0)