fix(xmss): advance key preparation window before signing (crash at slot 131072) - #1041
Merged
Conversation
…ot 131072)
zeam nodes aborted the signing worker at exactly slot 131072 with a Rust
panic from leansig's sign():
Signing: key not yet prepared for this epoch, try calling sk.advance_preparation.
Root cause: the generalized-XMSS secret key keeps only two consecutive
bottom trees in memory at a time (the "prepared interval"), covering
2 * 2^(LOG_LIFETIME/2) = 131072 epochs for LOG_LIFETIME=32, starting at
[0, 131072). Signing an epoch beyond that window requires sliding it
forward with advance_preparation(). The keys are correctly activated for
2^18 = 262144 epochs, but nothing in the glue or zeam ever advanced the
prepared window, so any epoch >= 131072 tripped leansig's assert and
aborted the process (the crash showed up on the attestation-signing path
at slot 131072).
Fix, in hashsig-glue:
- PrivateKey now wraps its secret key in a Mutex so the shared key handle
(opaque pointer on the Zig side, signed from parallel workers) can be
mutated safely.
- sign() advances the prepared window (advance_preparation in a loop, with
a no-progress guard) until it covers the requested epoch, then signs.
- Both leansig asserts (activation window and prepared window) are
pre-checked and turned into a recoverable SigningFailed error, so a
genuinely spent key fails to sign rather than aborting the node.
Zig is unaffected: it only holds these structs behind `opaque` pointers,
so dropping `#[repr(C)]` from PrivateKey/KeyPair is safe.
Adds a regression test using the 2^8 test scheme (prepared window 32,
activated for 48) that signs at epoch 35, reproducing the crash shape and
verifying advance-then-sign works. cargo build + clippy + test all clean.
Note: the first sign that crosses a 65536-epoch bottom-tree boundary pays
the advance cost (one bottom-tree recompute) inline, which may delay that
single signature; a future change could advance proactively in the
background.
Contributor
|
Adversarial review: LGTM, no blocking findings. I specifically checked the sharp edges introduced by this change:
Validation I ran locally:
Residual risk is limited to runtime cost on the first signature after each prepared-window boundary, since preparation now happens inline under the signer lock. That is expected for this fix and much better than crashing the process. Good to merge from my side. |
noopur23
approved these changes
Jul 14, 2026
This was referenced Jul 14, 2026
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the node crash at slot 131072.
Symptom
zeam nodes aborted the signing worker at exactly slot 131072, e.g. on the attestation path:
The abort is a Rust panic inside leansig's
sign():Root cause
The generalized-XMSS secret key only keeps two consecutive bottom trees in memory at a time (its "prepared interval"). For
LOG_LIFETIME = 32each bottom tree covers2^16epochs, so the prepared window is2 * 2^16 = 131072epochs, starting at[0, 131072). Signing an epoch at or beyond131072requires sliding that window forward withadvance_preparation().The keys are generated correctly (manifest:
num_active_epochs: 262144=2^18), so the activation window is fine. The bug is that neitherhashsig-gluenor zeam ever advanced the prepared window, so the first epoch past 131071 tripped leansig'sassert!(prepared_interval.contains(epoch))and aborted the process. leansig even ships the intended pattern (advance in a loop until prepared, then sign).Fix (
rust/hashsig-glue)PrivateKeywraps its secret key in aMutexso the shared key handle (anopaquepointer on the Zig side, signed from parallel workers) can be mutated safely.sign()advances the prepared window (advance_preparationin a loop, with a no-progress guard) until it covers the requested epoch, then signs.SigningFailederror, so a genuinely spent key fails to sign instead of aborting the node.Zig is unaffected: it holds these structs only behind
opaquepointers, so dropping#[repr(C)]fromPrivateKey/KeyPairis safe.Validation
cargo build -p hashsig-glue,cargo clippy -p hashsig-glue,cargo fmtall clean.advance_preparation_lets_key_sign_past_initial_window) uses the2^8test scheme (prepared window 32, activated for 48) and signs at epoch 35, reproducing the crash shape and verifying advance-then-sign works.Known minor
The first signature that crosses a
2^16-epoch bottom-tree boundary pays theadvance_preparationcost (one bottom-tree recompute) inline, which may delay that single signature at the boundary slot. A follow-up could advance proactively in the background.