Skip to content

Commit 9556545

Browse files
Merge pull request #68 from BitGo/BTC-2786.cosigning-with-strong-key-types
feat(wasm-utxo): implement signing workflow
2 parents 4b1832a + 014e051 commit 9556545

File tree

10 files changed

+925
-38
lines changed

10 files changed

+925
-38
lines changed

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,66 @@ export class BitGoPsbt {
142142
return this.wasm.verify_signature_with_pub(inputIndex, wasmECPair);
143143
}
144144

145+
/**
146+
* Sign a single input with a private key
147+
*
148+
* This method signs a specific input using the provided key. It accepts either:
149+
* - An xpriv (BIP32Arg: base58 string, BIP32 instance, or WasmBIP32) for wallet inputs - derives the key and signs
150+
* - A raw privkey (ECPairArg: Buffer, ECPair instance, or WasmECPair) for replay protection inputs - signs directly
151+
*
152+
* This method automatically detects and handles different input types:
153+
* - For regular inputs: uses standard PSBT signing
154+
* - For MuSig2 inputs: uses the FirstRound state stored by generateMusig2Nonces()
155+
* - For replay protection inputs: signs with legacy P2SH sighash
156+
*
157+
* @param inputIndex - The index of the input to sign (0-based)
158+
* @param key - Either an xpriv (BIP32Arg) or a raw privkey (ECPairArg)
159+
* @throws Error if signing fails, or if generateMusig2Nonces() was not called first for MuSig2 inputs
160+
*
161+
* @example
162+
* ```typescript
163+
* // Parse transaction to identify input types
164+
* const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection);
165+
*
166+
* // Sign regular wallet inputs with xpriv
167+
* for (let i = 0; i < parsed.inputs.length; i++) {
168+
* const input = parsed.inputs[i];
169+
* if (input.scriptId !== null && input.scriptType !== "p2shP2pk") {
170+
* psbt.sign(i, userXpriv);
171+
* }
172+
* }
173+
*
174+
* // Sign replay protection inputs with raw privkey
175+
* const userPrivkey = bip32.fromBase58(userXpriv).privateKey!;
176+
* for (let i = 0; i < parsed.inputs.length; i++) {
177+
* const input = parsed.inputs[i];
178+
* if (input.scriptType === "p2shP2pk") {
179+
* psbt.sign(i, userPrivkey);
180+
* }
181+
* }
182+
* ```
183+
*/
184+
sign(inputIndex: number, key: BIP32Arg | ECPairArg): void {
185+
// Detect key type
186+
// If string or has 'derive' method → BIP32Arg
187+
// Otherwise → ECPairArg
188+
if (
189+
typeof key === "string" ||
190+
(typeof key === "object" &&
191+
key !== null &&
192+
"derive" in key &&
193+
typeof key.derive === "function")
194+
) {
195+
// It's a BIP32Arg
196+
const wasmKey = BIP32.from(key as BIP32Arg);
197+
this.wasm.sign_with_xpriv(inputIndex, wasmKey.wasm);
198+
} else {
199+
// It's an ECPairArg
200+
const wasmKey = ECPair.from(key as ECPairArg);
201+
this.wasm.sign_with_privkey(inputIndex, wasmKey.wasm);
202+
}
203+
}
204+
145205
/**
146206
* @deprecated - use verifySignature with the replay protection key instead
147207
*
@@ -179,6 +239,72 @@ export class BitGoPsbt {
179239
return this.wasm.serialize();
180240
}
181241

242+
/**
243+
* Generate and store MuSig2 nonces for all MuSig2 inputs
244+
*
245+
* This method generates nonces using the State-Machine API and stores them in the PSBT.
246+
* The nonces are stored as proprietary fields in the PSBT and will be included when serialized.
247+
* After ALL participants have generated their nonces, you can sign MuSig2 inputs using
248+
* sign().
249+
*
250+
* @param key - The extended private key (xpriv) for signing. Can be a base58 string, BIP32 instance, or WasmBIP32
251+
* @param sessionId - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**.
252+
* On mainnets, a secure random session ID is always generated automatically.
253+
* Must be unique per signing session.
254+
* @throws Error if nonce generation fails, sessionId length is invalid, or custom sessionId is
255+
* provided on a mainnet (security restriction)
256+
*
257+
* @security The sessionId MUST be cryptographically random and unique for each signing session.
258+
* Never reuse a sessionId with the same key! On mainnets, sessionId is always randomly
259+
* generated for security. Custom sessionId is only allowed on testnets for testing purposes.
260+
*
261+
* @example
262+
* ```typescript
263+
* // Phase 1: Both parties generate nonces (with auto-generated session ID)
264+
* psbt.generateMusig2Nonces(userXpriv);
265+
* // Nonces are stored in the PSBT
266+
* // Send PSBT to counterparty
267+
*
268+
* // Phase 2: After receiving counterparty PSBT with their nonces
269+
* const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network);
270+
* psbt.combineMusig2Nonces(counterpartyPsbt);
271+
* // Sign MuSig2 key path inputs
272+
* const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection);
273+
* for (let i = 0; i < parsed.inputs.length; i++) {
274+
* if (parsed.inputs[i].scriptType === "p2trMusig2KeyPath") {
275+
* psbt.sign(i, userXpriv);
276+
* }
277+
* }
278+
* ```
279+
*/
280+
generateMusig2Nonces(key: BIP32Arg, sessionId?: Uint8Array): void {
281+
const wasmKey = BIP32.from(key);
282+
this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId);
283+
}
284+
285+
/**
286+
* Combine/merge data from another PSBT into this one
287+
*
288+
* This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the
289+
* source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange
290+
* and signature collection phases.
291+
*
292+
* @param sourcePsbt - The source PSBT containing data to merge
293+
* @throws Error if networks don't match
294+
*
295+
* @example
296+
* ```typescript
297+
* // After receiving counterparty's PSBT with their nonces
298+
* const counterpartyPsbt = BitGoPsbt.fromBytes(counterpartyPsbtBytes, network);
299+
* psbt.combineMusig2Nonces(counterpartyPsbt);
300+
* // Now can sign with all nonces present
301+
* psbt.sign(0, userXpriv);
302+
* ```
303+
*/
304+
combineMusig2Nonces(sourcePsbt: BitGoPsbt): void {
305+
this.wasm.combine_musig2_nonces(sourcePsbt.wasm);
306+
}
307+
182308
/**
183309
* Finalize all inputs in the PSBT
184310
*

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,71 @@ impl BitGoPsbt {
204204
}
205205
}
206206

207+
/// Combine/merge data from another PSBT into this one
208+
///
209+
/// This method copies MuSig2 nonces and signatures (proprietary key-value pairs) from the
210+
/// source PSBT to this PSBT. This is useful for merging PSBTs during the nonce exchange
211+
/// and signature collection phases.
212+
///
213+
/// # Arguments
214+
/// * `source_psbt` - The source PSBT containing data to merge
215+
///
216+
/// # Returns
217+
/// Ok(()) if data was successfully merged
218+
///
219+
/// # Errors
220+
/// Returns error if networks don't match
221+
pub fn combine_musig2_nonces(&mut self, source_psbt: &BitGoPsbt) -> Result<(), String> {
222+
// Check network match
223+
if self.network() != source_psbt.network() {
224+
return Err(format!(
225+
"Network mismatch: destination is {}, source is {}",
226+
self.network(),
227+
source_psbt.network()
228+
));
229+
}
230+
231+
let source = source_psbt.psbt();
232+
let dest = self.psbt_mut();
233+
234+
// Check that both PSBTs have the same number of inputs
235+
if source.inputs.len() != dest.inputs.len() {
236+
return Err(format!(
237+
"PSBT input count mismatch: source has {} inputs, destination has {}",
238+
source.inputs.len(),
239+
dest.inputs.len()
240+
));
241+
}
242+
243+
// Copy MuSig2 nonces and partial signatures (proprietary key-values with BITGO identifier)
244+
for (source_input, dest_input) in source.inputs.iter().zip(dest.inputs.iter_mut()) {
245+
// Only process if the input is a MuSig2 input
246+
if !p2tr_musig2_input::Musig2Input::is_musig2_input(source_input) {
247+
continue;
248+
}
249+
250+
// Parse nonces from source input using native Musig2 functions
251+
let nonces = p2tr_musig2_input::parse_musig2_nonces(source_input)
252+
.map_err(|e| format!("Failed to parse MuSig2 nonces from source: {}", e))?;
253+
254+
// Copy each nonce to the destination input
255+
for nonce in nonces {
256+
let (key, value) = nonce.to_key_value().to_key_value();
257+
dest_input.proprietary.insert(key, value);
258+
}
259+
260+
// Also copy partial signatures if present
261+
// Partial sigs are stored as tap_script_sigs in the PSBT input
262+
for (control_block, leaf_script) in &source_input.tap_script_sigs {
263+
dest_input
264+
.tap_script_sigs
265+
.insert(*control_block, *leaf_script);
266+
}
267+
}
268+
269+
Ok(())
270+
}
271+
207272
/// Serialize the PSBT to bytes, using network-specific logic
208273
pub fn serialize(&self) -> Result<Vec<u8>, SerializeError> {
209274
match self {
@@ -426,6 +491,146 @@ impl BitGoPsbt {
426491
.map_err(|e| e.to_string())
427492
}
428493

494+
/// Sign a single input with a raw private key
495+
///
496+
/// This method signs a specific input using the provided private key. It automatically
497+
/// detects the input type and uses the appropriate signing method:
498+
/// - Replay protection inputs (P2SH-P2PK): Signs with legacy P2SH sighash
499+
/// - Regular inputs: Uses standard PSBT signing
500+
/// - MuSig2 inputs: Returns error (requires FirstRound state, use sign_with_first_round)
501+
///
502+
/// # Arguments
503+
/// - `input_index`: The index of the input to sign
504+
/// - `privkey`: The private key to sign with
505+
///
506+
/// # Returns
507+
/// - `Ok(())` if signing was successful
508+
/// - `Err(String)` if signing fails or input type is not supported
509+
pub fn sign_with_privkey(
510+
&mut self,
511+
input_index: usize,
512+
privkey: &secp256k1::SecretKey,
513+
) -> Result<(), String> {
514+
use miniscript::bitcoin::{
515+
ecdsa::Signature as EcdsaSignature, hashes::Hash, sighash::SighashCache, PublicKey,
516+
};
517+
518+
// Get network before mutable borrow
519+
let network = self.network();
520+
let is_testnet = network.is_testnet();
521+
522+
let psbt = self.psbt_mut();
523+
524+
// Check bounds
525+
if input_index >= psbt.inputs.len() {
526+
return Err(format!(
527+
"Input index {} out of bounds (total inputs: {})",
528+
input_index,
529+
psbt.inputs.len()
530+
));
531+
}
532+
533+
// Check if this is a MuSig2 input
534+
if p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) {
535+
return Err(
536+
"MuSig2 inputs cannot be signed with raw privkey. Use sign_with_first_round instead."
537+
.to_string(),
538+
);
539+
}
540+
541+
let secp = secp256k1::Secp256k1::new();
542+
543+
// Derive public key from private key
544+
let public_key = PublicKey::from_slice(
545+
&secp256k1::PublicKey::from_secret_key(&secp, privkey).serialize(),
546+
)
547+
.map_err(|e| format!("Failed to derive public key: {}", e))?;
548+
549+
// Check if this is a replay protection input (P2SH-P2PK)
550+
if let Some(redeem_script) = &psbt.inputs[input_index].redeem_script.clone() {
551+
// Try to extract pubkey from redeem script
552+
if let Ok(redeem_pubkey) = Self::extract_pubkey_from_p2pk_redeem_script(redeem_script) {
553+
// This is a replay protection input - verify the derived pubkey matches
554+
if public_key != redeem_pubkey {
555+
return Err(
556+
"Public key mismatch: derived pubkey does not match redeem_script pubkey"
557+
.to_string(),
558+
);
559+
}
560+
561+
// Sign the replay protection input with legacy P2SH sighash
562+
let sighash_type = miniscript::bitcoin::sighash::EcdsaSighashType::All;
563+
let cache = SighashCache::new(&psbt.unsigned_tx);
564+
let sighash = cache
565+
.legacy_signature_hash(input_index, redeem_script, sighash_type.to_u32())
566+
.map_err(|e| format!("Failed to compute sighash: {}", e))?;
567+
568+
// Create ECDSA signature
569+
let message = secp256k1::Message::from_digest(sighash.to_byte_array());
570+
let signature = secp.sign_ecdsa(&message, privkey);
571+
let ecdsa_sig = EcdsaSignature {
572+
signature,
573+
sighash_type,
574+
};
575+
576+
// Add signature to partial_sigs
577+
psbt.inputs[input_index]
578+
.partial_sigs
579+
.insert(public_key, ecdsa_sig);
580+
581+
return Ok(());
582+
}
583+
}
584+
585+
// For regular inputs (non-RP, non-MuSig2), use standard signing via miniscript
586+
// This will handle legacy, SegWit, and Taproot script path inputs
587+
match self {
588+
BitGoPsbt::BitcoinLike(ref mut psbt, _network) => {
589+
// Create a key provider that returns our single key
590+
// Convert SecretKey to PrivateKey for the GetKey trait
591+
// Note: The network parameter is only used for WIF serialization, not for signing
592+
let bitcoin_network = if is_testnet {
593+
miniscript::bitcoin::Network::Testnet
594+
} else {
595+
miniscript::bitcoin::Network::Bitcoin
596+
};
597+
let private_key = miniscript::bitcoin::PrivateKey::new(*privkey, bitcoin_network);
598+
let key_map = std::collections::BTreeMap::from_iter([(public_key, private_key)]);
599+
600+
// Sign the PSBT
601+
let result = psbt.sign(&key_map, &secp);
602+
603+
// Check if our specific input was signed
604+
match result {
605+
Ok(signing_keys) => {
606+
if signing_keys.contains_key(&input_index) {
607+
Ok(())
608+
} else {
609+
Err(format!(
610+
"Input {} was not signed (no key found or already signed)",
611+
input_index
612+
))
613+
}
614+
}
615+
Err((partial_success, errors)) => {
616+
// Check if there's an error for our specific input
617+
if let Some(error) = errors.get(&input_index) {
618+
Err(format!("Failed to sign input {}: {:?}", input_index, error))
619+
} else if partial_success.contains_key(&input_index) {
620+
// Input was signed successfully despite other errors
621+
Ok(())
622+
} else {
623+
Err(format!("Input {} was not signed", input_index))
624+
}
625+
}
626+
}
627+
}
628+
BitGoPsbt::Zcash(_zcash_psbt, _network) => {
629+
Err("Zcash signing not yet implemented".to_string())
630+
}
631+
}
632+
}
633+
429634
/// Sign the PSBT with the provided key.
430635
/// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt.
431636
///

packages/wasm-utxo/src/wasm/bip32.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,4 +334,12 @@ impl WasmBIP32 {
334334
pub(crate) fn to_xpub(&self) -> Result<crate::bitcoin::bip32::Xpub, WasmUtxoError> {
335335
Ok(self.0.to_xpub())
336336
}
337+
338+
/// Convert to Xpriv (for internal Rust use, not exposed to JS)
339+
pub(crate) fn to_xpriv(&self) -> Result<crate::bitcoin::bip32::Xpriv, WasmUtxoError> {
340+
match &self.0 {
341+
BIP32Key::Private(xpriv) => Ok(*xpriv),
342+
BIP32Key::Public(_) => Err(WasmUtxoError::new("Cannot get xpriv from public key")),
343+
}
344+
}
337345
}

packages/wasm-utxo/src/wasm/ecpair.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ impl WasmECPair {
4141
pub(crate) fn get_public_key(&self) -> PublicKey {
4242
self.key.public_key()
4343
}
44+
45+
/// Get the private key as a secp256k1::SecretKey (for internal Rust use)
46+
pub(crate) fn get_private_key(&self) -> Result<SecretKey, WasmUtxoError> {
47+
self.key
48+
.secret_key()
49+
.ok_or_else(|| WasmUtxoError::new("Cannot get private key from public-only ECPair"))
50+
}
4451
}
4552

4653
#[wasm_bindgen]

0 commit comments

Comments
 (0)