From 4cab5151e40699a25e13c355009efa82f43d531b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:22:18 -0300 Subject: [PATCH] fix(crypto): key a proof's signer set on (slot, message), not slot `wire_keys` merged a Type-2's claims by epoch alone and returned `ConflictingMessages` when a second claim at that slot carried a different message. It runs on the block import path, so a block holding two distinct `AttestationData` at one slot was rejected before leanVM saw it. That is an ordinary block, not an equivocating one: validators attest moments apart and can see justification advance in between, so they disagree inside a slot routinely. On a mixed devnet one gean block was enough. All six ethlambda nodes rejected it, orphaned everything built on it, and the network split for good with both sides frozen at the same finalized slot. Nothing below the wrapper wanted the restriction. leanVM has keyed its XMSS groups on `(epoch, message)` since 48a90420, this crate's own build path hands it one claim at a time and never applied the rule, and leanSpec both produces and accepts such blocks. The verify path was the only place it lived, which left the crate able to build proofs its own verifier rejects. Key the groups on the pair in the match and in the sort. The sort is the load-bearing half and fails quietly if missed: with two groups at one slot, ordering on the epoch alone leaves them in the caller's order, and leanVM turns that away at decode as a malformed signer set on a proof that is perfectly valid. `ConflictingMessages` loses its only producer and goes. Closes #619 --- CLAUDE.md | 14 +-- crates/common/crypto/src/lib.rs | 186 ++++++++++++++++++++------------ 2 files changed, 126 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 864ff509..dcf9bcda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,13 +284,13 @@ actual_slot = finalized_slot + 1 + relative_index does, so the manifest records `leanvm_rev`. See [`docs/keygen.md`](docs/keygen.md) **Aggregation shape (one leanVM `AggregateSignature`, grouped by `(epoch, message)`):** -- Type-1 and Type-2 are the same object: one `XmssGroup` per slot, carrying the - one message signed at it and that group's sorted, deduplicated keys -- **A slot carries one message.** Two distinct `AttestationData` at one slot - cannot share an aggregate built here; `ethlambda_crypto::ConflictingMessages` - says so. leanVM lifted the constraint in `48a90420` (groups are keyed by - `(epoch, message)`), so it is now the wrapper's, and nothing below the wrapper - enforces it +- Type-1 and Type-2 are the same object: one `XmssGroup` per `(epoch, message)` + pair, carrying that group's sorted, deduplicated keys +- **A slot can carry several messages.** Validators attesting moments apart + disagree within a slot, so two distinct `AttestationData` at one slot is + ordinary rather than equivocation and a block routinely carries both. The + groups are sorted on the whole pair: with two of them at one slot, sorting on + the epoch alone rebuilds a different signer set and fails a valid proof - **The binding is off the wire.** `to_bytes_without_pubkeys()` carries neither the keys nor the `(slot, message)` pairs, so every decode rebuilds the whole signer set from a `SignerSet` per claim. A wrong set, message or slot decodes diff --git a/crates/common/crypto/src/lib.rs b/crates/common/crypto/src/lib.rs index b15fbdb0..595a6b98 100644 --- a/crates/common/crypto/src/lib.rs +++ b/crates/common/crypto/src/lib.rs @@ -9,21 +9,22 @@ //! one slot) and Type-2 proofs (several messages merged); both travel without their //! participant pubkeys, which a receiver rebuilds from its own validator registry. //! -//! # One aggregate type, grouped by slot +//! # One aggregate type, grouped by `(epoch, message)` //! //! leanVM has a single `AggregateSignature`, whose XMSS claims are grouped by -//! `(epoch, message)`. This crate builds one [`XmssGroup`] per slot, carrying -//! the one message signed at it and the group's strictly sorted, deduplicated -//! keys. Type-1 and Type-2 are the same object here, one group versus several, -//! and the wrappers below only differ in how many groups they build. +//! `(epoch, message)`. This crate builds the same grouping: one [`XmssGroup`] +//! per distinct pair, carrying that pair's strictly sorted, deduplicated keys. +//! Type-1 and Type-2 are the same object here, one group versus several, and +//! the wrappers below only differ in how many groups they build. //! //! Two consequences run through everything in this module: //! -//! - **A slot carries one message.** Several distinct `AttestationData` at one -//! slot have no representation inside a single aggregate built here; that is -//! [`ConflictingMessages`]. leanVM itself stopped requiring this: it groups -//! by `(epoch, message)`, so the restriction is this crate's, not the -//! prover's. +//! - **A slot can carry several messages.** Validators attest moments apart and +//! can see justification advance in between, so two distinct `AttestationData` +//! at one slot is ordinary rather than equivocation, and a proposer packs +//! both. Each pair is its own group, and the groups are ordered on the whole +//! pair: with two of them at one slot, ordering on the epoch alone would +//! rebuild a different signer set and fail a valid proof. //! - **The binding travels out of band.** The without-pubkeys wire form carries //! neither the keys nor the `(slot, message)` pairs, so a decode has to //! rebuild the whole signer set from the caller's own view of it (a @@ -120,18 +121,6 @@ impl SignerSet { } } -/// Two claims at one slot carrying different messages. -/// -/// leanVM keys an aggregate's XMSS groups by `(epoch, message)` and would take -/// both claims, but everything here builds one group per slot, so inside a -/// proof this crate assembles the message is a function of the slot: the group -/// holds a single message, and the second claim has nowhere to go. -#[derive(Debug, Clone, Copy, Error)] -#[error("slot {slot} carries two different messages in one aggregate")] -pub struct ConflictingMessages { - pub slot: u32, -} - /// Error type for signature aggregation operations. #[derive(Debug, Error)] pub enum AggregationError { @@ -153,9 +142,6 @@ pub enum AggregationError { #[error("need at least 2 children for recursive aggregation, got {0}")] InsufficientChildren(usize), - #[error(transparent)] - ConflictingMessages(#[from] ConflictingMessages), - #[error("split-by-message target not found in the aggregate's signer set")] UnknownMessage, @@ -174,9 +160,6 @@ pub enum VerificationError { #[error("verification failed: {0}")] VerificationFailed(String), - - #[error(transparent)] - ConflictingMessages(#[from] ConflictingMessages), } // ===================================================================== @@ -185,29 +168,24 @@ pub enum VerificationError { /// The signer set leanVM binds, built from the caller's view of the claims. /// -/// One group per slot, holding that slot's message and its keys strictly sorted -/// and deduplicated, with the groups themselves sorted by slot. Claims sharing a -/// slot merge into one group, so their keys are unioned; claims sharing a slot -/// under different messages are [`ConflictingMessages`]. +/// One group per `(slot, message)` pair, holding that pair's keys strictly +/// sorted and deduplicated, with the groups themselves sorted on the pair. +/// Claims sharing a pair merge into one group, so their keys are unioned; two +/// messages at one slot are two groups, which is what a block carries whenever +/// validators disagree inside a slot. /// -/// Getting this structure wrong is not caught at decode: it changes the digest +/// Getting the membership wrong is not caught at decode: it changes the digest /// the proof is checked against, so it surfaces as a verification failure. -fn wire_keys(components: &[SignerSet]) -> Result { +/// Getting the order wrong is caught, as a decode failure on a valid proof. +fn wire_keys(components: &[SignerSet]) -> SignatureClaims { let mut groups: Vec = Vec::with_capacity(components.len()); for component in components { let keys = component.public_keys.iter().map(|pk| pk.as_inner().clone()); match groups .iter_mut() - .find(|group| group.epoch == component.slot) + .find(|group| group.epoch == component.slot && group.message == component.message.0) { - Some(group) => { - if group.message != component.message.0 { - return Err(ConflictingMessages { - slot: component.slot, - }); - } - group.keys.extend(keys); - } + Some(group) => group.keys.extend(keys), None => groups.push(XmssClaimGroup { epoch: component.slot, message: component.message.0, @@ -218,13 +196,16 @@ fn wire_keys(components: &[SignerSet]) -> Result = groups + .iter() + .map(|group| (group.epoch, group.message)) + .collect(); + assert_eq!( + shape, + vec![(6, msg_low.0), (6, msg_high.0), (8, msg_low.0)], + "one group per (slot, message), strictly increasing on the pair" + ); + // The digest is over this structure, so each claim's keys have to land in + // its own group and nowhere else. + let sizes: Vec = groups.iter().map(|group| group.keys.len()).collect(); + assert_eq!( + sizes, + vec![2, 1, 1], + "keys follow their own (slot, message)" + ); } #[test] @@ -869,4 +879,46 @@ mod tests { verify_aggregated_signature(&split, vec![pk_a], &msg_a, slot_a).expect("verify split"); } + + /// The interop case, end to end: one slot, two validators, two different + /// messages. The build path has always allowed it, since it hands leanVM one + /// claim at a time and leanVM groups by `(epoch, message)`; this pins the + /// verify path to the same reading, digest included. + /// + /// The claims are handed over with the larger message first, the way a block + /// body orders them: nothing about a block sorts its attestations. Only the + /// `(epoch, message)` sort inside [`wire_keys`] puts them back in leanVM's + /// canonical order. Sorting on the epoch alone leaves this pair as it came + /// in, and the decode then turns a perfectly valid proof away. + #[test] + #[ignore = "too slow"] + fn test_type_2_two_messages_at_one_slot_round_trip() { + init(); + let msg_low = H256::from([0x11u8; 32]); + let msg_high = H256::from([0x22u8; 32]); + let slot: u32 = 9; + + // Two keys, so neither signs twice at one slot: XMSS one-time-signature + // safety is per key, and two validators disagreeing reuses nothing. + let (pk_low, sig_low) = generate_keypair_and_sign(201, 5, slot, &msg_low); + let (pk_high, sig_high) = generate_keypair_and_sign(202, 5, slot, &msg_high); + + let p_low = + aggregate_signatures(vec![pk_low.clone()], vec![sig_low], &msg_low, slot).unwrap(); + let p_high = + aggregate_signatures(vec![pk_high.clone()], vec![sig_high], &msg_high, slot).unwrap(); + + let components = vec![ + claim(msg_high, slot, &pk_high), + claim(msg_low, slot, &pk_low), + ]; + let merged = merge_type_1s_into_type_2(vec![ + (components[0].clone(), p_high), + (components[1].clone(), p_low), + ]) + .expect("merge"); + + verify_type_2_signature(merged.iter().as_slice(), &components) + .expect("verify type-2 over two messages at one slot"); + } }