Merge pull request #552 from LedgerHQ/musig-pregen-nonces
What changed, and why it matters
This commit changes how the Ledger Bitcoin app prepares nonces for MuSig2 multi-signature transactions. Previously, the nonce derivation included transaction-specific data, so a wallet had to know the exact transaction before asking the device for nonces. Now the nonces depend only on the wallet policy and input/key indexes, allowing a wallet to pre-generate nonces on one transaction and later use them for a different transaction of the same wallet policy. The change is intentional and documented, but it narrows session identity to the wallet policy: only one pending signing session is allowed per wallet policy, and starting a new round 1 silently replaces the old one. The commit also adds a check that the pubnonce in the PSBT matches the one the device would recompute, so a replaced session fails cleanly in round 2.
Treat this as a deliberate protocol-level behavior change rather than a vulnerability. Reviewers and integrators should ensure wallet software understands the new semantics: pubnonces are bound to wallet policy and input/key index, not to a specific PSBT, and a second round 1 for the same wallet policy invalidates any prior pending round 2. Wallet implementations should guard against accidental session replacement and should never reuse pubnonces across different wallet policies or after a failed round 2. No immediate patch is required unless the documented behavior is found to violate BIP-327 security assumptions.
Security signals we found
MuSig2 nonce derivation made transaction-independent by design
psbt_session_id now depends only on wallet policy, not transaction hashes
Single pending session per wallet policy enforced by session replacement
New round-2 pubnonce equality check prevents signing with stale or mismatched nonces
explicit_bzero added for signing state, cache, and MuSig session state after flow
Tests added for cross-transaction nonce reuse, session replacement, wrong pubnonce rejection, and single-use session behavior
Evidence from the diff
The patch refactors MuSig2 nonce generation in the Ledger Bitcoin app so that NonceGen arguments no longer depend on the transaction or the UTXO being spent. aggpk is now the untweaked aggregate key of the musig() expression, and msg/extra_in remain omitted. The psbt_session_id is now computed from the descriptor template hash and keys-info Merkle root only, not from transaction hashes. This enables pre-generation of pubnonces for future transactions sharing the same wallet policy. To prevent nonce reuse, at most one session per wallet policy is permitted; a new round 1 overwrites the prior session. The device now verifies in round 2 that the recomputed pubnonce equals the one supplied in the PSBT, returning SW_INCORRECT_DATA on mismatch. Stack state is also explicitly zeroed at the end of the handler.
Changed components
src/handler/sign_psbt.csrc/handler/sign_psbt/musig_signing.csrc/handler/sign_psbt/musig_signing.hsrc/musig/musig_sessions.csrc/musig/musig_sessions.hdoc/musig.mdtests/test_sign_psbt_musig.pyInspect captured patch +575 / −148
### CHANGELOG.md
@@ -13,6 +13,11 @@ Dates are in `dd-mm-yyyy` format.
- Support for [BIP-0322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) (v2.0.0) generic signed messages: a PSBT carrying the `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` global field (0x09) is now verified to have the exact BIP-322 *to_sign* structure and reviewed on-screen as a message signature (account, address and message), instead of being shown as a transaction with an `OP_RETURN` output and no fees. Proof-of-funds requests (additional inputs spending real coins of the account) are supported, with the total proven amount shown in the review. Works with any supported wallet policy, including multisig and miniscript.
+### Changed
+
+- MuSig2: the pubnonces no longer depend on the transaction, so they can be pre-generated before the transaction is known. See [doc/musig.md](doc/musig.md).
+- MuSig2 (breaking): as a consequence, at most one signing session can be pending for a given wallet policy; executing round 1 again for the same wallet policy discards the previous session, and round 2 with the old pubnonces fails.
+
### Fixed
- The transaction lock time is now determined as BIP-370 prescribes, from each input's `PSBT_IN_REQUIRED_TIME_LOCKTIME` / `PSBT_IN_REQUIRED_HEIGHT_LOCKTIME` together with `PSBT_GLOBAL_FALLBACK_LOCKTIME`, instead of always using the fallback verbatim. PSBTs not using the individual preferred locktime fields are unaffected, as they will keep depending on `PSBT_GLOBAL_FALLBACK_LOCKTIME` alone.
### doc/musig.md
@@ -10,7 +10,8 @@ MuSig2 is a 2-round multi-signature scheme compatible with the public keys and s
- At most 5 keys are allowed in the musig expression; performance limitations, however, might apply in practice.
- `musig(...)` is allowed among the key expressions of `multi_a`, but not of `sortedmulti_a`.
-- At most 8 parallel MuSig signing sessions are supported, due to the need to persist state in the device's memory.
+- At most 8 MuSig2 signing sessions can be pending at the same time, due to the need to persist state in the device's memory; moreover, at most one session can be pending for each wallet policy (see [below](#generalization-to-multiple-psbt-signing-sessions)).
+- The pubnonces can be generated before the transaction is known, and then used for a different transaction than the one given to the device in round 1 (see [below](#pre-generating-the-pubnonces)).
- Only `musig(...)/**` or `musig(...)/<M;N>/*` key expressions are supported; the public keys must be xpubs aggregated without any further derivation. Schemes where each pubkey is derived prior to aggregation (for example descriptors similar to `musig(xpub1/<0;1>/*,xpub2/<0;1>/*,...)`) are not supported.
## State minimization
@@ -43,6 +44,13 @@ In the concatenation, a fixed-length encoding of $i$ and $j$ is used in order to
The *j* parameter allows to handle wallet policies that contain more than one `musig()` key expression involving the signing device.
+The other arguments of *NonceGen* are chosen so that they do not depend on the transaction, nor on the UTXO being spent:
+- *pk* is the public key of the signing device in the `musig()` key expression;
+- *aggpk* is the aggregate public key of the `musig()` key expression _before_ any tweak, that is, before the BIP-32 derivation steps and the BIP-0341 taptweak that depend on the UTXO;
+- *msg* and *extra_in* are omitted.
+
+Therefore, each *(secnonce, pubnonce)* pair only depends on `rand_root`, on $i$ and $j$, and on the wallet policy.
+
#### Signing flow in detail
This section describes the handling of the psbt-level sessions, plugging on top of the default signing flow of BIP-0327.
@@ -58,13 +66,13 @@ The term *persistent memory* refers to secure storage that is not wiped out when
- A new session is created in volatile memory.
- The device produces a fresh random number $rand\_{root}$, and saves it in the current session.
- The device generates the randomness for the $i$-th input and for the $j$-th key as: $rand_{i,j} = SHA256(rand\_{root} \| i \| j)$.
-- Compute each *(secnonce, pubnonce)* as per the `NonceGen` algorithm.
+- Compute each *(secnonce, pubnonce)* as per the `NonceGen` algorithm, with the arguments described above.
- At completion (after all the pubnonces are returned), the session secret $rand\_{root}$ is copied into the persistent memory.
**Phase 2: partial signature generation:** A PSBT containing all the pubnonces is sent to the device.
- *A copy of the session is stored in the volatile memory, and the session is deleted from the persistent memory*.
- For each input/musig-key pair $(i, j)$:
- - Recompute the pubnonce/secnonce pair using `NonceGen` with the synthetic randomness $rand_{i,j}$ as above.
+ - Recompute the pubnonce/secnonce pair using `NonceGen` with the synthetic randomness $rand_{i,j}$ and the other arguments as above.
- Verify that the pubnonce contained in the PSBT matches the one synthetically recomputed.
- Continue the signing flow as per BIP-0327, generating the partial signature.
@@ -76,13 +84,29 @@ Storing the session in persistent memory only at the end of Phase 1, and deletin
Generating $rand_{i, j}$ synthetically is not a problem, since the $rand\_{root}$ value is kept secret and never leaves the device. This ensures that all the values produced for different $i$ and $j$ are not predictable for an attacker.
-#### Malleability of the PSBT
-If the optional parameters are passed to the _NonceGen_ function, they will depend on the transaction data present in the PSBT. Therefore, there is no guarantee that they will be unchanged the next time the PSBT is provided.
+#### Optional arguments of NonceGen
+In BIP-0327, *aggpk*, *msg* and *extra_in* are optional arguments of _NonceGen_. They only add entropy, as a defense in depth against a faulty source of randomness. They are not needed for the uniqueness of the nonces, which comes from $rand\_{root}$ being generated by the hardware RNG, and from the $(i, j)$ domain separation in $rand_{i,j}$.
-However, that does not constitute a security risk, as those parameters are only used as additional sources of entropy in _NonceGen_. A malicious software wallet can't affect the _secnonce_/_pubnonce_ pairs in any predictable way. Changing any of the parameters used in _NonceGen_ would cause a failure during Phase 2, as the recomputed _pubnonce_ would not match the one in the psbt.
+BIP-0327 suggests using the tweaked aggregate key as *aggpk*. Using the untweaked one instead is what makes the pubnonces independent of the transaction, and of the (change, address index) of the UTXO being spent. Since none of the arguments depend on the PSBT, a malicious software wallet can't affect the _secnonce_/_pubnonce_ pairs in any way. A PSBT whose pubnonces were not produced by the current session makes Phase 2 fail, as the recomputed _pubnonce_ does not match the one in the PSBT.
### Generalization to multiple PSBT signing sessions
The approach described above assumes that no attempt to sign a PSBT for a wallet policy containing `musig()` keys is initiated while a session is already in progress.
-In order to generalize this to an arbitrary number of parallel signing sessions, one can identify each signing session with a `psbt_session_id`. Such `psbt_session_id` should deterministically depend on the transaction being signed (ignoring all the other PSBT fields), and the wallet policy being signed. In praticular, the computed `psbt_session_id` should be identical between Round 1 and Round 2 of the protocol. Note that malicious collisions of the `psbt_session_id` (for example by tampering with some details of the PSBT, like the SIGHASH flags) _are_ possible, but they do not constitute a security risk.
+In order to generalize this to multiple parallel signing sessions, each signing session is identified by a `psbt_session_id`, which must be identical between Round 1 and Round 2 of the protocol. The device computes it as:
+
+$\qquad psbt\_session\_id = H_{PsbtSessionId}(descriptor\_template\_hash \| keys\_info\_merkle\_root)$
+
+where $H_{tag}$ is the BIP-0340 tagged hash, and the two arguments are the hash of the descriptor template and the root of the Merkle tree of the keys information of the wallet policy. The name of the wallet policy is not committed to, as it plays no role in signing.
+
+The `psbt_session_id` deliberately depends only on the wallet policy, and not on the transaction; together with the transaction-independent arguments of _NonceGen_, this is what allows [pre-generating the pubnonces](#pre-generating-the-pubnonces). The tradeoff is that at most one session can be pending for each wallet policy; the device can store up to 8 sessions, each for a different wallet policy.
+
+Collisions of the `psbt_session_id` do not constitute a security risk: they only cause the previous session to be deleted, and the following Phase 2 to fail.
+
+### Pre-generating the pubnonces
+
+Since neither the pubnonces nor the `psbt_session_id` depend on the transaction, a software wallet can execute Phase 1 before the transaction is known, for example while the device happens to be connected. It can then use the pubnonces returned by the device for a different transaction, provided that:
+- the transaction is for the same wallet policy, and no other Phase 1 or Phase 2 for that wallet policy was executed in the meantime;
+- all inputs are internal, and the PSBT used in Phase 1 has at least as many inputs as the PSBT used in Phase 2, and each pubnonce is put in the Phase 2 PSBT under the key (of the `PSBT_IN_MUSIG2_PUB_NONCE` field) computed for the new transaction. This key contains the aggregate public key _after_ the tweaks, and, for script path spends, the tapleaf hash; therefore, it depends on the transaction, not merely on the input's position.
+
+More generally, this can also work when some inputs are not internal, as long as for each internal input of the PSBT used in Phase 2, there is a corresponding internal input in the PSBT used in Phase 1.
### src/handler/sign_psbt.c
@@ -43,35 +43,25 @@
#include "sw.h"
#include "txhashes.h"
-void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
- LOG_PROCESSOR(__FILE__, __LINE__, __func__);
-
- /* Setting transaction loading information screen */
- ui_set_processing_screen_text(GA_LOADING_TRANSACTION);
-
- sign_psbt_state_t st;
- memset(&st, 0, sizeof(st));
-
- st.protocol_version = protocol_version;
-
+static void sign_psbt(dispatcher_context_t *dc,
+ sign_psbt_state_t *st,
+ sign_psbt_cache_t *cache,
+ signing_state_t *signing_state) {
// read APDU inputs, initialize global state and read global PSBT map
- if (!init_global_state(dc, &st)) return;
+ if (!init_global_state(dc, st)) return;
#ifdef HAVE_SWAP
- if (G_called_from_swap && st.bip322.is_message_signing) {
+ if (G_called_from_swap && st->bip322.is_message_signing) {
PRINTF("BIP-322 message signing is not allowed during swap\n");
SEND_SW_EC(dc, SW_NOT_SUPPORTED, EC_SIGN_PSBT_BIP322_NOT_ALLOWED_IN_SWAP);
return;
}
#endif /* HAVE_SWAP */
- if (st.bip322.is_message_signing) {
+ if (st->bip322.is_message_signing) {
ui_set_processing_screen_text(GA_LOADING_MESSAGE);
}
- sign_psbt_cache_t cache;
- init_sign_psbt_cache(&cache);
-
// bitmap to keep track of which inputs are internal
uint8_t internal_inputs[BITVECTOR_REAL_SIZE(MAX_N_INPUTS_CAN_SIGN)];
memset(internal_inputs, 0, sizeof(internal_inputs));
@@ -88,58 +78,52 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
* - detect internal inputs that should be signed, and if there are external inputs or unusual
* sighashes
*/
- if (!preprocess_inputs(dc, &st, &cache, internal_inputs)) return;
+ if (!preprocess_inputs(dc, st, cache, internal_inputs)) return;
/** OUTPUTS VERIFICATION FLOW
*
* For each output, check if it's a change address.
* Check if it's an acceptable output.
*/
- if (!preprocess_outputs(dc, &st, &cache, internal_outputs)) return;
+ if (!preprocess_outputs(dc, st, cache, internal_outputs)) return;
/** BIP-322 STRUCTURAL VALIDATION
*
* If the PSBT declares itself as a BIP-322 message signing request, enforce the exact
* to_sign structure; the PSBT is never reviewed as a transaction once the field is present,
* but it shares the same signing flow.
*/
- if (st.bip322.is_message_signing && !validate_bip322_request(dc, &st)) return;
+ if (st->bip322.is_message_signing && !validate_bip322_request(dc, st)) return;
// check if we're only executing the MuSig2 Round 1
bool only_signing_for_musig = true;
- for (size_t i = 0; i < st.account.n_internal_key_expressions; i++) {
- if (st.account.internal_key_expressions[i].to_sign &&
- st.account.internal_key_expressions[i].key_expression_ptr->type !=
+ for (size_t i = 0; i < st->account.n_internal_key_expressions; i++) {
+ if (st->account.internal_key_expressions[i].to_sign &&
+ st->account.internal_key_expressions[i].key_expression_ptr->type !=
KEY_EXPRESSION_MUSIG) {
// at least one of the key expressions we're signing for is not a MuSig
only_signing_for_musig = false;
}
}
- signing_state_t signing_state;
- memset(&signing_state, 0, sizeof(signing_state));
-
- // Make sure that the signing state for MuSig2 is initialized correctly
- musigsession_initialize_signing_state(&signing_state.musig);
-
// compute all the tx-wide hashes
- if (!compute_tx_hashes(dc, &st, &signing_state.tx_hashes)) {
+ if (!compute_tx_hashes(dc, st, &signing_state->tx_hashes)) {
return;
}
- if (!st.has_musig2_pub_nonces) {
+ if (!st->has_musig2_pub_nonces) {
// We execute the first round of MuSig for any musig2 key expression, producing the
// pubnonces; this does not involve the private keys, therefore we can do it without user
// confirmation
- if (!produce_musig2_pubnonces(dc, &st, &signing_state, &cache, internal_inputs)) {
+ if (!produce_musig2_pubnonces(dc, st, signing_state, cache, internal_inputs)) {
return;
}
}
// we execute the signing flow only if we're expected to produce any signature
// (including, possibly, any MuSig2 partial signature from Round 2 of MuSig2)
- if (!only_signing_for_musig || st.has_musig2_pub_nonces) {
+ if (!only_signing_for_musig || st->has_musig2_pub_nonces) {
#ifdef HAVE_SWAP
if (G_called_from_swap) {
/** SWAP CHECKS
@@ -148,23 +132,23 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
*/
// During swaps, the user approval was already obtained in the exchange app
- if (!execute_swap_checks(dc, &st)) return;
+ if (!execute_swap_checks(dc, st)) return;
} else
#endif /* HAVE_SWAP */
{
- if (st.bip322.is_message_signing) {
+ if (st->bip322.is_message_signing) {
/** BIP-322 MESSAGE CONFIRMATION
*
* Review as a message signature (account, address, message).
*/
- if (!display_bip322_message(dc, &st)) return;
+ if (!display_bip322_message(dc, st)) return;
} else {
/** TRANSACTION CONFIRMATION
*
* Display each non-change output, and transaction fees, and acquire user
* confirmation,
*/
- if (!display_transaction(dc, &st, internal_outputs)) return;
+ if (!display_transaction(dc, st, internal_outputs)) return;
}
}
@@ -176,13 +160,13 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
* For each internal key expression, and for each internal input, sign using the
* appropriate algorithm.
*/
- int sign_result = sign_transaction(dc, &st, &cache, &signing_state, internal_inputs);
+ int sign_result = sign_transaction(dc, st, cache, signing_state, internal_inputs);
#ifdef HAVE_SWAP
if (!G_called_from_swap)
#endif /* HAVE_SWAP */
{
- if (st.bip322.is_message_signing) {
+ if (st->bip322.is_message_signing) {
ui_post_processing_confirm_message(dc, sign_result);
} else {
ui_post_processing_confirm_transaction(dc, sign_result);
@@ -204,7 +188,34 @@ void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
// MuSig2: if there is an active session at the end of round 1, we move it to persistent
// storage. It is important that this is only done at the very end of the signing process,
// end only if everything is successful.
- musigsession_commit(&signing_state.musig);
+ musigsession_commit(&signing_state->musig);
SEND_SW(dc, SW_OK);
}
+
+void handler_sign_psbt(dispatcher_context_t *dc, uint8_t protocol_version) {
+ LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+
+ /* Setting transaction loading information screen */
+ ui_set_processing_screen_text(GA_LOADING_TRANSACTION);
+
+ sign_psbt_state_t st;
+ memset(&st, 0, sizeof(st));
+ st.protocol_version = protocol_version;
+
+ sign_psbt_cache_t cache;
+ init_sign_psbt_cache(&cache);
+
+ signing_state_t signing_state;
+ memset(&signing_state, 0, sizeof(signing_state));
+
+ // Make sure that the signing state for MuSig2 is initialized correctly
+ musigsession_initialize_signing_state(&signing_state.musig);
+
+ sign_psbt(dc, &st, &cache, &signing_state);
+
+ // Leave nothing behind on the stack, whatever the outcome of the signing flow
+ explicit_bzero(&st, sizeof(st));
+ explicit_bzero(&cache, sizeof(cache));
+ explicit_bzero(&signing_state, sizeof(signing_state));
+}
### src/handler/sign_psbt/musig_signing.c
@@ -30,9 +30,42 @@
#include "psbt.h"
#include "sw.h"
+/**
+ * Computes the id of the psbt-level MuSig2 signing session.
+ *
+ * The id identifies the session during both rounds of the protocol; it is the same for all the
+ * musig key expressions of the policy (if more than one), and for all the inputs of the psbt.
+ *
+ * It deliberately depends only on the wallet policy, and _not_ on the transaction being signed.
+ * That, together with the transaction-independent nonce derivation of musig_derive_nonce(), allows
+ * a client to execute round 1 before knowing the transaction, storing the pubnonces for later use.
+ *
+ * The id commits to both the descriptor template and the keys of the policy, making sure that
+ * collisions are not possible for different wallet accounts.
+ * However, this implies that at most one session can be pending for a wallet policy:
+ * starting a new round 1 deletes the previous session, attempting to complete round 2 for a
+ * deleted session results in failure, as the generated pubnonces do not match.
+ *
+ * Software wallets are responsible for managing the state correctly.
+ */
+static void musig_compute_session_id(const sign_psbt_state_t *st, uint8_t out[static 32]) {
+ // musig() key expressions are only allowed in V2 wallet policies, where the header contains the
+ // hash of the descriptor template rather than the descriptor template itself
+ LEDGER_ASSERT(st->account.wallet_header.version == WALLET_POLICY_VERSION_V2,
+ "MuSig2 requires a V2 wallet policy");
+
+ crypto_tr_tagged_hash(
+ (uint8_t[]) {'P', 's', 'b', 't', 'S', 'e', 's', 's', 'i', 'o', 'n', 'I', 'd'},
+ 13,
+ st->account.wallet_header.descriptor_template_sha256,
+ 32,
+ st->account.wallet_header.keys_info_merkle_root,
+ 32,
+ out);
+}
+
bool compute_musig_per_input_info(dispatcher_context_t *dc,
sign_psbt_state_t *st,
- signing_state_t *signing_state,
const input_info_t *input,
const keyexpr_info_t *keyexpr_info,
musig_per_input_info_t *out) {
@@ -49,7 +82,6 @@ bool compute_musig_per_input_info(dispatcher_context_t *dc,
// 1) compute aggregate pubkey
// 2) compute musig2 tweaks
// 3) compute taproot tweak (if keypath spend)
- // 4) compute the psbt_session_id that identifies the psbt-level signing session
wallet_derivation_info_t wdi = {
.n_keys = st->account.wallet_header.n_keys,
@@ -144,23 +176,50 @@ bool compute_musig_per_input_info(dispatcher_context_t *dc,
sizeof(out->agg_key_tweaked.parent_fingerprint));
memset(out->agg_key_tweaked.version, 0, sizeof(out->agg_key_tweaked.version));
- // The psbt_session_id identifies the musig signing session for the entire (psbt, wallet_policy)
- // pair, in both rounds 1 and 2 of the protocol; it is the same for all the musig placeholders
- // in the policy (if more than one), and it is the same for all the inputs in the psbt. By
- // making the hash depend on both the wallet policy and the transaction hashes, we make sure
- // that an accidental collision is impossible, allowing for independent, parallel MuSig2 signing
- // sessions for different transactions or wallet policies.
- // Malicious collisions are not a concern, as they would only result in a signing failure (since
- // the nonces would be incorrectly regenerated during round 2 of MuSig2).
- crypto_tr_tagged_hash(
- (uint8_t[]) {'P', 's', 'b', 't', 'S', 'e', 's', 's', 'i', 'o', 'n', 'I', 'd'},
- 13,
- st->account.wallet_header
- .keys_info_merkle_root, // TODO: wallet policy id would be more precise
- 32,
- (uint8_t *) &signing_state->tx_hashes,
- sizeof(tx_hashes_t),
- out->psbt_session_id);
+ return true;
+}
+
+/**
+ * Derives the (secnonce, pubnonce) pair for the given (input index, key expression) pair, out of
+ * the synthetic randomness of the psbt-level MuSig2 session.
+ *
+ * The nonce deliberately depends only on the session randomness, the two indices and the wallet
+ * policy; it does _not_ depend on the transaction, nor on the input's (change, address_index).
+ * Therefore, the `aggpk` argument of NonceGen is the aggregate key of the musig() key expression
+ * _before_ any of the tweaks, rather than the tweaked key that is actually being signed for.
+ * In BIP-0327, `aggpk` (like `msg`, which this app already omits) is an optional argument whose
+ * only purpose is to add further entropy to the derivation, as a defense in depth; it is not
+ * needed for the uniqueness of the nonce, which here is guaranteed by `rand_root` coming from
+ * the hardware RNG and by the (input_index, keyexpr_index) domain separation in compute_rand_i_j.
+ *
+ * Making the nonce independent of the transaction is what allows a client to run round 1 of MuSig2
+ * before knowing the transaction that will be signed. See doc/musig.md.
+ *
+ * On failure, the secnonce is zeroed out before returning.
+ */
+static bool __attribute__((noinline)) musig_derive_nonce(const musig_psbt_session_t *psbt_session,
+ const keyexpr_info_t *keyexpr_info,
+ unsigned int input_index,
+ musig_secnonce_t *secnonce,
+ musig_pubnonce_t *pubnonce) {
+ uint8_t rand_i_j[32];
+ compute_rand_i_j(psbt_session, input_index, keyexpr_info->index, rand_i_j);
+
+ int res = musig_nonce_gen(rand_i_j,
+ sizeof(rand_i_j),
+ keyexpr_info->internal_pubkey.compressed_pubkey,
+ // untweaked aggregate key of the musig() key expression
+ keyexpr_info->pubkey.compressed_pubkey + 1,
+ secnonce,
+ pubnonce);
+
+ explicit_bzero(rand_i_j, sizeof(rand_i_j));
+
+ if (0 > res) {
+ PRINTF("MuSig2 nonce generation failed\n");
+ explicit_bzero(secnonce, sizeof(*secnonce));
+ return false;
+ }
return true;
}
@@ -266,44 +325,37 @@ bool produce_and_yield_pubnonce(dispatcher_context_t *dc,
LOG_PROCESSOR(__FILE__, __LINE__, __func__);
musig_per_input_info_t musig_per_input_info;
- if (!compute_musig_per_input_info(dc,
- st,
- signing_state,
- input,
- keyexpr_info,
- &musig_per_input_info)) {
+ if (!compute_musig_per_input_info(dc, st, input, keyexpr_info, &musig_per_input_info)) {
return false;
}
/**
* Round 1 of the MuSig2 protocol: generate and yield pubnonce
**/
+ uint8_t psbt_session_id[32];
+ musig_compute_session_id(st, psbt_session_id);
+
const musig_psbt_session_t *psbt_session =
- musigsession_round1_initialize(musig_per_input_info.psbt_session_id, &signing_state->musig);
+ musigsession_round1_initialize(psbt_session_id, &signing_state->musig);
if (psbt_session == NULL) {
// This should never happen
PRINTF("Unexpected: failed to initialize MuSig2 round 1\n");
SEND_SW(dc, SW_BAD_STATE);
return false;
}
- bool ret = false;
- uint8_t rand_i_j[32];
- compute_rand_i_j(psbt_session, cur_input_index, keyexpr_info->index, rand_i_j);
-
musig_secnonce_t secnonce;
musig_pubnonce_t pubnonce;
- int res = musig_nonce_gen(rand_i_j,
- sizeof(rand_i_j),
- keyexpr_info->internal_pubkey.compressed_pubkey,
- musig_per_input_info.agg_key_tweaked.compressed_pubkey + 1,
- &secnonce,
- &pubnonce);
+ bool nonce_ok =
+ musig_derive_nonce(psbt_session, keyexpr_info, cur_input_index, &secnonce, &pubnonce);
+
+ // round 1 only publishes the pubnonce; the secnonce is recomputed in round 2
explicit_bzero(&secnonce, sizeof(secnonce));
- if (0 > res) {
- PRINTF("MuSig2 nonce generation failed\n");
- goto cleanup;
+
+ if (!nonce_ok) {
+ SEND_SW(dc, SW_BAD_STATE); // should never happen
+ return false;
}
if (!yield_musig_pubnonce(dc,
@@ -314,14 +366,6 @@ bool produce_and_yield_pubnonce(dispatcher_context_t *dc,
musig_per_input_info.agg_key_tweaked.compressed_pubkey,
keyexpr_info->is_tapscript ? keyexpr_info->tapleaf_hash : NULL)) {
PRINTF("Failed yielding MuSig2 pubnonce\n");
- goto cleanup;
- }
-
- ret = true;
-
-cleanup:
- explicit_bzero(rand_i_j, sizeof(rand_i_j));
- if (!ret) {
SEND_SW(dc, SW_BAD_STATE); // should never happen
return false;
}
@@ -339,12 +383,7 @@ bool __attribute__((noinline)) sign_sighash_musig_and_yield(dispatcher_context_t
LOG_PROCESSOR(__FILE__, __LINE__, __func__);
musig_per_input_info_t musig_per_input_info;
- if (!compute_musig_per_input_info(dc,
- st,
- signing_state,
- input,
- keyexpr_info,
- &musig_per_input_info)) {
+ if (!compute_musig_per_input_info(dc, st, input, keyexpr_info, &musig_per_input_info)) {
return false;
}
@@ -384,8 +423,11 @@ bool __attribute__((noinline)) sign_sighash_musig_and_yield(dispatcher_context_t
* Round 2 of the MuSig2 protocol
**/
+ uint8_t psbt_session_id[32];
+ musig_compute_session_id(st, psbt_session_id);
+
const musig_psbt_session_t *psbt_session =
- musigsession_round2_initialize(musig_per_input_info.psbt_session_id, &signing_state->musig);
+ musigsession_round2_initialize(psbt_session_id, &signing_state->musig);
if (psbt_session == NULL) {
// The PSBT contains a partial nonce, but we do not have the corresponding psbt
@@ -432,25 +474,28 @@ bool __attribute__((noinline)) sign_sighash_musig_and_yield(dispatcher_context_t
}
// recompute secnonce from psbt_session randomness
- uint8_t rand_i_j[32];
- compute_rand_i_j(psbt_session, cur_input_index, keyexpr_info->index, rand_i_j);
-
musig_secnonce_t secnonce;
musig_pubnonce_t pubnonce;
- if (0 > musig_nonce_gen(rand_i_j,
- sizeof(rand_i_j),
- keyexpr_info->internal_pubkey.compressed_pubkey,
- musig_per_input_info.agg_key_tweaked.compressed_pubkey + 1,
- &secnonce,
- &pubnonce)) {
- PRINTF("MuSig2 nonce generation failed\n");
- explicit_bzero(rand_i_j, sizeof(rand_i_j));
- explicit_bzero(&secnonce, sizeof(secnonce));
+ if (!musig_derive_nonce(psbt_session, keyexpr_info, cur_input_index, &secnonce, &pubnonce)) {
SEND_SW(dc, SW_BAD_STATE); // should never happen
return false;
}
+ // Check that the pubnonce we just recomputed is indeed the one that the client put in the psbt.
+ // A mismatch means that the psbt was not built with the pubnonces of this session; for example,
+ // because a more recent round 1 for the same wallet policy replaced the session in storage.
+ // Signing anyway would produce a partial signature that does not match the aggregate nonce, and
+ // therefore an invalid aggregate signature; per the "Identifying Disruptive Signers" section of
+ // BIP-327, the other cosigners would then legitimately blame this signer as disruptive.
+ // Fail cleanly instead.
+ if (memcmp(&pubnonce, &my_pubnonce, sizeof(pubnonce)) != 0) {
+ PRINTF("The pubnonce in the PSBT does not match the MuSig2 session\n");
+ explicit_bzero(&secnonce, sizeof(secnonce));
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
+
// generate and yield partial signature
cx_ecfp_private_key_t private_key = {0};
@@ -498,7 +543,6 @@ bool __attribute__((noinline)) sign_sighash_musig_and_yield(dispatcher_context_t
} while (false);
explicit_bzero(&private_key, sizeof(private_key));
- explicit_bzero(rand_i_j, sizeof(rand_i_j));
explicit_bzero(&secnonce, sizeof(secnonce));
if (err) {
### src/handler/sign_psbt/musig_signing.h
@@ -29,7 +29,6 @@
typedef struct {
plain_pk_t keys[MAX_PUBKEYS_PER_MUSIG];
serialized_extended_pubkey_t agg_key_tweaked;
- uint8_t psbt_session_id[32];
uint8_t tweaks[3][32]; // 2 or three tweaks
size_t n_tweaks; // always 2 or 3 for supported BIP-388 wallet policies
bool is_xonly[3]; // 2 or 3 elements
@@ -46,7 +45,6 @@ typedef struct {
*/
bool compute_musig_per_input_info(dispatcher_context_t *dc,
sign_psbt_state_t *st,
- signing_state_t *signing_state,
const input_info_t *input,
const keyexpr_info_t *keyexpr_info,
musig_per_input_info_t *out);
### src/musig/musig_sessions.c
@@ -145,4 +145,7 @@ void musigsession_commit(musig_signing_state_t *musig_signing_state) {
sizeof(musig_signing_state->_round1._id))) {
musigsession_store(musig_signing_state->_round1._id, &musig_signing_state->_round1);
}
+
+ // The signing flow is over: leave no secret behind
+ explicit_bzero(musig_signing_state, sizeof(musig_signing_state_t));
}
### src/musig/musig_sessions.h
@@ -100,7 +100,7 @@ __attribute__((warn_unused_result)) const musig_psbt_session_t *musigsession_rou
/**
* If a session produced in round 1 is active in volatile memory, it is stored in the persistent
- * memory.
+ * memory. The signing state is then zeroed out, as the signing flow is over.
* This must be called at the end of a successful signing flow, after all the public nonces have
* been returned to the client. It must _not_ be called if any error occurs, or if the signing
* process is aborted for any reason.
### tests/test_sign_psbt_musig.py
@@ -3,19 +3,25 @@
from hashlib import sha256
import hmac
-from typing import Optional
+from typing import Dict, Optional, Tuple
+import pytest
+from ragger.error import ExceptionRAPDU
+
+from ledger_bitcoin.exception.errors import BadStateError, IncorrectDataError
+from ledger_bitcoin.exception.device_exception import DeviceException
from ledger_bitcoin.client_base import Client, MusigPartialSignature, MusigPubNonce
-from ledger_bitcoin.key import ExtendedKey
+from ledger_bitcoin.key import ExtendedKey, KeyOriginInfo
from ledger_bitcoin.psbt import PSBT
from ragger.navigator import Navigator
from ragger.firmware import Firmware
from ledger_bitcoin.wallet import WalletPolicy
from ragger_bitcoin import RaggerClient
from test_utils import SpeculosGlobals, bip0327
-from test_utils.musig2 import HotMusig2Cosigner, MuSig2KeyPlaceholder, PsbtMusig2Cosigner, TrDescriptorTemplate, run_musig2_test
+from test_utils.musig2 import HotMusig2Cosigner, MuSig2KeyPlaceholder, PsbtMusig2Cosigner, TrDescriptorTemplate, aggregate_musig_pubkey, process_placeholder, run_musig2_test, tapleaf_hash
+from test_utils.taproot import taproot_output_script
from .instructions import *
tests_root: Path = Path(__file__).parent
@@ -107,28 +113,38 @@ def generate_partial_signatures(self, psbt: PSBT) -> None:
raise ValueError("Expected partial signatures, got a pubnonce")
-def test_sign_psbt_musig2_keypath(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
- cosigner_1_xpub = "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+KEYPATH_COSIGNER_1_XPUB = "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+KEYPATH_COSIGNER_2_XPRIV = "tprv8gFWbQBTLFhbX3EK3cS7LmenwE3JjXbD9kN9yXfq7LcBm81RSf8vPGPqGPjZSeX41LX9ZN14St3z8YxW48aq5Yhr9pQZVAyuBthfi6quTCf"
+KEYPATH_COSIGNER_2_XPUB = "tpubDCwYjpDhUdPGQWG6wG6hkBJuWFZEtrn7j3xwG3i8XcQabcGC53xWZm1hSXrUPFS5UvZ3QhdPSjXWNfWmFGTioARHuG5J7XguEjgg7p8PxAm"
+
+KEYPATH_PSBT_B64 = "cHNidP8BAIACAAAAAdF2HhQ2XCgTpd3Sel7VkS5FvESbwo1rgeuG4tBt9GICAAAAAAD9////AQAAAAAAAAAARGpCVGhpcyBpbnB1dHMgaGFzIHR3byBwdWJrZXlzIGJ1dCB5b3Ugb25seSBzZWUgb25lLiAjbXBjZ2FuZyByZXZlbmdlAAAAAAABASuf/gQAAAAAACJRIMH9/r7QY6oUg0DEUTLmcY2N6BRmriuQkp49kyg2TNbtIRaQZkYWUCCfi7xZsFr10WFcUPX3nBiNe+dC/ZMiUvaPDA0AW4+8kwAAAAADAAAAAAA="
- cosigner_2_xpriv = "tprv8gFWbQBTLFhbX3EK3cS7LmenwE3JjXbD9kN9yXfq7LcBm81RSf8vPGPqGPjZSeX41LX9ZN14St3z8YxW48aq5Yhr9pQZVAyuBthfi6quTCf"
- cosigner_2_xpub = "tpubDCwYjpDhUdPGQWG6wG6hkBJuWFZEtrn7j3xwG3i8XcQabcGC53xWZm1hSXrUPFS5UvZ3QhdPSjXWNfWmFGTioARHuG5J7XguEjgg7p8PxAm"
+KEYPATH_SIGHASHES = [
+ bytes.fromhex(
+ "a3aeecb6c236b4a7e72c95fa138250d449b97a75c573f8ab612356279ff64046")
+]
+
+def keypath_wallet_policy(speculos_globals: SpeculosGlobals) -> Tuple[WalletPolicy, bytes]:
wallet_policy = WalletPolicy(
name="Musig for my ears",
descriptor_template="tr(musig(@0,@1)/**)",
- keys_info=[cosigner_1_xpub, cosigner_2_xpub]
+ keys_info=[KEYPATH_COSIGNER_1_XPUB, KEYPATH_COSIGNER_2_XPUB]
)
wallet_hmac = hmac.new(
speculos_globals.wallet_registration_key, wallet_policy.id, sha256).digest()
+ return wallet_policy, wallet_hmac
+
+
+def test_sign_psbt_musig2_keypath(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ cosigner_2_xpriv = KEYPATH_COSIGNER_2_XPRIV
+
+ wallet_policy, wallet_hmac = keypath_wallet_policy(speculos_globals)
- psbt_b64 = "cHNidP8BAIACAAAAAdF2HhQ2XCgTpd3Sel7VkS5FvESbwo1rgeuG4tBt9GICAAAAAAD9////AQAAAAAAAAAARGpCVGhpcyBpbnB1dHMgaGFzIHR3byBwdWJrZXlzIGJ1dCB5b3Ugb25seSBzZWUgb25lLiAjbXBjZ2FuZyByZXZlbmdlAAAAAAABASuf/gQAAAAAACJRIMH9/r7QY6oUg0DEUTLmcY2N6BRmriuQkp49kyg2TNbtIRaQZkYWUCCfi7xZsFr10WFcUPX3nBiNe+dC/ZMiUvaPDA0AW4+8kwAAAAADAAAAAAA="
psbt = PSBT()
- psbt.deserialize(psbt_b64)
+ psbt.deserialize(KEYPATH_PSBT_B64)
- sighashes = [
- bytes.fromhex(
- "a3aeecb6c236b4a7e72c95fa138250d449b97a75c573f8ab612356279ff64046")
- ]
+ sighashes = KEYPATH_SIGHASHES
signer_1 = LedgerMusig2Cosigner(client, wallet_policy, wallet_hmac,
navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True, has_feewarning=True), testname=test_name)
@@ -137,36 +153,362 @@ def test_sign_psbt_musig2_keypath(navigator: Navigator, firmware: Firmware, clie
run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], sighashes)
-def test_sign_psbt_musig2_scriptpath(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
- cosigner_1_xpub = "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+SCRIPTPATH_INTERNAL_XPUB = "tpubD6NzVbkrYhZ4WLczPJWReQycCJdd6YVWXubbVUFnJ5KgU5MDQrD998ZJLSmaB7GVcCnJSDWprxmrGkJ6SvgQC6QAffVpqSvonXmeizXcrkN"
+SCRIPTPATH_COSIGNER_1_XPUB = "[f5acc2fd/44'/1'/0']tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT"
+SCRIPTPATH_COSIGNER_2_XPRIV = "tprv8gFWbQBTLFhbX3EK3cS7LmenwE3JjXbD9kN9yXfq7LcBm81RSf8vPGPqGPjZSeX41LX9ZN14St3z8YxW48aq5Yhr9pQZVAyuBthfi6quTCf"
+SCRIPTPATH_COSIGNER_2_XPUB = ExtendedKey.deserialize(
+ SCRIPTPATH_COSIGNER_2_XPRIV).neutered().to_string()
- cosigner_2_xpriv = "tprv8gFWbQBTLFhbX3EK3cS7LmenwE3JjXbD9kN9yXfq7LcBm81RSf8vPGPqGPjZSeX41LX9ZN14St3z8YxW48aq5Yhr9pQZVAyuBthfi6quTCf"
- cosigner_2_xpub = ExtendedKey.deserialize(
- cosigner_2_xpriv).neutered().to_string()
+SCRIPTPATH_PSBT_B64 = "cHNidP8BAFoCAAAAAdOnEESfpXpBe9X59Q4jxz1u9E4Wovn2bkAuuyqUUY0mAAAAAAD9////AQAAAAAAAAAAHmocTXVzaWcyLiBOb3cgZXZlbiBpbiBTY3JpcHRzLgAAAAAAAQErOTAAAAAAAAAiUSDtVR7h2JYPJC463zrCcmfKriiugHBXAcXDP1O2ptF2LyIVwethFsEeXf/x51pIczoAIsj9RoVePIBTyk/rOMW8B6uIIyCQZkYWUCCfi7xZsFr10WFcUPX3nBiNe+dC/ZMiUvaPDKzAIRaQZkYWUCCfi7xZsFr10WFcUPX3nBiNe+dC/ZMiUvaPDC0BuYMCXh1wIlpyBMdMaCFPSwOeOyvhqg+FJ+fOMoWlJsRbj7yTAAAAAAMAAAABFyDrYRbBHl3/8edaSHM6ACLI/UaFXjyAU8pP6zjFvAeriAEYILmDAl4dcCJacgTHTGghT0sDnjsr4aoPhSfnzjKFpSbEAAA="
+SCRIPTPATH_SIGHASHES = [
+ bytes.fromhex(
+ "28f86cd95c144ed4a877701ae7166867e8805b654c43d9f44da45d7b0070c313")
+]
+
+
+def scriptpath_wallet_policy(speculos_globals: SpeculosGlobals) -> Tuple[WalletPolicy, bytes]:
wallet_policy = WalletPolicy(
name="Musig2 in the scriptpath",
descriptor_template="tr(@0/**,pk(musig(@1,@2)/**))",
keys_info=[
- "tpubD6NzVbkrYhZ4WLczPJWReQycCJdd6YVWXubbVUFnJ5KgU5MDQrD998ZJLSmaB7GVcCnJSDWprxmrGkJ6SvgQC6QAffVpqSvonXmeizXcrkN",
- cosigner_1_xpub,
- cosigner_2_xpub
+ SCRIPTPATH_INTERNAL_XPUB,
+ SCRIPTPATH_COSIGNER_1_XPUB,
+ SCRIPTPATH_COSIGNER_2_XPUB
]
)
wallet_hmac = hmac.new(
speculos_globals.wallet_registration_key, wallet_policy.id, sha256).digest()
+ return wallet_policy, wallet_hmac
- psbt_b64 = "cHNidP8BAFoCAAAAAdOnEESfpXpBe9X59Q4jxz1u9E4Wovn2bkAuuyqUUY0mAAAAAAD9////AQAAAAAAAAAAHmocTXVzaWcyLiBOb3cgZXZlbiBpbiBTY3JpcHRzLgAAAAAAAQErOTAAAAAAAAAiUSDtVR7h2JYPJC463zrCcmfKriiugHBXAcXDP1O2ptF2LyIVwethFsEeXf/x51pIczoAIsj9RoVePIBTyk/rOMW8B6uIIyCQZkYWUCCfi7xZsFr10WFcUPX3nBiNe+dC/ZMiUvaPDKzAIRaQZkYWUCCfi7xZsFr10WFcUPX3nBiNe+dC/ZMiUvaPDC0BuYMCXh1wIlpyBMdMaCFPSwOeOyvhqg+FJ+fOMoWlJsRbj7yTAAAAAAMAAAABFyDrYRbBHl3/8edaSHM6ACLI/UaFXjyAU8pP6zjFvAeriAEYILmDAl4dcCJacgTHTGghT0sDnjsr4aoPhSfnzjKFpSbEAAA="
- psbt = PSBT()
- psbt.deserialize(psbt_b64)
- sighashes = [
- bytes.fromhex(
- "28f86cd95c144ed4a877701ae7166867e8805b654c43d9f44da45d7b0070c313")
- ]
+def test_sign_psbt_musig2_scriptpath(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ wallet_policy, wallet_hmac = scriptpath_wallet_policy(speculos_globals)
+
+ psbt = PSBT()
+ psbt.deserialize(SCRIPTPATH_PSBT_B64)
signer_1 = LedgerMusig2Cosigner(client, wallet_policy, wallet_hmac,
navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True), testname=test_name)
- signer_2 = HotMusig2Cosigner(wallet_policy, cosigner_2_xpriv)
+ signer_2 = HotMusig2Cosigner(wallet_policy, SCRIPTPATH_COSIGNER_2_XPRIV)
- run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], sighashes)
+ run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], SCRIPTPATH_SIGHASHES)
+
+
+def musig_pubnonce_ids(wallet_policy: WalletPolicy, psbt: PSBT) -> Dict[Tuple[int, int], Tuple[bytes, Optional[bytes]]]:
+ """
+ For each (input index, musig() key placeholder index) pair of the psbt, returns the
+ (aggregate pubkey after the tweaks, tapleaf hash) pair that, together with the participant's
+ pubkey, identifies the pubnonces and partial signatures in the psbt.
+ """
+ desc_tmpl = TrDescriptorTemplate.from_string(wallet_policy.descriptor_template)
+ result = {}
+ for placeholder_index, (placeholder, tapleaf_desc) in enumerate(desc_tmpl.placeholders()):
+ if not isinstance(placeholder, MuSig2KeyPlaceholder):
+ continue
+
+ agg_xpub_str, keyagg_ctx = aggregate_musig_pubkey(
+ wallet_policy.keys_info[i] for i in placeholder.key_indexes)
+ agg_xpub = ExtendedKey.deserialize(agg_xpub_str)
+
+ for input_index, input in enumerate(psbt.inputs):
+ res = process_placeholder(
+ wallet_policy, input, placeholder, keyagg_ctx, agg_xpub, tapleaf_desc, desc_tmpl)
+ if res is not None:
+ (_, _, leaf_script, aggpk_tweaked) = res
+ result[(input_index, placeholder_index)] = (
+ aggpk_tweaked, tapleaf_hash(leaf_script))
+ return result
+
+
+class Round1OnOtherTxCosigner(LedgerMusig2Cosigner):
+ """
+ A LedgerMusig2Cosigner that executes round 1 on a completely different transaction than the one
+ that is going to be signed, and then reuses the resulting pubnonces. This is what a software
+ wallet does when it pre-generates the pubnonces before the transaction is known.
+ """
+
+ def __init__(self, other_psbt: PSBT, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.other_psbt = other_psbt
+
+ def generate_public_nonces(self, psbt: PSBT) -> None:
+ # the device only ever sees the unrelated transaction during round 1
+ super().generate_public_nonces(self.other_psbt)
+
+ # Transplant the pubnonces into the transaction that will actually be signed.
+ src_ids = musig_pubnonce_ids(self.wallet_policy, self.other_psbt)
+ dst_ids = musig_pubnonce_ids(self.wallet_policy, psbt)
+ n_transplanted = 0
+ for (input_index, placeholder_index), (src_aggpk, src_leaf_hash) in src_ids.items():
+ dst_aggpk, dst_leaf_hash = dst_ids[(input_index, placeholder_index)]
+ src_key = (self.pubkey.pubkey, src_aggpk, src_leaf_hash)
+ if src_key in self.other_psbt.inputs[input_index].musig2_pub_nonces:
+ psbt.inputs[input_index].musig2_pub_nonces[(self.pubkey.pubkey, dst_aggpk, dst_leaf_hash)] = \
+ self.other_psbt.inputs[input_index].musig2_pub_nonces[src_key]
+ n_transplanted += 1
+ assert n_transplanted > 0
+
+
+def test_sign_psbt_musig2_round1_on_another_transaction(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # Neither the pubnonces nor the psbt_session_id depend on the transaction, therefore pubnonces
+ # obtained in round 1 for one transaction are valid for any other transaction of the same wallet
+ # policy, as long as the inputs are at the same indexes.
+ # This is needed by software wallets that want to pre-generate the pubnonces before the
+ # transaction is known, keeping the UX of musig similar to a regular multisig.
+ #
+ # This test would have failed on versions of the app until 2.5.1, since the psbt_session_id
+ # used to depend on the transaction.
+ wallet_policy, wallet_hmac = keypath_wallet_policy(speculos_globals)
+
+ psbt = PSBT()
+ psbt.deserialize(KEYPATH_PSBT_B64)
+
+ other_psbt = PSBT()
+ other_psbt.deserialize(KEYPATH_PSBT_B64)
+ other_psbt.tx.vin[0].prevout.hash ^= 1
+ other_psbt.tx.vout[0].nValue += 1000
+ other_psbt.tx.rehash()
+
+ other_input = other_psbt.inputs[0]
+ ((_, (_, key_origin)),) = other_input.tap_bip32_paths.items()
+ assert key_origin.path == [0, 3]
+ other_steps = [1, 7] # change address with index 7, instead of receive address with index 3
+ agg_xpub = ExtendedKey.deserialize(aggregate_musig_pubkey(wallet_policy.keys_info)[0])
+ other_internal_key = agg_xpub.derive_pub_path(other_steps).pubkey[1:]
+ other_input.tap_bip32_paths = {
+ other_internal_key: (set(), KeyOriginInfo(key_origin.fingerprint, other_steps))
+ }
+ other_input.witness_utxo.scriptPubKey = taproot_output_script(other_internal_key, None)
+
+ assert musig_pubnonce_ids(wallet_policy, other_psbt)[(0, 0)] != \
+ musig_pubnonce_ids(wallet_policy, psbt)[(0, 0)]
+
+ signer_1 = Round1OnOtherTxCosigner(other_psbt, client, wallet_policy, wallet_hmac,
+ navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True, has_feewarning=True), testname=test_name)
+ signer_2 = HotMusig2Cosigner(wallet_policy, KEYPATH_COSIGNER_2_XPRIV)
+
+ # run_musig2_test also aggregates the partial signatures and checks the resulting Schnorr
+ # signature against the sighash of `psbt`
+ run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], KEYPATH_SIGHASHES)
+
+
+def test_sign_psbt_musig2_scriptpath_round1_on_another_transaction(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # Same as test_sign_psbt_musig2_round1_on_another_transaction, for a musig() in a tapleaf.
+ wallet_policy, wallet_hmac = scriptpath_wallet_policy(speculos_globals)
+
+ psbt = PSBT()
+ psbt.deserialize(SCRIPTPATH_PSBT_B64)
+
+ other_psbt = PSBT()
+ other_psbt.deserialize(SCRIPTPATH_PSBT_B64)
+ other_psbt.tx.vin[0].prevout.hash ^= 1
+ other_psbt.tx.vout[0].nValue += 1000
+ other_psbt.tx.rehash()
+
+ signer_1 = Round1OnOtherTxCosigner(other_psbt, client, wallet_policy, wallet_hmac,
+ navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True), testname=test_name)
+ signer_2 = HotMusig2Cosigner(wallet_policy, SCRIPTPATH_COSIGNER_2_XPRIV)
+
+ run_musig2_test(wallet_policy, psbt, [signer_1, signer_2], SCRIPTPATH_SIGHASHES)
+
+
+def test_sign_psbt_musig2_round1_twice_replaces_session(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # Since the psbt_session_id does not depend on the transaction, a second round 1 for the same
+ # wallet policy (on any transaction) replaces the pending session: round 2 with the pubnonces
+ # of the first round 1 must then fail.
+ wallet_policy, wallet_hmac = keypath_wallet_policy(speculos_globals)
+
+ signer_1 = LedgerMusig2Cosigner(client, wallet_policy, wallet_hmac,
+ navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True, has_feewarning=True), testname=test_name)
+ signer_2 = HotMusig2Cosigner(wallet_policy, KEYPATH_COSIGNER_2_XPRIV)
+
+ psbt = PSBT()
+ psbt.deserialize(KEYPATH_PSBT_B64)
+ signer_1.generate_public_nonces(psbt)
+ signer_2.generate_public_nonces(psbt)
+
+ other_psbt = PSBT()
+ other_psbt.deserialize(KEYPATH_PSBT_B64)
+ other_psbt.tx.vout[0].nValue += 1000
+ other_psbt.tx.rehash()
+ signer_1.generate_public_nonces(other_psbt)
+ assert other_psbt.inputs[0].musig2_pub_nonces != psbt.inputs[0].musig2_pub_nonces
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ signer_1.generate_partial_signatures(psbt)
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
+ assert len(psbt.inputs[0].musig2_partial_sigs) == 0
+
+
+class Round1ForOtherPolicyCosigner(PsbtMusig2Cosigner):
+ """
+ Not a real cosigner: when asked for its pubnonces, it makes the device execute round 1 for a
+ different wallet policy, on a copy of the psbt. Used to check that this does not interfere with
+ the pending session of the policy that is actually being signed.
+ """
+
+ def __init__(self, ledger_cosigner: LedgerMusig2Cosigner) -> None:
+ super().__init__()
+ self.ledger_cosigner = ledger_cosigner
+
+ def get_participant_pubkey(self) -> bip0327.Point:
+ return self.ledger_cosigner.get_participant_pubkey()
+
+ def generate_public_nonces(self, psbt: PSBT) -> None:
+ # the copy must not contain any pubnonce, or the device would execute round 2
+ psbt_copy = PSBT()
+ psbt_copy.deserialize(psbt.serialize())
+ for input in psbt_copy.inputs:
+ input.musig2_pub_nonces.clear()
+ self.ledger_cosigner.generate_public_nonces(psbt_copy)
+ assert any(len(input.musig2_pub_nonces) > 0 for input in psbt_copy.inputs)
+
+ def generate_partial_signatures(self, psbt: PSBT) -> None:
+ pass
+
+
+def test_sign_psbt_musig2_policies_with_same_keys(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # Two wallet policies with the same keys, but different descriptor templates, must not share the
+ # same psbt_session_id: otherwise, round 1 for one policy would delete the session that the
+ # other policy is waiting to use in round 2.
+ wallet_policy, wallet_hmac = keypath_wallet_policy(speculos_globals)
+
+ # The keys in musig() are sorted, so this policy has the same aggregate key and the same
+ # addresses as wallet_policy; it can therefore execute round 1 on the very same psbt.
+ other_wallet_policy = WalletPolicy(
+ name="Musig for my other ears",
+ descriptor_template="tr(musig(@1,@0)/**)",
+ keys_info=wallet_policy.keys_info
+ )
+ other_wallet_hmac = hmac.new(
+ speculos_globals.wallet_registration_key, other_wallet_policy.id, sha256).digest()
+ assert other_wallet_policy.id != wallet_policy.id
+
+ psbt = PSBT()
+ psbt.deserialize(KEYPATH_PSBT_B64)
+
+ signer_1 = LedgerMusig2Cosigner(client, wallet_policy, wallet_hmac,
+ navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True, has_feewarning=True), testname=test_name)
+ interloper = Round1ForOtherPolicyCosigner(
+ LedgerMusig2Cosigner(client, other_wallet_policy, other_wallet_hmac, testname=test_name))
+ signer_2 = HotMusig2Cosigner(wallet_policy, KEYPATH_COSIGNER_2_XPRIV)
+
+ # the device executes round 1 for wallet_policy, then for other_wallet_policy, then round 2 for
+ # wallet_policy
+ run_musig2_test(wallet_policy, psbt, [signer_1, interloper, signer_2], KEYPATH_SIGHASHES)
+
+
+def test_sign_psbt_musig2_wrong_pubnonce(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # If the pubnonce in the psbt is not the one that the device would derive for the current
+ # session, the device must refuse to produce a partial signature: signing anyway would produce a
+ # partial signature that does not match the aggregate nonce.
+ wallet_policy, wallet_hmac = keypath_wallet_policy(speculos_globals)
+
+ psbt = PSBT()
+ psbt.deserialize(KEYPATH_PSBT_B64)
+
+ signer_1 = LedgerMusig2Cosigner(client, wallet_policy, wallet_hmac,
+ navigator=navigator, instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False, has_spend_from_wallet=True, has_feewarning=True), testname=test_name)
+ signer_2 = HotMusig2Cosigner(
+ wallet_policy, KEYPATH_COSIGNER_2_XPRIV)
+
+ # Round 1: both cosigners add their pubnonce
+ signer_1.generate_public_nonces(psbt)
+ signer_2.generate_public_nonces(psbt)
+
+ # Replace the device's pubnonce with a different, but still valid, one. Corrupting the bytes
+ # arbitrarily would not do: the device would fail earlier while aggregating the nonces, which is
+ # a different error path.
+ ledger_pubkey = signer_1.pubkey.pubkey
+ n_replaced = 0
+ for input in psbt.inputs:
+ for psbt_key in input.musig2_pub_nonces.keys():
+ if psbt_key[0] != ledger_pubkey:
+ continue
+ _, other_pubnonce = bip0327.nonce_gen_internal(
+ rand_=b'\x42' * 32, sk=None, pk=ledger_pubkey, aggpk=None, msg=None, extra_in=None)
+ assert other_pubnonce != input.musig2_pub_nonces[psbt_key]
+ input.musig2_pub_nonces[psbt_key] = other_pubnonce
+ n_replaced += 1
+ assert n_replaced == 1
+
+ # Round 2 must fail, rather than yielding a partial signature for a nonce it did not commit to
+ with pytest.raises(ExceptionRAPDU) as e:
+ signer_1.generate_partial_signatures(psbt)
+
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
+
+
+def test_sign_psbt_musig2_session_is_single_use(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name: str, speculos_globals: SpeculosGlobals):
+ # Since the psbt_session_id and the nonces do not depend on the transaction, the only thing
+ # preventing the device from reusing a nonce is that round 2 deletes the session from storage
+ # before producing any partial signature, whether it then succeeds or not. Reusing a secnonce for
+ # a different message, or with a different aggregate nonce, would leak the private key.
+ wallet_policy, wallet_hmac = keypath_wallet_policy(speculos_globals)
+ ledger_instructions = sign_psbt_instruction_approve(
+ firmware, save_screenshot=False, has_spend_from_wallet=True, has_feewarning=True)
+
+ signer_1 = LedgerMusig2Cosigner(client, wallet_policy, wallet_hmac,
+ navigator=navigator, instructions=ledger_instructions, testname=test_name)
+ signer_2 = HotMusig2Cosigner(wallet_policy, KEYPATH_COSIGNER_2_XPRIV)
+ ledger_pubkey = signer_1.pubkey.pubkey
+
+ def replace_pubnonces(psbt: PSBT, own: bool, rand_: bytes) -> None:
+ # replaces the pubnonce of the device (if own is True) or of the other cosigner with a
+ # different, but still valid, one
+ n_replaced = 0
+ for input in psbt.inputs:
+ for psbt_key in input.musig2_pub_nonces.keys():
+ if (psbt_key[0] == ledger_pubkey) != own:
+ continue
+ _, other_pubnonce = bip0327.nonce_gen_internal(
+ rand_=rand_, sk=None, pk=psbt_key[0], aggpk=None, msg=None, extra_in=None)
+ assert other_pubnonce != input.musig2_pub_nonces[psbt_key]
+ input.musig2_pub_nonces[psbt_key] = other_pubnonce
+ n_replaced += 1
+ assert n_replaced == 1
+
+ def copy_psbt(psbt: PSBT) -> PSBT:
+ result = PSBT()
+ result.deserialize(psbt.serialize())
+ return result
+
+ # 1) A successful round 2 consumes the session: a second round 2 for a different transaction, and
+ # with a different aggregate nonce, must not produce another partial signature.
+ psbt = PSBT()
+ psbt.deserialize(KEYPATH_PSBT_B64)
+ signer_1.generate_public_nonces(psbt)
+ signer_2.generate_public_nonces(psbt)
+
+ replayed_psbt = copy_psbt(psbt)
+ replayed_psbt.tx.vout[0].nValue += 1000
+ replayed_psbt.tx.rehash()
+ replace_pubnonces(replayed_psbt, own=False, rand_=b'\x42' * 32)
+
+ signer_1.generate_partial_signatures(psbt)
+ assert len(psbt.inputs[0].musig2_partial_sigs) == 1
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ signer_1.generate_partial_signatures(replayed_psbt)
+ assert DeviceException.exc.get(e.value.status) == BadStateError
+ assert len(replayed_psbt.inputs[0].musig2_partial_sigs) == 0
+
+ # 2) A failed round 2 consumes the session as well: after a round 2 that is rejected because of
+ # a wrong pubnonce, retrying with the correct psbt must fail too.
+ psbt = PSBT()
+ psbt.deserialize(KEYPATH_PSBT_B64)
+ signer_1.generate_public_nonces(psbt)
+ signer_2.generate_public_nonces(psbt)
+
+ wrong_psbt = copy_psbt(psbt)
+ replace_pubnonces(wrong_psbt, own=True, rand_=b'\x43' * 32)
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ signer_1.generate_partial_signatures(wrong_psbt)
+ assert DeviceException.exc.get(e.value.status) == IncorrectDataError
+
+ with pytest.raises(ExceptionRAPDU) as e:
+ signer_1.generate_partial_signatures(psbt)
+ assert DeviceException.exc.get(e.value.status) == BadStateError
+ assert len(psbt.inputs[0].musig2_partial_sigs) == 0Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.