Skip to content

Commit c9e4fb8

Browse files
authored
Merge pull request #6878 from BitGo/BTC-2375.verify
feat: Verify BIP322 proofs in both transaction and psbt formats
2 parents bc6de97 + 531a83c commit c9e4fb8

File tree

5 files changed

+600
-2
lines changed

5 files changed

+600
-2
lines changed

modules/abstract-utxo/src/transaction/bip322.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { decodeOrElse } from '@bitgo/sdk-core';
2+
import { bip322 } from '@bitgo/utxo-core';
3+
import { bitgo, networks, Network } from '@bitgo/utxo-lib';
24
import * as t from 'io-ts';
35

46
const BIP322MessageInfo = t.type({
@@ -33,3 +35,63 @@ export function deserializeBIP322BroadcastableMessage(hex: string): BIP322Messag
3335
throw new Error(`Failed to decode ${BIP322MessageBroadcastable.name}: ${error}`);
3436
});
3537
}
38+
39+
export function verifyTransactionFromBroadcastableMessage(
40+
message: BIP322MessageBroadcastable,
41+
coinName: string
42+
): boolean {
43+
let network: Network = networks.bitcoin;
44+
if (coinName === 'tbtc4') {
45+
network = networks.bitcoinTestnet4;
46+
} else if (coinName !== 'btc') {
47+
throw new Error('Only tbtc4 or btc coinNames are supported.');
48+
}
49+
if (bitgo.isPsbt(message.txHex)) {
50+
const psbt = bitgo.createPsbtFromBuffer(Buffer.from(message.txHex, 'hex'), network);
51+
try {
52+
bip322.assertBip322PsbtProof(psbt, message.messageInfo);
53+
return true;
54+
} catch (error) {
55+
return false;
56+
}
57+
} else {
58+
const tx = bitgo.createTransactionFromBuffer(Buffer.from(message.txHex, 'hex'), network, { amountType: 'bigint' });
59+
try {
60+
bip322.assertBip322TxProof(tx, message.messageInfo);
61+
return true;
62+
} catch (error) {
63+
return false;
64+
}
65+
}
66+
}
67+
68+
export function generateBIP322MessageListAndVerifyFromMessageBroadcastable(
69+
messageBroadcastables: BIP322MessageBroadcastable[],
70+
coinName: string
71+
): { address: string; message: string }[] {
72+
// Map from the address to the message. If there are duplicates of the address, make sure that the
73+
// message is the same. If there are duplicate addresses and the messages are not the same, throw an error.
74+
const addressMap = new Map<string, string>();
75+
76+
messageBroadcastables.forEach((message, index) => {
77+
if (verifyTransactionFromBroadcastableMessage(message, coinName)) {
78+
message.messageInfo.forEach((info) => {
79+
const { address, message: msg } = info;
80+
if (addressMap.has(address)) {
81+
if (addressMap.get(address) !== msg) {
82+
throw new Error(`Duplicate address ${address} has different messages`);
83+
}
84+
} else {
85+
addressMap.set(address, msg);
86+
}
87+
});
88+
} else {
89+
throw new Error(`Message Broadcastable ${index} did not have a successful validation`);
90+
}
91+
});
92+
93+
return Array.from(addressMap.entries()).map(([address, message]) => ({
94+
address,
95+
message,
96+
}));
97+
}
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)