Skip to content

fix(xmss): advance key preparation window before signing (crash at slot 131072) - #1041

Merged
ch4r10t33r merged 2 commits into
mainfrom
fix/xmss-advance-preparation
Jul 14, 2026
Merged

fix(xmss): advance key preparation window before signing (crash at slot 131072)#1041
ch4r10t33r merged 2 commits into
mainfrom
fix/xmss-advance-preparation

Conversation

@ch4r10t33r

Copy link
Copy Markdown
Contributor

Fixes the node crash at slot 131072.

Symptom

zeam nodes aborted the signing worker at exactly slot 131072, e.g. on the attestation path:

[s=131072 i=2] ...
chain.BeamChain.attestImpl
validator_client.ValidatorClient.mayBeDoAttestation

The abort is a Rust panic inside leansig's sign():

Signing: key not yet prepared for this epoch, try calling sk.advance_preparation.

Root cause

The generalized-XMSS secret key only keeps two consecutive bottom trees in memory at a time (its "prepared interval"). For LOG_LIFETIME = 32 each bottom tree covers 2^16 epochs, so the prepared window is 2 * 2^16 = 131072 epochs, starting at [0, 131072). Signing an epoch at or beyond 131072 requires sliding that window forward with advance_preparation().

The keys are generated correctly (manifest: num_active_epochs: 262144 = 2^18), so the activation window is fine. The bug is that neither hashsig-glue nor zeam ever advanced the prepared window, so the first epoch past 131071 tripped leansig's assert!(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)

  • PrivateKey wraps its secret key in a Mutex so the shared key handle (an 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 converted into a recoverable SigningFailed error, so a genuinely spent key fails to sign instead of aborting the node.

Zig is unaffected: it holds these structs only behind opaque pointers, so dropping #[repr(C)] from PrivateKey/KeyPair is safe.

Validation

  • cargo build -p hashsig-glue, cargo clippy -p hashsig-glue, cargo fmt all clean.
  • New regression test (advance_preparation_lets_key_sign_past_initial_window) uses the 2^8 test 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 the advance_preparation cost (one bottom-tree recompute) inline, which may delay that single signature at the boundary slot. A follow-up could advance proactively in the background.

…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.
@zclawz

zclawz commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Adversarial review: LGTM, no blocking findings.

I specifically checked the sharp edges introduced by this change:

  • FFI layout: KeyPair / PrivateKey are only exposed to Zig as opaque handles (pkgs/xmss/src/hashsig.zig), and Zig obtains public/private-key handles only through Rust accessors, so removing #[repr(C)] from the wrappers that now contain Mutex does not create a layout dependency.
  • Concurrent signing/serialization: the private key is now locked across advance_preparation, sign, and secret-key serialization, which is the right granularity for mutating the prepared-tree window and avoids races between signer workers using the same key handle.
  • Activation/preparation bounds: sign() now checks activation before touching leansig signing, advances until the target epoch is prepared, and has a no-progress escape hatch, so the boundary that crashed at slot 131072 becomes a recoverable signing failure instead of an abort/panic path.
  • Public-key/signature ABI: those remain #[repr(C)], and multisig-glue only mirrors those two layouts, so aggregation ABI compatibility is preserved.

Validation I ran locally:

  • cargo fmt --manifest-path rust/Cargo.toml --all -- --check
  • cargo clippy --manifest-path rust/Cargo.toml -p hashsig-glue -- -D warnings
  • cargo test --manifest-path rust/Cargo.toml -p hashsig-glue --lib
  • Targeted regression: advance_preparation_tests::advance_preparation_lets_key_sign_past_initial_window

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.

@ch4r10t33r
ch4r10t33r merged commit bd34185 into main Jul 14, 2026
14 checks passed
@ch4r10t33r
ch4r10t33r deleted the fix/xmss-advance-preparation branch July 14, 2026 08:52
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.

3 participants