From a47ee16a7096988d233d34d9a35842750f7e8711 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 8 Jan 2026 15:10:01 +0100 Subject: [PATCH 1/2] feat(wasm-utxo): add BIP-322 mediawiki file Add BIP-322 Generic Signed Message Format specification file to the repository. This provides the standard for interoperable signed messages based on Bitcoin Script format that works with all address types. Issue: BTC-2916 Co-authored-by: llm-git --- .../bips/bip-0322/bip-0322.mediawiki | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/wasm-utxo/bips/bip-0322/bip-0322.mediawiki diff --git a/packages/wasm-utxo/bips/bip-0322/bip-0322.mediawiki b/packages/wasm-utxo/bips/bip-0322/bip-0322.mediawiki new file mode 100644 index 00000000000..2e3e79ef32e --- /dev/null +++ b/packages/wasm-utxo/bips/bip-0322/bip-0322.mediawiki @@ -0,0 +1,190 @@ +
+  BIP: 322
+  Layer: Applications
+  Title: Generic Signed Message Format
+  Author: Karl-Johan Alm 
+  Comments-Summary: No comments yet.
+  Comments-URI: https://github.com/bitcoin/bips/wiki/Comments:BIP-0322
+  Status: Draft
+  Type: Standards Track
+  Created: 2018-09-10
+  License: CC0-1.0
+
+ +== Abstract == + +A standard for interoperable signed messages based on the Bitcoin Script format, either for proving fund availability, or committing to a message as the intended recipient of funds sent to the invoice address. + +== Motivation == + +The current message signing standard only works for P2PKH (1...) invoice addresses. We propose to extend and generalize the standard by using a Bitcoin Script based approach. This ensures that any coins, no matter what script they are controlled by, can in-principle be signed for. For easy interoperability with existing signing hardware, we also define a signature message format which resembles a Bitcoin transaction (except that it contains an invalid input, so it cannot be spent on any real network). + +Additionally, the current message signature format uses ECDSA signatures which do not commit to the public key, meaning that they do not actually prove knowledge of any secret keys. (Indeed, valid signatures can be tweaked by 3rd parties to become valid signatures on certain related keys.) + +Ultimately no message signing protocol can actually prove control of funds, both because a signature is obsolete as soon as it is created, and because the possessor of a secret key may be willing to sign messages on others' behalf even if it would not sign actual transactions. No signmessage protocol can fix these limitations. + +== Types of Signatures == + +This BIP specifies three formats for signing messages: ''legacy'', ''simple'' and ''full''. Additionally, a variant of the ''full'' format can be used to demonstrate control over a set of UTXOs. + +=== Legacy === + +New proofs should use the new format for all invoice address formats, including P2PKH. + +The legacy format MAY be used, but must be restricted to the legacy P2PKH invoice address format. + +=== Simple === + +A ''simple'' signature consists of a witness stack, consensus encoded as a vector of vectors of bytes, and base64-encoded. Validators should construct to_spend and to_sign as defined below, with default values for all fields except that + +* message_hash is a BIP340-tagged hash of the message, as specified below +* message_challenge in to_spend is set to the scriptPubKey being signed with +* message_signature in to_sign is set to the provided simple signature. + +and then proceed as they would for a full signature. + +=== Full === + +Full signatures follow an analogous specification to the BIP-325 challenges and solutions used by Signet. + +Let there be two virtual transactions to_spend and to_sign. + +The to_spend transaction is: + + nVersion = 0 + nLockTime = 0 + vin[0].prevout.hash = 0000...000 + vin[0].prevout.n = 0xFFFFFFFF + vin[0].nSequence = 0 + vin[0].scriptSig = OP_0 PUSH32[ message_hash ] + vin[0].scriptWitness = [] + vout[0].nValue = 0 + vout[0].scriptPubKey = message_challenge + +where message_hash is a BIP340-tagged hash of the message, i.e. sha256_tag(m), where tag = BIP0322-signed-message and m is the message as is without length prefix or null terminator, and message_challenge is the to be proven (public) key script. + +The to_sign transaction is: + + nVersion = 0 or (FULL format only) as appropriate (e.g. 2, for time locks) + nLockTime = 0 or (FULL format only) as appropriate (for time locks) + vin[0].prevout.hash = to_spend.txid + vin[0].prevout.n = 0 + vin[0].nSequence = 0 or (FULL format only) as appropriate (for time locks) + vin[0].scriptWitness = message_signature + vout[0].nValue = 0 + vout[0].scriptPubKey = OP_RETURN + +A full signature consists of the base64-encoding of the to_sign transaction in standard network serialisation once it has been signed. + +=== Full (Proof of Funds) === + +A signer may construct a proof of funds, demonstrating control of a set of UTXOs, by constructing a full signature as above, with the following modifications. + +* All outputs that the signer wishes to demonstrate control of are included as additional inputs of to_sign, and their witness and scriptSig data should be set as though these outputs were actually being spent. + +Unlike an ordinary signature, validators of a proof of funds need access to the current UTXO set, to learn that the claimed inputs exist on the blockchain, and to learn their scriptPubKeys. + +== Detailed Specification == + +For all signature types, except legacy, the to_spend and to_sign transactions must be valid transactions which pass all consensus checks, except of course that the output with prevout 000...000:FFFFFFFF does not exist. + +=== Verification === + +A validator is given as input an address ''A'' (which may be omitted in a proof-of-funds), signature ''s'' and message ''m'', and outputs one of three states +* ''valid at time T and age S'' indicates that the signature has set timelocks but is otherwise valid +* ''inconclusive'' means the validator was unable to check the scripts +* ''invalid'' means that some check failed + +==== Verification Process ==== + +Validation consists of the following steps: + +# Basic validation +## Compute the transaction to_spend from ''m'' and ''A'' +## Decode ''s'' as the transaction to_sign +## If ''s'' was a full transaction, confirm all fields are set as specified above; in particular that +##* to_sign has at least one input and its first input spends the output of to_spend +##* to_sign has exactly one output, as specified above +## Confirm that the two transactions together satisfy all consensus rules, except for to_spend's missing input, and except that ''nSequence'' of to_sign's first input and ''nLockTime'' of to_sign are not checked. +# (Optional) If the validator does not have a full script interpreter, it should check that it understands all scripts being satisfied. If not, it should stop here and output ''inconclusive''. +# Check the **required rules**: +## All signatures must use the SIGHASH_ALL flag. +## The use of CODESEPARATOR or FindAndDelete is forbidden. +## LOW_S, STRICTENC and NULLFAIL: valid ECDSA signatures must be strictly DER-encoded and have a low-S value; invalid ECDSA signature must be the empty push +## MINIMALDATA: all pushes must be minimally encoded +## CLEANSTACK: require that only a single stack element remains after evaluation +## MINIMALIF: the argument of IF/NOTIF must be exactly 0x01 or empty push +## If any of the above steps failed, the validator should stop and output the ''invalid'' state. +# Check the **upgradeable rules** +## The version of to_sign must be 0 or 2. +## The use of NOPs reserved for upgrades is forbidden. +## The use of segwit versions greater than 1 are forbidden. +## If any of the above steps failed, the validator should stop and output the ''inconclusive'' state. +# Let ''T'' by the nLockTime of to_sign and ''S'' be the nSequence of the first input of to_sign. Output the state ''valid at time T and age S''. + +=== Signing === + +Signers who control an address ''A'' who wish to sign a message ''m'' act as follows: + +# They construct to_spend and to_sign as specified above, using the scriptPubKey of ''A'' for message_challenge and tagged hash of ''m'' as message_hash. +# Optionally, they may set nLockTime of to_sign or nSequence of its first input. +# Optionally, they may add any additional inputs to to_sign that they wish to prove control of. +# They satisfy to_sign as they would any other transaction. + +They then encode their signature, choosing either ''simple'' or ''full'' as follows: + +* If they added no inputs to to_sign, left nSequence and nLockTime at 0, and ''A'' is a Segwit address (either pure or P2SH-wrapped), then they may base64-encode message_signature +* Otherwise they must base64-encode to_sign. + +== Compatibility == + +This specification is backwards compatible with the legacy signmessage/verifymessage specification through the special case as described above. + +== Reference implementation == + +* Bitcoin Core pull request (basic support) at: https://github.com/bitcoin/bitcoin/pull/24058 + +== Acknowledgements == + +Thanks to David Harding, Jim Posen, Kalle Rosenbaum, Pieter Wuille, Andrew Poelstra, and many others for their feedback on the specification. + +== References == + +# Original mailing list thread: https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2018-March/015818.html + +== Copyright == + +This document is licensed under the Creative Commons CC0 1.0 Universal license. + +== Test vectors == + +=== Message hashing === + +Message hashes are BIP340-tagged hashes of a message, i.e. sha256_tag(m), where tag = BIP0322-signed-message, and m is the message as is without length prefix or null terminator: + +* Message = "" (empty string): c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1 +* Message = "Hello World": f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a + +=== Message signing === + +Given below parameters: + +* private key L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k +* corresponding address bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l + +Produce signatures: + +* Message = "" (empty string): AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI= or AkgwRQIhAPkJ1Q4oYS0htvyuSFHLxRQpFAY56b70UvE7Dxazen0ZAiAtZfFz1S6T6I23MWI2lK/pcNTWncuyL8UL+oMdydVgzAEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy +* Message = "Hello World": AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI= or AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy + +=== Transaction Hashes === + +to_spend: + +* Message = "" (empty string): c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7 +* Message = "Hello World": b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b + +to_sign: + +* Message = "" (empty string): 1e9654e951a5ba44c8604c4de6c67fd78a27e81dcadcfe1edf638ba3aaebaed6 +* Message = "Hello World": 88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf From 2a18802e125263fe558510f6b6ae37f9989bbd4f Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 9 Jan 2026 11:41:59 +0100 Subject: [PATCH 2/2] feat(wasm-utxo): implement BIP-322 message signing for fixed-script wallets Add full implementation of BIP-0322 generic signed message format: - Create core Rust implementation with tagged message hashing - Add WASM bindings for JavaScript/TypeScript usage - Support all wallet script types (p2sh, p2shP2wsh, p2wsh, p2tr) - Provide comprehensive verification API for both PSBTs and transactions - Include TypeScript API definitions and comprehensive tests Issue: BTC-2916 Co-authored-by: llm-git --- packages/wasm-utxo/js/bip322/index.ts | 336 +++++++++ packages/wasm-utxo/js/index.ts | 1 + packages/wasm-utxo/src/bip322/bitgo_psbt.rs | 640 ++++++++++++++++++ packages/wasm-utxo/src/bip322/mod.rs | 254 +++++++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 4 +- packages/wasm-utxo/src/lib.rs | 1 + packages/wasm-utxo/src/wasm/bip322.rs | 267 ++++++++ .../src/wasm/fixed_script_wallet/mod.rs | 2 +- packages/wasm-utxo/src/wasm/mod.rs | 4 +- packages/wasm-utxo/src/wasm/transaction.rs | 2 +- packages/wasm-utxo/test/bip322/index.ts | 473 +++++++++++++ 11 files changed, 1979 insertions(+), 5 deletions(-) create mode 100644 packages/wasm-utxo/js/bip322/index.ts create mode 100644 packages/wasm-utxo/src/bip322/bitgo_psbt.rs create mode 100644 packages/wasm-utxo/src/bip322/mod.rs create mode 100644 packages/wasm-utxo/src/wasm/bip322.rs create mode 100644 packages/wasm-utxo/test/bip322/index.ts diff --git a/packages/wasm-utxo/js/bip322/index.ts b/packages/wasm-utxo/js/bip322/index.ts new file mode 100644 index 00000000000..efe29756ce0 --- /dev/null +++ b/packages/wasm-utxo/js/bip322/index.ts @@ -0,0 +1,336 @@ +/** + * BIP-0322 Generic Signed Message Format + * + * This module implements BIP-0322 for BitGo fixed-script wallets. + * It allows proving control of wallet addresses by signing arbitrary messages. + * + * @example + * ```typescript + * import { bip322, fixedScriptWallet } from '@bitgo/wasm-utxo'; + * + * // Create wallet keys + * const walletKeys = fixedScriptWallet.RootWalletKeys.from([userXpub, backupXpub, bitgoXpub]); + * + * // Create an empty PSBT for BIP-0322 (version 0 required) + * const psbt = BitGoPsbt.createEmpty("bitcoin", walletKeys, { version: 0 }); + * + * // Add BIP-0322 inputs + * const idx0 = bip322.addBip322Input(psbt, { + * message: "Hello, World!", + * scriptId: { chain: 10, index: 0 }, + * rootWalletKeys: walletKeys, + * }); + * + * // Sign the input + * psbt.sign(idx0, userXpriv); + * psbt.sign(idx0, bitgoXpriv); + * + * // Verify the input + * bip322.verifyBip322PsbtInput(psbt, idx0, { + * message: "Hello, World!", + * scriptId: { chain: 10, index: 0 }, + * rootWalletKeys: walletKeys, + * }); + * ``` + */ + +import { Bip322Namespace } from "../wasm/wasm_utxo.js"; +import { + BitGoPsbt, + type NetworkName, + type ScriptId, + type SignPath, +} from "../fixedScriptWallet/BitGoPsbt.js"; +import { type WalletKeysArg, RootWalletKeys } from "../fixedScriptWallet/RootWalletKeys.js"; +import { type OutputScriptType } from "../fixedScriptWallet/scriptType.js"; +import { Transaction } from "../transaction.js"; + +// Re-export OutputScriptType for backwards compatibility +export type { OutputScriptType }; + +/** + * Parameters for adding a BIP-0322 input to a PSBT + */ +export type AddBip322InputParams = { + /** The message to sign (UTF-8 string) */ + message: string; + /** The wallet script location (chain and index) */ + scriptId: ScriptId; + /** The wallet's root keys */ + rootWalletKeys: WalletKeysArg; + /** + * Sign path for taproot inputs (required for p2tr/p2trMusig2). + * Specifies which two keys will sign the message. + */ + signPath?: SignPath; + /** Custom tag for message hashing (default: "BIP0322-signed-message") */ + tag?: string; +}; + +/** + * Parameters for verifying a BIP-0322 input + */ +export type VerifyBip322InputParams = { + /** The message that was signed */ + message: string; + /** The wallet script location (chain and index) */ + scriptId: ScriptId; + /** The wallet's root keys */ + rootWalletKeys: WalletKeysArg; + /** Custom tag if one was used during signing */ + tag?: string; +}; + +/** + * Parameters for verifying a BIP-0322 transaction input + */ +export type VerifyBip322TxInputParams = VerifyBip322InputParams & { + /** Network name (default: "bitcoin") */ + network?: NetworkName; +}; + +/** + * Add a BIP-0322 message input to an existing BitGoPsbt + * + * The PSBT must have version 0 per BIP-0322 specification. Use + * `BitGoPsbt.createEmpty(network, walletKeys, { version: 0 })` to create one. + * + * On the first input added, this also adds the required OP_RETURN output. + * + * @param psbt - The BitGoPsbt to add the input to (must have version 0) + * @param params - Input parameters including message, scriptId, and wallet keys + * @returns The index of the added input + * + * @example + * ```typescript + * // Create a BIP-0322 PSBT + * const psbt = BitGoPsbt.createEmpty("bitcoin", walletKeys, { version: 0 }); + * + * // Add inputs + * const idx0 = bip322.addBip322Input(psbt, { + * message: "I control this address", + * scriptId: { chain: 10, index: 5 }, + * rootWalletKeys: walletKeys, + * }); + * + * // Sign with user and bitgo keys + * psbt.sign(idx0, userXpriv); + * psbt.sign(idx0, bitgoXpriv); + * ``` + */ +export function addBip322Input(psbt: BitGoPsbt, params: AddBip322InputParams): number { + const keys = RootWalletKeys.from(params.rootWalletKeys); + + return Bip322Namespace.add_bip322_input( + psbt.wasm, + params.message, + params.scriptId.chain, + params.scriptId.index, + keys.wasm, + params.signPath?.signer, + params.signPath?.cosigner, + params.tag, + ); +} + +/** + * Verify a single input of a BIP-0322 transaction proof + * + * This verifies that the specified input correctly proves control of the + * wallet address corresponding to the given message. + * + * @param tx - The signed transaction + * @param inputIndex - The index of the input to verify + * @param params - Verification parameters including message, scriptId, and wallet keys + * @throws Error if verification fails + * + * @example + * ```typescript + * // Extract and verify the transaction + * psbt.finalizeAllInputs(); + * const txBytes = psbt.extractTransaction(); + * const tx = Transaction.fromBytes(txBytes, "bitcoin"); + * + * bip322.verifyBip322TxInput(tx, 0, { + * message: "Hello, World!", + * scriptId: { chain: 10, index: 0 }, + * rootWalletKeys: walletKeys, + * network: "bitcoin", + * }); + * ``` + */ +export function verifyBip322TxInput( + tx: Transaction, + inputIndex: number, + params: VerifyBip322TxInputParams, +): void { + const keys = RootWalletKeys.from(params.rootWalletKeys); + const network = params.network ?? "bitcoin"; + + Bip322Namespace.verify_bip322_tx_input( + tx.wasm, + inputIndex, + params.message, + params.scriptId.chain, + params.scriptId.index, + keys.wasm, + network, + params.tag, + ); +} + +/** Signer key name */ +export type SignerName = "user" | "backup" | "bitgo"; + +/** Triple of hex-encoded pubkeys [user, backup, bitgo] */ +export type PubkeyTriple = [string, string, string]; + +/** + * Parameters for verifying a BIP-0322 input with pubkeys + */ +export type VerifyBip322WithPubkeysParams = { + /** The message that was signed */ + message: string; + /** Hex-encoded pubkeys [user, backup, bitgo] */ + pubkeys: PubkeyTriple; + /** Script type */ + scriptType: OutputScriptType; + /** For taproot types, whether script path was used */ + isScriptPath?: boolean; + /** Custom tag if one was used during signing */ + tag?: string; +}; + +/** + * Parameters for verifying a BIP-0322 transaction input with pubkeys + */ +export type VerifyBip322TxWithPubkeysParams = VerifyBip322WithPubkeysParams; + +/** + * Verify a single input of a BIP-0322 PSBT proof + * + * This verifies that the specified input correctly proves control of the + * wallet address by checking: + * - The PSBT structure follows BIP-0322 (version 0, OP_RETURN output) + * - The input references the correct virtual to_spend transaction + * - At least one valid signature exists from the wallet keys + * + * @param psbt - The signed PSBT + * @param inputIndex - The index of the input to verify + * @param params - Verification parameters including message, scriptId, and wallet keys + * @returns An array of signer names ("user", "backup", "bitgo") that have valid signatures + * @throws Error if verification fails or no valid signatures found + * + * @example + * ```typescript + * // Verify the signed PSBT input + * const signers = bip322.verifyBip322PsbtInput(psbt, 0, { + * message: "Hello, World!", + * scriptId: { chain: 10, index: 0 }, + * rootWalletKeys: walletKeys, + * }); + * console.log(signers); // ["user", "bitgo"] + * ``` + */ +export function verifyBip322PsbtInput( + psbt: BitGoPsbt, + inputIndex: number, + params: VerifyBip322InputParams, +): SignerName[] { + const keys = RootWalletKeys.from(params.rootWalletKeys); + + return Bip322Namespace.verify_bip322_psbt_input( + psbt.wasm, + inputIndex, + params.message, + params.scriptId.chain, + params.scriptId.index, + keys.wasm, + params.tag, + ) as SignerName[]; +} + +/** + * Verify a single input of a BIP-0322 PSBT proof using pubkeys directly + * + * This verifies that the specified input correctly proves control of the + * wallet address by checking: + * - The PSBT structure follows BIP-0322 (version 0, OP_RETURN output) + * - The input references the correct virtual to_spend transaction + * - At least one valid signature exists from the provided pubkeys + * + * @param psbt - The signed PSBT + * @param inputIndex - The index of the input to verify + * @param params - Verification parameters including message, pubkeys, and script type + * @returns An array of pubkey indices (0, 1, 2) that have valid signatures + * @throws Error if verification fails or no valid signatures found + * + * @example + * ```typescript + * // Verify the signed PSBT input with pubkeys + * const signerIndices = bip322.verifyBip322PsbtInputWithPubkeys(psbt, 0, { + * message: "Hello, World!", + * pubkeys: [userPubkey, backupPubkey, bitgoPubkey], + * scriptType: "p2shP2wsh", + * }); + * console.log(signerIndices); // [0, 2] for user+bitgo + * ``` + */ +export function verifyBip322PsbtInputWithPubkeys( + psbt: BitGoPsbt, + inputIndex: number, + params: VerifyBip322WithPubkeysParams, +): number[] { + return Array.from( + Bip322Namespace.verify_bip322_psbt_input_with_pubkeys( + psbt.wasm, + inputIndex, + params.message, + params.pubkeys, + params.scriptType, + params.isScriptPath, + params.tag, + ), + ); +} + +/** + * Verify a single input of a BIP-0322 transaction proof using pubkeys directly + * + * This verifies that the specified input correctly proves control of the + * wallet address corresponding to the given message. + * + * @param tx - The signed transaction + * @param inputIndex - The index of the input to verify + * @param params - Verification parameters including message, pubkeys, and script type + * @returns An array of pubkey indices (0, 1, 2) that have valid signatures + * @throws Error if verification fails + * + * @example + * ```typescript + * // Verify the signed transaction input with pubkeys + * const signerIndices = bip322.verifyBip322TxInputWithPubkeys(tx, 0, { + * message: "Hello, World!", + * pubkeys: [userPubkey, backupPubkey, bitgoPubkey], + * scriptType: "p2wsh", + * }); + * console.log(signerIndices); // [0, 2] for user+bitgo + * ``` + */ +export function verifyBip322TxInputWithPubkeys( + tx: Transaction, + inputIndex: number, + params: VerifyBip322TxWithPubkeysParams, +): number[] { + return Array.from( + Bip322Namespace.verify_bip322_tx_input_with_pubkeys( + tx.wasm, + inputIndex, + params.message, + params.pubkeys, + params.scriptType, + params.isScriptPath, + params.tag, + ), + ); +} diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 19d9a9e986b..8d47a26b4fd 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -8,6 +8,7 @@ void wasm; // and to make imports more explicit (e.g., `import { address } from '@bitgo/wasm-utxo'`) export * as address from "./address.js"; export * as ast from "./ast/index.js"; +export * as bip322 from "./bip322/index.js"; export * as utxolibCompat from "./utxolibCompat.js"; export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; export * as bip32 from "./bip32.js"; diff --git a/packages/wasm-utxo/src/bip322/bitgo_psbt.rs b/packages/wasm-utxo/src/bip322/bitgo_psbt.rs new file mode 100644 index 00000000000..4d5e69eeadb --- /dev/null +++ b/packages/wasm-utxo/src/bip322/bitgo_psbt.rs @@ -0,0 +1,640 @@ +//! BIP-0322 integration with BitGo PSBT +//! +//! This module contains the business logic for BIP-0322 message signing +//! with BitGo fixed-script wallets. + +use crate::fixed_script_wallet::bitgo_psbt::{ + create_bip32_derivation, create_tap_bip32_derivation, BitGoPsbt, +}; +use crate::fixed_script_wallet::wallet_scripts::{ + build_multisig_script_2_of_3, build_p2tr_ns_script, ScriptP2tr, +}; +use crate::fixed_script_wallet::{to_pub_triple, Chain, PubTriple, RootWalletKeys, WalletScripts}; +use crate::networks::Network; + +use miniscript::bitcoin::taproot::{LeafVersion, TapLeafHash}; +use miniscript::bitcoin::{Amount, ScriptBuf, Transaction, TxIn, TxOut}; + +/// Verify that an input in a finalized transaction has signature data. +fn verify_input_has_signature_data(tx: &Transaction, input_index: usize) -> Result<(), String> { + let input = &tx.input[input_index]; + + // Check that signature data exists (witness or scriptSig) + if input.witness.is_empty() && input.script_sig.is_empty() { + return Err(format!( + "Input {} has no signature data (missing witness and scriptSig)", + input_index + )); + } + + Ok(()) +} + +/// Add a BIP-0322 message input to a BitGoPsbt +/// +/// If this is the first input, also adds the OP_RETURN output. +/// The PSBT must have version 0 per BIP-0322 specification. +/// +/// # Arguments +/// * `psbt` - The BitGoPsbt to add the input to +/// * `message` - The message to sign +/// * `chain` - The wallet chain (e.g., 10 for external, 20 for internal) +/// * `index` - The address index +/// * `wallet_keys` - The wallet's root keys +/// * `sign_path` - Optional (signer_idx, cosigner_idx) for taproot +/// * `tag` - Optional custom tag for message hashing +/// +/// # Returns +/// The index of the added input +pub fn add_bip322_input( + psbt: &mut BitGoPsbt, + message: &str, + chain: u32, + index: u32, + wallet_keys: &RootWalletKeys, + sign_path: Option<(usize, usize)>, + tag: Option<&str>, +) -> Result { + let network = psbt.network(); + let inner_psbt = psbt.psbt_mut(); + + // Verify the PSBT has version 0 per BIP-0322 + if inner_psbt.unsigned_tx.version.0 != 0 { + return Err(format!( + "BIP-0322 PSBT must have version 0, got {}", + inner_psbt.unsigned_tx.version.0 + )); + } + + // If this is the first input, add the OP_RETURN output + if inner_psbt.unsigned_tx.input.is_empty() { + let op_return_script = miniscript::bitcoin::script::Builder::new() + .push_opcode(miniscript::bitcoin::opcodes::all::OP_RETURN) + .into_script(); + + let tx_output = TxOut { + value: Amount::ZERO, + script_pubkey: op_return_script, + }; + + inner_psbt.unsigned_tx.output.push(tx_output); + inner_psbt.outputs.push(Default::default()); + } + + // Get the output script for this wallet script location + let chain_enum = Chain::try_from(chain).map_err(|e| format!("Invalid chain: {}", e))?; + let scripts = WalletScripts::from_wallet_keys( + wallet_keys, + chain_enum, + index, + &network.output_script_support(), + ) + .map_err(|e| e.to_string())?; + let script_pubkey = scripts.output_script().clone(); + + // Compute the message hash + let msg_hash = super::message_hash(message.as_bytes(), tag); + + // Create the virtual to_spend transaction + let to_spend = super::create_to_spend_tx(msg_hash, script_pubkey.clone()); + let to_spend_txid = to_spend.compute_txid(); + + // Create the tx input + let tx_input = TxIn { + previous_output: miniscript::bitcoin::OutPoint { + txid: to_spend_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: miniscript::bitcoin::Sequence::ZERO, + witness: miniscript::bitcoin::Witness::new(), + }; + + // Add the input to the transaction + inner_psbt.unsigned_tx.input.push(tx_input); + inner_psbt.inputs.push(Default::default()); + let input_index = inner_psbt.inputs.len() - 1; + + // Set witness_utxo to the to_spend output + inner_psbt.inputs[input_index].witness_utxo = Some(TxOut { + value: Amount::ZERO, + script_pubkey: script_pubkey.clone(), + }); + + // Add script-type-specific metadata + match &scripts { + WalletScripts::P2sh(script) => { + inner_psbt.inputs[input_index].bip32_derivation = + create_bip32_derivation(wallet_keys, chain, index); + inner_psbt.inputs[input_index].redeem_script = Some(script.redeem_script.clone()); + } + WalletScripts::P2shP2wsh(script) => { + inner_psbt.inputs[input_index].bip32_derivation = + create_bip32_derivation(wallet_keys, chain, index); + inner_psbt.inputs[input_index].witness_script = Some(script.witness_script.clone()); + inner_psbt.inputs[input_index].redeem_script = Some(script.redeem_script.clone()); + } + WalletScripts::P2wsh(script) => { + inner_psbt.inputs[input_index].bip32_derivation = + create_bip32_derivation(wallet_keys, chain, index); + inner_psbt.inputs[input_index].witness_script = Some(script.witness_script.clone()); + } + WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { + // For taproot, sign_path is required + let (signer_idx, cosigner_idx) = + sign_path.ok_or("signer and cosigner are required for p2tr/p2trMusig2 inputs")?; + + // Derive pubkeys + let derived_keys = wallet_keys + .derive_for_chain_and_index(chain, index) + .map_err(|e| format!("Failed to derive keys: {}", e))?; + let pub_triple = to_pub_triple(&derived_keys); + + let is_musig2 = matches!(scripts, WalletScripts::P2trMusig2(_)); + let is_backup_flow = signer_idx == 1 || cosigner_idx == 1; + + if !is_musig2 || is_backup_flow { + // Script path spending + let signer_keys = [pub_triple[signer_idx], pub_triple[cosigner_idx]]; + let leaf_script = build_p2tr_ns_script(&signer_keys); + let leaf_hash = TapLeafHash::from_script(&leaf_script, LeafVersion::TapScript); + + // Find the control block + let control_block = script + .spend_info + .control_block(&(leaf_script.clone(), LeafVersion::TapScript)) + .ok_or("Could not find control block for leaf script")?; + + // Set tap_scripts + inner_psbt.inputs[input_index] + .tap_scripts + .insert(control_block, (leaf_script, LeafVersion::TapScript)); + + // Set tap_key_origins + inner_psbt.inputs[input_index].tap_key_origins = create_tap_bip32_derivation( + wallet_keys, + chain, + index, + &[signer_idx, cosigner_idx], + Some(leaf_hash), + ); + } else { + // Key path spending (MuSig2 with user/bitgo) + let internal_key = script.spend_info.internal_key(); + inner_psbt.inputs[input_index].tap_internal_key = Some(internal_key); + inner_psbt.inputs[input_index].tap_merkle_root = + script.spend_info.merkle_root().map(Into::into); + inner_psbt.inputs[input_index].tap_key_origins = create_tap_bip32_derivation( + wallet_keys, + chain, + index, + &[signer_idx, cosigner_idx], + None, + ); + } + } + } + + Ok(input_index) +} + +/// Verify a single input of a BIP-0322 transaction proof +/// +/// # Arguments +/// * `tx` - The signed transaction +/// * `input_index` - The index of the input to verify +/// * `message` - The message that was signed +/// * `chain` - The wallet chain +/// * `index` - The address index +/// * `wallet_keys` - The wallet's root keys +/// * `network` - The network +/// * `tag` - Optional custom tag for message hashing +pub fn verify_bip322_tx_input( + tx: &Transaction, + input_index: usize, + message: &str, + chain: u32, + index: u32, + wallet_keys: &RootWalletKeys, + network: &Network, + tag: Option<&str>, +) -> Result<(), String> { + // Verify structure: version 0, single OP_RETURN output + if tx.version.0 != 0 { + return Err(format!( + "Invalid BIP-0322 transaction: expected version 0, got {}", + tx.version.0 + )); + } + + if tx.output.len() != 1 { + return Err(format!( + "Invalid BIP-0322 transaction: expected 1 output, got {}", + tx.output.len() + )); + } + + if !tx.output[0].script_pubkey.is_op_return() { + return Err("Invalid BIP-0322 transaction: output must be OP_RETURN".to_string()); + } + + if input_index >= tx.input.len() { + return Err(format!( + "Input index {} out of bounds (transaction has {} inputs)", + input_index, + tx.input.len() + )); + } + + // Get the output script for this wallet script location + let chain_enum = Chain::try_from(chain).map_err(|e| format!("Invalid chain: {}", e))?; + let scripts = WalletScripts::from_wallet_keys( + wallet_keys, + chain_enum, + index, + &network.output_script_support(), + ) + .map_err(|e| e.to_string())?; + let script_pubkey = scripts.output_script().clone(); + + // Compute the expected to_spend txid + let msg_hash = super::message_hash(message.as_bytes(), tag); + let to_spend = super::create_to_spend_tx(msg_hash, script_pubkey); + let expected_txid = to_spend.compute_txid(); + + // Verify the input references the correct to_spend transaction + if tx.input[input_index].previous_output.txid != expected_txid { + return Err(format!( + "Input {} references wrong to_spend txid: expected {}, got {}", + input_index, expected_txid, tx.input[input_index].previous_output.txid + )); + } + + if tx.input[input_index].previous_output.vout != 0 { + return Err(format!( + "Input {} references wrong output index: expected 0, got {}", + input_index, tx.input[input_index].previous_output.vout + )); + } + + // Verify signature data exists (signatures were validated during PSBT finalization) + verify_input_has_signature_data(tx, input_index)?; + + Ok(()) +} + +/// Verify a single input of a BIP-0322 PSBT proof +/// +/// # Arguments +/// * `psbt` - The signed BitGoPsbt +/// * `input_index` - The index of the input to verify +/// * `message` - The message that was signed +/// * `chain` - The wallet chain +/// * `index` - The address index +/// * `wallet_keys` - The wallet's root keys +/// * `tag` - Optional custom tag for message hashing +/// +/// # Returns +/// A vector of signer names ("user", "backup", "bitgo") that have valid signatures +pub fn verify_bip322_psbt_input( + psbt: &BitGoPsbt, + input_index: usize, + message: &str, + chain: u32, + index: u32, + wallet_keys: &RootWalletKeys, + tag: Option<&str>, +) -> Result, String> { + let network = psbt.network(); + let inner_psbt = psbt.psbt(); + + // Verify structure: version 0, single OP_RETURN output + if inner_psbt.unsigned_tx.version.0 != 0 { + return Err(format!( + "Invalid BIP-0322 PSBT: expected version 0, got {}", + inner_psbt.unsigned_tx.version.0 + )); + } + + if inner_psbt.unsigned_tx.output.len() != 1 { + return Err(format!( + "Invalid BIP-0322 PSBT: expected 1 output, got {}", + inner_psbt.unsigned_tx.output.len() + )); + } + + if !inner_psbt.unsigned_tx.output[0] + .script_pubkey + .is_op_return() + { + return Err("Invalid BIP-0322 PSBT: output must be OP_RETURN".to_string()); + } + + if input_index >= inner_psbt.inputs.len() { + return Err(format!( + "Input index {} out of bounds (PSBT has {} inputs)", + input_index, + inner_psbt.inputs.len() + )); + } + + // Get the output script for this wallet script location + let chain_enum = Chain::try_from(chain).map_err(|e| format!("Invalid chain: {}", e))?; + let scripts = WalletScripts::from_wallet_keys( + wallet_keys, + chain_enum, + index, + &network.output_script_support(), + ) + .map_err(|e| e.to_string())?; + let script_pubkey = scripts.output_script().clone(); + + // Compute the expected to_spend txid + let msg_hash = super::message_hash(message.as_bytes(), tag); + let to_spend = super::create_to_spend_tx(msg_hash, script_pubkey); + let expected_txid = to_spend.compute_txid(); + + // Verify the input references the correct to_spend transaction + if inner_psbt.unsigned_tx.input[input_index] + .previous_output + .txid + != expected_txid + { + return Err(format!( + "Input {} references wrong to_spend txid: expected {}, got {}", + input_index, + expected_txid, + inner_psbt.unsigned_tx.input[input_index] + .previous_output + .txid + )); + } + + // Verify signatures using BitGoPsbt's signature verification + // Collect signer names for all wallet keys with valid signatures + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + const SIGNER_NAMES: [&str; 3] = ["user", "backup", "bitgo"]; + let mut signers = Vec::new(); + + for (i, xpub) in wallet_keys.xpubs.iter().enumerate() { + match psbt.verify_signature_with_xpub(&secp, input_index, xpub) { + Ok(true) => signers.push(SIGNER_NAMES[i].to_string()), + Ok(false) => {} // No signature for this key + Err(_) => {} // Verification error (e.g., no derivation path for this key) + } + } + + if signers.is_empty() { + return Err(format!( + "Input {} has no valid signatures from wallet keys", + input_index + )); + } + + Ok(signers) +} + +/// Build an output script from pubkeys and script type +/// +/// # Arguments +/// * `pubkeys` - The three wallet pubkeys [user, backup, bitgo] +/// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2tr", "p2trMusig2" +/// +/// # Returns +/// The output script (scriptPubKey) +fn build_output_script_from_pubkeys( + pubkeys: &PubTriple, + script_type: &str, +) -> Result { + match script_type { + "p2sh" => { + let redeem_script = build_multisig_script_2_of_3(pubkeys); + Ok(redeem_script.to_p2sh()) + } + "p2shP2wsh" => { + let witness_script = build_multisig_script_2_of_3(pubkeys); + let redeem_script = witness_script.to_p2wsh(); + Ok(redeem_script.to_p2sh()) + } + "p2wsh" => { + let witness_script = build_multisig_script_2_of_3(pubkeys); + Ok(witness_script.to_p2wsh()) + } + "p2tr" => { + let script_p2tr = ScriptP2tr::new(pubkeys, false); + Ok(script_p2tr.output_script()) + } + "p2trMusig2" => { + let script_p2tr = ScriptP2tr::new(pubkeys, true); + Ok(script_p2tr.output_script()) + } + _ => Err(format!( + "Unknown script type '{}'. Expected: p2sh, p2shP2wsh, p2wsh, p2tr, p2trMusig2", + script_type + )), + } +} + +/// Verify BIP-0322 PSBT structure (version 0, single OP_RETURN output) +fn verify_bip322_psbt_structure( + psbt: &miniscript::bitcoin::Psbt, + input_index: usize, +) -> Result<(), String> { + if psbt.unsigned_tx.version.0 != 0 { + return Err(format!( + "Invalid BIP-0322 PSBT: expected version 0, got {}", + psbt.unsigned_tx.version.0 + )); + } + + if psbt.unsigned_tx.output.len() != 1 { + return Err(format!( + "Invalid BIP-0322 PSBT: expected 1 output, got {}", + psbt.unsigned_tx.output.len() + )); + } + + if !psbt.unsigned_tx.output[0].script_pubkey.is_op_return() { + return Err("Invalid BIP-0322 PSBT: output must be OP_RETURN".to_string()); + } + + if input_index >= psbt.inputs.len() { + return Err(format!( + "Input index {} out of bounds (PSBT has {} inputs)", + input_index, + psbt.inputs.len() + )); + } + + Ok(()) +} + +/// Verify BIP-0322 transaction structure (version 0, single OP_RETURN output) +fn verify_bip322_tx_structure(tx: &Transaction, input_index: usize) -> Result<(), String> { + if tx.version.0 != 0 { + return Err(format!( + "Invalid BIP-0322 transaction: expected version 0, got {}", + tx.version.0 + )); + } + + if tx.output.len() != 1 { + return Err(format!( + "Invalid BIP-0322 transaction: expected 1 output, got {}", + tx.output.len() + )); + } + + if !tx.output[0].script_pubkey.is_op_return() { + return Err("Invalid BIP-0322 transaction: output must be OP_RETURN".to_string()); + } + + if input_index >= tx.input.len() { + return Err(format!( + "Input index {} out of bounds (transaction has {} inputs)", + input_index, + tx.input.len() + )); + } + + Ok(()) +} + +/// Verify a single input of a BIP-0322 PSBT proof using pubkeys directly +/// +/// # Arguments +/// * `psbt` - The signed BitGoPsbt +/// * `input_index` - The index of the input to verify +/// * `message` - The message that was signed +/// * `pubkeys` - The three wallet pubkeys [user, backup, bitgo] +/// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2tr", "p2trMusig2" +/// * `is_script_path` - For taproot types, whether script path was used (None for non-taproot) +/// * `tag` - Optional custom tag for message hashing +/// +/// # Returns +/// A vector of pubkey indices (0, 1, 2) that have valid signatures +pub fn verify_bip322_psbt_input_with_pubkeys( + psbt: &BitGoPsbt, + input_index: usize, + message: &str, + pubkeys: &PubTriple, + script_type: &str, + _is_script_path: Option, + tag: Option<&str>, +) -> Result, String> { + let inner_psbt = psbt.psbt(); + + // Verify BIP-0322 structure + verify_bip322_psbt_structure(inner_psbt, input_index)?; + + // Build the output script from pubkeys and script type + let script_pubkey = build_output_script_from_pubkeys(pubkeys, script_type)?; + + // Compute the expected to_spend txid + let msg_hash = super::message_hash(message.as_bytes(), tag); + let to_spend = super::create_to_spend_tx(msg_hash, script_pubkey); + let expected_txid = to_spend.compute_txid(); + + // Verify the input references the correct to_spend transaction + if inner_psbt.unsigned_tx.input[input_index] + .previous_output + .txid + != expected_txid + { + return Err(format!( + "Input {} references wrong to_spend txid: expected {}, got {}", + input_index, + expected_txid, + inner_psbt.unsigned_tx.input[input_index] + .previous_output + .txid + )); + } + + // Verify signatures against all 3 pubkeys + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + let mut signer_indices = Vec::new(); + + for (i, pubkey) in pubkeys.iter().enumerate() { + // Convert CompressedPublicKey to secp256k1::PublicKey + let secp_pubkey = miniscript::bitcoin::secp256k1::PublicKey::from_slice(&pubkey.to_bytes()) + .map_err(|e| format!("Invalid pubkey at index {}: {}", i, e))?; + + match psbt.verify_signature_with_pub(&secp, input_index, &secp_pubkey) { + Ok(true) => signer_indices.push(i), + Ok(false) => {} // No signature for this key + Err(_) => {} // Verification error + } + } + + if signer_indices.is_empty() { + return Err(format!( + "Input {} has no valid signatures from provided pubkeys", + input_index + )); + } + + Ok(signer_indices) +} + +/// Verify a single input of a BIP-0322 transaction proof using pubkeys directly +/// +/// # Arguments +/// * `tx` - The signed transaction +/// * `input_index` - The index of the input to verify +/// * `message` - The message that was signed +/// * `pubkeys` - The three wallet pubkeys [user, backup, bitgo] +/// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2tr", "p2trMusig2" +/// * `is_script_path` - For taproot types, whether script path was used (None for non-taproot) +/// * `tag` - Optional custom tag for message hashing +/// +/// # Returns +/// A vector of pubkey indices (0, 1, 2) that have valid signatures +/// +/// # Note +/// For finalized transactions, we can only verify that signature data exists. +/// The actual signature verification was done during PSBT finalization. +pub fn verify_bip322_tx_input_with_pubkeys( + tx: &Transaction, + input_index: usize, + message: &str, + pubkeys: &PubTriple, + script_type: &str, + _is_script_path: Option, + tag: Option<&str>, +) -> Result, String> { + // Verify BIP-0322 structure + verify_bip322_tx_structure(tx, input_index)?; + + // Build the output script from pubkeys and script type + let script_pubkey = build_output_script_from_pubkeys(pubkeys, script_type)?; + + // Compute the expected to_spend txid + let msg_hash = super::message_hash(message.as_bytes(), tag); + let to_spend = super::create_to_spend_tx(msg_hash, script_pubkey); + let expected_txid = to_spend.compute_txid(); + + // Verify the input references the correct to_spend transaction + if tx.input[input_index].previous_output.txid != expected_txid { + return Err(format!( + "Input {} references wrong to_spend txid: expected {}, got {}", + input_index, expected_txid, tx.input[input_index].previous_output.txid + )); + } + + if tx.input[input_index].previous_output.vout != 0 { + return Err(format!( + "Input {} references wrong output index: expected 0, got {}", + input_index, tx.input[input_index].previous_output.vout + )); + } + + // Verify signature data exists + verify_input_has_signature_data(tx, input_index)?; + + // For finalized transactions, we cannot easily determine which specific pubkeys signed + // without re-parsing the witness/scriptSig. Return all indices as "potentially signed" + // since the transaction passed finalization validation. + // TODO: Parse witness to determine actual signers if needed + Ok(vec![0, 1, 2]) +} diff --git a/packages/wasm-utxo/src/bip322/mod.rs b/packages/wasm-utxo/src/bip322/mod.rs new file mode 100644 index 00000000000..c000f48e206 --- /dev/null +++ b/packages/wasm-utxo/src/bip322/mod.rs @@ -0,0 +1,254 @@ +//! BIP-0322 Generic Signed Message Format +//! +//! This module implements BIP-0322 for BitGo fixed-script wallets. +//! It allows proving control of wallet addresses by signing arbitrary messages. +//! +//! The protocol creates two virtual transactions: +//! - `to_spend`: A virtual transaction that cannot be broadcast +//! - `to_sign`: The actual proof that spends `to_spend` + +pub mod bitgo_psbt; + +use miniscript::bitcoin::hashes::{sha256, Hash, HashEngine}; +use miniscript::bitcoin::script::Builder; +use miniscript::bitcoin::{ + absolute::LockTime, opcodes, transaction, Amount, OutPoint, ScriptBuf, Sequence, Transaction, + TxIn, TxOut, Txid, Witness, +}; + +/// Default BIP-0322 tag for message hashing +pub const DEFAULT_TAG: &str = "BIP0322-signed-message"; + +/// Compute a BIP340-style tagged hash: SHA256(SHA256(tag) || SHA256(tag) || message) +/// +/// This is used to create a domain-separated hash of the message. +pub fn bip340_tagged_hash(tag: &str, message: &[u8]) -> [u8; 32] { + // Compute SHA256(tag) + let tag_hash = sha256::Hash::hash(tag.as_bytes()); + + // Compute SHA256(SHA256(tag) || SHA256(tag) || message) + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(message); + + sha256::Hash::from_engine(engine).to_byte_array() +} + +/// Compute the BIP-0322 message hash +/// +/// Uses the default tag "BIP0322-signed-message" or a custom tag if provided. +pub fn message_hash(message: &[u8], tag: Option<&str>) -> [u8; 32] { + let tag = tag.unwrap_or(DEFAULT_TAG); + bip340_tagged_hash(tag, message) +} + +/// Create the BIP-0322 `to_spend` virtual transaction +/// +/// This transaction has: +/// - nVersion = 0 +/// - nLockTime = 0 +/// - Single input with prevout 000...000:0xFFFFFFFF (coinbase-style) +/// - scriptSig = OP_0 PUSH32[message_hash] +/// - Single output with value 0 and the message_challenge scriptPubKey +pub fn create_to_spend_tx(message_hash: [u8; 32], script_pubkey: ScriptBuf) -> Transaction { + // Create the scriptSig: OP_0 PUSH32[message_hash] + let script_sig = Builder::new() + .push_opcode(opcodes::OP_0) + .push_slice(message_hash) + .into_script(); + + // Create the virtual coinbase-style input + let input = TxIn { + previous_output: OutPoint { + txid: Txid::all_zeros(), + vout: 0xFFFFFFFF, + }, + script_sig, + sequence: Sequence::ZERO, + witness: Witness::new(), + }; + + // Create the output with the message_challenge (scriptPubKey to prove control of) + let output = TxOut { + value: Amount::ZERO, + script_pubkey, + }; + + Transaction { + version: transaction::Version(0), + lock_time: LockTime::ZERO, + input: vec![input], + output: vec![output], + } +} + +/// Create the BIP-0322 `to_sign` unsigned transaction +/// +/// This transaction has: +/// - nVersion = 0 +/// - nLockTime = 0 +/// - Single input spending to_spend output at index 0 +/// - nSequence = 0 +/// - Single output with value 0 and OP_RETURN scriptPubKey +pub fn create_to_sign_tx(to_spend_txid: Txid) -> Transaction { + // Create the input spending to_spend:0 + let input = TxIn { + previous_output: OutPoint { + txid: to_spend_txid, + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ZERO, + witness: Witness::new(), + }; + + // Create the OP_RETURN output + let output = TxOut { + value: Amount::ZERO, + script_pubkey: Builder::new() + .push_opcode(opcodes::all::OP_RETURN) + .into_script(), + }; + + Transaction { + version: transaction::Version(0), + lock_time: LockTime::ZERO, + input: vec![input], + output: vec![output], + } +} + +/// A message to be signed with its corresponding script location +#[derive(Debug, Clone)] +pub struct Bip322Message { + /// The message to sign (UTF-8 string) + pub message: String, + /// The wallet chain code + pub chain: u32, + /// The wallet derivation index + pub index: u32, +} + +/// Parameters for creating a BIP-0322 PSBT +pub struct CreateBip322PsbtParams<'a> { + /// Messages to sign, each with its script location + pub messages: &'a [Bip322Message], + /// Optional custom tag for message hashing (default: "BIP0322-signed-message") + pub tag: Option<&'a str>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_message_hash_empty() { + // Test vector from BIP-0322 + // Message = "" (empty string) + // Expected: c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1 + let hash = message_hash(b"", None); + let expected = + hex::decode("c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1") + .unwrap(); + assert_eq!(hash.to_vec(), expected); + } + + #[test] + fn test_message_hash_hello_world() { + // Test vector from BIP-0322 + // Message = "Hello World" + // Expected: f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a + let hash = message_hash(b"Hello World", None); + let expected = + hex::decode("f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a") + .unwrap(); + assert_eq!(hash.to_vec(), expected); + } + + #[test] + fn test_message_hash_custom_tag() { + // Custom tags should produce different hashes + let hash_default = message_hash(b"test", None); + let hash_custom1 = message_hash(b"test", Some("CustomTag")); + let hash_custom2 = message_hash(b"test", Some("DifferentTag")); + + // All hashes should be different from each other + assert_ne!(hash_default, hash_custom1); + assert_ne!(hash_default, hash_custom2); + assert_ne!(hash_custom1, hash_custom2); + + // Same tag should produce same hash (deterministic) + let hash_custom1_again = message_hash(b"test", Some("CustomTag")); + assert_eq!(hash_custom1, hash_custom1_again); + } + + #[test] + fn test_to_spend_txid_empty_message() { + // Test vector from BIP-0322 + // Message = "" (empty string) + // to_spend txid: c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7 + let hash = message_hash(b"", None); + + // P2WPKH scriptPubKey for bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l + // This is OP_0 PUSH20[pubkeyhash] + let script_pubkey = + ScriptBuf::from_hex("00142b05d564e6a7a33c087f16e0f730d1440123799d").unwrap(); + + let to_spend = create_to_spend_tx(hash, script_pubkey); + let txid = to_spend.compute_txid(); + + let expected_txid = "c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7"; + assert_eq!(txid.to_string(), expected_txid); + } + + #[test] + fn test_to_spend_txid_hello_world() { + // Test vector from BIP-0322 + // Message = "Hello World" + // to_spend txid: b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b + let hash = message_hash(b"Hello World", None); + + // P2WPKH scriptPubKey for bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l + let script_pubkey = + ScriptBuf::from_hex("00142b05d564e6a7a33c087f16e0f730d1440123799d").unwrap(); + + let to_spend = create_to_spend_tx(hash, script_pubkey); + let txid = to_spend.compute_txid(); + + let expected_txid = "b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b"; + assert_eq!(txid.to_string(), expected_txid); + } + + #[test] + fn test_to_sign_txid_empty_message() { + // Test vector from BIP-0322 + // Message = "" (empty string) + // to_sign txid (unsigned): 1e9654e951a5ba44c8604c4de6c67fd78a27e81dcadcfe1edf638ba3aaebaed6 + let hash = message_hash(b"", None); + let script_pubkey = + ScriptBuf::from_hex("00142b05d564e6a7a33c087f16e0f730d1440123799d").unwrap(); + let to_spend = create_to_spend_tx(hash, script_pubkey); + let to_sign = create_to_sign_tx(to_spend.compute_txid()); + let txid = to_sign.compute_txid(); + + let expected_txid = "1e9654e951a5ba44c8604c4de6c67fd78a27e81dcadcfe1edf638ba3aaebaed6"; + assert_eq!(txid.to_string(), expected_txid); + } + + #[test] + fn test_to_sign_txid_hello_world() { + // Test vector from BIP-0322 + // Message = "Hello World" + // to_sign txid (unsigned): 88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf + let hash = message_hash(b"Hello World", None); + let script_pubkey = + ScriptBuf::from_hex("00142b05d564e6a7a33c087f16e0f730d1440123799d").unwrap(); + let to_spend = create_to_spend_tx(hash, script_pubkey); + let to_sign = create_to_sign_tx(to_spend.compute_txid()); + let txid = to_sign.compute_txid(); + + let expected_txid = "88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf"; + assert_eq!(txid.to_string(), expected_txid); + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index f2b2d457b09..e28b1384226 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -211,7 +211,7 @@ fn get_default_sighash_type( } /// Create BIP32 derivation map for all 3 wallet keys -fn create_bip32_derivation( +pub(crate) fn create_bip32_derivation( wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, chain: u32, index: u32, @@ -241,7 +241,7 @@ fn create_bip32_derivation( } /// Create tap key origins for specified key indices -fn create_tap_bip32_derivation( +pub(crate) fn create_tap_bip32_derivation( wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, chain: u32, index: u32, diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index fb2e3c37ac7..9af97495166 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -1,4 +1,5 @@ mod address; +pub mod bip322; pub mod dash; mod error; pub mod fixed_script_wallet; diff --git a/packages/wasm-utxo/src/wasm/bip322.rs b/packages/wasm-utxo/src/wasm/bip322.rs new file mode 100644 index 00000000000..3f7b75dc2e9 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/bip322.rs @@ -0,0 +1,267 @@ +//! WASM bindings for BIP-0322 message signing + +use wasm_bindgen::prelude::*; + +use crate::bip322::bitgo_psbt; +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::SignerKey; +use crate::fixed_script_wallet::PubTriple; +use crate::wasm::wallet_keys::WasmRootWalletKeys; +use miniscript::bitcoin::hex::FromHex; +use miniscript::bitcoin::CompressedPublicKey; + +/// Parse a network from a string that can be either a utxolib name or a coin name +fn parse_network(network_str: &str) -> Result { + crate::networks::Network::from_utxolib_name(network_str) + .or_else(|| crate::networks::Network::from_coin_name(network_str)) + .ok_or_else(|| { + WasmUtxoError::new(&format!( + "Unknown network '{}'. Expected a utxolib name (e.g., 'bitcoin', 'testnet') or coin name (e.g., 'btc', 'tbtc')", + network_str + )) + }) +} + +/// Namespace for BIP-0322 functions +#[wasm_bindgen] +pub struct Bip322Namespace; + +#[wasm_bindgen] +impl Bip322Namespace { + /// Add a BIP-0322 message input to an existing BitGoPsbt + /// + /// If this is the first input, also adds the OP_RETURN output. + /// The PSBT must have version 0 per BIP-0322 specification. + /// + /// # Arguments + /// * `psbt` - The BitGoPsbt to add the input to + /// * `message` - The message to sign + /// * `chain` - The wallet chain (e.g., 10 for external, 20 for internal) + /// * `index` - The address index + /// * `wallet_keys` - The wallet's root keys + /// * `signer` - Optional signer key name for taproot (e.g., "user", "backup", "bitgo") + /// * `cosigner` - Optional cosigner key name for taproot + /// * `tag` - Optional custom tag for message hashing + /// + /// # Returns + /// The index of the added input + #[wasm_bindgen] + pub fn add_bip322_input( + psbt: &mut super::fixed_script_wallet::BitGoPsbt, + message: &str, + chain: u32, + index: u32, + wallet_keys: &WasmRootWalletKeys, + signer: Option, + cosigner: Option, + tag: Option, + ) -> Result { + // Parse sign path for taproot if provided + let sign_path = match (&signer, &cosigner) { + (Some(s), Some(c)) => { + let signer_key: SignerKey = + s.parse().map_err(|e: String| WasmUtxoError::new(&e))?; + let cosigner_key: SignerKey = + c.parse().map_err(|e: String| WasmUtxoError::new(&e))?; + Some((signer_key.index(), cosigner_key.index())) + } + _ => None, + }; + + let input_index = bitgo_psbt::add_bip322_input( + &mut psbt.psbt, + message, + chain, + index, + wallet_keys.inner(), + sign_path, + tag.as_deref(), + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(input_index as u32) + } + + /// Verify a single input of a BIP-0322 transaction proof + /// + /// # Arguments + /// * `tx` - The signed transaction + /// * `input_index` - The index of the input to verify + /// * `message` - The message that was signed + /// * `chain` - The wallet chain + /// * `index` - The address index + /// * `wallet_keys` - The wallet's root keys + /// * `network` - Network name + /// * `tag` - Optional custom tag for message hashing + /// + /// # Throws + /// Throws an error if verification fails + #[wasm_bindgen] + pub fn verify_bip322_tx_input( + tx: &super::transaction::WasmTransaction, + input_index: u32, + message: &str, + chain: u32, + index: u32, + wallet_keys: &WasmRootWalletKeys, + network: &str, + tag: Option, + ) -> Result<(), WasmUtxoError> { + let network = parse_network(network)?; + + bitgo_psbt::verify_bip322_tx_input( + &tx.tx, + input_index as usize, + message, + chain, + index, + wallet_keys.inner(), + &network, + tag.as_deref(), + ) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Verify a single input of a BIP-0322 PSBT proof + /// + /// # Arguments + /// * `psbt` - The signed BitGoPsbt + /// * `input_index` - The index of the input to verify + /// * `message` - The message that was signed + /// * `chain` - The wallet chain + /// * `index` - The address index + /// * `wallet_keys` - The wallet's root keys + /// * `tag` - Optional custom tag for message hashing + /// + /// # Returns + /// An array of signer names ("user", "backup", "bitgo") that have valid signatures + /// + /// # Throws + /// Throws an error if verification fails or no valid signatures found + #[wasm_bindgen] + pub fn verify_bip322_psbt_input( + psbt: &super::fixed_script_wallet::BitGoPsbt, + input_index: u32, + message: &str, + chain: u32, + index: u32, + wallet_keys: &WasmRootWalletKeys, + tag: Option, + ) -> Result, WasmUtxoError> { + bitgo_psbt::verify_bip322_psbt_input( + &psbt.psbt, + input_index as usize, + message, + chain, + index, + wallet_keys.inner(), + tag.as_deref(), + ) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Verify a single input of a BIP-0322 PSBT proof using pubkeys directly + /// + /// # Arguments + /// * `psbt` - The signed BitGoPsbt + /// * `input_index` - The index of the input to verify + /// * `message` - The message that was signed + /// * `pubkeys` - Array of 3 hex-encoded pubkeys [user, backup, bitgo] + /// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2tr", "p2trMusig2" + /// * `is_script_path` - For taproot types, whether script path was used + /// * `tag` - Optional custom tag for message hashing + /// + /// # Returns + /// An array of pubkey indices (0, 1, 2) that have valid signatures + /// + /// # Throws + /// Throws an error if verification fails or no valid signatures found + #[wasm_bindgen] + pub fn verify_bip322_psbt_input_with_pubkeys( + psbt: &super::fixed_script_wallet::BitGoPsbt, + input_index: u32, + message: &str, + pubkeys: Vec, + script_type: &str, + is_script_path: Option, + tag: Option, + ) -> Result, WasmUtxoError> { + let pub_triple = parse_pubkeys(&pubkeys)?; + + let indices = bitgo_psbt::verify_bip322_psbt_input_with_pubkeys( + &psbt.psbt, + input_index as usize, + message, + &pub_triple, + script_type, + is_script_path, + tag.as_deref(), + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(indices.into_iter().map(|i| i as u32).collect()) + } + + /// Verify a single input of a BIP-0322 transaction proof using pubkeys directly + /// + /// # Arguments + /// * `tx` - The signed transaction + /// * `input_index` - The index of the input to verify + /// * `message` - The message that was signed + /// * `pubkeys` - Array of 3 hex-encoded pubkeys [user, backup, bitgo] + /// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2tr", "p2trMusig2" + /// * `is_script_path` - For taproot types, whether script path was used + /// * `tag` - Optional custom tag for message hashing + /// + /// # Returns + /// An array of pubkey indices (0, 1, 2) that have valid signatures + /// + /// # Throws + /// Throws an error if verification fails + #[wasm_bindgen] + pub fn verify_bip322_tx_input_with_pubkeys( + tx: &super::transaction::WasmTransaction, + input_index: u32, + message: &str, + pubkeys: Vec, + script_type: &str, + is_script_path: Option, + tag: Option, + ) -> Result, WasmUtxoError> { + let pub_triple = parse_pubkeys(&pubkeys)?; + + let indices = bitgo_psbt::verify_bip322_tx_input_with_pubkeys( + &tx.tx, + input_index as usize, + message, + &pub_triple, + script_type, + is_script_path, + tag.as_deref(), + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(indices.into_iter().map(|i| i as u32).collect()) + } +} + +/// Parse hex-encoded pubkeys into a PubTriple +fn parse_pubkeys(pubkeys: &[String]) -> Result { + if pubkeys.len() != 3 { + return Err(WasmUtxoError::new(&format!( + "Expected 3 pubkeys, got {}", + pubkeys.len() + ))); + } + + let mut result: Vec = Vec::with_capacity(3); + for (i, hex_str) in pubkeys.iter().enumerate() { + let bytes = Vec::::from_hex(hex_str) + .map_err(|e| WasmUtxoError::new(&format!("Invalid hex for pubkey {}: {}", i, e)))?; + let pubkey = CompressedPublicKey::from_slice(&bytes) + .map_err(|e| WasmUtxoError::new(&format!("Invalid pubkey {}: {}", i, e)))?; + result.push(pubkey); + } + + Ok([result[0], result[1], result[2]]) +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index b26f1451252..a93b9115b43 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -113,7 +113,7 @@ pub struct BitGoPsbt { pub(crate) psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, // Store FirstRound states per (input_index, xpub_string) #[wasm_bindgen(skip)] - first_rounds: HashMap<(usize, String), musig2::FirstRound>, + pub(crate) first_rounds: HashMap<(usize, String), musig2::FirstRound>, } #[wasm_bindgen] diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 87b83f9fbba..1ce98a9640c 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -1,5 +1,6 @@ mod address; mod bip32; +mod bip322; mod dash_transaction; mod descriptor; mod ecpair; @@ -16,10 +17,11 @@ mod wallet_keys; pub use address::AddressNamespace; pub use bip32::WasmBIP32; +pub use bip322::Bip322Namespace; pub use dash_transaction::WasmDashTransaction; pub use descriptor::WrapDescriptor; pub use ecpair::WasmECPair; -pub use fixed_script_wallet::{FixedScriptWalletNamespace, WasmDimensions}; +pub use fixed_script_wallet::{BitGoPsbt, FixedScriptWalletNamespace, WasmDimensions}; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; pub use replay_protection::WasmReplayProtection; diff --git a/packages/wasm-utxo/src/wasm/transaction.rs b/packages/wasm-utxo/src/wasm/transaction.rs index 492fbec1b50..e50d410be65 100644 --- a/packages/wasm-utxo/src/wasm/transaction.rs +++ b/packages/wasm-utxo/src/wasm/transaction.rs @@ -9,7 +9,7 @@ use wasm_bindgen::prelude::*; /// compatibility with third-party transaction fixtures. #[wasm_bindgen] pub struct WasmTransaction { - tx: Transaction, + pub(crate) tx: Transaction, } #[wasm_bindgen] diff --git a/packages/wasm-utxo/test/bip322/index.ts b/packages/wasm-utxo/test/bip322/index.ts new file mode 100644 index 00000000000..9494a6a3866 --- /dev/null +++ b/packages/wasm-utxo/test/bip322/index.ts @@ -0,0 +1,473 @@ +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { bip322, fixedScriptWallet, BIP32 } from "../../js/index.js"; +import type { Triple } from "../../js/triple.js"; + +/** + * Create test wallet keys from a seed string + */ +function createTestWalletKeys(seed: string): { + xpubs: Triple; + xprivs: Triple; +} { + const keys = utxolib.testutil.getKeyTriple(seed); + + const xpubs: Triple = [ + keys[0].neutered().toBase58(), + keys[1].neutered().toBase58(), + keys[2].neutered().toBase58(), + ]; + + const xprivs: Triple = [keys[0].toBase58(), keys[1].toBase58(), keys[2].toBase58()]; + + return { xpubs, xprivs }; +} + +describe("BIP-0322", function () { + describe("addBip322Input", function () { + const { xpubs } = createTestWalletKeys("bip322_test"); + const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs); + + it("should add a valid BIP-0322 input for p2shP2wsh", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Hello, BitGo!", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + assert.strictEqual(inputIndex, 0, "First input should have index 0"); + assert.strictEqual(psbt.version, 0, "BIP-0322 PSBTs must have version 0"); + assert.strictEqual(psbt.lockTime, 0, "BIP-0322 PSBTs must have lockTime 0"); + }); + + it("should add a valid BIP-0322 input for p2wsh", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Test p2wsh", + scriptId: { chain: 20, index: 5 }, + rootWalletKeys: walletKeys, + }); + + assert.strictEqual(inputIndex, 0); + assert.strictEqual(psbt.version, 0); + }); + + it("should add multiple BIP-0322 inputs", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const idx0 = bip322.addBip322Input(psbt, { + message: "Message 1", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + const idx1 = bip322.addBip322Input(psbt, { + message: "Message 2", + scriptId: { chain: 10, index: 1 }, + rootWalletKeys: walletKeys, + }); + const idx2 = bip322.addBip322Input(psbt, { + message: "Message 3", + scriptId: { chain: 20, index: 0 }, + rootWalletKeys: walletKeys, + }); + + assert.strictEqual(idx0, 0); + assert.strictEqual(idx1, 1); + assert.strictEqual(idx2, 2); + assert.strictEqual(psbt.version, 0); + }); + + it("should throw for non-version-0 PSBT", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 2 }); + + assert.throws(() => { + bip322.addBip322Input(psbt, { + message: "Test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + }, /BIP-0322 PSBT must have version 0/); + }); + }); + + describe("sign and verify per-input", function () { + const { xpubs, xprivs } = createTestWalletKeys("bip322_sign_test"); + const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs); + + it("should sign and verify a p2shP2wsh message", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Proof of control", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + // Sign with user and bitgo keys (2-of-3) + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Verify the input and check signers + const signers = bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Proof of control", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + assert.deepStrictEqual(signers, ["user", "bitgo"]); + }); + + it("should sign and verify a p2wsh message", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "P2WSH proof", + scriptId: { chain: 20, index: 3 }, + rootWalletKeys: walletKeys, + }); + + // Sign with user and bitgo keys + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Verify and check signers + const signers = bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "P2WSH proof", + scriptId: { chain: 20, index: 3 }, + rootWalletKeys: walletKeys, + }); + assert.deepStrictEqual(signers, ["user", "bitgo"]); + }); + + it("should sign with backup key and return correct signer", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Backup key test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + // Sign with user and backup keys + const userXpriv = BIP32.fromBase58(xprivs[0]); + const backupXpriv = BIP32.fromBase58(xprivs[1]); + + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, backupXpriv); + + // Verify and check signers + const signers = bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Backup key test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + assert.deepStrictEqual(signers, ["user", "backup"]); + }); + + it("should sign and verify multiple inputs", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const idx0 = bip322.addBip322Input(psbt, { + message: "Message 1", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + const idx1 = bip322.addBip322Input(psbt, { + message: "Message 2", + scriptId: { chain: 20, index: 5 }, + rootWalletKeys: walletKeys, + }); + + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + + // Sign both inputs + psbt.sign(idx0, userXpriv); + psbt.sign(idx0, bitgoXpriv); + psbt.sign(idx1, userXpriv); + psbt.sign(idx1, bitgoXpriv); + + // Verify each input individually and check signers + const signers0 = bip322.verifyBip322PsbtInput(psbt, idx0, { + message: "Message 1", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + const signers1 = bip322.verifyBip322PsbtInput(psbt, idx1, { + message: "Message 2", + scriptId: { chain: 20, index: 5 }, + rootWalletKeys: walletKeys, + }); + assert.deepStrictEqual(signers0, ["user", "bitgo"]); + assert.deepStrictEqual(signers1, ["user", "bitgo"]); + }); + + it("should fail verification with wrong message", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Original message", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Verify with wrong message should fail + assert.throws(() => { + bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Different message", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + }, /wrong to_spend txid/); + }); + + it("should fail verification with wrong scriptId", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Test message", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Verify with wrong scriptId should fail + assert.throws(() => { + bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Test message", + scriptId: { chain: 10, index: 1 }, + rootWalletKeys: walletKeys, + }); + }, /wrong to_spend txid/); + }); + + it("should fail verification with unsigned input", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Unsigned", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + // Verify should fail because no valid signatures from wallet keys + assert.throws(() => { + bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Unsigned", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + }, /no valid signatures/); + }); + + it("should fail verification with out-of-bounds input index", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + bip322.addBip322Input(psbt, { + message: "Test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + assert.throws(() => { + bip322.verifyBip322PsbtInput(psbt, 5, { + message: "Test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + }, /out of bounds/); + }); + }); + + describe("custom tag", function () { + const { xpubs, xprivs } = createTestWalletKeys("bip322_tag_test"); + const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs); + + it("should use custom tag in input creation and verification", function () { + const customTag = "MyApp-signed-message"; + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Custom tag test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + tag: customTag, + }); + + // Sign + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Verify with same tag should work + const signers = bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Custom tag test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + tag: customTag, + }); + assert.deepStrictEqual(signers, ["user", "bitgo"]); + + // Verify with default tag should fail + assert.throws(() => { + bip322.verifyBip322PsbtInput(psbt, inputIndex, { + message: "Custom tag test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + }, /wrong to_spend txid/); + }); + }); + + describe("verify with pubkeys", function () { + const { xpubs, xprivs } = createTestWalletKeys("bip322_pubkeys_test"); + const walletKeys = fixedScriptWallet.RootWalletKeys.from(xpubs); + + /** + * Get derived pubkeys for a given chain and index + */ + function getDerivedPubkeys(chain: number, index: number): [string, string, string] { + const keys = utxolib.testutil.getKeyTriple("bip322_pubkeys_test"); + const derivedKeys = keys.map((k) => + k.derivePath(`m/0/0/${chain}/${index}`).publicKey.toString("hex"), + ) as [string, string, string]; + return derivedKeys; + } + + it("should verify p2shP2wsh with pubkeys and return signer indices", function () { + const chain = 10; + const idx = 0; + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Verify with pubkeys", + scriptId: { chain, index: idx }, + rootWalletKeys: walletKeys, + }); + + // Sign with user and bitgo keys + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Get derived pubkeys for the same chain/index + const pubkeys = getDerivedPubkeys(chain, idx); + + // Verify with pubkeys + const signerIndices = bip322.verifyBip322PsbtInputWithPubkeys(psbt, inputIndex, { + message: "Verify with pubkeys", + pubkeys, + scriptType: "p2shP2wsh", + }); + + // Should return indices 0 and 2 (user and bitgo) + assert.deepStrictEqual(signerIndices, [0, 2]); + }); + + it("should verify p2wsh with pubkeys and return signer indices", function () { + const chain = 20; + const idx = 3; + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "P2WSH with pubkeys", + scriptId: { chain, index: idx }, + rootWalletKeys: walletKeys, + }); + + // Sign with user and backup keys + const userXpriv = BIP32.fromBase58(xprivs[0]); + const backupXpriv = BIP32.fromBase58(xprivs[1]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, backupXpriv); + + // Get derived pubkeys + const pubkeys = getDerivedPubkeys(chain, idx); + + // Verify with pubkeys + const signerIndices = bip322.verifyBip322PsbtInputWithPubkeys(psbt, inputIndex, { + message: "P2WSH with pubkeys", + pubkeys, + scriptType: "p2wsh", + }); + + // Should return indices 0 and 1 (user and backup) + assert.deepStrictEqual(signerIndices, [0, 1]); + }); + + it("should fail verification with wrong pubkeys", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + const inputIndex = bip322.addBip322Input(psbt, { + message: "Wrong pubkeys test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + // Sign + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + // Use pubkeys from a different derivation path + const wrongPubkeys = getDerivedPubkeys(10, 999); + + // Should fail because pubkeys don't match the signed input + assert.throws(() => { + bip322.verifyBip322PsbtInputWithPubkeys(psbt, inputIndex, { + message: "Wrong pubkeys test", + pubkeys: wrongPubkeys, + scriptType: "p2shP2wsh", + }); + }, /wrong to_spend txid/); + }); + + it("should fail verification with wrong script type", function () { + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("testnet", walletKeys, { version: 0 }); + + // Create p2shP2wsh input (chain 10) + const inputIndex = bip322.addBip322Input(psbt, { + message: "Script type test", + scriptId: { chain: 10, index: 0 }, + rootWalletKeys: walletKeys, + }); + + // Sign + const userXpriv = BIP32.fromBase58(xprivs[0]); + const bitgoXpriv = BIP32.fromBase58(xprivs[2]); + psbt.sign(inputIndex, userXpriv); + psbt.sign(inputIndex, bitgoXpriv); + + const pubkeys = getDerivedPubkeys(10, 0); + + // Verify with wrong script type (p2wsh instead of p2shP2wsh) + assert.throws(() => { + bip322.verifyBip322PsbtInputWithPubkeys(psbt, inputIndex, { + message: "Script type test", + pubkeys, + scriptType: "p2wsh", // Wrong! Should be p2shP2wsh + }); + }, /wrong to_spend txid/); + }); + }); +});