diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 47103e5..80cb5a2 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -142,6 +142,66 @@ export class BitGoPsbt { return this.wasm.verify_signature_with_pub(inputIndex, wasmECPair); } + /** + * Sign a single input with a private key + * + * This method signs a specific input using the provided key. It accepts either: + * - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs - derives the key and signs + * - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs - signs directly + * + * This method automatically detects and handles different input types: + * - For regular inputs: uses standard PSBT signing + * - For MuSig2 inputs: uses the FirstRound state stored by generateMusig2Nonces() + * - For replay protection inputs: signs with legacy P2SH sighash + * + * @param inputIndex - The index of the input to sign (0-based) + * @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg) + * @throws Error if signing fails, or if generateMusig2Nonces() was not called first for MuSig2 inputs + * + * @example + * ```typescript + * // Parse transaction to identify input types + * const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection); + * + * // Sign regular wallet inputs with xpriv + * for (let i = 0; i < parsed.inputs.length; i++) { + * const input = parsed.inputs[i]; + * if (input.scriptId !== null && input.scriptType !== "p2shP2pk") { + * psbt.sign(i, userXpriv); + * } + * } + * + * // Sign replay protection inputs with raw privkey + * const userPrivkey = bip32.fromBase58(userXpriv).privateKey!; + * for (let i = 0; i < parsed.inputs.length; i++) { + * const input = parsed.inputs[i]; + * if (input.scriptType === "p2shP2pk") { + * psbt.sign(i, userPrivkey); + * } + * } + * ``` + */ + sign(inputIndex: number, key: BIP32Arg | ECPairArg): void { + // Detect key type + // If string or has 'derive' method → BIP32Arg + // Otherwise → ECPairArg + if ( + typeof key === "string" || + (typeof key === "object" && + key !== null && + "derive" in key && + typeof key.derive === "function") + ) { + // It's a BIP32Arg + const wasmKey = BIP32.from(key as BIP32Arg); + this.wasm.sign_with_xpriv(inputIndex, wasmKey.wasm); + } else { + // It's an ECPairArg + const wasmKey = ECPair.from(key as ECPairArg); + this.wasm.sign_with_privkey(inputIndex, wasmKey.wasm); + } + } + /** * @deprecated - use verifySignature with the replay protection key instead * @@ -179,6 +239,72 @@ export class BitGoPsbt { return this.wasm.serialize(); } + /** + * Generate and store MuSig2 nonces for all MuSig2 inputs + * + * This method generates nonces using the State-Machine API and stores them in the PSBT. + * The nonces are stored as proprietary fields in the PSBT and will be included when serialized. + * After ALL participants have generated their nonces, you can sign MuSig2 inputs using + * sign(). + * + * @param key - The extended private key (xpriv) for signing. Can be a base58 string, BIP32 instance, or WasmBIP32 + * @param sessionId - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**. + * On mainnets, a secure random session ID is always generated automatically. + * Must be unique per signing session. + * @throws Error if nonce generation fails, sessionId length is invalid, or custom sessionId is + * provided on a mainnet (security restriction) + * + * @security The sessionId MUST be cryptographically random and unique for each signing session. + * Never reuse a sessionId with the same key! On mainnets, sessionId is always randomly + * generated for security. Custom sessionId is only allowed on testnets for testing purposes. + * + * @example + * ```typescript + * // Phase 1: Both parties generate nonces (with auto-generated session ID) + * psbt.generateMusig2Nonces(userXpriv); + * // Nonces are stored in the PSBT + * // Send PSBT to counterparty + * + * // Phase 2: After receiving counterparty PSBT with their nonces + * const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network); + * psbt.combineMusig2Nonces(counterpartyPsbt); + * // Sign MuSig2 key path inputs + * const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection); + * for (let i = 0; i < parsed.inputs.length; i++) { + * if (parsed.inputs[i].scriptType === "p2trMusig2KeyPath") { + * psbt.sign(i, userXpriv); + * } + * } + * ``` + */ + generateMusig2Nonces(key: BIP32Arg, sessionId?: Uint8Array): void { + const wasmKey = BIP32.from(key); + this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId); + } + + /** + * Combine/merge data from another PSBT into this one + * + * This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the + * source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange + * and signature collection phases. + * + * @param sourcePsbt - The source PSBT containing data to merge + * @throws Error if networks don't match + * + * @example + * ```typescript + * // After receiving counterparty's PSBT with their nonces + * const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network); + * psbt.combineMusig2Nonces(counterpartyPsbt); + * // Now can sign with all nonces present + * psbt.sign(0, userXpriv); + * ``` + */ + combineMusig2Nonces(sourcePsbt: BitGoPsbt): void { + this.wasm.combine_musig2_nonces(sourcePsbt.wasm); + } + /** * Finalize all inputs in the PSBT * 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 477b533..33a8a7c 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 @@ -204,6 +204,71 @@ impl BitGoPsbt { } } + /// Combine/merge data from another PSBT into this one + /// + /// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the + /// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange + /// and signature collection phases. + /// + /// # Arguments + /// * `source_psbt` - The source PSBT containing data to merge + /// + /// # Returns + /// Ok(()) if data was successfully merged + /// + /// # Errors + /// Returns error if networks don't match + pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), String> { + // Check network match + if self.network() != source_psbt.network() { + return Err(format!( + "Network mismatch: destination is {}, source is {}", + self.network(), + source_psbt.network() + )); + } + + let source = source_psbt.psbt(); + let dest = self.psbt_mut(); + + // Check that both PSBTs have the same number of inputs + if source.inputs.len() != dest.inputs.len() { + return Err(format!( + "PSBT input count mismatch: source has {} inputs, destination has {}", + source.inputs.len(), + dest.inputs.len() + )); + } + + // Copy MuSig2 nonces and partial signatures (proprietary key-values with BITGO identifier) + for (source_input, dest_input) in source.inputs.iter().zip(dest.inputs.iter_mut()) { + // Only process if the input is a MuSig2 input + if !p2tr_musig2_input::Musig2Input::is_musig2_input(source_input) { + continue; + } + + // Parse nonces from source input using native Musig2 functions + let nonces = p2tr_musig2_input::parse_musig2_nonces(source_input) + .map_err(|e| format!("Failed to parse MuSig2 nonces from source: {}", e))?; + + // Copy each nonce to the destination input + for nonce in nonces { + let (key, value) = nonce.to_key_value().to_key_value(); + dest_input.proprietary.insert(key, value); + } + + // Also copy partial signatures if present + // Partial sigs are stored as tap_script_sigs in the PSBT input + for (control_block, leaf_script) in &source_input.tap_script_sigs { + dest_input + .tap_script_sigs + .insert(*control_block, *leaf_script); + } + } + + Ok(()) + } + /// Serialize the PSBT to bytes, using network-specific logic pub fn serialize(&self) -> Result, SerializeError> { match self { @@ -426,6 +491,146 @@ impl BitGoPsbt { .map_err(|e| e.to_string()) } + /// Sign a single input with a raw private key + /// + /// This method signs a specific input using the provided private key. It automatically + /// detects the input type and uses the appropriate signing method: + /// - Replay protection inputs (P2SH-P2PK): Signs with legacy P2SH sighash + /// - Regular inputs: Uses standard PSBT signing + /// - MuSig2 inputs: Returns error (requires FirstRound state, use sign_with_first_round) + /// + /// # Arguments + /// - `input_index`: The index of the input to sign + /// - `privkey`: The private key to sign with + /// + /// # Returns + /// - `Ok(())` if signing was successful + /// - `Err(String)` if signing fails or input type is not supported + pub fn sign_with_privkey( + &mut self, + input_index: usize, + privkey: &secp256k1::SecretKey, + ) -> Result<(), String> { + use miniscript::bitcoin::{ + ecdsa::Signature as EcdsaSignature, hashes::Hash, sighash::SighashCache, PublicKey, + }; + + // Get network before mutable borrow + let network = self.network(); + let is_testnet = network.is_testnet(); + + let psbt = self.psbt_mut(); + + // Check bounds + if input_index >= psbt.inputs.len() { + return Err(format!( + "Input index {} out of bounds (total inputs: {})", + input_index, + psbt.inputs.len() + )); + } + + // Check if this is a MuSig2 input + if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { + return Err( + "MuSig2 inputs cannot be signed with raw privkey. Use sign_with_first_round instead." + .to_string(), + ); + } + + let secp = secp256k1::Secp256k1::new(); + + // Derive public key from private key + let public_key = PublicKey::from_slice( + &secp256k1::PublicKey::from_secret_key(&secp, privkey).serialize(), + ) + .map_err(|e| format!("Failed to derive public key: {}", e))?; + + // Check if this is a replay protection input (P2SH-P2PK) + if let Some(redeem_script) = &psbt.inputs[input_index].redeem_script.clone() { + // Try to extract pubkey from redeem script + if let Ok(redeem_pubkey) = Self::extract_pubkey_from_p2pk_redeem_script(redeem_script) { + // This is a replay protection input - verify the derived pubkey matches + if public_key != redeem_pubkey { + return Err( + "Public key mismatch: derived pubkey does not match redeem_script pubkey" + .to_string(), + ); + } + + // Sign the replay protection input with legacy P2SH sighash + let sighash_type = miniscript::bitcoin::sighash::EcdsaSighashType::All; + let cache = SighashCache::new(&psbt.unsigned_tx); + let sighash = cache + .legacy_signature_hash(input_index, redeem_script, sighash_type.to_u32()) + .map_err(|e| format!("Failed to compute sighash: {}", e))?; + + // Create ECDSA signature + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + let signature = secp.sign_ecdsa(&message, privkey); + let ecdsa_sig = EcdsaSignature { + signature, + sighash_type, + }; + + // Add signature to partial_sigs + psbt.inputs[input_index] + .partial_sigs + .insert(public_key, ecdsa_sig); + + return Ok(()); + } + } + + // For regular inputs (non-RP, non-MuSig2), use standard signing via miniscript + // This will handle legacy, SegWit, and Taproot script path inputs + match self { + BitGoPsbt::BitcoinLike(ref mut psbt, _network) => { + // Create a key provider that returns our single key + // Convert SecretKey to PrivateKey for the GetKey trait + // Note: The network parameter is only used for WIF serialization, not for signing + let bitcoin_network = if is_testnet { + miniscript::bitcoin::Network::Testnet + } else { + miniscript::bitcoin::Network::Bitcoin + }; + let private_key = miniscript::bitcoin::PrivateKey::new(*privkey, bitcoin_network); + let key_map = std::collections::BTreeMap::from_iter([(public_key, private_key)]); + + // Sign the PSBT + let result = psbt.sign(&key_map, &secp); + + // Check if our specific input was signed + match result { + Ok(signing_keys) => { + if signing_keys.contains_key(&input_index) { + Ok(()) + } else { + Err(format!( + "Input {} was not signed (no key found or already signed)", + input_index + )) + } + } + Err((partial_success, errors)) => { + // Check if there's an error for our specific input + if let Some(error) = errors.get(&input_index) { + Err(format!("Failed to sign input {}: {:?}", input_index, error)) + } else if partial_success.contains_key(&input_index) { + // Input was signed successfully despite other errors + Ok(()) + } else { + Err(format!("Input {} was not signed", input_index)) + } + } + } + } + BitGoPsbt::Zcash(_zcash_psbt, _network) => { + Err("Zcash signing not yet implemented".to_string()) + } + } + } + /// Sign the PSBT with the provided key. /// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt. /// diff --git a/packages/wasm-utxo/src/wasm/bip32.rs b/packages/wasm-utxo/src/wasm/bip32.rs index 0090bc1..c03934b 100644 --- a/packages/wasm-utxo/src/wasm/bip32.rs +++ b/packages/wasm-utxo/src/wasm/bip32.rs @@ -334,4 +334,12 @@ impl WasmBIP32 { pub(crate) fn to_xpub(&self) -> Result { Ok(self.0.to_xpub()) } + + /// Convert to Xpriv (for internal Rust use, not exposed to JS) + pub(crate) fn to_xpriv(&self) -> Result { + match &self.0 { + BIP32Key::Private(xpriv) => Ok(*xpriv), + BIP32Key::Public(_) => Err(WasmUtxoError::new("Cannot get xpriv from public key")), + } + } } diff --git a/packages/wasm-utxo/src/wasm/ecpair.rs b/packages/wasm-utxo/src/wasm/ecpair.rs index 71ef81a..eacbf1a 100644 --- a/packages/wasm-utxo/src/wasm/ecpair.rs +++ b/packages/wasm-utxo/src/wasm/ecpair.rs @@ -41,6 +41,13 @@ impl WasmECPair { pub(crate) fn get_public_key(&self) -> PublicKey { self.key.public_key() } + + /// Get the private key as a secp256k1::SecretKey (for internal Rust use) + pub(crate) fn get_private_key(&self) -> Result { + self.key + .secret_key() + .ok_or_else(|| WasmUtxoError::new("Cannot get private key from public-only ECPair")) + } } #[wasm_bindgen] diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 4406bb0..c65daa3 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; @@ -83,6 +84,9 @@ impl FixedScriptWalletNamespace { #[wasm_bindgen] pub struct BitGoPsbt { 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>, } #[wasm_bindgen] @@ -95,7 +99,10 @@ impl BitGoPsbt { crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::deserialize(bytes, network) .map_err(|e| WasmUtxoError::new(&format!("Failed to deserialize PSBT: {}", e)))?; - Ok(BitGoPsbt { psbt }) + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) } /// Get the unsigned transaction ID @@ -262,6 +269,261 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to serialize PSBT: {}", e))) } + /// Generate and store MuSig2 nonces for all MuSig2 inputs + /// + /// This method generates nonces using the State-Machine API and stores them in the PSBT. + /// The nonces are stored as proprietary fields in the PSBT and will be included when serialized. + /// After ALL participants have generated their nonces, they can sign MuSig2 inputs using + /// sign_with_xpriv(). + /// + /// # Arguments + /// * `xpriv` - The extended private key (xpriv) for signing + /// * `session_id_bytes` - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**. + /// On mainnets, a secure random session ID is always generated automatically. + /// Must be unique per signing session. + /// + /// # Returns + /// Ok(()) if nonces were successfully generated and stored + /// + /// # Errors + /// Returns error if: + /// - Nonce generation fails + /// - session_id length is invalid + /// - Custom session_id is provided on a mainnet (security restriction) + /// + /// # Security + /// The session_id MUST be cryptographically random and unique for each signing session. + /// Never reuse a session_id with the same key! On mainnets, session_id is always randomly + /// generated for security. Custom session_id is only allowed on testnets for testing purposes. + pub fn generate_musig2_nonces( + &mut self, + xpriv: &WasmBIP32, + session_id_bytes: Option>, + ) -> Result<(), WasmUtxoError> { + // Extract Xpriv from WasmBIP32 + let xpriv = xpriv.to_xpriv()?; + + // Get the network from the PSBT to check if custom session_id is allowed + let network = self.psbt.network(); + + // Get or generate session ID + let session_id = match session_id_bytes { + Some(bytes) => { + // Only allow custom session_id on testnets for security + if !network.is_testnet() { + return Err(WasmUtxoError::new( + "Custom session_id is only allowed on testnets. On mainnets, session_id is always randomly generated for security." + )); + } + if bytes.len() != 32 { + return Err(WasmUtxoError::new(&format!( + "Session ID must be 32 bytes, got {}", + bytes.len() + ))); + } + let mut session_id = [0u8; 32]; + session_id.copy_from_slice(&bytes); + session_id + } + None => { + // Generate secure random session ID + use getrandom::getrandom; + let mut session_id = [0u8; 32]; + getrandom(&mut session_id).map_err(|e| { + WasmUtxoError::new(&format!("Failed to generate random session ID: {}", e)) + })?; + session_id + } + }; + + // Derive xpub from xpriv to use as key + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv); + let xpub_str = xpub.to_string(); + + // Iterate over all inputs and generate nonces for MuSig2 inputs + let input_count = self.psbt.psbt().unsigned_tx.input.len(); + for input_index in 0..input_count { + // Check if this input is a MuSig2 input + let psbt = self.psbt.psbt(); + if !crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { + continue; + } + + // Generate nonce and get the FirstRound + // The nonce is automatically stored in the PSBT + let (first_round, _pub_nonce) = self + .psbt + .generate_nonce_first_round(input_index, &xpriv, session_id) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to generate nonce for input {}: {}", + input_index, e + )) + })?; + + // Store the FirstRound for later use in signing + // Use (input_index, xpub) as key so multiple parties can store their FirstRounds + self.first_rounds + .insert((input_index, xpub_str.clone()), first_round); + } + + Ok(()) + } + + /// Sign a single input with an extended private key (xpriv) + /// + /// This method signs a specific input using the provided xpriv. It accepts: + /// - An xpriv (WasmBIP32) for wallet inputs - derives the key and signs + /// + /// This method automatically detects and handles different input types: + /// - For regular inputs: uses standard PSBT signing + /// - For MuSig2 inputs: uses the FirstRound state stored by generate_musig2_nonces() + /// - For replay protection inputs: returns error (use sign_with_privkey instead) + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `xpriv`: The extended private key as a WasmBIP32 instance + /// + /// # Returns + /// - `Ok(())` if signing was successful + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_with_xpriv( + &mut self, + input_index: usize, + xpriv: &WasmBIP32, + ) -> Result<(), WasmUtxoError> { + // Extract Xpriv from WasmBIP32 + let xpriv = xpriv.to_xpriv()?; + + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + + // Check if this is a MuSig2 input + let psbt = self.psbt.psbt(); + if input_index >= psbt.inputs.len() { + return Err(WasmUtxoError::new(&format!( + "Input index {} out of bounds (total inputs: {})", + input_index, + psbt.inputs.len() + ))); + } + + if crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input( + &psbt.inputs[input_index], + ) { + // This is a MuSig2 input - use FirstRound signing + let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv); + let xpub_str = xpub.to_string(); + + // Remove the stored FirstRound for this (input, xpub) pair (it can only be used once) + let first_round = self.first_rounds.remove(&(input_index, xpub_str.clone())) + .ok_or_else(|| WasmUtxoError::new(&format!( + "No FirstRound found for input {} and xpub {}. You must call generate_musig2_nonces() first.", + input_index, xpub_str + )))?; + + // Sign with the FirstRound + self.psbt + .sign_with_first_round(input_index, first_round, &xpriv) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to sign MuSig2 input {}: {}", + input_index, e + )) + })?; + + Ok(()) + } else { + // This is a regular input - use standard signing + // Sign the PSBT - this will attempt to sign all inputs but we only care about the result + // The miniscript sign method returns (SigningKeysMap, SigningErrors) on error + let result = self.psbt.sign(&xpriv, &secp); + + // Check if this specific input was signed successfully + match result { + Ok(signing_keys) => { + // Check if our input_index was in the successfully signed keys + if signing_keys.contains_key(&input_index) { + Ok(()) + } else { + Err(WasmUtxoError::new(&format!( + "Input {} was not signed (no key found or already signed)", + input_index + ))) + } + } + Err((partial_success, errors)) => { + // Check if there's an error for our specific input + if let Some(error) = errors.get(&input_index) { + Err(WasmUtxoError::new(&format!( + "Failed to sign input {}: {:?}", + input_index, error + ))) + } else if partial_success.contains_key(&input_index) { + // Input was signed successfully despite other errors + Ok(()) + } else { + Err(WasmUtxoError::new(&format!( + "Input {} was not signed", + input_index + ))) + } + } + } + } + } + + /// Sign a single input with a raw private key + /// + /// This method signs a specific input using the provided ECPair. It accepts: + /// - A raw privkey (WasmECPair) for replay protection inputs - signs directly + /// + /// This method automatically detects and handles different input types: + /// - For replay protection inputs: signs with legacy P2SH sighash + /// - For regular inputs: uses standard PSBT signing + /// - For MuSig2 inputs: returns error (requires FirstRound, use sign_with_xpriv instead) + /// + /// # Arguments + /// - `input_index`: The index of the input to sign (0-based) + /// - `ecpair`: The ECPair containing the private key + /// + /// # Returns + /// - `Ok(())` if signing was successful + /// - `Err(WasmUtxoError)` if signing fails + pub fn sign_with_privkey( + &mut self, + input_index: usize, + ecpair: &WasmECPair, + ) -> Result<(), WasmUtxoError> { + // Extract private key from WasmECPair + let privkey = ecpair.get_private_key()?; + + // Call the Rust implementation + self.psbt + .sign_with_privkey(input_index, &privkey) + .map_err(|e| WasmUtxoError::new(&format!("Failed to sign input: {}", e))) + } + + /// Combine/merge data from another PSBT into this one + /// + /// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the + /// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange + /// and signature collection phases. + /// + /// # Arguments + /// * `source_psbt` - The source PSBT containing data to merge + /// + /// # Returns + /// Ok(()) if data was successfully merged + /// + /// # Errors + /// Returns error if networks don't match + pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), WasmUtxoError> { + self.psbt + .combine_musig2_nonces(&source_psbt.psbt) + .map_err(|e| WasmUtxoError::new(&format!("Failed to combine PSBTs: {}", e))) + } + /// Finalize all inputs in the PSBT /// /// This method attempts to finalize all inputs in the PSBT, computing the final diff --git a/packages/wasm-utxo/src/wasm/replay_protection.rs b/packages/wasm-utxo/src/wasm/replay_protection.rs index 6f749b5..e50e506 100644 --- a/packages/wasm-utxo/src/wasm/replay_protection.rs +++ b/packages/wasm-utxo/src/wasm/replay_protection.rs @@ -14,6 +14,8 @@ pub struct WasmReplayProtection { impl WasmReplayProtection { /// Create from output scripts directly #[wasm_bindgen] + // Box<[T]> is required by wasm-bindgen for passing JavaScript arrays + #[allow(clippy::boxed_local)] pub fn from_output_scripts(output_scripts: Box<[js_sys::Uint8Array]>) -> WasmReplayProtection { let scripts = output_scripts .iter() @@ -29,6 +31,8 @@ impl WasmReplayProtection { /// Create from addresses (requires network for decoding) #[wasm_bindgen] + // Box<[T]> is required by wasm-bindgen for passing JavaScript arrays + #[allow(clippy::boxed_local)] pub fn from_addresses( addresses: Box<[JsValue]>, network: &str, @@ -68,6 +72,8 @@ impl WasmReplayProtection { /// Create from public keys (derives P2SH-P2PK output scripts) #[wasm_bindgen] + // Box<[T]> is required by wasm-bindgen for passing JavaScript arrays + #[allow(clippy::boxed_local)] pub fn from_public_keys( public_keys: Box<[js_sys::Uint8Array]>, ) -> Result { diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index 4423ca8..75013cb 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -7,6 +7,8 @@ import type { IWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import { BIP32, type BIP32Interface } from "../../js/bip32.js"; import { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; import { ECPair } from "../../js/ecpair.js"; +import { fixedScriptWallet } from "../../js/index.js"; +import type { BitGoPsbt, NetworkName } from "../../js/fixedScriptWallet/index.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -103,6 +105,16 @@ export function getPsbtBuffer(fixture: Fixture): Buffer { return Buffer.from(fixture.psbtBase64, "base64"); } +/** + * Get BitGoPsbt from a fixture + * @param fixture - The test fixture + * @param networkName - The network name for deserializing the PSBT + * @returns A BitGoPsbt instance + */ +export function getBitGoPsbt(fixture: Fixture, networkName: NetworkName): BitGoPsbt { + return fixedScriptWallet.BitGoPsbt.fromBytes(getPsbtBuffer(fixture), networkName); +} + /** * Load a PSBT fixture from JSON file */ diff --git a/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts b/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts new file mode 100644 index 0000000..156bae1 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/musig2Nonces.ts @@ -0,0 +1,109 @@ +import assert from "assert"; +import { BIP32 } from "../../js/bip32.js"; +import { loadPsbtFixture, getBitGoPsbt, type Fixture } from "./fixtureUtil.js"; + +describe("MuSig2 nonce management", function () { + describe("Bitcoin mainnet", function () { + const networkName = "bitcoin"; + let fixture: Fixture; + let userKey: BIP32; + let backupKey: BIP32; + let bitgoKey: BIP32; + + before(function () { + fixture = loadPsbtFixture(networkName, "unsigned"); + userKey = BIP32.fromBase58(fixture.walletKeys[0]); + backupKey = BIP32.fromBase58(fixture.walletKeys[1]); + bitgoKey = BIP32.fromBase58(fixture.walletKeys[2]); + }); + + it("should generate nonces for MuSig2 inputs with auto-generated session ID", function () { + const unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName); + // Generate nonces with auto-generated session ID (no second parameter) + assert.doesNotThrow(() => { + unsignedBitgoPsbt.generateMusig2Nonces(userKey); + }); + // Verify nonces were stored by serializing and deserializing + const serializedWithUserNonces = unsignedBitgoPsbt.serialize(); + assert.ok( + serializedWithUserNonces.length > getBitGoPsbt(fixture, networkName).serialize().length, + ); + + assert.doesNotThrow(() => { + unsignedBitgoPsbt.generateMusig2Nonces(bitgoKey); + }); + + const serializedWithBitgoNonces = unsignedBitgoPsbt.serialize(); + assert.ok(serializedWithBitgoNonces.length > serializedWithUserNonces.length); + + assert.throws(() => { + unsignedBitgoPsbt.generateMusig2Nonces(backupKey); + }, "Should throw error when generating nonces for backup key"); + }); + + it("implements combineMusig2Nonces", function () { + const unsignedBitgoPsbtWithUserNonces = getBitGoPsbt(fixture, networkName); + unsignedBitgoPsbtWithUserNonces.generateMusig2Nonces(userKey); + + const unsignedBitgoPsbtWithBitgoNonces = getBitGoPsbt(fixture, networkName); + unsignedBitgoPsbtWithBitgoNonces.generateMusig2Nonces(bitgoKey); + + const unsignedBitgoPsbtWithBothNonces = getBitGoPsbt(fixture, networkName); + unsignedBitgoPsbtWithBothNonces.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces); + unsignedBitgoPsbtWithBothNonces.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces); + + { + const psbt = getBitGoPsbt(fixture, networkName); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces); + assert.strictEqual( + psbt.serialize().length, + unsignedBitgoPsbtWithUserNonces.serialize().length, + ); + } + + { + const psbt = getBitGoPsbt(fixture, networkName); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces); + assert.strictEqual( + psbt.serialize().length, + unsignedBitgoPsbtWithBitgoNonces.serialize().length, + ); + } + + { + const psbt = getBitGoPsbt(fixture, networkName); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithUserNonces); + psbt.combineMusig2Nonces(unsignedBitgoPsbtWithBitgoNonces); + assert.strictEqual( + psbt.serialize().length, + unsignedBitgoPsbtWithBothNonces.serialize().length, + ); + } + }); + + it("should reject invalid session ID length", function () { + const unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName); + + // Invalid session ID (wrong length) + const invalidSessionId = new Uint8Array(16); // Should be 32 bytes + + assert.throws(() => { + unsignedBitgoPsbt.generateMusig2Nonces(userKey, invalidSessionId); + }, "Should throw error for invalid session ID length"); + }); + + it("should reject custom session ID on mainnet (security)", function () { + const unsignedBitgoPsbt = getBitGoPsbt(fixture, "bitcoin"); + // Custom session ID should be rejected on mainnet for security + const customSessionId = new Uint8Array(32).fill(1); + + assert.throws( + () => { + unsignedBitgoPsbt.generateMusig2Nonces(userKey, customSessionId); + }, + /Custom session_id is only allowed on testnets/, + "Should throw error when providing custom session_id on mainnet", + ); + }); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts similarity index 64% rename from packages/wasm-utxo/test/fixedScript/verifySignature.ts rename to packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts index a6b5b96..394c457 100644 --- a/packages/wasm-utxo/test/fixedScript/verifySignature.ts +++ b/packages/wasm-utxo/test/fixedScript/signAndVerifySignature.ts @@ -1,11 +1,16 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; -import { fixedScriptWallet, BIP32, ECPair } from "../../js/index.js"; -import { BitGoPsbt, RootWalletKeys, ParsedTransaction } from "../../js/fixedScriptWallet/index.js"; +import { BIP32, ECPair } from "../../js/index.js"; +import { + BitGoPsbt, + RootWalletKeys, + ParsedTransaction, + type NetworkName, +} from "../../js/fixedScriptWallet/index.js"; import { loadPsbtFixture, loadWalletKeysFromFixture, - getPsbtBuffer, + getBitGoPsbt, type Fixture, loadReplayProtectionKeyFromFixture, } from "./fixtureUtil.js"; @@ -16,9 +21,31 @@ type ExpectedSignatures = | { hasReplayProtectionSignature: boolean } | { user: boolean; backup: boolean; bitgo: boolean }; +type RootWalletXprivs = { + user: BIP32; + backup: BIP32; + bitgo: BIP32; +}; + +/** + * Load xprivs from a fixture + * @param fixture - The test fixture + * @returns The xprivs for user, backup, and bitgo keys + */ +function loadXprivsFromFixture(fixture: Fixture): RootWalletXprivs { + const [userXpriv, backupXpriv, bitgoXpriv] = fixture.walletKeys.map((xprv) => + BIP32.fromBase58(xprv), + ); + return { + user: userXpriv, + backup: backupXpriv, + bitgo: bitgoXpriv, + }; +} + /** * Get expected signature state for an input based on type and signing stage - * @param inputType - The type of input (e.g., "p2shP2pk", "p2trMusig2") + * @param inputType - The type of input (e.g., "p2shP2pk", "p2trMusig2", "taprootKeyPathSpend") * @param signatureStage - The signing stage (unsigned, halfsigned, fullsigned) * @returns Expected signature state for replay protection OR multi-key signatures */ @@ -100,20 +127,22 @@ function verifyInputSignatures( const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.backupKey()); const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, rootWalletKeys.bitgoKey()); + const scriptType = parsed.inputs[inputIndex].scriptType; + assert.strictEqual( hasUserSig, expectedSignatures.user, - `Input ${inputIndex} user key signature mismatch`, + `Input ${inputIndex} user key signature mismatch type=${scriptType}`, ); assert.strictEqual( hasBackupSig, expectedSignatures.backup, - `Input ${inputIndex} backup key signature mismatch`, + `Input ${inputIndex} backup key signature mismatch type=${scriptType}`, ); assert.strictEqual( hasBitGoSig, expectedSignatures.bitgo, - `Input ${inputIndex} BitGo key signature mismatch`, + `Input ${inputIndex} BitGo key signature mismatch type=${scriptType}`, ); } @@ -148,6 +177,107 @@ function verifyAllInputSignatures( }); } +function signInputAndVerify( + bitgoPsbt: BitGoPsbt, + index: number, + key: BIP32 | ECPair, + keyName: string, + inputType: string, +): void { + bitgoPsbt.sign(index, key); + assert.strictEqual( + bitgoPsbt.verifySignature(index, key), + true, + `Input ${index} signature mismatch key=${keyName} type=${inputType}`, + ); +} + +/** + * Sign all inputs in a PSBT according to the signature stage + * @param bitgoPsbt - The PSBT to sign + * @param rootWalletKeys - Wallet keys for parsing the transaction + * @param xprivs - The xprivs to use for signing + * @param replayProtectionKey - The ECPair for signing replay protection (p2shP2pk) inputs + */ +function signAllInputs( + bitgoPsbt: BitGoPsbt, + rootWalletKeys: RootWalletKeys, + xprivs: RootWalletXprivs, + replayProtectionKey: ECPair, +): void { + // Parse transaction to get input types + const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + publicKeys: [replayProtectionKey], + }); + + // Generate MuSig2 nonces for user and backup keys (MuSig2 uses 2-of-2 with user+backup) + bitgoPsbt.generateMusig2Nonces(xprivs.user); + bitgoPsbt.generateMusig2Nonces(xprivs.bitgo); + + // First pass: sign with user key (skip p2shP2pk inputs) + parsed.inputs.forEach((input, index) => { + switch (input.scriptType) { + case "p2shP2pk": + break; + default: + signInputAndVerify(bitgoPsbt, index, xprivs.user, "user", input.scriptType); + break; + } + }); + + // Second pass: sign with appropriate second key + parsed.inputs.forEach((input, index) => { + switch (input.scriptType) { + case "p2shP2pk": + signInputAndVerify( + bitgoPsbt, + index, + replayProtectionKey, + "replayProtection", + input.scriptType, + ); + break; + case "p2trMusig2ScriptPath": + // MuSig2 script path inputs use backup key for second signature + signInputAndVerify(bitgoPsbt, index, xprivs.backup, "backup", input.scriptType); + break; + default: + // Regular multisig uses bitgo key + signInputAndVerify(bitgoPsbt, index, xprivs.bitgo, "bitgo", input.scriptType); + break; + } + }); +} + +/** + * Run tests for a fixture: load PSBT, verify, sign, and verify again + * @param fixture - The test fixture + * @param networkName - The network name for deserializing the PSBT + * @param rootWalletKeys - Wallet keys for verification + * @param replayProtectionKey - Key for replay protection inputs + * @param xprivs - The xprivs to use for signing + * @param signatureStage - The current signing stage + */ +function runTestsForFixture( + fixture: Fixture, + networkName: NetworkName, + rootWalletKeys: RootWalletKeys, + replayProtectionKey: ECPair, + xprivs: RootWalletXprivs, + signatureStage: SignatureStage, +): void { + // Load PSBT from fixture + const bitgoPsbt = getBitGoPsbt(fixture, networkName); + + // Verify current state + verifyAllInputSignatures(bitgoPsbt, fixture, rootWalletKeys, replayProtectionKey, signatureStage); + + // Sign inputs (if not already fully signed) + if (signatureStage !== "unsigned") { + signAllInputs(bitgoPsbt, rootWalletKeys, xprivs, replayProtectionKey); + } +} + describe("verifySignature", function () { const supportedNetworks = utxolib.getNetworkList().filter((network) => { return ( @@ -166,12 +296,10 @@ describe("verifySignature", function () { describe(`network: ${networkName}`, function () { let rootWalletKeys: RootWalletKeys; let replayProtectionKey: ECPair; + let xprivs: RootWalletXprivs; let unsignedFixture: Fixture; let halfsignedFixture: Fixture; let fullsignedFixture: Fixture; - let unsignedBitgoPsbt: BitGoPsbt; - let halfsignedBitgoPsbt: BitGoPsbt; - let fullsignedBitgoPsbt: BitGoPsbt; before(function () { unsignedFixture = loadPsbtFixture(networkName, "unsigned"); @@ -179,39 +307,30 @@ describe("verifySignature", function () { fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); rootWalletKeys = loadWalletKeysFromFixture(fullsignedFixture); replayProtectionKey = loadReplayProtectionKeyFromFixture(fullsignedFixture); - unsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( - getPsbtBuffer(unsignedFixture), - networkName, - ); - halfsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( - getPsbtBuffer(halfsignedFixture), - networkName, - ); - fullsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( - getPsbtBuffer(fullsignedFixture), - networkName, - ); + xprivs = loadXprivsFromFixture(fullsignedFixture); }); describe("unsigned PSBT", function () { - it("should return false for unsigned inputs", function () { - verifyAllInputSignatures( - unsignedBitgoPsbt, + it("should return false for unsigned inputs, then sign and verify", function () { + runTestsForFixture( unsignedFixture, + networkName, rootWalletKeys, replayProtectionKey, + xprivs, "unsigned", ); }); }); describe("half-signed PSBT", function () { - it("should return true for signed xpubs and false for unsigned", function () { - verifyAllInputSignatures( - halfsignedBitgoPsbt, + it("should return true for signed xpubs and false for unsigned, then sign and verify", function () { + runTestsForFixture( halfsignedFixture, + networkName, rootWalletKeys, replayProtectionKey, + xprivs, "halfsigned", ); }); @@ -219,11 +338,12 @@ describe("verifySignature", function () { describe("fully signed PSBT", function () { it("should have 2 signatures (2-of-3 multisig)", function () { - verifyAllInputSignatures( - fullsignedBitgoPsbt, + runTestsForFixture( fullsignedFixture, + networkName, rootWalletKeys, replayProtectionKey, + xprivs, "fullsigned", ); }); @@ -231,9 +351,10 @@ describe("verifySignature", function () { describe("error handling", function () { it("should throw error for out of bounds input index", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(999, rootWalletKeys.userKey()); + psbt.verifySignature(999, rootWalletKeys.userKey()); }, (error: Error) => { return error.message.includes("Input index 999 out of bounds"); @@ -243,9 +364,10 @@ describe("verifySignature", function () { }); it("should throw error for invalid xpub", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(0, "invalid-xpub"); + psbt.verifySignature(0, "invalid-xpub"); }, (error: Error) => { return error.message.includes("Invalid"); @@ -255,13 +377,14 @@ describe("verifySignature", function () { }); it("should return false for xpub not in derivation path", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); // Create a different xpub that's not in the wallet // Use a proper 32-byte seed (256 bits) const differentSeed = Buffer.alloc(32, 0xaa); // 32 bytes filled with 0xaa const differentKey = BIP32.fromSeed(differentSeed); const differentXpub = differentKey.neutered(); - const result = fullsignedBitgoPsbt.verifySignature(0, differentXpub); + const result = psbt.verifySignature(0, differentXpub); assert.strictEqual( result, false, @@ -270,9 +393,10 @@ describe("verifySignature", function () { }); it("should verify signature with raw public key (Uint8Array)", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); // Verify that xpub-based verification works const userKey = rootWalletKeys.userKey(); - const hasXpubSig = fullsignedBitgoPsbt.verifySignature(0, userKey); + const hasXpubSig = psbt.verifySignature(0, userKey); // This test specifically checks that raw public key verification works // We test the underlying WASM API by ensuring both xpub and raw pubkey @@ -284,7 +408,7 @@ describe("verifySignature", function () { const randomPubkey = randomKey.publicKey; // This should return false (no signature for this key) - const result = fullsignedBitgoPsbt.verifySignature(0, randomPubkey); + const result = psbt.verifySignature(0, randomPubkey); assert.strictEqual(result, false, "Should return false for public key not in PSBT"); // Verify the xpub check still works (regression test) @@ -292,12 +416,13 @@ describe("verifySignature", function () { }); it("should return false for raw public key with no signature", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); // Create a random public key that's not in the PSBT const randomSeed = Buffer.alloc(32, 0xbb); const randomKey = BIP32.fromSeed(randomSeed); const randomPubkey = randomKey.publicKey; - const result = fullsignedBitgoPsbt.verifySignature(0, randomPubkey); + const result = psbt.verifySignature(0, randomPubkey); assert.strictEqual( result, false, @@ -306,11 +431,12 @@ describe("verifySignature", function () { }); it("should throw error for invalid key length", function () { + const psbt = getBitGoPsbt(fullsignedFixture, networkName); const invalidKey = Buffer.alloc(31); // Invalid length (should be 32 for private key or 33 for public key) assert.throws( () => { - fullsignedBitgoPsbt.verifySignature(0, invalidKey); + psbt.verifySignature(0, invalidKey); }, (error: Error) => { return error.message.includes("Invalid key length"); diff --git a/packages/wasm-utxo/test/fixedScript/walletKeys.util.ts b/packages/wasm-utxo/test/fixedScript/walletKeys.util.ts new file mode 100644 index 0000000..9172ac5 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/walletKeys.util.ts @@ -0,0 +1,26 @@ +import * as utxolib from "@bitgo/utxo-lib"; +import type { Triple } from "../../js/triple.js"; + +/** + * Convert utxolib BIP32 keys to WASM wallet keys format (Triple) + */ +export function toWasmWalletKeys( + keys: [utxolib.BIP32Interface, utxolib.BIP32Interface, utxolib.BIP32Interface], +): Triple { + return [ + keys[0].neutered().toBase58(), + keys[1].neutered().toBase58(), + keys[2].neutered().toBase58(), + ]; +} + +/** + * Get standard replay protection configuration + */ +export function getStandardReplayProtection(): { outputScripts: Uint8Array[] } { + const replayProtectionScript = Buffer.from( + "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", + "hex", + ); + return { outputScripts: [replayProtectionScript] }; +}