Skip to content

[WIP] Allow pregenerating nonces for future MuSig2 signing - #552

Draft
bigspider wants to merge 5 commits into
developfrom
musig-pregen-nonces
Draft

[WIP] Allow pregenerating nonces for future MuSig2 signing#552
bigspider wants to merge 5 commits into
developfrom
musig-pregen-nonces

Conversation

@bigspider

Copy link
Copy Markdown
Contributor

Exploring how to address #551

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

elf sizes
source = source branch musig-pregen-nonces
target = target branch develop

Device .text source .text target .text delta .bss source .bss target .bss delta max stack size source max stack size target max stack size delta
apex_p 105640 105640 0 7352 7352 0 33600 33600 0
stax 109431 109431 0 7352 7352 0 29504 29504 0
nanox 85108 85108 0 4052 4052 0 8192 8192 0
flex 109481 109481 0 7352 7352 0 29504 29504 0
nanos2 85620 85620 0 6216 6216 0 34736 34736 0

Stack consumption summary

⚠️ This summary is for informative purpose only. It may not give the application actual worst case, for example if the test coverage is low.

Device Worst case (bytes) Remaining stack (bytes) Test
apex_p 12513 21087 test_e2e_musig2.py::test_e2e_musig2_keypath[apex_p-apex_p]
flex 12513 16991 test_e2e_musig2.py::test_e2e_musig2_keypath[flex-flex]
nanosp 12513 22223 test_e2e_musig2.py::test_e2e_musig2_keypath[nanosp-nanosp]
nanox 11225 13387 test_e2e_musig2.py::test_e2e_musig2_keypath[nanox-nanox]
stax 12513 16991 test_e2e_musig2.py::test_e2e_musig2_keypath[stax-stax]

Full details

@codecov-commenter

codecov-commenter commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 40.42553% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.46%. Comparing base (610292a) to head (385711a).

Files with missing lines Patch % Lines
src/handler/sign_psbt/musig_signing.c 34.88% 28 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #552      +/-   ##
===========================================
+ Coverage    76.36%   76.46%   +0.09%     
===========================================
  Files           96       96              
  Lines        10887    10882       -5     
  Branches      2529     2531       +2     
===========================================
+ Hits          8314     8321       +7     
+ Misses        2244     2232      -12     
  Partials       329      329              
Flag Coverage Δ
functionaltests 87.81% <94.44%> (+0.08%) ⬆️
fuzzing 55.64% <6.38%> (+0.05%) ⬆️
unittests 71.92% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

bigspider and others added 5 commits September 4, 2026 12:18
Round 1 and round 2 each had their own inline call to musig_nonce_gen,
with their own handling of the synthetic randomness. They must derive
exactly the same nonce: if the two ever diverged, the partial signatures
of round 2 would not match the pubnonces published in round 1, and the
aggregate signature would be invalid.

Move the derivation into musig_derive_nonce(), which owns the lifetime of
rand_i_j and zeroes the secnonce on failure. This is a pure refactor; the
derivation itself is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `aggpk` argument passed to NonceGen was the aggregate key after all the
tweaks, which depends on the input's (change, address_index) and on the
taptree. That made each pubnonce usable for one input of one transaction
only, forcing a client to involve the signing device twice for a MuSig2
signature: once to learn the pubnonces, and once to sign.

Pass the aggregate key of the musig() key expression before any tweak
instead. It is a per-account value, so the nonce now depends only on the
session randomness, the input index, the key expression index and the
wallet policy.

In BIP-0327 `aggpk` is an optional argument of NonceGen, and (like `msg`,
which this app already omits) it only serves to add entropy as a defense in
depth against a faulty RNG. The uniqueness of the nonce does not rely on
it: `rand_root` comes from the hardware RNG, and compute_rand_i_j provides
the (input_index, keyexpr_index) domain separation.

The two rounds change together, so pubnonces and partial signatures stay
consistent; the aggregate key, and therefore the PSBT field keys, are
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
doc/musig.md prescribes that round 2 verifies that the pubnonce contained
in the psbt matches the one synthetically recomputed, but the check was
never implemented: my_pubnonce was read from the psbt only to detect its
presence, and its value was never compared to the regenerated pubnonce.

Without the check, a psbt whose pubnonces do not belong to the session in
storage still produces a partial signature, computed with a secnonce that
is not the one committed to in the aggregate nonce. The result is an
invalid aggregate signature, and the other cosigners rightly identify this
signer as the disruptive one. The new test asserts the clean failure; it
fails with "DID NOT RAISE" without this commit.

This check also replaces the guarantee that used to come from having the
transaction in the psbt_session_id: rather than approximating "same
transaction", it directly verifies "these are the nonces of the session I
am about to use", which is the property that actually matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The psbt_session_id used to commit to the transaction hashes, so round 2
could only find the session that round 1 had stored if it was given the same
transaction. Together with the nonce derivation, that forced a client to
involve the signing device twice per MuSig2 signature.

Derive the id from the wallet policy alone. A client can now execute round 1
whenever it likes -- for example while the device happens to be connected --
and use the resulting pubnonces for whichever transaction it later builds,
as long as its inputs sit at the same indexes.

The tradeoff is that at most one round-1 batch can be pending per wallet
policy, since a new round 1 replaces the session in storage. When that
happens, round 2 for the older batch now fails cleanly on the pubnonce
check of the previous commit instead of silently producing a partial
signature for a nonce that was never published.

The id is versioned in the tagged hash ("PsbtSessionId2"), so that a
session stored by an older version of the app can never be matched.

Since tx_hashes was its only use, compute_musig_per_input_info() no longer
takes the signing state, and the id moves out of musig_per_input_info_t: it
is a property of the account, not of the input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now that the psbt_session_id does not depend on the transaction, the id is
no longer a second line of defense for the volatile copy of the session that
round 2 pops from storage: a _round2 that survived into a later signing flow
would be _accepted_ by musigsession_round2_initialize() instead of being
rejected for having a different id. The same secnonce would then sign two
different transactions, which is enough to recover the private key.

Today the invariant holds, because handler_sign_psbt keeps the signing state
in a zeroed stack local. But the comment above G_sign_psbt_cache explicitly
contemplates moving such state to globals to stay within the stack budget,
so make the requirement explicit instead of incidental:

- document in musig_sessions.h that the state must never outlive the flow,
  and never become a global;
- assert in musigsession_initialize_signing_state() that _round2 is zeroed
  on entry, which fails loudly if the state ever starts being reused;
- zero the whole state in musigsession_commit(), so that a completed flow
  leaves neither a usable session nor a rand_root in RAM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Code coverage report

Code Coverage

Per-file coverage
Package Line Rate Branch Rate Health
src.boilerplate 100% 0%
src.common 89% 70%
src 88% 61%
src.debug-helpers 100% 0%
src.handler 85% 82%
src.handler.lib 67% 61%
src.handler.sign_psbt 93% 88%
src.musig 23% 15%
src.ui 86% 100%
Summary 79% (3868 / 4883) 66% (2137 / 3250)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants