Skip to content

Commit a9d717b

Browse files
feat(wasm-utxo): implement MuSig2 nonce generation
- Add FirstRound HashMap storage to WASM BitGoPsbt struct - Add generate_musig2_nonces WASM method with security checks - Add generateMusig2Nonces() TypeScript method - Add to_xpriv() method to WasmBIP32 for internal use - Create walletKeys.util.ts test helper - Create musig2Nonces.ts test file with comprehensive test cases - Security: Custom session IDs only allowed on testnets Issue: BTC-2786
1 parent 4b1832a commit a9d717b

File tree

5 files changed

+242
-1
lines changed

5 files changed

+242
-1
lines changed

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,48 @@ export class BitGoPsbt {
179179
return this.wasm.serialize();
180180
}
181181

182+
/**
183+
* Generate and store MuSig2 nonces for all MuSig2 inputs
184+
*
185+
* This method generates nonces using the State-Machine API and stores them in the PSBT.
186+
* The nonces are stored as proprietary fields in the PSBT and will be included when serialized.
187+
* After ALL participants have generated their nonces, you can sign MuSig2 inputs using
188+
* sign().
189+
*
190+
* @param key - The extended private key (xpriv) for signing. Can be a base58 string, BIP32 instance, or WasmBIP32
191+
* @param sessionId - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**.
192+
* On mainnets, a secure random session ID is always generated automatically.
193+
* Must be unique per signing session.
194+
* @throws Error if nonce generation fails, sessionId length is invalid, or custom sessionId is
195+
* provided on a mainnet (security restriction)
196+
*
197+
* @security The sessionId MUST be cryptographically random and unique for each signing session.
198+
* Never reuse a sessionId with the same key! On mainnets, sessionId is always randomly
199+
* generated for security. Custom sessionId is only allowed on testnets for testing purposes.
200+
*
201+
* @example
202+
* ```typescript
203+
* // Phase 1: Both parties generate nonces (with auto-generated session ID)
204+
* psbt.generateMusig2Nonces(userXpriv);
205+
* // Nonces are stored in the PSBT
206+
* // Send PSBT to counterparty
207+
*
208+
* // Phase 2: After receiving counterparty PSBT with their nonces
209+
* psbt.combine(counterpartyPsbtBytes);
210+
* // Sign MuSig2 key path inputs
211+
* const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, replayProtection);
212+
* for (let i = 0; i < parsed.inputs.length; i++) {
213+
* if (parsed.inputs[i].scriptType === "p2trMusig2KeyPath") {
214+
* psbt.sign(i, userXpriv);
215+
* }
216+
* }
217+
* ```
218+
*/
219+
generateMusig2Nonces(key: BIP32Arg, sessionId?: Uint8Array): void {
220+
const wasmKey = BIP32.from(key);
221+
this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId);
222+
}
223+
182224
/**
183225
* Finalize all inputs in the PSBT
184226
*

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/fixed_script_wallet.rs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use wasm_bindgen::prelude::*;
23
use wasm_bindgen::JsValue;
34

@@ -83,6 +84,9 @@ impl FixedScriptWalletNamespace {
8384
#[wasm_bindgen]
8485
pub struct BitGoPsbt {
8586
psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt,
87+
// Store FirstRound states per (input_index, xpub_string)
88+
#[wasm_bindgen(skip)]
89+
first_rounds: HashMap<(usize, String), musig2::FirstRound>,
8690
}
8791

8892
#[wasm_bindgen]
@@ -95,7 +99,10 @@ impl BitGoPsbt {
9599
crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::deserialize(bytes, network)
96100
.map_err(|e| WasmUtxoError::new(&format!("Failed to deserialize PSBT: {}", e)))?;
97101

98-
Ok(BitGoPsbt { psbt })
102+
Ok(BitGoPsbt {
103+
psbt,
104+
first_rounds: HashMap::new(),
105+
})
99106
}
100107

101108
/// Get the unsigned transaction ID
@@ -262,6 +269,108 @@ impl BitGoPsbt {
262269
.map_err(|e| WasmUtxoError::new(&format!("Failed to serialize PSBT: {}", e)))
263270
}
264271

272+
/// Generate and store MuSig2 nonces for all MuSig2 inputs
273+
///
274+
/// This method generates nonces using the State-Machine API and stores them in the PSBT.
275+
/// The nonces are stored as proprietary fields in the PSBT and will be included when serialized.
276+
/// After ALL participants have generated their nonces, they can sign MuSig2 inputs using
277+
/// sign_with_xpriv().
278+
///
279+
/// # Arguments
280+
/// * `xpriv` - The extended private key (xpriv) for signing
281+
/// * `session_id_bytes` - Optional 32-byte session ID for nonce generation. **Only allowed on testnets**.
282+
/// On mainnets, a secure random session ID is always generated automatically.
283+
/// Must be unique per signing session.
284+
///
285+
/// # Returns
286+
/// Ok(()) if nonces were successfully generated and stored
287+
///
288+
/// # Errors
289+
/// Returns error if:
290+
/// - Nonce generation fails
291+
/// - session_id length is invalid
292+
/// - Custom session_id is provided on a mainnet (security restriction)
293+
///
294+
/// # Security
295+
/// The session_id MUST be cryptographically random and unique for each signing session.
296+
/// Never reuse a session_id with the same key! On mainnets, session_id is always randomly
297+
/// generated for security. Custom session_id is only allowed on testnets for testing purposes.
298+
pub fn generate_musig2_nonces(
299+
&mut self,
300+
xpriv: &WasmBIP32,
301+
session_id_bytes: Option<Vec<u8>>,
302+
) -> Result<(), WasmUtxoError> {
303+
// Extract Xpriv from WasmBIP32
304+
let xpriv = xpriv.to_xpriv()?;
305+
306+
// Get the network from the PSBT to check if custom session_id is allowed
307+
let network = self.psbt.network();
308+
309+
// Get or generate session ID
310+
let session_id = match session_id_bytes {
311+
Some(bytes) => {
312+
// Only allow custom session_id on testnets for security
313+
if !network.is_testnet() {
314+
return Err(WasmUtxoError::new(
315+
"Custom session_id is only allowed on testnets. On mainnets, session_id is always randomly generated for security."
316+
));
317+
}
318+
if bytes.len() != 32 {
319+
return Err(WasmUtxoError::new(&format!(
320+
"Session ID must be 32 bytes, got {}",
321+
bytes.len()
322+
)));
323+
}
324+
let mut session_id = [0u8; 32];
325+
session_id.copy_from_slice(&bytes);
326+
session_id
327+
}
328+
None => {
329+
// Generate secure random session ID
330+
use getrandom::getrandom;
331+
let mut session_id = [0u8; 32];
332+
getrandom(&mut session_id).map_err(|e| {
333+
WasmUtxoError::new(&format!("Failed to generate random session ID: {}", e))
334+
})?;
335+
session_id
336+
}
337+
};
338+
339+
// Derive xpub from xpriv to use as key
340+
let secp = miniscript::bitcoin::secp256k1::Secp256k1::new();
341+
let xpub = miniscript::bitcoin::bip32::Xpub::from_priv(&secp, &xpriv);
342+
let xpub_str = xpub.to_string();
343+
344+
// Iterate over all inputs and generate nonces for MuSig2 inputs
345+
let input_count = self.psbt.psbt().unsigned_tx.input.len();
346+
for input_index in 0..input_count {
347+
// Check if this input is a MuSig2 input
348+
let psbt = self.psbt.psbt();
349+
if !crate::fixed_script_wallet::bitgo_psbt::p2tr_musig2_input::Musig2Input::is_musig2_input(&psbt.inputs[input_index]) {
350+
continue;
351+
}
352+
353+
// Generate nonce and get the FirstRound
354+
// The nonce is automatically stored in the PSBT
355+
let (first_round, _pub_nonce) = self
356+
.psbt
357+
.generate_nonce_first_round(input_index, &xpriv, session_id)
358+
.map_err(|e| {
359+
WasmUtxoError::new(&format!(
360+
"Failed to generate nonce for input {}: {}",
361+
input_index, e
362+
))
363+
})?;
364+
365+
// Store the FirstRound for later use in signing
366+
// Use (input_index, xpub) as key so multiple parties can store their FirstRounds
367+
self.first_rounds
368+
.insert((input_index, xpub_str.clone()), first_round);
369+
}
370+
371+
Ok(())
372+
}
373+
265374
/// Finalize all inputs in the PSBT
266375
///
267376
/// This method attempts to finalize all inputs in the PSBT, computing the final
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import assert from "assert";
2+
import { BitGoPsbt } from "../../js/fixedScriptWallet/index.js";
3+
import { BIP32 } from "../../js/bip32.js";
4+
import {
5+
loadPsbtFixture,
6+
getBitGoPsbt,
7+
type Fixture,
8+
} from "./fixtureUtil.js";
9+
10+
describe("MuSig2 nonce management", function () {
11+
describe("Bitcoin mainnet", function () {
12+
const networkName = "bitcoin";
13+
let fixture: Fixture;
14+
let unsignedBitgoPsbt: BitGoPsbt;
15+
let userKey: BIP32;
16+
17+
before(function () {
18+
fixture = loadPsbtFixture(networkName, "unsigned");
19+
unsignedBitgoPsbt = getBitGoPsbt(fixture, networkName);
20+
userKey = BIP32.fromBase58(fixture.walletKeys[0]);
21+
});
22+
23+
it("should generate nonces for MuSig2 inputs with auto-generated session ID", function () {
24+
// Generate nonces with auto-generated session ID (no second parameter)
25+
assert.doesNotThrow(() => {
26+
unsignedBitgoPsbt.generateMusig2Nonces(userKey);
27+
});
28+
29+
// Verify nonces were stored by serializing and deserializing
30+
const serialized = unsignedBitgoPsbt.serialize();
31+
assert.ok(serialized.length > getBitGoPsbt(fixture, networkName).serialize().length);
32+
});
33+
34+
it("should reject invalid session ID length", function () {
35+
// Invalid session ID (wrong length)
36+
const invalidSessionId = new Uint8Array(16); // Should be 32 bytes
37+
38+
assert.throws(() => {
39+
unsignedBitgoPsbt.generateMusig2Nonces(userKey, invalidSessionId);
40+
}, "Should throw error for invalid session ID length");
41+
});
42+
43+
it("should reject custom session ID on mainnet (security)", function () {
44+
// Custom session ID should be rejected on mainnet for security
45+
const customSessionId = new Uint8Array(32).fill(1);
46+
47+
assert.throws(
48+
() => {
49+
unsignedBitgoPsbt.generateMusig2Nonces(userKey, customSessionId);
50+
},
51+
/Custom session_id is only allowed on testnets/,
52+
"Should throw error when providing custom session_id on mainnet",
53+
);
54+
});
55+
});
56+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as utxolib from "@bitgo/utxo-lib";
2+
import type { Triple } from "../../js/triple.js";
3+
4+
/**
5+
* Convert utxolib BIP32 keys to WASM wallet keys format (Triple<string>)
6+
*/
7+
export function toWasmWalletKeys(
8+
keys: [utxolib.BIP32Interface, utxolib.BIP32Interface, utxolib.BIP32Interface],
9+
): Triple<string> {
10+
return [
11+
keys[0].neutered().toBase58(),
12+
keys[1].neutered().toBase58(),
13+
keys[2].neutered().toBase58(),
14+
];
15+
}
16+
17+
/**
18+
* Get standard replay protection configuration
19+
*/
20+
export function getStandardReplayProtection(): { outputScripts: Uint8Array[] } {
21+
const replayProtectionScript = Buffer.from(
22+
"a91420b37094d82a513451ff0ccd9db23aba05bc5ef387",
23+
"hex",
24+
);
25+
return { outputScripts: [replayProtectionScript] };
26+
}

0 commit comments

Comments
 (0)