From 85725cbaed0b7f8ce524c317f2c729cb28efeeb8 Mon Sep 17 00:00:00 2001 From: bitgobot Date: Tue, 29 Sep 2026 20:32:38 +0000 Subject: [PATCH 1/2] feat(wasm-utxo): add sighash policy primitives to BitGoPsbt Add get_input_sighash_types() and assert_sighash_all_policy() to the BitGoPsbt wasm class and its TypeScript wrapper. assert_sighash_all_policy enforces the policy required when signing externally supplied PSBTs: every input's declared sighash type and every signature already present in the PSBT must commit to the entire transaction (SIGHASH_ALL 0x01, SIGHASH_ALL|FORKID 0x41 on BCH-family coins, SIGHASH_DEFAULT 0x00/SIGHASH_ALL on Taproot inputs). An absent sighash type is accepted and uses the signer default. get_input_sighash_types exposes each input's declared BIP-174 sighash type so callers can display it or apply their own policies. Why: signers honor the per-input PSBT_IN_SIGHASH_TYPE field, and SIGHASH_NONE/SINGLE/ANYONECANPAY signatures do not bind the signer to the transaction outputs. Callers that ingest foreign PSBTs (wallet recovery tools) need one shared, network-aware primitive instead of per-app reimplementations that miss the BCH FORKID and Taproot rules. Ticket: WCN-1994 Session-Id: 5c05e047-31d0-43fa-91ab-f1c595b37850 Task-Id: f7cdbdd7-f9f0-4164-a69b-0c7e58e609a8 --- .../js/fixedScriptWallet/BitGoPsbt.ts | 43 +++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 311 ++++++++++++++++++ .../fixed_script_wallet/bitgo_psbt/sighash.rs | 148 +++++++++ .../src/wasm/fixed_script_wallet/mod.rs | 26 ++ 4 files changed, 528 insertions(+) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 887a3169d03..53636edf178 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -1020,6 +1020,49 @@ export class BitGoPsbt return this._wasm.get_unsigned_tx(); } + /** + * Returns each input's declared sighash type (BIP-174 PSBT_IN_SIGHASH_TYPE), + * in input order. + * + * Entries are `undefined` when the input does not declare a sighash type, in + * which case the signer's default sighash type applies. + */ + getInputSighashTypes(): (number | undefined)[] { + return (this._wasm.get_input_sighash_types() as (number | null)[]).map( + (sighashType) => sighashType ?? undefined, + ); + } + + /** + * Asserts the sighash policy required when signing an externally supplied + * PSBT: every input must commit to the entire transaction. + * + * For every input this checks that + * + * 1. the declared sighash type (PSBT_IN_SIGHASH_TYPE), if present, commits + * to every input and every output: SIGHASH_ALL (0x01) — SIGHASH_ALL | + * SIGHASH_FORKID (0x41) on BCH-family coins — or SIGHASH_DEFAULT + * (0x00) / SIGHASH_ALL (0x01) on Taproot inputs. An absent sighash type + * is accepted: the signer then applies its default, which commits to + * the entire transaction. + * 2. every signature already present on the input (ECDSA partial + * signatures, Taproot key-path and script-path signatures) uses such a + * sighash type. MuSig2 partial signatures carry no sighash byte; the + * declared sighash type checked above governs them. + * + * SIGHASH_NONE, SIGHASH_SINGLE, SIGHASH_ANYONECANPAY, and combinations + * thereof leave outputs (or inputs) uncommitted, so a signature produced + * under one of them does not bind the signer to the transaction the signer + * reviewed. Callers that ingest foreign PSBTs must run this check before + * signing and again afterwards. + * + * @throws Error if any input declares a sighash type, or carries a + * signature, that does not commit to the entire transaction. + */ + assertSighashAllPolicy(): void { + this._wasm.assert_sighash_all_policy(); + } + /** * Get all PSBT outputs with resolved address strings * 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 a47b1489ec4..e2d9b705ef3 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 @@ -238,6 +238,12 @@ fn get_default_sighash_type( } } +/// An input carrying Taproot metadata spends a Taproot output, so its +/// signatures are schnorr signatures governed by the Taproot sighash rules. +fn is_taproot_input(input: &miniscript::bitcoin::psbt::Input) -> bool { + input.tap_internal_key.is_some() || !input.tap_scripts.is_empty() +} + /// Create BIP32 derivation map for all 3 wallet keys pub(crate) fn create_bip32_derivation( wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, @@ -1334,6 +1340,88 @@ impl BitGoPsbt { } } + /// Returns the declared sighash type (BIP-174 `PSBT_IN_SIGHASH_TYPE`) of + /// every input, in input order. + /// + /// `None` means the input does not declare a sighash type, in which case + /// the signer's default sighash type applies. + pub fn get_input_sighash_types(&self) -> Vec> { + self.psbt() + .inputs + .iter() + .map(|input| input.sighash_type.map(|sighash_type| sighash_type.to_u32())) + .collect() + } + + /// Asserts the sighash policy required when signing an externally + /// supplied PSBT: every input must commit to the entire transaction. + /// + /// For every input this checks that + /// + /// 1. the declared sighash type (`PSBT_IN_SIGHASH_TYPE`), if present, + /// commits to every input and every output: SIGHASH_ALL (0x01) — + /// SIGHASH_ALL | SIGHASH_FORKID (0x41) on BCH-family coins — or + /// SIGHASH_DEFAULT (0x00) / SIGHASH_ALL on Taproot inputs. An absent + /// sighash type is accepted: the signer then applies its default, + /// which commits to the entire transaction. + /// 2. every signature already present on the input (ECDSA partial + /// signatures, Taproot key-path and script-path signatures) uses + /// such a sighash type. MuSig2 partial signatures carry no sighash + /// byte; the declared sighash type checked above governs them. + /// + /// SIGHASH_NONE, SIGHASH_SINGLE, SIGHASH_ANYONECANPAY, and combinations + /// thereof leave outputs (or inputs) uncommitted, so a signature produced + /// under one of them does not bind the signer to the transaction the + /// signer reviewed. Callers that ingest foreign PSBTs must run this check + /// before signing and again afterwards. + /// + /// See WCN-1994. + pub fn assert_sighash_all_policy(&self) -> Result<(), String> { + let network = self.network(); + for (index, input) in self.psbt().inputs.iter().enumerate() { + let taproot_input = is_taproot_input(input); + if let Some(declared) = input.sighash_type { + let declared = declared.to_u32(); + if !sighash::commits_to_entire_transaction(declared, network, taproot_input) { + return Err(format!( + "Input {index} declares sighash type 0x{declared:02x}, which does not commit \ + to the entire transaction. Only SIGHASH_ALL (0x01) is accepted \ + (SIGHASH_ALL|SIGHASH_FORKID, 0x41, on BCH-family coins; SIGHASH_DEFAULT, \ + 0x00, or SIGHASH_ALL on Taproot inputs)" + )); + } + } + for (pubkey, signature) in &input.partial_sigs { + // The BitGo fork keeps the ECDSA sighash type as a raw u32 so + // that BCH-family signatures can carry SIGHASH_FORKID (0x40). + let sighash_type = signature.sighash_type; + if !sighash::commits_to_entire_transaction(sighash_type, network, false) { + return Err(format!( + "Input {index} carries an ECDSA partial signature from {pubkey} with sighash \ + type 0x{sighash_type:02x}, which does not commit to the entire transaction. \ + Only SIGHASH_ALL (0x01) is accepted (SIGHASH_ALL|SIGHASH_FORKID, 0x41, on \ + BCH-family coins)" + )); + } + } + for signature in input + .tap_key_sig + .iter() + .chain(input.tap_script_sigs.values()) + { + let sighash_type = signature.sighash_type as u32; + if !sighash::commits_to_entire_transaction(sighash_type, network, true) { + return Err(format!( + "Input {index} carries a Taproot signature with sighash type \ + 0x{sighash_type:02x}, which does not commit to the entire transaction. Only \ + SIGHASH_DEFAULT (0x00) and SIGHASH_ALL (0x01) are accepted on Taproot inputs" + )); + } + } + } + Ok(()) + } + /// Combine/merge data from another PSBT into this one /// /// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the @@ -5985,4 +6073,227 @@ mod tests { "Zcash signature over 256-byte (block-aligned) outputs preimage must verify" ); } + + fn new_psbt_with_wallet_input(network: Network, seed: &str) -> BitGoPsbt { + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use miniscript::bitcoin::hashes::Hash; + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let mut psbt = BitGoPsbt::new(network, &wallet_keys, Some(2), Some(0)); + psbt.add_wallet_input( + Txid::all_zeros(), + 0, + 100_000, + &wallet_keys, + ScriptId { + chain: 20, + index: 0, + }, + WalletInputOptions::default(), + ) + .expect("add_wallet_input"); + psbt + } + + #[test] + fn test_get_input_sighash_types() { + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use miniscript::bitcoin::hashes::Hash; + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("input_sighash_types")); + let mut psbt = BitGoPsbt::new(Network::Bitcoin, &wallet_keys, Some(2), Some(0)); + psbt.add_wallet_input( + Txid::all_zeros(), + 0, + 100_000, + &wallet_keys, + ScriptId { + chain: 20, + index: 0, + }, + WalletInputOptions::default(), + ) + .expect("add_wallet_input"); + + // add_wallet_input stamps the network's default sighash type (SIGHASH_ALL) + assert_eq!(vec![Some(1)], psbt.get_input_sighash_types()); + + // A foreign PSBT may omit the field entirely; the signer default then applies + crate::psbt_ops::PsbtAccess::psbt_mut(&mut psbt).inputs[0].sighash_type = None; + assert_eq!(vec![None], psbt.get_input_sighash_types()); + } + + #[test] + fn test_assert_sighash_all_policy_declared_types() { + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use miniscript::bitcoin::hashes::Hash; + use miniscript::bitcoin::psbt::PsbtSighashType; + + let make_psbt = |network: Network, sighash_type: Option| { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("sighash_policy")); + let mut psbt = BitGoPsbt::new(network, &wallet_keys, Some(2), Some(0)); + psbt.add_wallet_input( + Txid::all_zeros(), + 0, + 100_000, + &wallet_keys, + // p2sh works on every network under test, including BCH + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .expect("add_wallet_input"); + crate::psbt_ops::PsbtAccess::psbt_mut(&mut psbt).inputs[0].sighash_type = + sighash_type.map(PsbtSighashType::from_u32); + psbt + }; + + make_psbt(Network::Bitcoin, None) + .assert_sighash_all_policy() + .expect("absent sighash type accepted"); + make_psbt(Network::Bitcoin, Some(0x01)) + .assert_sighash_all_policy() + .expect("SIGHASH_ALL accepted"); + + // Every declaration that leaves parts of the transaction uncommitted is rejected + for sighash_type in [0x02, 0x03, 0x80, 0x81, 0x82, 0x83] { + let error = make_psbt(Network::Bitcoin, Some(sighash_type)) + .assert_sighash_all_policy() + .expect_err("unsafe declared type must be rejected"); + assert!( + error.contains("Only SIGHASH_ALL"), + "unexpected error for 0x{sighash_type:02x}: {error}" + ); + assert!( + error.contains("Input 0"), + "must name the offending input: {error}" + ); + } + + // BCH-family coins require the FORKID form of SIGHASH_ALL + make_psbt(Network::BitcoinCash, Some(0x41)) + .assert_sighash_all_policy() + .expect("SIGHASH_ALL|FORKID accepted on BCH"); + let error = make_psbt(Network::BitcoinCash, Some(0x01)) + .assert_sighash_all_policy() + .expect_err("plain SIGHASH_ALL must be rejected on BCH"); + assert!(error.contains("Only SIGHASH_ALL")); + } + + #[test] + fn test_assert_sighash_all_policy_checks_existing_signatures() { + use miniscript::bitcoin::hashes::{sha256, Hash}; + use miniscript::bitcoin::sighash::{EcdsaSighashType, TapSighashType}; + use miniscript::bitcoin::{ecdsa, taproot}; + + let secp = secp256k1::Secp256k1::new(); + let seed = sha256::Hash::hash(b"sighash_policy_signatures").to_byte_array(); + let secret_key = secp256k1::SecretKey::from_slice(&seed).expect("secret key"); + let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + let dummy_ecdsa_signature = + secp256k1::ecdsa::Signature::from_compact(&[0u8; 64]).expect("ecdsa signature"); + let dummy_schnorr_signature = + secp256k1::schnorr::Signature::from_slice(&[0u8; 64]).expect("schnorr signature"); + + let insert_partial_sig = |sighash_type: EcdsaSighashType| { + let mut psbt = + new_psbt_with_wallet_input(Network::Bitcoin, "sighash_policy_signatures"); + crate::psbt_ops::PsbtAccess::psbt_mut(&mut psbt).inputs[0] + .partial_sigs + .insert( + miniscript::bitcoin::PublicKey::new(public_key), + ecdsa::Signature { + signature: dummy_ecdsa_signature, + sighash_type: sighash_type as u32, + }, + ); + psbt + }; + + insert_partial_sig(EcdsaSighashType::All) + .assert_sighash_all_policy() + .expect("SIGHASH_ALL partial signature accepted"); + let error = insert_partial_sig(EcdsaSighashType::None) + .assert_sighash_all_policy() + .expect_err("SIGHASH_NONE partial signature must be rejected"); + assert!(error.contains("ECDSA partial signature")); + assert!(error.contains("Only SIGHASH_ALL")); + let error = insert_partial_sig(EcdsaSighashType::AllPlusAnyoneCanPay) + .assert_sighash_all_policy() + .expect_err("ANYONECANPAY partial signature must be rejected"); + assert!(error.contains("Only SIGHASH_ALL")); + + let insert_tap_key_sig = |sighash_type: TapSighashType| { + let mut psbt = + new_psbt_with_wallet_input(Network::Bitcoin, "sighash_policy_signatures"); + crate::psbt_ops::PsbtAccess::psbt_mut(&mut psbt).inputs[0].tap_key_sig = + Some(taproot::Signature { + signature: dummy_schnorr_signature, + sighash_type, + }); + psbt + }; + + insert_tap_key_sig(TapSighashType::Default) + .assert_sighash_all_policy() + .expect("SIGHASH_DEFAULT taproot signature accepted"); + insert_tap_key_sig(TapSighashType::All) + .assert_sighash_all_policy() + .expect("SIGHASH_ALL taproot signature accepted"); + let error = insert_tap_key_sig(TapSighashType::Single) + .assert_sighash_all_policy() + .expect_err("SIGHASH_SINGLE taproot signature must be rejected"); + assert!(error.contains("Taproot signature")); + } + + #[test] + fn test_assert_sighash_all_policy_accepts_signed_wallet_psbt() { + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use miniscript::bitcoin::bip32::{DerivationPath, Xpriv}; + use miniscript::bitcoin::hashes::{sha256, Hash}; + use miniscript::bitcoin::secp256k1::Secp256k1; + use miniscript::bitcoin::Network as BitcoinNetwork; + + let seed = "sighash_policy_signed"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let mut psbt = BitGoPsbt::new(Network::Bitcoin, &wallet_keys, Some(2), Some(0)); + psbt.add_wallet_input( + Txid::all_zeros(), + 0, + 100_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .expect("add_wallet_input"); + psbt.add_wallet_output(1, 0, 90_000, &wallet_keys) + .expect("add_wallet_output"); + + let secp = Secp256k1::new(); + let user_xpriv = Xpriv::new_master( + BitcoinNetwork::Testnet, + &sha256::Hash::hash(format!("{seed}.0").as_bytes()).to_byte_array(), + ) + .expect("user xpriv"); + let bitgo_xpriv = Xpriv::new_master( + BitcoinNetwork::Testnet, + &sha256::Hash::hash(format!("{seed}.2").as_bytes()).to_byte_array(), + ) + .expect("bitgo xpriv"); + let path = DerivationPath::from_str("m/0/0/0/0").expect("derivation path"); + let user_privkey = user_xpriv + .derive_priv(&secp, &path) + .expect("derive user") + .private_key; + let bitgo_privkey = bitgo_xpriv + .derive_priv(&secp, &path) + .expect("derive bitgo") + .private_key; + + psbt.sign_with_privkey(0, &user_privkey).expect("sign user"); + psbt.sign_with_privkey(0, &bitgo_privkey) + .expect("sign bitgo"); + + psbt.assert_sighash_all_policy() + .expect("signed wallet PSBT satisfies the policy"); + } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs index c64c5ec6c98..13ced4c01ec 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs @@ -76,6 +76,45 @@ pub fn validate_sighash_type(sighash_type: u32, network: Network) -> Result<(), } } +/// Implicit Taproot sighash type (BIP-341). +/// +/// A 64-byte Taproot signature is implicitly SIGHASH_DEFAULT which, like +/// SIGHASH_ALL, commits to every input and every output of the transaction. +const SIGHASH_DEFAULT: u32 = 0x00; + +/// Returns true when a sighash type commits a signature to the entire +/// transaction: every input and every output. +/// +/// These are the only sighash types accepted when signing externally +/// supplied PSBTs. By input kind and network: +/// +/// - Taproot inputs: SIGHASH_DEFAULT (0x00) and SIGHASH_ALL (0x01) +/// - BCH-family networks (BCH/BSV/BTG/Ecash): SIGHASH_ALL | SIGHASH_FORKID (0x41) +/// - all other inputs and networks: SIGHASH_ALL (0x01) +/// +/// SIGHASH_NONE, SIGHASH_SINGLE, SIGHASH_ANYONECANPAY, and combinations +/// thereof leave parts of the transaction (outputs or inputs) uncommitted, +/// so a signature produced under one of them does not bind the signer to +/// the outputs the signer intended to authorize. +pub(crate) fn commits_to_entire_transaction( + sighash_type: u32, + network: Network, + taproot_input: bool, +) -> bool { + if taproot_input { + return matches!(sighash_type, SIGHASH_DEFAULT | SIGHASH_ALL); + } + let uses_forkid = matches!( + network.mainnet(), + Network::BitcoinCash | Network::BitcoinGold | Network::BitcoinSV | Network::Ecash + ); + if uses_forkid { + sighash_type == SIGHASH_ALL | SIGHASH_FORKID + } else { + sighash_type == SIGHASH_ALL + } +} + #[cfg(test)] mod tests { use super::*; @@ -219,4 +258,113 @@ mod tests { .is_err() ); } + + #[test] + fn test_commits_to_entire_transaction_bitcoin() { + // Taproot inputs accept SIGHASH_DEFAULT and SIGHASH_ALL + assert!(commits_to_entire_transaction( + SIGHASH_ALL, + Network::Bitcoin, + true + )); + assert!(commits_to_entire_transaction( + SIGHASH_DEFAULT, + Network::Bitcoin, + true + )); + for sighash_type in [SIGHASH_NONE, SIGHASH_SINGLE, 0x80, 0x81, 0x82, 0x83, 0x41] { + assert!( + !commits_to_entire_transaction(sighash_type, Network::Bitcoin, true), + "Taproot input must reject 0x{sighash_type:02x}" + ); + } + + // Non-Taproot inputs accept SIGHASH_ALL only + assert!(commits_to_entire_transaction( + SIGHASH_ALL, + Network::Bitcoin, + false + )); + for sighash_type in [ + SIGHASH_DEFAULT, + SIGHASH_NONE, + SIGHASH_SINGLE, + 0x80, + 0x81, + 0x82, + 0x83, + 0x41, + ] { + assert!( + !commits_to_entire_transaction(sighash_type, Network::Bitcoin, false), + "Bitcoin input must reject 0x{sighash_type:02x}" + ); + } + + // Non-forked testnets follow the same rules as their mainnet + assert!(commits_to_entire_transaction( + SIGHASH_ALL, + Network::BitcoinTestnet3, + false + )); + assert!(!commits_to_entire_transaction( + SIGHASH_ALL | SIGHASH_FORKID, + Network::Litecoin, + false + )); + assert!(commits_to_entire_transaction( + SIGHASH_ALL, + Network::Dogecoin, + false + )); + } + + #[test] + fn test_commits_to_entire_transaction_forkid_networks() { + let forkid_networks = [ + Network::BitcoinCash, + Network::BitcoinGold, + Network::BitcoinSV, + Network::Ecash, + ]; + for network in forkid_networks { + assert!( + commits_to_entire_transaction(SIGHASH_ALL | SIGHASH_FORKID, network, false), + "{network:?} must accept SIGHASH_ALL|FORKID" + ); + for sighash_type in [ + SIGHASH_ALL, + SIGHASH_NONE, + SIGHASH_SINGLE, + SIGHASH_DEFAULT, + 0x42, + 0x43, + 0xC1, + 0xC2, + 0xC3, + ] { + assert!( + !commits_to_entire_transaction(sighash_type, network, false), + "{network:?} must reject 0x{sighash_type:02x}" + ); + } + } + + // Testnet variants normalize to their mainnet + assert!(commits_to_entire_transaction( + SIGHASH_ALL | SIGHASH_FORKID, + Network::BitcoinCashTestnet, + false + )); + assert!(commits_to_entire_transaction( + SIGHASH_ALL | SIGHASH_FORKID, + Network::BitcoinGoldTestnet, + false + )); + assert!(!commits_to_entire_transaction( + SIGHASH_ALL, + Network::BitcoinCashTestnet, + false + )); + } } 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 b8894311a1f..ca61c11c249 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1289,6 +1289,32 @@ impl BitGoPsbt { self.psbt.network().to_string() } + /// Returns each input's declared sighash type (BIP-174 + /// `PSBT_IN_SIGHASH_TYPE`), in input order. + /// + /// Entries are `null` when the input does not declare a sighash type, in + /// which case the signer's default sighash type applies. + pub fn get_input_sighash_types(&self) -> Result { + self.psbt.get_input_sighash_types().try_to_js_value() + } + + /// Asserts the sighash policy required when signing an externally + /// supplied PSBT: every input must commit to the entire transaction. + /// + /// Rejects SIGHASH_NONE, SIGHASH_SINGLE, SIGHASH_ANYONECANPAY, and + /// combinations thereof — in both the declared per-input sighash type + /// and the signatures already present in the PSBT — because signatures + /// produced under those types do not bind the signer to the outputs of + /// the transaction. Accepted types are SIGHASH_ALL (0x01), + /// SIGHASH_ALL | SIGHASH_FORKID (0x41) on BCH-family networks, and + /// SIGHASH_DEFAULT (0x00) / SIGHASH_ALL (0x01) on Taproot inputs; an + /// absent sighash type uses the signer default. + pub fn assert_sighash_all_policy(&self) -> Result<(), WasmUtxoError> { + self.psbt + .assert_sighash_all_policy() + .map_err(|e| WasmUtxoError::new(&e)) + } + /// Get the network type for transaction extraction /// /// Returns "bitcoin", "dash", or "zcash" to indicate which transaction From b8403ec0289591b7b0d1415591835efe9900b53c Mon Sep 17 00:00:00 2001 From: bitgobot Date: Tue, 29 Sep 2026 20:39:02 +0000 Subject: [PATCH 2/2] test(wasm-utxo): cover the BitGoPsbt sighash policy primitives Add mocha coverage for getInputSighashTypes and assertSighashAllPolicy: declared NONE/SINGLE/ANYONECANPAY and combined modes are rejected before signing, absent types use the signer default, BCH-family coins require SIGHASH_ALL|FORKID, Taproot inputs accept SIGHASH_DEFAULT, and real signing runs (p2wsh, BCH p2sh, Taproot) keep the policy satisfied while binding signatures to the outputs (an output swap invalidates every signature). Ticket: WCN-1994 Session-Id: 5c05e047-31d0-43fa-91ab-f1c595b37850 Task-Id: f7cdbdd7-f9f0-4164-a69b-0c7e58e609a8 --- .../test/fixedScript/sighashPolicy.ts | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 packages/wasm-utxo/test/fixedScript/sighashPolicy.ts diff --git a/packages/wasm-utxo/test/fixedScript/sighashPolicy.ts b/packages/wasm-utxo/test/fixedScript/sighashPolicy.ts new file mode 100644 index 00000000000..5bbe38c836a --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/sighashPolicy.ts @@ -0,0 +1,243 @@ +import assert from "node:assert"; +import { BitGoPsbt, ChainCode } from "../../js/fixedScriptWallet/index.js"; +import type { CoinName } from "../../js/coinName.js"; +import type { OutputScriptType } from "../../js/fixedScriptWallet/scriptType.js"; +import { getKey, getWalletKeysForSeed } from "../../js/testutils/keys.js"; + +const SEED = "sighash-policy"; +const RECIPIENT = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; +const ATTACKER = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"; +const INPUT_VALUE = 100_000n; + +function userKey(): ReturnType { + return getKey(`${SEED}.0`); +} + +/** + * Build a wallet PSBT with `inputCount` wallet inputs of the given script + * type. `addWalletInput` stamps each input with the network's default sighash + * type, mirroring the PSBTs produced by wasm-utxo itself. + */ +function createWalletPsbt( + coinName: CoinName, + inputCount: number, + scriptType: OutputScriptType, +): BitGoPsbt { + const walletKeys = getWalletKeysForSeed(SEED); + const psbt = BitGoPsbt.createEmpty(coinName, walletKeys, { version: 2, lockTime: 0 }); + const chain = ChainCode.value(scriptType, "external"); + for (let inputIndex = 0; inputIndex < inputCount; inputIndex++) { + psbt.addWalletInput( + { + txid: inputIndex.toString(16).padStart(2, "0").repeat(32), + vout: inputIndex, + value: INPUT_VALUE, + }, + walletKeys, + { + scriptId: { chain, index: inputIndex }, + signPath: scriptType.startsWith("p2tr") ? { signer: "user", cosigner: "bitgo" } : undefined, + }, + ); + } + return psbt; +} + +function toHex(psbt: BitGoPsbt): string { + return Buffer.from(psbt.serialize()).toString("hex"); +} + +function fromHex(hex: string, coinName: CoinName): BitGoPsbt { + return BitGoPsbt.fromBytes(Buffer.from(hex, "hex"), coinName); +} + +function readCompactSize(bytes: Buffer, offset: number): [number, number] { + const prefix = bytes[offset]; + if (prefix < 0xfd) return [prefix, offset + 1]; + if (prefix === 0xfd) return [bytes.readUInt16LE(offset + 1), offset + 3]; + if (prefix === 0xfe) return [bytes.readUInt32LE(offset + 1), offset + 5]; + return [Number(bytes.readBigUInt64LE(offset + 1)), offset + 9]; +} + +/** + * Rewrite (or remove) the BIP-174 PSBT_IN_SIGHASH_TYPE value of a single input + * in serialized PSBT bytes, simulating a foreign PSBT crafted with an + * attacker-chosen sighash type. + */ +function rewriteInputSighashType( + psbtHex: string, + inputIndex: number, + sighashType: number | undefined, +): string { + const bytes = Buffer.from(psbtHex, "hex"); + let offset = 5; + + function readMap(targetInput: boolean): Buffer | undefined { + while (offset < bytes.length) { + const entryStart = offset; + const [keyLength, keyStart] = readCompactSize(bytes, offset); + offset = keyStart; + if (keyLength === 0) return undefined; + + const keyType = keyLength === 1 ? bytes[offset] : -1; + offset += keyLength; + const [valueLength, valueStart] = readCompactSize(bytes, offset); + offset = valueStart; + const valueEnd = valueStart + valueLength; + + if (targetInput && keyType === 0x03) { + if (sighashType === undefined) { + return Buffer.concat([bytes.subarray(0, entryStart), bytes.subarray(valueEnd)]); + } + if (valueLength !== 4) { + throw new Error("Expected a four-byte PSBT sighash value"); + } + bytes.writeUInt32LE(sighashType, valueStart); + return bytes; + } + offset = valueEnd; + } + return undefined; + } + + readMap(false); // global map + for (let index = 0; index <= inputIndex; index++) { + const rewritten = readMap(index === inputIndex); + if (rewritten) return rewritten.toString("hex"); + } + throw new Error(`No sighash type found for input ${inputIndex}`); +} + +function craftPsbt( + coinName: CoinName, + inputCount: number, + scriptType: OutputScriptType, + inputIndex: number, + sighashType: number | undefined, +): BitGoPsbt { + return fromHex( + rewriteInputSighashType( + toHex(createWalletPsbt(coinName, inputCount, scriptType)), + inputIndex, + sighashType, + ), + coinName, + ); +} + +describe("BitGoPsbt sighash policy", function () { + it("reports the declared sighash type of every input", function () { + const psbt = createWalletPsbt("btc", 2, "p2wsh"); + assert.deepStrictEqual(psbt.getInputSighashTypes(), [0x01, 0x01]); + + const crafted = craftPsbt("btc", 2, "p2wsh", 1, 0x03); + assert.deepStrictEqual(crafted.getInputSighashTypes(), [0x01, 0x03]); + + const omitted = craftPsbt("btc", 2, "p2wsh", 1, undefined); + assert.deepStrictEqual(omitted.getInputSighashTypes(), [0x01, undefined]); + }); + + it("accepts SIGHASH_ALL and absent sighash types", function () { + createWalletPsbt("btc", 1, "p2wsh").assertSighashAllPolicy(); + craftPsbt("btc", 1, "p2wsh", 0, 0x01).assertSighashAllPolicy(); + craftPsbt("btc", 2, "p2wsh", 0, undefined).assertSighashAllPolicy(); + }); + + const unsafeSighashModes = [ + ["SIGHASH_NONE", 0x02], + ["SIGHASH_SINGLE", 0x03], + ["SIGHASH_ANYONECANPAY", 0x80], + ["SIGHASH_ALL|ANYONECANPAY", 0x81], + ["SIGHASH_NONE|ANYONECANPAY", 0x82], + ["SIGHASH_SINGLE|ANYONECANPAY", 0x83], + ] as const; + + for (const [name, sighashType] of unsafeSighashModes) { + it(`rejects ${name}`, function () { + const psbt = craftPsbt("btc", 1, "p2wsh", 0, sighashType); + assert.throws(() => psbt.assertSighashAllPolicy(), /Only SIGHASH_ALL/); + }); + } + + it("rejects an unsafe sighash type on any input and names it", function () { + // SIGHASH_SINGLE on the second input of a PSBT that has no matching + // output would produce a signature over the constant "one" hash. + const psbt = craftPsbt("btc", 2, "p2wsh", 1, 0x03); + assert.throws(() => psbt.assertSighashAllPolicy(), /Input 1 .*Only SIGHASH_ALL/); + }); + + it("requires the FORKID form of SIGHASH_ALL on BCH-family coins", function () { + const bch = createWalletPsbt("bch", 1, "p2sh"); + assert.deepStrictEqual(bch.getInputSighashTypes(), [0x41]); + bch.assertSighashAllPolicy(); + + assert.throws( + () => craftPsbt("bch", 1, "p2sh", 0, 0x01).assertSighashAllPolicy(), + /Only SIGHASH_ALL/, + ); + assert.throws( + () => craftPsbt("bch", 1, "p2sh", 0, 0x42).assertSighashAllPolicy(), + /Only SIGHASH_ALL/, + ); + craftPsbt("bch", 1, "p2sh", 0, 0x41).assertSighashAllPolicy(); + }); + + it("accepts SIGHASH_DEFAULT only on Taproot inputs", function () { + const taproot = createWalletPsbt("btc", 1, "p2trLegacy"); + assert.deepStrictEqual(taproot.getInputSighashTypes(), [0x00]); + taproot.assertSighashAllPolicy(); + craftPsbt("btc", 1, "p2trLegacy", 0, 0x01).assertSighashAllPolicy(); + assert.throws( + () => craftPsbt("btc", 1, "p2trLegacy", 0, 0x02).assertSighashAllPolicy(), + /Only SIGHASH_ALL/, + ); + + // SIGHASH_DEFAULT is not a valid ECDSA sighash type + assert.throws( + () => craftPsbt("btc", 1, "p2wsh", 0, 0x00).assertSighashAllPolicy(), + /Only SIGHASH_ALL/, + ); + }); + + it("keeps the policy satisfied when signing wallet PSBTs and binds signatures to the outputs", function () { + const psbt = createWalletPsbt("btc", 2, "p2wsh"); + psbt.addOutput(RECIPIENT, 190_000n); + const signedInputIndexes = psbt.sign(userKey()); + assert.deepStrictEqual(signedInputIndexes, [0, 1]); + + psbt.assertSighashAllPolicy(); + for (const inputIndex of signedInputIndexes) { + assert(psbt.verifySignature(inputIndex, userKey().neutered()), `input ${inputIndex}`); + } + + // A SIGHASH_ALL signature commits to every output: swapping the + // recipient output invalidates the signatures. + const outputValue = psbt.getOutputs()[0].value; + psbt.removeOutput(0); + psbt.addOutput(ATTACKER, outputValue); + for (const inputIndex of [0, 1]) { + let signatureValid = false; + try { + signatureValid = psbt.verifySignature(inputIndex, userKey().neutered()); + } catch { + // a mismatched sighash may be reported as a verification error + } + assert(!signatureValid, `input ${inputIndex} must not validate after an output swap`); + } + }); + + it("accepts signed BCH PSBTs whose signatures carry SIGHASH_ALL|FORKID", function () { + const psbt = createWalletPsbt("bch", 1, "p2sh"); + psbt.sign(userKey()); + psbt.assertSighashAllPolicy(); + assert(psbt.verifySignature(0, userKey().neutered())); + }); + + it("accepts signed Taproot PSBTs whose signatures use SIGHASH_DEFAULT", function () { + const psbt = createWalletPsbt("btc", 1, "p2trLegacy"); + const signedInputIndexes = psbt.sign(userKey()); + assert.deepStrictEqual(signedInputIndexes, [0]); + psbt.assertSighashAllPolicy(); + assert(psbt.verifySignature(0, userKey().neutered())); + }); +});