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"); + } }