diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 14b71bde..ff6c5193 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -61,6 +61,23 @@ jobs: env: RUSTFLAGS: ${{ matrix.rustflags }} SIGNERS_CACHE_DIR: ${{ github.workspace }}/.signers-cache + # `lean_multisig_api`'s two `#[ignore]`d tests prove real 1500- and 1501-signature batches, + # which is the only check that `plan::LEAF_TARGET` is a leaf size the prover accepts. Without + # them a bad constant means every aggregation past 1500 signers fails in production while the + # default suite stays green — its largest single node holds three signatures. + # + # Scoped to one test binary rather than `--include-ignored` across the workspace, which would + # also drag in six unrelated ignored tests, several of them benchmarks. + # + # ~12s here: the step above already generates or loads the 10,000-signer cache (non-ignored + # tests in `tests/test_multisignatures.rs` call `get_benchmark_signatures`), so the marginal + # cost is the proving alone. + - name: Ignored slow tests + if: ${{ matrix.run_tests == true }} + run: cargo test --release -p lean_multisig_api --test round_trip --verbose -- --ignored + env: + RUSTFLAGS: ${{ matrix.rustflags }} + SIGNERS_CACHE_DIR: ${{ github.workspace }}/.signers-cache cargo-clippy: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 55d8a4c3..0f5cede7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /docs/benchmark_graphs/.venv minimal_zkVM.synctex.gz .claude -misc/.build \ No newline at end of file +misc/.build +/.worktrees diff --git a/Cargo.lock b/Cargo.lock index fc55cca1..d5e85c36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,6 +1505,20 @@ dependencies = [ "xmss", ] +[[package]] +name = "lean_multisig_api" +version = "0.1.0" +dependencies = [ + "backend", + "ethereum_ssz", + "lean_vm", + "postcard", + "rand 0.10.1", + "rec_aggregation", + "sha2", + "xmss", +] + [[package]] name = "lean_prover" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9e7f5977..eb1c4a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ serde = { version = "1.0.228", features = ["derive"] } tracing-subscriber = { version = "0.3.23", features = ["std", "env-filter"] } tracing-forest = { version = "0.3.0", features = ["ansi", "smallvec"] } postcard = { version = "1.1.3", features = ["alloc"] } +sha2 = "0.10.9" ssz = { package = "ethereum_ssz", version = "0.10" } include_dir = "0.7" libc = "0.2" diff --git a/TODO.md b/TODO.md index 5bd14c0e..3eb93b0c 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,26 @@ - Rewrite the compiler, it's bad right now. - double check single-message / multi-message dispatch, and try to simplify the various data layouts +## Tooling + +- The clippy config never reaches the member crates. Root `Cargo.toml` has `[lints.clippy]` + (`all`/`nursery`/`pedantic` at warn, plus the `allow` list), but that is a *package*-level + table, so it applies only to the root `lean-multisig` package. `[workspace.lints]` carries + just the `rust.*` and `rustdoc.*` keys, so every crate under `crates/` writing + `[lints] workspace = true` inherits those alone and gets no clippy nursery/pedantic. + Moving the table to `[workspace.lints.clippy]` would fix it, but surfaces a backlog across + the 19 member crates, so it wants doing deliberately rather than as a drive-by. + +- `#[ignore]` carries two meanings, so CI selects slow tests by naming a binary. It marks both + "this is a benchmark, never run it in CI" (`benchmark_poseidons.rs`, `benchmark.rs`, + `grinding.rs`, `wots.rs`, `quotient_gkr`, `test_zkvm.rs`) and "this is a real test, too slow + for a local run, but CI must run it" (`lean_multisig_api`'s two `LEAF_TARGET` boundary tests). + Because `--include-ignored` cannot tell them apart, `rust.yml`'s `Ignored slow tests` step + names one test binary explicitly. That is correct today but is a hand-maintained allowlist: + the next ignored-but-required test is silently not run, with nothing failing to say so. + Distinguishing them — keep `#[ignore]` for benchmarks, gate slow-but-required tests behind a + `slow-tests` feature — would let CI run one command with no per-binary list. + # Ideas - About range checks, that can currently be done in 3 cycles (see 2.5.3 of the zkVM pdf) + 3 memory cells used. For small ranges we can save 2 memory cells. diff --git a/crates/backend/poly/src/eq_mle.rs b/crates/backend/poly/src/eq_mle.rs index 67952b06..447b5d92 100644 --- a/crates/backend/poly/src/eq_mle.rs +++ b/crates/backend/poly/src/eq_mle.rs @@ -1021,8 +1021,14 @@ fn base_eval_eq_packed_with_packed_output( F: Field, EF: ExtensionField, { - // `eval_points` is the middle slice from `par_eval_eq`, so its length says nothing about the - // packing width (the callers assert that against the full point). + // Ensure that the output buffer size is correct: + // It should be of size `2^n`, where `n` is the number of variables. + // + // `eval_points` is the *middle* slice handed over by `par_eval_eq`, not the full point: + // the `log_packing_width` suffix is already folded into `eq_evals` and the `log_chunks` + // prefix into `packed_scalar`. Its length is therefore unrelated to the packing width, + // and asserting `log_packing_width <= eval_points.len()` here is wrong — that invariant + // belongs to the callers, which check it against the *full* point. debug_assert_eq!(out.len(), 1 << eval_points.len()); match eval_points.len() { @@ -1317,6 +1323,38 @@ mod tests { } } + /// `base_eval_eq_packed_with_packed_output` receives the *middle* slice of the eval + /// points: `par_eval_eq` strips a `log_chunks` prefix and a `log_packing_width` suffix, + /// leaving `n - log_packing_width - log_chunks` variables. The packed path only requires + /// that to be at least 2, so the middle slice is routinely *shorter* than + /// `log_packing_width` and the kernel must not assume otherwise. + /// + /// This covers the narrow band of `n_vars` just above the packed-path threshold, where + /// that happens. Both the assertion and the band are machine-dependent (they move with + /// the thread count and SIMD width), so the bounds are computed rather than hardcoded. + #[test] + fn base_packed_handles_middle_slice_shorter_than_packing_width() { + let log_packing_width = log2_strict_usize(::Packing::WIDTH); + let (log_chunks, _) = parallel_split(); + let mut rng = StdRng::seed_from_u64(11); + + // Lower bound: first `n_vars` taking the packed path (see `compute_eval_eq_base_packed`). + // Upper bound: first `n_vars` whose middle slice reaches `log_packing_width`. + for n_vars in (log_packing_width + log_chunks + 2)..=(2 * log_packing_width + log_chunks) { + let eval: Vec = (0..n_vars).map(|_| rng.random()).collect(); + let scalar: EF = rng.random(); + + let mut expected = EF::zero_vec(1 << n_vars); + compute_eval_eq_base::(&eval, &mut expected, scalar); + + let mut packed = >::ExtensionPacking::zero_vec(1 << (n_vars - log_packing_width)); + compute_eval_eq_base_packed::(&eval, &mut packed, scalar); + + let unpacked: Vec = >::ExtensionPacking::to_ext_iter_vec(packed); + assert_eq!(expected, unpacked, "n_vars = {n_vars}"); + } + } + /// `par_eval_eq` hands the kernel a middle slice of any length >= 2, so the hardcoded arms /// below `log_packing_width` must agree with the unpacked-output twin. Calling the kernel /// directly keeps this independent of the SIMD width and thread count. diff --git a/crates/lean_multisig_api/Cargo.toml b/crates/lean_multisig_api/Cargo.toml new file mode 100644 index 00000000..8819fe76 --- /dev/null +++ b/crates/lean_multisig_api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lean_multisig_api" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +# `lean_vm` is here only for the compile-time assertion in `plan.rs` that the crate's +# `log_inv_rate` choices sit inside the band `lean_prover::default_whir_config` accepts. +lean_vm.workspace = true +xmss.workspace = true +rec_aggregation.workspace = true +backend.workspace = true +ssz.workspace = true +postcard.workspace = true +rand.workspace = true +sha2.workspace = true + +[dev-dependencies] +# `round_trip.rs`'s `#[ignore]`d LEAF_TARGET tests need 1501 real signatures, which +# `xmss::signers_cache` has pre-generated and cached on disk. The feature is already on in any +# workspace build because `rec_aggregation` enables it, so this line changes nothing today — it +# states the dependency this crate's own tests have, rather than borrowing another crate's. +xmss = { workspace = true, features = ["test-utils"] } diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs new file mode 100644 index 00000000..13918ed6 --- /dev/null +++ b/crates/lean_multisig_api/src/error.rs @@ -0,0 +1,114 @@ +use std::fmt::{Display, Formatter}; + +/// Every way a `lean_multisig_api` operation can fail. +#[non_exhaustive] +#[derive(Debug)] +pub enum Error { + /// [`crate::setup`] must be called before operations involving recursive proofs. + NotInitialized, + KeyGen(xmss::XmssKeyGenError), + Sign(xmss::XmssSignatureError), + /// A raw signature did not verify. The index refers to the input of [`crate::aggregate`], + /// or is zero when verifying one standalone [`crate::Signature`]. + InvalidSignature { + index: usize, + source: xmss::XmssVerifyError, + }, + Aggregation(rec_aggregation::AggregationError), + Proof(backend::ProofError), + /// A serialized [`crate::Signature`] envelope was malformed or unsupported. + MalformedSignature, + /// A serialized [`crate::MultiClaimProof`] envelope was malformed or unsupported. + MalformedMultiClaimProof, + /// A caller-supplied public key was not canonically encoded. + MalformedPublicKey, + /// Secret-key bytes failed their format or integrity checks. + MalformedSecretKey, + TooManySigners { + got: usize, + max: usize, + }, + TooManyClaims { + got: usize, + max: usize, + }, + Empty, + MessageMismatch, + SignerSetMismatch, + ClaimSetMismatch, +} + +impl From for Error { + fn from(err: xmss::XmssKeyGenError) -> Self { + Self::KeyGen(err) + } +} + +impl From for Error { + fn from(err: xmss::XmssSignatureError) -> Self { + Self::Sign(err) + } +} + +impl From for Error { + fn from(err: rec_aggregation::AggregationError) -> Self { + match err { + rec_aggregation::AggregationError::InvalidChildProof(err) => Self::Proof(err), + err => Self::Aggregation(err), + } + } +} + +impl From for Error { + fn from(err: backend::ProofError) -> Self { + Self::Proof(err) + } +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotInitialized => write!(f, "Call lean_multisig_api::setup() before using recursive proofs"), + Self::KeyGen(_) => write!(f, "Key generation failed"), + Self::Sign(_) => write!(f, "XMSS signing operation failed"), + Self::InvalidSignature { index, .. } => write!(f, "Signature {index} is invalid"), + Self::Aggregation(_) => write!(f, "Aggregation failed"), + Self::Proof(_) => write!(f, "Proof error"), + Self::MalformedSignature => write!(f, "The supplied bytes are not a well-formed signature"), + Self::MalformedMultiClaimProof => { + write!(f, "The supplied bytes are not a well-formed multi-claim proof") + } + Self::MalformedPublicKey => write!(f, "A supplied public key is not canonically encoded"), + Self::MalformedSecretKey => write!(f, "Secret key bytes failed validation"), + Self::TooManySigners { got, max } => write!(f, "Too many signers: {got} (max {max})"), + Self::TooManyClaims { got, max } => write!(f, "Too many distinct claims: {got} (max {max})"), + Self::Empty => write!(f, "Nothing to aggregate: no signatures were supplied"), + Self::MessageMismatch => write!(f, "The signature proves a different claim than the one supplied"), + Self::SignerSetMismatch => write!(f, "The proved signer set differs from the expected one"), + Self::ClaimSetMismatch => write!(f, "The proved claims or signer sets differ from the expected ones"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::NotInitialized => None, + Self::KeyGen(err) => Some(err), + Self::Sign(err) => Some(err), + Self::InvalidSignature { source, .. } => Some(source), + Self::Aggregation(err) => Some(err), + Self::Proof(err) => Some(err), + Self::MalformedSignature + | Self::MalformedMultiClaimProof + | Self::MalformedPublicKey + | Self::MalformedSecretKey + | Self::TooManySigners { .. } + | Self::TooManyClaims { .. } + | Self::Empty + | Self::MessageMismatch + | Self::SignerSetMismatch + | Self::ClaimSetMismatch => None, + } + } +} diff --git a/crates/lean_multisig_api/src/key.rs b/crates/lean_multisig_api/src/key.rs new file mode 100644 index 00000000..ff1c285f --- /dev/null +++ b/crates/lean_multisig_api/src/key.rs @@ -0,0 +1,430 @@ +//! The `SecretKey` handle. +//! +//! Unlike the inert persistence bytes returned by `to_bytes`, this handle retains the signing +//! cache that makes repeated use practical. + +use crate::{Claim, Error, PublicKey, Signature, encode_public_key}; +use sha2::{Digest, Sha256}; +use std::ops::RangeInclusive; +use xmss::{XmssKeyGenError, XmssSecretKey, xmss_key_gen, xmss_key_gen_from_seed, xmss_sign}; + +const SECRET_KEY_MAGIC: &[u8; 4] = b"LMSK"; +const SECRET_KEY_VERSION: u8 = 1; +const SECRET_KEY_HEADER_LEN: usize = SECRET_KEY_MAGIC.len() + 1; +const SECRET_KEY_CHECKSUM_LEN: usize = 32; + +// The constructors' `# Errors` docs claim that the lifetime half of `InvalidRange` is +// unreachable through this API: slots are `u32`, so the widest possible range ends at exactly +// `1 << 32`, and upstream only rejects `activation_end > 1 << LOG_LIFETIME`. That claim holds +// only while the lifetime is at least 32 bits, and `LOG_LIFETIME` belongs to another crate. +// Shrinking it upstream makes the documented error reachable and the doc wrong, so fail here +// rather than in a caller's error handling. +const _: () = assert!(xmss::LOG_LIFETIME >= 32); + +/// Converts an inclusive slot range into the `(activation_slot, num_active_slots)` pair that +/// `xmss` takes. +/// +/// The count is widened to `u64` before the `+ 1`: a full `0..=u32::MAX` range spans 2^32 slots, +/// one more than a `u32` can hold, which is why upstream takes `u64` at all. +/// +/// The emptiness check is not a nicety — `end - start` underflows on an inverted range, which +/// panics in debug and silently produces an enormous count in release. +fn span(slots: &RangeInclusive) -> Result<(u64, u64), Error> { + let (start, end) = (*slots.start(), *slots.end()); + if start > end { + // An empty range means zero active slots, which is exactly the condition upstream + // already rejects as `InvalidRange`. Reusing it keeps one meaning for one fault + // rather than giving the caller two names to match on for the same mistake. + return Err(Error::KeyGen(XmssKeyGenError::InvalidRange)); + } + Ok((u64::from(start), u64::from(end - start) + 1)) +} + +/// An XMSS secret key, active for a fixed slot range. +/// +/// This is a handle rather than a byte slice on purpose. [`XmssSecretKey`] holds a +/// bottom-subtree cache that [`sign`](Self::sign) warms and reuses across calls, and +/// serialization deliberately drops that cache. A bytes-in/bytes-out `sign` would therefore +/// deserialize the top tree and rebuild a bottom subtree on *every* signature. Bytes appear +/// here only at the boundary, via [`to_bytes`](Self::to_bytes) and +/// [`from_bytes`](Self::from_bytes), where they are genuinely a storage format. +/// +/// # Warning: XMSS is stateful +/// +/// Never sign two different messages at the same slot; doing so leaks the one-time WOTS key +/// for that slot. Signing is derandomized from `(seed, slot, message)`, so repeating the same +/// `(slot, message)` is harmless and returns identical bytes. This type does *not* track which +/// slots have been used — that state belongs to the caller, who alone knows what has been +/// published. +/// +/// [`to_bytes`](Self::to_bytes)/[`from_bytes`](Self::from_bytes) carry no usage state either: +/// restoring the same bytes twice yields two keys that know nothing about each other or about +/// what the original signed. A caller must persist its own high-water slot alongside the key +/// bytes, advance and durably store it *before* publishing a signature, and never sign at or +/// below it with different content. +/// +/// # Concurrency +/// +/// The cache sits behind a mutex, so the type is `Send + Sync` and [`sign`](Self::sign) takes +/// `&self`. Concurrent signing is therefore sound, but not fast: the cache holds exactly one +/// bottom subtree, so threads signing slots that fall in *different* subtrees evict each +/// other's entry and rebuild it, on top of serializing on the mutex. Sign sequentially per +/// key, or give each concurrent signer its own key. +/// +/// # Secrecy +/// +/// The derived [`Debug`] delegates to `XmssSecretKey`'s hand-written one, which prints only the +/// slot range and split level and is `finish_non_exhaustive`. Neither the seed nor the tree is +/// printed, so logging a `SecretKey` does not leak key material. This is pinned by a test. +#[derive(Debug)] +pub struct SecretKey(XmssSecretKey); + +impl SecretKey { + /// Generates a key active for exactly `slots`, seeded from the operating system. + /// + /// The range is inclusive at both ends and round-trips through [`slots`](Self::slots): + /// `SecretKey::generate(100..=115)?.slots() == 100..=115`. + /// + /// Keygen cost is linear in the width of the range, so a wide range is not free — it builds + /// one Merkle leaf per slot. + /// + /// # Errors + /// + /// [`Error::KeyGen`] if `slots` is empty, meaning `end < start`. The variant also covers a + /// range extending past the XMSS lifetime, which no `u32` range can do while `LOG_LIFETIME` + /// is 32: `0..=u32::MAX` lands exactly on the limit, guaranteed by a compile-time assertion + /// in this module. + /// + /// # Panics + /// + /// If the OS entropy source is unavailable, which `rand`'s thread RNG treats as fatal. + pub fn generate(slots: RangeInclusive) -> Result { + let (activation_slot, num_active_slots) = span(&slots)?; + let mut rng = rand::rng(); + let (_, sk) = xmss_key_gen(&mut rng, activation_slot, num_active_slots)?; + Ok(Self(sk)) + } + + /// Deterministic [`Self::generate`]. The seed is the key's entire secret material: the same + /// `(seed, slots)` always regenerates the same key. + /// + /// # Errors + /// + /// As [`Self::generate`]. + pub fn from_seed(seed: [u8; 32], slots: RangeInclusive) -> Result { + let (activation_slot, num_active_slots) = span(&slots)?; + let (_, sk) = xmss_key_gen_from_seed(seed, activation_slot, num_active_slots)?; + Ok(Self(sk)) + } + + /// Restores a key from [`Self::to_bytes`]. + /// + /// The signing cache starts empty, so the first [`sign`](Self::sign) after this rebuilds a + /// bottom subtree; [`prepare`](Self::prepare) can absorb that cost ahead of time. + /// + /// Usage state is not restored either — see the type-level warning. A restored key will + /// happily re-sign a slot the original already used. + /// + /// # Errors + /// + /// [`Error::MalformedSecretKey`] if the bytes are truncated, damaged, carry an unsupported + /// format version, describe a tree whose shape contradicts its slot range, or have trailing + /// bytes after a complete key. A SHA-256 checksum detects accidental corruption without + /// rebuilding the expensive XMSS tree. It is not authentication against an attacker who can + /// rewrite both the secret payload and its checksum. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < SECRET_KEY_HEADER_LEN + SECRET_KEY_CHECKSUM_LEN + || &bytes[..SECRET_KEY_MAGIC.len()] != SECRET_KEY_MAGIC + || bytes[SECRET_KEY_MAGIC.len()] != SECRET_KEY_VERSION + { + return Err(Error::MalformedSecretKey); + } + let (authenticated, checksum) = bytes.split_at(bytes.len() - SECRET_KEY_CHECKSUM_LEN); + if Sha256::digest(authenticated).as_slice() != checksum { + return Err(Error::MalformedSecretKey); + } + let payload = &authenticated[SECRET_KEY_HEADER_LEN..]; + let (key, rest) = postcard::take_from_bytes::(payload).map_err(|_| Error::MalformedSecretKey)?; + if rest.is_empty() { + Ok(Self(key)) + } else { + Err(Error::MalformedSecretKey) + } + } + + /// Serializes the key for storage: the seed, slot range, and top tree. + /// + /// The bottom-subtree cache is *not* persisted — it is derived state, cheap to rebuild and + /// meaningless without the slot it was built for. + /// + /// The returned bytes are the key's entire secret material: anyone holding them can sign. + /// Neither this crate nor `xmss` zeroizes anything, so wiping this buffer, and any file it + /// is written to, is the caller's responsibility. + /// + /// The envelope is versioned and checksummed so accidental changes are rejected by + /// [`Self::from_bytes`]. The checksum is not a MAC and provides no protection against an + /// attacker with write access to the key file. + /// + /// # Panics + /// + /// Never. `postcard::to_allocvec` grows its output buffer, so the only remaining failure + /// mode is a `Serialize` impl reporting a custom error, and `XmssSecretKey` serializes as a + /// tuple of integers, byte arrays, and vectors, none of which can. The same reasoning backs + /// the identical `expect` in `rec_aggregation`'s aggregate codecs. + #[must_use] + pub fn to_bytes(&self) -> Vec { + let payload = postcard::to_allocvec(&self.0).expect("XmssSecretKey serialization is infallible"); + let mut bytes = Vec::with_capacity(SECRET_KEY_HEADER_LEN + payload.len() + SECRET_KEY_CHECKSUM_LEN); + bytes.extend_from_slice(SECRET_KEY_MAGIC); + bytes.push(SECRET_KEY_VERSION); + bytes.extend(payload); + let checksum = Sha256::digest(&bytes); + bytes.extend_from_slice(&checksum); + bytes + } + + /// The matching public key, SSZ-encoded: exactly `xmss::PUB_KEY_SSZ_LEN` bytes, ready for an + /// expected-signer set passed to [`crate::verify`]. Raw signatures already carry this key. + #[must_use] + pub fn public_key(&self) -> PublicKey { + encode_public_key(&self.0.public_key()) + } + + /// The inclusive range of slots this key can sign for. + #[must_use] + pub const fn slots(&self) -> RangeInclusive { + self.0.activation_slots() + } + + /// Warms the signing cache for `slot`. + /// + /// Worth calling when the next slot is known ahead of time; this is the one tuning choice + /// the library cannot make for you, because only the caller knows which slot is coming. + /// Calling it is never required — [`sign`](Self::sign) warms the cache itself. + /// + /// # Errors + /// + /// [`Error::Sign`] if `slot` is outside [`slots`](Self::slots). + pub fn prepare(&self, slot: u32) -> Result<(), Error> { + self.0.prepare(slot).map_err(Into::into) + } + + /// Signs `claim`, returning the same opaque [`Signature`] type accepted by `aggregate`. + /// + /// Read the type-level warning first: signing two different messages at one slot breaks the + /// scheme, and nothing here prevents it. + /// + /// # Errors + /// + /// [`Error::Sign`] if [`Claim::slot`] is outside [`slots`](Self::slots), or if no valid WOTS + /// encoding was found within the attempt budget. + pub fn sign(&self, claim: &Claim) -> Result { + let signature = xmss_sign(&self.0, claim.slot(), claim.message())?; + Ok(Signature::raw(*claim, self.0.public_key(), signature)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signature::Kind; + use ssz::Decode; + use xmss::XmssPublicKey; + + #[test] + fn sign_then_verify_round_trips() { + // That `sign` and `public_key` agree is the type's functional contract, and it is the + // one thing length checks and self-relative comparisons cannot see: a `public_key` + // returning the wrong tree root, or a `sign` encoding against a slot other than the one + // asked for, leaves every other test in this module green. `aggregate` consumes + // both, where a disagreement costs a whole tree of proving before surfacing as something + // unreadable. + let sk = SecretKey::from_seed([1u8; 32], 100..=115).unwrap(); + let claim = Claim::new([9u8; 32], 100); + let sig = sk.sign(&claim).unwrap(); + assert_eq!(sk.public_key().len(), xmss::PUB_KEY_SSZ_LEN); + + let pk = XmssPublicKey::from_ssz_bytes(&sk.public_key()).unwrap(); + let Kind::Raw { signature, .. } = sig.0 else { + unreachable!() + }; + assert!(xmss::xmss_verify(&pk, 100, &[9u8; 32], &signature).is_ok()); + + // Bound to the exact (slot, message) the caller passed, not merely well-formed. + assert!(xmss::xmss_verify(&pk, 101, &[9u8; 32], &signature).is_err()); + assert!(xmss::xmss_verify(&pk, 100, &[8u8; 32], &signature).is_err()); + } + + #[test] + fn from_seed_is_deterministic() { + let a = SecretKey::from_seed([2u8; 32], 100..=115).unwrap(); + let b = SecretKey::from_seed([2u8; 32], 100..=115).unwrap(); + assert_eq!(a.public_key(), b.public_key()); + } + + #[test] + fn serialization_preserves_signing() { + // The cache is dropped on deserialize; signatures must still be identical, since + // signing is derandomized from (seed, slot, message). + let sk = SecretKey::from_seed([3u8; 32], 100..=115).unwrap(); + let claim = Claim::new([4u8; 32], 105); + let before = sk.sign(&claim).unwrap().to_bytes(); + let restored = SecretKey::from_bytes(&sk.to_bytes()).unwrap(); + assert_eq!(restored.public_key(), sk.public_key()); + assert_eq!(restored.sign(&claim).unwrap().to_bytes(), before); + + // The positive half of the "exactly one encoding" claim that justifies `take_from_bytes`. + // `from_parts` recomputes the derived fields on load, so if that recomputation ever + // diverged from keygen the round trip would break here while signatures still matched. + assert_eq!(restored.to_bytes(), sk.to_bytes()); + } + + #[test] + fn repeated_signing_of_one_slot_is_byte_identical() { + // The safety carve-out on the stateful-signing warning: repeating the same + // (slot, message) is harmless *because* it returns identical bytes, which is what makes + // crash-retry safe. Tested twice on a single handle, so it also pins that a warm cache + // hit — the path this whole caching design exists for — changes nothing about the output. + let sk = SecretKey::from_seed([12u8; 32], 100..=115).unwrap(); + let message = [13u8; 32]; + let claim = Claim::new(message, 105); + let first = sk.sign(&claim).unwrap().to_bytes(); + let second = sk.sign(&claim).unwrap().to_bytes(); + assert_eq!(first, second); + + // A different message at the same slot must NOT collide; the carve-out is exact. + assert_ne!(sk.sign(&Claim::new([14u8; 32], 105)).unwrap().to_bytes(), first); + } + + #[test] + fn signing_outside_the_slot_range_fails() { + let sk = SecretKey::from_seed([5u8; 32], 100..=115).unwrap(); + assert_eq!(sk.slots(), 100..=115); + assert!(sk.sign(&Claim::new([0u8; 32], 116)).is_err()); + assert!(sk.sign(&Claim::new([0u8; 32], 99)).is_err()); + } + + #[test] + fn prepare_warms_in_range_and_rejects_out_of_range() { + // `prepare` is the one tuning decision this facade deliberately leaves to the caller, + // so it should not be the one method with no coverage. Both paths its rustdoc promises + // are exercised here; the warming itself is a performance effect and is not asserted. + let sk = SecretKey::from_seed([8u8; 32], 100..=115).unwrap(); + assert!(sk.prepare(105).is_ok()); + // Idempotent: warming a slot already cached must not start reporting failure. + assert!(sk.prepare(105).is_ok()); + assert!(matches!(sk.prepare(116), Err(Error::Sign(_)))); + assert!(matches!(sk.prepare(99), Err(Error::Sign(_)))); + } + + #[test] + fn generate_produces_a_key_over_the_requested_range() { + // The only test that actually runs `generate`: every other call site stops at `span`'s + // early return, so `rand::rng()` is never reached at runtime. The `CryptoRng` bound is + // checked at compile time, but that the call succeeds is a separate claim. + let sk = SecretKey::generate(0..=15).unwrap(); + assert_eq!(sk.slots(), 0..=15); + assert_eq!(sk.public_key().len(), xmss::PUB_KEY_SSZ_LEN); + } + + #[test] + fn generate_is_randomized() { + // The one property separating `generate` from `from_seed`. Not flaky: the seed is 32 + // bytes from a CSPRNG, so a collision is a 2^-256 event, far below the rate at which + // the machine running this test would fail in other ways. + let a = SecretKey::generate(0..=15).unwrap(); + let b = SecretKey::generate(0..=15).unwrap(); + assert_ne!(a.public_key(), b.public_key()); + } + + #[test] + fn malformed_bytes_are_rejected() { + assert!(matches!( + SecretKey::from_bytes(&[0u8; 3]), + Err(Error::MalformedSecretKey) + )); + } + + #[test] + fn corruption_inside_a_well_formed_key_is_rejected() { + let sk = SecretKey::from_seed([0x55; 32], 100..=115).unwrap(); + let mut bytes = sk.to_bytes(); + bytes[SECRET_KEY_HEADER_LEN + 1] ^= 1; + + assert!(matches!(SecretKey::from_bytes(&bytes), Err(Error::MalformedSecretKey))); + } + + #[test] + fn an_empty_range_is_rejected() { + // `end - start` underflows on an inverted range: a debug panic, or in release a count + // near 2^32 that would send keygen away for the rest of the decade. Both constructors + // must reject it, and both must call it the same thing upstream already does. + for (start, end) in [(100u32, 99u32), (1, 0), (u32::MAX, 0)] { + // Built with `RangeInclusive::new` rather than written as `100..=99`, which trips + // clippy's deny-by-default `reversed_empty_ranges`. That lint only sees literals, + // so it protects nobody who computes the bounds at runtime — which is precisely the + // case `span` has to catch, and the reason this test is not redundant with it. + let slots = RangeInclusive::new(start, end); + assert!(matches!( + SecretKey::from_seed([7u8; 32], slots.clone()), + Err(Error::KeyGen(XmssKeyGenError::InvalidRange)) + )); + assert!(matches!( + SecretKey::generate(slots), + Err(Error::KeyGen(XmssKeyGenError::InvalidRange)) + )); + } + } + + #[test] + fn the_full_slot_range_converts_without_overflowing() { + // `0..=u32::MAX` spans 2^32 slots, one more than a `u32` holds — the whole reason the + // upstream signature is `u64`. Asserted on `span` rather than by generating: keygen + // builds one Merkle leaf per slot, so a real full-lifetime key is 2^32 WOTS keygens, + // which is not a unit test at any timeout. This checks the arithmetic that the width + // actually threatens. + assert_eq!(span(&(0..=u32::MAX)).unwrap(), (0, 1u64 << 32)); + + // And that the pair lands inside what upstream accepts: it rejects + // `activation_slot + num_active_slots > 1 << LOG_LIFETIME`, so the full range sits + // exactly on the boundary rather than one past it. + let (start, count) = span(&(0..=u32::MAX)).unwrap(); + assert_eq!(start + count, 1u64 << xmss::LOG_LIFETIME); + + // The off-by-one this is really guarding: an inclusive range of one slot is one slot. + assert_eq!(span(&(7..=7)).unwrap(), (7, 1)); + } + + #[test] + fn trailing_bytes_are_rejected() { + // Appending bytes moves the checksum away from the end of the authenticated payload and + // must not give one key multiple accepted encodings. + let sk = SecretKey::from_seed([6u8; 32], 100..=115).unwrap(); + let mut bytes = sk.to_bytes(); + bytes.push(0); + assert!(matches!(SecretKey::from_bytes(&bytes), Err(Error::MalformedSecretKey))); + } + + #[test] + fn debug_does_not_print_key_material() { + // A security property, not formatting taste. `#[derive(Debug)]` on the newtype delegates + // to `XmssSecretKey`'s hand-written impl, which prints only the slot range and split + // level. If that upstream impl ever becomes a derive, the seed and the whole top tree + // start appearing in every log line that formats a key — and this test fails first. + let sk = SecretKey::from_seed([0xab; 32], 100..=115).unwrap(); + let rendered = format!("{sk:?}"); + assert!(!rendered.contains("seed"), "{rendered}"); + assert!(!rendered.contains("top"), "{rendered}"); + assert!(!rendered.contains("171"), "seed byte 0xab leaked: {rendered}"); + // The non-exhaustive marker: whatever else the upstream struct gains stays unprinted. + assert!(rendered.contains(".."), "{rendered}"); + } + + #[test] + fn the_handle_is_send_and_sync() { + // `sign` takes `&self` because the cache is behind a `Mutex`. That is only useful if the + // handle can actually cross threads, and only sound if nothing non-`Sync` creeps in. + const fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs new file mode 100644 index 00000000..5f726617 --- /dev/null +++ b/crates/lean_multisig_api/src/lib.rs @@ -0,0 +1,283 @@ +//! A small, opinionated facade over XMSS and recursive aggregation. +//! +//! [`Signature`] hides whether one-claim contribution is a raw XMSS signature or an aggregate. +//! [`MultiClaimProof`] groups any mixture of those contributions by claim and binds the resulting +//! groups in one proof. Wire encodings contain cryptographic material only; claims and signer sets +//! are supplied from the outer protocol container when decoding. The recursion topology, proof +//! parameters, public-key pairing, and proof representations are internal choices. Call [`setup`] +//! once before using operations involving recursive proofs. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod error; +mod key; +mod multi_claim_proof; +mod plan; +mod signature; + +use rec_aggregation::{ + MAX_XMSS_AGGREGATED, SingleMessageAggregateSignature, aggregate_single_message_signatures, + init_aggregation_bytecode, verify_single_message_aggregate, +}; +use signature::Kind; +use ssz::{Decode, Encode}; +use std::borrow::Cow; +use std::collections::BTreeSet; +use std::sync::OnceLock; +use xmss::{XmssPublicKey, XmssSignature, xmss_verify}; + +pub use error::Error; +pub use key::SecretKey; +pub use multi_claim_proof::{ClaimSigners, MultiClaimProof, merge_claims, verified_claims, verify_claims}; +pub use signature::{Claim, Signature}; + +/// A canonically encoded, 32-byte XMSS public key. +/// +/// This alias documents where public-key bytes are expected without imposing a wrapper on +/// callers' storage or serialization types. +pub type PublicKey = [u8; 32]; + +/// Maximum number of distinct claim components in one [`MultiClaimProof`]. +pub const MAX_CLAIMS: usize = rec_aggregation::MAX_RECURSIONS; + +const _: () = assert!(xmss::PUB_KEY_SSZ_LEN == size_of::()); + +type Raw = (XmssPublicKey, XmssSignature); +static INITIALIZED: OnceLock<()> = OnceLock::new(); + +pub(crate) fn encode_public_key(public_key: &XmssPublicKey) -> PublicKey { + public_key + .as_ssz_bytes() + .try_into() + .expect("XMSS public-key SSZ encoding must be 32 bytes") +} + +pub(crate) fn decode_public_keys(public_keys: &[PublicKey]) -> Result, Error> { + let mut decoded = public_keys + .iter() + .map(|bytes| XmssPublicKey::from_ssz_bytes(bytes).map_err(|_| Error::MalformedPublicKey)) + .collect::, _>>()?; + decoded.sort(); + decoded.dedup(); + Ok(decoded) +} + +fn proves(signature: &SingleMessageAggregateSignature, claim: &Claim) -> bool { + signature.info.core.message == *claim.message() && signature.info.core.slot == claim.slot() +} + +/// Initializes the process-wide resources used by recursive proofs. +/// +/// Call this once before aggregating, decoding an aggregate, or verifying an aggregate. It is +/// safe and inexpensive to call repeatedly after the first initialization. +pub fn setup() { + init_aggregation_bytecode(); + INITIALIZED.get_or_init(|| ()); +} + +pub(crate) fn require_setup() -> Result<(), Error> { + INITIALIZED.get().copied().ok_or(Error::NotInitialized) +} + +/// Combines raw and previously aggregated signatures proving one [`Claim`]. +/// +/// Call [`setup`] before using this function. +/// +/// Every in-memory input owns its context: a raw signature has its public key, while an aggregate +/// has its signer set. Callers neither classify entries nor maintain a parallel public-key vector. +/// Raw signatures and supplied aggregate proofs are verified before proving begins. +pub fn aggregate(signatures: Vec, claim: &Claim) -> Result { + if signatures.is_empty() { + return Err(Error::Empty); + } + require_setup()?; + + let mut raw = Vec::new(); + let mut children = Vec::new(); + for (index, signature) in signatures.into_iter().enumerate() { + if signature.claim() != *claim { + return Err(Error::MessageMismatch); + } + match signature.0 { + Kind::Raw { + public_key, signature, .. + } => { + xmss_verify(&public_key, claim.slot(), claim.message(), &signature) + .map_err(|source| Error::InvalidSignature { index, source })?; + raw.push((public_key, *signature)); + } + Kind::Aggregate(signature) => { + // Do this before executing any raw leaf. The upstream aggregation call verifies + // children again at their consuming node, but waiting until then makes the error + // and wasted work depend on the private recursion shape. + verify_single_message_aggregate(&signature)?; + children.push(signature); + } + } + } + + dedup_signers(&mut raw); + + check_signer_limit(&raw, &children)?; + + let tree = plan::plan(raw.len(), children.len()); + execute(&tree, &raw, &children, *claim).map(|signature| Signature::aggregate(signature.into_owned())) +} + +fn check_signer_limit(raw: &[Raw], children: &[SingleMessageAggregateSignature]) -> Result<(), Error> { + let mut signers: BTreeSet<&XmssPublicKey> = raw.iter().map(|(public_key, _)| public_key).collect(); + signers.extend(children.iter().flat_map(|child| child.info.pubkeys.iter())); + let got = signers.len(); + if got > MAX_XMSS_AGGREGATED { + return Err(Error::TooManySigners { + got, + max: MAX_XMSS_AGGREGATED, + }); + } + Ok(()) +} + +fn dedup_signers(raw: &mut Vec) { + raw.sort_by(|(a, _), (b, _)| a.cmp(b)); + raw.dedup_by(|(a, _), (b, _)| a == b); +} + +fn execute<'a>( + node: &plan::Plan, + raw: &[Raw], + children: &'a [SingleMessageAggregateSignature], + claim: Claim, +) -> Result, Error> { + match node { + plan::Plan::Passthrough(index) => Ok(Cow::Borrowed(&children[*index])), + plan::Plan::Node { + raw: range, + children: child_plans, + log_inv_rate, + } => { + let proved = child_plans + .iter() + .map(|child| execute(child, raw, children, claim).map(Cow::into_owned)) + .collect::, _>>()?; + aggregate_single_message_signatures( + &proved, + raw[range.clone()].to_vec(), + *claim.message(), + claim.slot(), + *log_inv_rate, + ) + .map(Cow::Owned) + .map_err(Into::into) + } + } +} + +/// Verifies a signature and returns the canonical, deduplicated signer set it proves. +/// +/// Verifying an aggregate requires [`setup`]; verifying a raw signature does not. +/// +/// This is the inspection-oriented operation. Most callers should use [`verify`], which also +/// checks the expected signer set and cannot accidentally omit that authorization decision. +#[must_use = "a valid signature is useful only after checking who signed it"] +pub fn verified_signers(signature: &Signature, claim: &Claim) -> Result, Error> { + if signature.claim() != *claim { + return Err(Error::MessageMismatch); + } + match &signature.0 { + Kind::Raw { + public_key, signature, .. + } => { + xmss_verify(public_key, claim.slot(), claim.message(), signature) + .map_err(|source| Error::InvalidSignature { index: 0, source })?; + Ok(vec![encode_public_key(public_key)]) + } + Kind::Aggregate(signature) => { + require_setup()?; + if !proves(signature, claim) { + return Err(Error::MessageMismatch); + } + verify_single_message_aggregate(signature)?; + Ok(signature.info.pubkeys.iter().map(encode_public_key).collect()) + } + } +} + +/// Verifies a signature against its claim and exact expected signer set. +/// +/// Ordering and duplicate entries in `expected` are ignored; both sides are compared as sets. +pub fn verify(signature: &Signature, expected: &[PublicKey], claim: &Claim) -> Result<(), Error> { + let proved = verified_signers(signature, claim)?; + let expected: BTreeSet<&PublicKey> = expected.iter().collect(); + if proved.len() == expected.len() && proved.iter().all(|key| expected.contains(key)) { + Ok(()) + } else { + Err(Error::SignerSetMismatch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lean_vm::{EF, F}; + use ssz::Decode; + use xmss::XmssSignature; + + const CLAIM: Claim = Claim::new([42u8; 32], 100); + + #[test] + fn aggregate_rejects_a_wrong_raw_public_key_before_proving() { + setup(); + let alice = SecretKey::from_seed([1u8; 32], 100..=115).unwrap(); + let bob = SecretKey::from_seed([2u8; 32], 100..=115).unwrap(); + let Kind::Raw { signature, .. } = alice.sign(&CLAIM).unwrap().0 else { + unreachable!() + }; + let wrong_public_key = XmssPublicKey::from_ssz_bytes(&bob.public_key()).unwrap(); + let signature = Signature::raw(CLAIM, wrong_public_key, *signature); + + assert!(matches!( + aggregate(vec![signature], &CLAIM), + Err(Error::InvalidSignature { index: 0, .. }) + )); + } + + #[test] + fn invalid_child_proofs_have_one_error_in_every_plan_shape() { + let invalid = unprovable_aggregate(); + assert!(matches!(aggregate(vec![invalid.clone()], &CLAIM), Err(Error::Proof(_)))); + assert!(matches!( + aggregate(vec![invalid.clone(), invalid], &CLAIM), + Err(Error::Proof(_)) + )); + } + + #[test] + fn signer_limit_is_checked_before_proving() { + let signature = XmssSignature::from_ssz_bytes(&[0u8; xmss::SIGNATURE_SSZ_LEN]).unwrap(); + let n = MAX_XMSS_AGGREGATED + 1; + let raw = (0..u32::try_from(n).unwrap()) + .map(|index| { + let mut bytes = vec![0u8; xmss::PUB_KEY_SSZ_LEN]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + (XmssPublicKey::from_ssz_bytes(&bytes).unwrap(), signature.clone()) + }) + .collect::>(); + + assert!(matches!( + check_signer_limit(&raw, &[]), + Err(Error::TooManySigners { got, max }) if got == n && max == MAX_XMSS_AGGREGATED + )); + } + + fn unprovable_aggregate() -> Signature { + setup(); + let point = vec![EF::default(); rec_aggregation::get_aggregation_bytecode().cumulated_n_vars()]; + let mut public_key = vec![0u8; xmss::PUB_KEY_SSZ_LEN]; + public_key[0] = 1; + let public_keys = [XmssPublicKey::from_ssz_bytes(&public_key).unwrap()]; + let payload = postcard::to_allocvec(&(point, (Vec::::new(), Vec::::new()))).unwrap(); + let mut envelope = b"LMSI\x01\x01".to_vec(); + envelope.extend(payload); + let public_keys = public_keys.iter().map(encode_public_key).collect::>(); + Signature::from_bytes(&envelope, &CLAIM, &public_keys).unwrap() + } +} diff --git a/crates/lean_multisig_api/src/multi_claim_proof.rs b/crates/lean_multisig_api/src/multi_claim_proof.rs new file mode 100644 index 00000000..2ee466bf --- /dev/null +++ b/crates/lean_multisig_api/src/multi_claim_proof.rs @@ -0,0 +1,189 @@ +use crate::signature::Kind; +use crate::{Claim, Error, PublicKey, Signature, aggregate, decode_public_keys, encode_public_key, require_setup}; +use rec_aggregation::{ + MultiMessageAggregateSignature, merge_single_message_aggregates, verify_multi_message_aggregate, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::{Debug, Formatter}; + +const MAGIC: &[u8; 4] = b"LMCM"; +const VERSION: u8 = 1; +const HEADER_LEN: usize = MAGIC.len() + 1; + +/// One claim and the exact signer set authorized for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimSigners { + /// The message and slot this group signed. + pub claim: Claim, + /// The exact public-key set authorized for this claim. Resolve validator bitlists to public + /// keys before constructing this value. + pub signers: Vec, +} + +/// A proof binding one or more distinct claims to their signer sets. +/// +/// Build this from any mixture of raw and aggregated [`Signature`] values with +/// [`merge_claims`]. Inputs sharing a claim are grouped automatically. Serialized values rely on +/// claims and signer sets carried by the outer protocol container. +#[derive(Clone)] +pub struct MultiClaimProof(pub(crate) MultiMessageAggregateSignature); + +impl Debug for MultiClaimProof { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MultiClaimProof") + .field("claims", &self.0.info.len()) + .finish_non_exhaustive() + } +} + +impl MultiClaimProof { + /// Serializes only the cryptographic proof material into a versioned envelope. + /// + /// Claims and signer sets are intentionally omitted. They belong in the outer protocol + /// container and must be supplied to [`Self::from_bytes`]. + #[must_use] + pub fn to_bytes(&self) -> Vec { + let payload = self.0.to_bytes_without_context(); + let mut bytes = Vec::with_capacity(HEADER_LEN + payload.len()); + bytes.extend_from_slice(MAGIC); + bytes.push(VERSION); + bytes.extend(payload); + bytes + } + + /// Restores a multi-claim proof produced by [`Self::to_bytes`] using context resolved from the + /// outer protocol container. + /// + /// Call [`crate::setup`] before using this function. + /// + /// Claim-group and signer ordering are ignored, as are duplicate signers within a group. + /// Repeating a claim is rejected. This checks framing and canonical encodings only; use + /// [`verify_claims`] or [`verified_claims`] to establish that the supplied context is proved. + pub fn from_bytes(bytes: &[u8], groups: &[ClaimSigners]) -> Result { + if bytes.len() <= HEADER_LEN || &bytes[..MAGIC.len()] != MAGIC || bytes[MAGIC.len()] != VERSION { + return Err(Error::MalformedMultiClaimProof); + } + require_setup()?; + let groups = canonical_groups(groups).ok_or(Error::ClaimSetMismatch)?; + if groups.is_empty() { + return Err(Error::Empty); + } + if groups.len() > crate::MAX_CLAIMS { + return Err(Error::TooManyClaims { + got: groups.len(), + max: crate::MAX_CLAIMS, + }); + } + let contexts = groups + .into_iter() + .map(|(claim, signers)| { + if signers.is_empty() { + return Err(Error::SignerSetMismatch); + } + let signers = signers.into_iter().collect::>(); + let signers = decode_public_keys(&signers)?; + if signers.len() > rec_aggregation::MAX_XMSS_AGGREGATED { + return Err(Error::TooManySigners { + got: signers.len(), + max: rec_aggregation::MAX_XMSS_AGGREGATED, + }); + } + Ok((*claim.message(), claim.slot(), signers)) + }) + .collect::, Error>>()?; + let proof = MultiMessageAggregateSignature::from_bytes_without_context(&bytes[HEADER_LEN..], contexts) + .ok_or(Error::MalformedMultiClaimProof)?; + Ok(Self(proof)) + } +} + +/// Groups signatures by claim and proves all groups in one bundle. +/// +/// Call [`crate::setup`] before using this function. +/// +/// Raw and already aggregated signatures may be mixed freely. Signatures for the same claim +/// are combined before the resulting per-claim proofs are merged. +pub fn merge_claims(signatures: Vec) -> Result { + if signatures.is_empty() { + return Err(Error::Empty); + } + + let mut groups: BTreeMap> = BTreeMap::new(); + for signature in signatures { + groups.entry(signature.claim()).or_default().push(signature); + } + if groups.len() > crate::MAX_CLAIMS { + return Err(Error::TooManyClaims { + got: groups.len(), + max: crate::MAX_CLAIMS, + }); + } + + let single_claims = groups + .into_iter() + .map(|(claim, signatures)| { + let signature = aggregate(signatures, &claim)?; + let Kind::Aggregate(signature) = signature.0 else { + unreachable!("aggregate always returns an aggregate representation") + }; + Ok(signature) + }) + .collect::, Error>>()?; + + merge_single_message_aggregates(single_claims, crate::plan::RATE_ROOT) + .map(MultiClaimProof) + .map_err(Into::into) +} + +/// Verifies a multi-claim proof and returns its canonical claim-to-signer mapping. +/// +/// Call [`crate::setup`] before using this function. +/// +/// Most callers should use [`verify_claims`] so the expected authorization decision cannot be +/// accidentally omitted. +#[must_use = "a valid signature is useful only after checking its claims and signers"] +pub fn verified_claims(proof: &MultiClaimProof) -> Result, Error> { + require_setup()?; + verify_multi_message_aggregate(&proof.0)?; + let mut groups = proof + .0 + .info + .iter() + .map(|info| ClaimSigners { + claim: Claim::new(info.core.message, info.core.slot), + signers: info.pubkeys.iter().map(encode_public_key).collect(), + }) + .collect::>(); + groups.sort_by_key(|group| group.claim); + Ok(groups) +} + +fn canonical_groups(groups: &[ClaimSigners]) -> Option>> { + let mut canonical = BTreeMap::new(); + for group in groups { + let signers = group.signers.iter().copied().collect(); + if canonical.insert(group.claim, signers).is_some() { + return None; + } + } + Some(canonical) +} + +/// Verifies a multi-claim proof against the exact expected claims and signer sets. +/// +/// Claim-group and signer ordering are ignored, as are duplicate signers within one expected +/// group. Repeating an expected claim as a second group is rejected. +pub fn verify_claims(proof: &MultiClaimProof, expected: &[ClaimSigners]) -> Result<(), Error> { + let proved = verified_claims(proof)?; + let Some(proved) = canonical_groups(&proved) else { + return Err(Error::ClaimSetMismatch); + }; + let Some(expected) = canonical_groups(expected) else { + return Err(Error::ClaimSetMismatch); + }; + if proved == expected { + Ok(()) + } else { + Err(Error::ClaimSetMismatch) + } +} diff --git a/crates/lean_multisig_api/src/plan.rs b/crates/lean_multisig_api/src/plan.rs new file mode 100644 index 00000000..20f3cc85 --- /dev/null +++ b/crates/lean_multisig_api/src/plan.rs @@ -0,0 +1,381 @@ +//! Recursion-tree planner. +//! +//! A single proving job has a bounded trace size, so aggregating more than roughly +//! `LEAF_TARGET` signatures needs a tree: leaves prove chunks of raw signatures, internal +//! nodes prove batches of child proofs, and the root produces the proof that goes on the wire. +//! +//! This module computes only the *shape* of that tree. It is pure: no proving, no I/O. That +//! keeps the topology testable in milliseconds rather than at proving cost. + +use rec_aggregation::MAX_RECURSIONS; +use std::ops::Range; + +/// Raw signatures per leaf. +/// +/// Originally taken from `src/main.rs`'s tuned topology (leaves of 508..1550), then measured: +/// `round_trip.rs`'s two `#[ignore]`d boundary tests prove a full 1500-signature leaf and the +/// 1501 split, so this is a size the prover demonstrably accepts rather than a number inherited +/// from a benchmark. +/// +/// Still unmeasured: the *largest* leaf that proves. 1500 sits under that topology's observed +/// 1550 for inherited reasons, so this is known-good, not known-optimal, and the headroom above +/// it is unknown. The 2^22 table height is the underlying bound but has never been computed +/// against. Raising it is a measurement task, and those two tests are where to do it. +pub(crate) const LEAF_TARGET: usize = 1500; + +/// Most children one node may recurse over. Upstream rejects `children.len() > MAX_RECURSIONS`, +/// so a fan-in of exactly `MAX_RECURSIONS` is legal. +pub(crate) const MAX_FAN_IN: usize = MAX_RECURSIONS; + +// Termination and well-formedness both depend on these, and `MAX_FAN_IN` comes from another +// crate: at a fan-in of 1 the fold below would never shrink the pool, and `step_by(0)` panics. +const _: () = assert!(MAX_FAN_IN >= 2 && LEAF_TARGET >= 1); + +/// Fast proving, large proof. Leaf proofs are consumed immediately, so size is irrelevant. +pub(crate) const RATE_LEAF: usize = 1; + +/// Internal proofs are also consumed by their parent, but there are fewer of them than leaves, +/// so they can afford a slower rate for a smaller intermediate proof. +pub(crate) const RATE_INTERNAL: usize = 2; + +/// Smallest proof. Only the root goes on the wire. +pub(crate) const RATE_ROOT: usize = 4; + +// Proving and verification report an out-of-band rate as a typed error at runtime. Keep this +// compile-time guard as an earlier signal if the accepted band changes upstream. +const _: () = assert!( + RATE_LEAF >= lean_vm::MIN_WHIR_LOG_INV_RATE + && RATE_LEAF <= lean_vm::MAX_WHIR_LOG_INV_RATE + && RATE_INTERNAL >= lean_vm::MIN_WHIR_LOG_INV_RATE + && RATE_INTERNAL <= lean_vm::MAX_WHIR_LOG_INV_RATE + && RATE_ROOT >= lean_vm::MIN_WHIR_LOG_INV_RATE + && RATE_ROOT <= lean_vm::MAX_WHIR_LOG_INV_RATE, + "aggregation rates must remain inside the accepted WHIR rate band" +); + +// Intermediate proofs trade progressively more proving time for smaller proofs as they approach +// the root. Keep that topology policy independent of the upstream validity band above. +const _: () = assert!( + RATE_LEAF <= RATE_INTERNAL && RATE_INTERNAL <= RATE_ROOT, + "aggregation rates must be nondecreasing from leaves to root" +); + +/// One node of the recursion tree, or a caller-supplied aggregate reused as-is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Plan { + /// Return a caller-supplied aggregate unchanged; the index is into the supplied children. + Passthrough(usize), + /// A proving job. + Node { + /// Range into the raw-signature vector. Always empty when `children` is non-empty: + /// the planner never mixes raw signatures and child proofs in one node, because + /// `LEAF_TARGET` was tuned for raw-only nodes and the combined trace size is unmeasured. + /// Upstream permits mixing (`src/main.rs` does it at raw counts of 10 and 25), so folding + /// a small raw batch directly into a merging node — one proving job instead of two — is + /// still open. It is roughly a 2x on the incremental "add my signatures to an existing + /// aggregate" path, which is the likeliest real call shape. What blocks it is that + /// whether `LEAF_TARGET` raw plus a full fan-in of children fits the trace bound has + /// never been measured, and guessing wrong means a failed proof after minutes of work. + raw: Range, + children: Vec, + log_inv_rate: usize, + }, +} + +/// Shapes the recursion tree for `n_raw` raw signatures and `n_children` supplied aggregates. +/// +/// Does not enforce the `MAX_XMSS_AGGREGATED` signer ceiling: that is a property of the signer +/// set, not of the tree, and `aggregate` checks it up front before any proving happens. +/// +/// Callers must reject empty input before calling: `plan(0, 0)` returns an empty root node +/// rather than erroring, because `aggregate` has already returned `Error::Empty` by then. +pub(crate) fn plan(n_raw: usize, n_children: usize) -> Plan { + // A lone aggregate is already a valid proof; re-proving it would buy nothing. + if n_raw == 0 && n_children == 1 { + return Plan::Passthrough(0); + } + // Everything fits in one node: prove it directly at the root rate. + if n_raw <= LEAF_TARGET && n_children == 0 { + return Plan::Node { + raw: 0..n_raw, + children: vec![], + log_inv_rate: RATE_ROOT, + }; + } + + // Bottom level: raw signatures partitioned into leaves. Supplied aggregates join them as + // passthroughs, since they are already proved. + // + // The split is greedy, so the remainder can be tiny: `plan(LEAF_TARGET + 1, 0)` gives leaves + // of 1500 and 1, a whole proving job for one signature. `execute` proves nodes one after + // another, so wall-clock is the sum over nodes and a greedy split is not itself worse + // than a balanced 751 + 750 — the open question is whether per-node trace padding makes the + // degenerate leaf cost more than balancing would. Unmeasured. + // + // One data point against balancing: proving 1501 as [1500, 1] plus a root measures *faster* + // than proving 1500 as a single node (6.7s vs 8.2s), because leaves run at `RATE_LEAF` and + // only the root pays `RATE_ROOT`. The rate a node is assigned dominates the node count. + let mut pool: Vec = (0..n_raw) + .step_by(LEAF_TARGET) + .map(|start| Plan::Node { + raw: start..(start + LEAF_TARGET).min(n_raw), + children: vec![], + log_inv_rate: RATE_LEAF, + }) + .collect(); + pool.extend((0..n_children).map(Plan::Passthrough)); + + // Fold the pool upwards until one node can fan in over all of what is left. Greedy again: + // 17 items become 16 + 1 rather than a balanced 9 + 8. Since wall-clock is the sum over + // nodes rather than a critical path, greedy chunking minimizes the node count and is the + // better default; whether trace padding makes a lopsided split cost more is the same + // unmeasured question as above. + while pool.len() > MAX_FAN_IN { + pool = pool + .chunks(MAX_FAN_IN) + .map(|group| { + // A leftover group of one is already a valid proof of exactly its own contents; + // wrapping it in a node would prove it a second time for no benefit. + if let [only] = group { + only.clone() + } else { + Plan::Node { + raw: 0..0, + children: group.to_vec(), + log_inv_rate: RATE_INTERNAL, + } + } + }) + .collect(); + } + + Plan::Node { + raw: 0..0, + children: pool, + log_inv_rate: RATE_ROOT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rec_aggregation::MAX_XMSS_AGGREGATED; + + #[test] + fn leaf_target_is_mirrored_in_the_integration_suite() { + // `tests/round_trip.rs` cannot see this constant — `pub(crate)` in a private module, and + // an integration test is a separate crate — so it hard-codes 1500 to size the two + // `#[ignore]`d tests that prove a real leaf of exactly `LEAF_TARGET` signatures and a real + // split at `LEAF_TARGET + 1`. If you change this, change that: otherwise those tests go on + // passing while testing a boundary that has moved out from under them. + // + // Every other test in this module uses `LEAF_TARGET` symbolically and so would follow a + // change silently. This is the only one that pins the value. + assert_eq!(LEAF_TARGET, 1500); + } + + #[test] + fn single_child_alone_is_passed_through() { + // Re-proving a lone aggregate would burn a whole proving job for no benefit. + assert_eq!(plan(0, 1), Plan::Passthrough(0)); + } + + #[test] + fn small_raw_batch_is_one_node_at_root_rate() { + // Must be RATE_ROOT, not RATE_LEAF: this node IS the wire proof. + assert_eq!( + plan(1, 0), + Plan::Node { + raw: 0..1, + children: vec![], + log_inv_rate: RATE_ROOT + } + ); + assert_eq!( + plan(LEAF_TARGET, 0), + Plan::Node { + raw: 0..LEAF_TARGET, + children: vec![], + log_inv_rate: RATE_ROOT + } + ); + } + + #[test] + fn empty_input_plans_an_empty_root() { + // Documented contract: `plan` does not reject empty input, because `aggregate` has + // already returned `Error::Empty` before it gets here. + assert_eq!( + plan(0, 0), + Plan::Node { + raw: 0..0, + children: vec![], + log_inv_rate: RATE_ROOT + } + ); + } + + #[test] + fn overflowing_one_leaf_splits_and_adds_a_root() { + let p = plan(LEAF_TARGET + 1, 0); + let Plan::Node { + raw, + children, + log_inv_rate, + } = p + else { + panic!("expected a node") + }; + assert!(raw.is_empty()); + assert_eq!(log_inv_rate, RATE_ROOT); + assert_eq!(children.len(), 2); + assert_eq!( + children[0], + Plan::Node { + raw: 0..LEAF_TARGET, + children: vec![], + log_inv_rate: RATE_LEAF + } + ); + // The greedy split leaves a whole proving job for one signature. Asserted so the + // degenerate shape is recorded rather than merely tolerated. + assert_eq!( + children[1], + Plan::Node { + raw: LEAF_TARGET..LEAF_TARGET + 1, + children: vec![], + log_inv_rate: RATE_LEAF + } + ); + } + + /// The sizes every whole-tree invariant is checked against. `LEAF_TARGET * 17` is the one + /// that folds to a leftover group of one, the shape most likely to regress into a + /// single-child node. + const SHAPES: [usize; 6] = [ + 1, + 2, + LEAF_TARGET, + LEAF_TARGET * 17, + LEAF_TARGET * 40, + MAX_XMSS_AGGREGATED, + ]; + + #[test] + fn shape_invariants_hold_at_every_node() { + for n in SHAPES { + assert_shape(&plan(n, 0)); + } + } + + fn assert_shape(p: &Plan) { + if let Plan::Node { raw, children, .. } = p { + assert!(children.len() <= MAX_FAN_IN, "fan-in {} too wide", children.len()); + // A node has either no children or at least two: a single-child node would prove its + // only child a second time for nothing. Generalizes the leftover-of-one rule to every + // level, including the root. + assert!(children.len() != 1, "pointless single-child node"); + // The planner never mixes raw signatures with child proofs. + assert!(raw.is_empty() || children.is_empty(), "unexpected mixed node"); + children.iter().for_each(assert_shape); + } + } + + #[test] + fn a_leftover_group_of_one_is_not_wrapped_in_a_pointless_node() { + // 17 leaves chunk into 16 + 1. Giving that lone leftover its own node would prove it a + // second time for no benefit, the same waste `Passthrough` exists to avoid. + let p = plan(LEAF_TARGET * 17, 0); + let Plan::Node { children, .. } = &p else { + panic!("expected a node") + }; + assert_eq!(children.len(), 2); + assert_eq!( + children[1], + Plan::Node { + raw: LEAF_TARGET * 16..LEAF_TARGET * 17, + children: vec![], + log_inv_rate: RATE_LEAF + } + ); + } + + #[test] + fn every_raw_signature_is_covered_exactly_once() { + // The planner returns index ranges, so off-by-ones would otherwise be silent. + let n = LEAF_TARGET * 3 + 7; + let mut seen = vec![0u8; n]; + // No children supplied: any Passthrough here is itself a failure. + collect(&plan(n, 0), &mut seen, &mut []); + assert!(seen.iter().all(|&c| c == 1), "each raw sig must appear exactly once"); + } + + #[test] + fn mixed_raw_and_children_are_each_covered_exactly_once() { + // Passthrough indices are used to index the caller's supplied-children slice, so a + // dropped, duplicated, or off-by-one index is an out-of-bounds panic at best and the + // wrong signer set proved at worst. Wide enough to need more than one fold level. + let (n_raw, n_children) = (LEAF_TARGET * 17 + 3, 20); + let p = plan(n_raw, n_children); + // The whole-tree invariants are otherwise only checked on raw-only plans. + assert_shape(&p); + assert_rates(&p, true); + let mut raw_seen = vec![0u8; n_raw]; + let mut child_seen = vec![0u8; n_children]; + collect(&p, &mut raw_seen, &mut child_seen); + assert!( + raw_seen.iter().all(|&c| c == 1), + "each raw sig must appear exactly once" + ); + assert!( + child_seen.iter().all(|&c| c == 1), + "each supplied child must appear exactly once" + ); + } + + /// Tallies how often each raw-signature index and each supplied-child index appears. + /// An index outside either slice fails here, which is itself the failure we want. + fn collect(p: &Plan, raw_seen: &mut [u8], child_seen: &mut [u8]) { + match p { + Plan::Passthrough(i) => { + let n_children = child_seen.len(); + let Some(tally) = child_seen.get_mut(*i) else { + panic!("passthrough index {i} out of range (n_children = {n_children})") + }; + *tally += 1; + } + Plan::Node { raw, children, .. } => { + for i in raw.clone() { + raw_seen[i] += 1; + } + children.iter().for_each(|c| collect(c, raw_seen, child_seen)); + } + } + } + + #[test] + fn log_inv_rate_rises_toward_the_root() { + for n in SHAPES { + assert_rates(&plan(n, 0), true); + } + } + + /// The root ships on the wire, so it is proved at `RATE_ROOT` whether or not it has + /// children. Below it, leaves get `RATE_LEAF` and every internal node `RATE_INTERNAL`. + fn assert_rates(p: &Plan, is_root: bool) { + if let Plan::Node { + children, log_inv_rate, .. + } = p + { + let expected = if is_root { + RATE_ROOT + } else if children.is_empty() { + RATE_LEAF + } else { + RATE_INTERNAL + }; + assert_eq!(*log_inv_rate, expected, "wrong rate for {p:?}"); + children.iter().for_each(|c| assert_rates(c, false)); + } + } +} diff --git a/crates/lean_multisig_api/src/signature.rs b/crates/lean_multisig_api/src/signature.rs new file mode 100644 index 00000000..b8174039 --- /dev/null +++ b/crates/lean_multisig_api/src/signature.rs @@ -0,0 +1,165 @@ +use crate::{Error, PublicKey, decode_public_keys, require_setup}; +use rec_aggregation::SingleMessageAggregateSignature; +use ssz::{Decode, Encode}; +use std::fmt::{Debug, Formatter}; +use xmss::{XmssPublicKey, XmssSignature}; + +const MAGIC: &[u8; 4] = b"LMSI"; +const VERSION: u8 = 1; +const RAW: u8 = 0; +const AGGREGATE: u8 = 1; +const HEADER_LEN: usize = MAGIC.len() + 2; +const RAW_LEN: usize = HEADER_LEN + xmss::SIGNATURE_SSZ_LEN; + +/// The statement signed by every input to one aggregation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Claim { + message: [u8; 32], + slot: u32, +} + +impl Claim { + #[must_use] + pub const fn new(message: [u8; 32], slot: u32) -> Self { + Self { message, slot } + } + + #[must_use] + pub const fn message(&self) -> &[u8; 32] { + &self.message + } + + #[must_use] + pub const fn slot(&self) -> u32 { + self.slot + } +} + +/// One signature contribution, whether it is raw XMSS or recursively aggregated. +/// +/// The representation is deliberately private. Values produced by [`crate::SecretKey::sign`] +/// and [`crate::aggregate`] can be mixed in one vector, serialized with [`Self::to_bytes`], and +/// restored with [`Self::from_bytes`] without the caller identifying which representation they +/// contain. Serialized values rely on the claim and signer set carried by the outer protocol +/// container. +#[derive(Clone)] +pub struct Signature(pub(crate) Kind); + +#[derive(Clone)] +pub(crate) enum Kind { + Raw { + claim: Claim, + public_key: XmssPublicKey, + signature: Box, + }, + Aggregate(SingleMessageAggregateSignature), +} + +impl Debug for Signature { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Signature") + .field("claim", &self.claim()) + .field( + "representation", + &match self.0 { + Kind::Raw { .. } => "raw", + Kind::Aggregate(_) => "aggregate", + }, + ) + .finish_non_exhaustive() + } +} + +impl Signature { + pub(crate) fn raw(claim: Claim, public_key: XmssPublicKey, signature: XmssSignature) -> Self { + Self(Kind::Raw { + claim, + public_key, + signature: Box::new(signature), + }) + } + + pub(crate) const fn aggregate(signature: SingleMessageAggregateSignature) -> Self { + Self(Kind::Aggregate(signature)) + } + + #[must_use] + pub fn claim(&self) -> Claim { + match &self.0 { + Kind::Raw { claim, .. } => *claim, + Kind::Aggregate(signature) => Claim::new(signature.info.core.message, signature.info.core.slot), + } + } + + /// Serializes the cryptographic material into a tagged envelope. + /// + /// The claim and signer set are intentionally omitted. They belong in the outer protocol + /// container and must be supplied to [`Self::from_bytes`]. + #[must_use] + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(MAGIC); + out.push(VERSION); + match &self.0 { + Kind::Raw { signature, .. } => { + out.reserve(RAW_LEN - out.len()); + out.push(RAW); + out.extend_from_slice(&signature.as_ssz_bytes()); + } + Kind::Aggregate(signature) => { + out.push(AGGREGATE); + out.extend_from_slice(&signature.to_bytes_without_context()); + } + } + out + } + + /// Restores a signature produced by [`Self::to_bytes`] using context resolved from the outer + /// protocol container. + /// + /// Call [`crate::setup`] first when decoding an aggregate. Raw signatures do not require + /// setup. + /// + /// Signer ordering and duplicates are ignored. A raw signature requires exactly one distinct + /// signer. When signers originate in a validator bitlist, resolve that bitlist to public keys + /// before calling this method. This checks framing and canonical encodings only; use + /// [`crate::verify`] or [`crate::aggregate`] to establish that the supplied context is the one + /// proved. + pub fn from_bytes(bytes: &[u8], claim: &Claim, signers: &[PublicKey]) -> Result { + if bytes.len() < HEADER_LEN || &bytes[..MAGIC.len()] != MAGIC || bytes[MAGIC.len()] != VERSION { + return Err(Error::MalformedSignature); + } + let public_keys = decode_public_keys(signers)?; + if public_keys.len() > rec_aggregation::MAX_XMSS_AGGREGATED { + return Err(Error::TooManySigners { + got: public_keys.len(), + max: rec_aggregation::MAX_XMSS_AGGREGATED, + }); + } + if public_keys.is_empty() { + return Err(Error::SignerSetMismatch); + } + match bytes[MAGIC.len() + 1] { + RAW if bytes.len() == RAW_LEN => { + let [public_key] = public_keys.as_slice() else { + return Err(Error::SignerSetMismatch); + }; + let signature = + XmssSignature::from_ssz_bytes(&bytes[HEADER_LEN..]).map_err(|_| Error::MalformedSignature)?; + Ok(Self::raw(*claim, public_key.clone(), signature)) + } + AGGREGATE => { + require_setup()?; + SingleMessageAggregateSignature::from_bytes_without_context( + &bytes[HEADER_LEN..], + *claim.message(), + claim.slot(), + public_keys, + ) + .map(Self::aggregate) + .ok_or(Error::MalformedSignature) + } + _ => Err(Error::MalformedSignature), + } + } +} diff --git a/crates/lean_multisig_api/tests/explicit_setup.rs b/crates/lean_multisig_api/tests/explicit_setup.rs new file mode 100644 index 00000000..b44a3cb8 --- /dev/null +++ b/crates/lean_multisig_api/tests/explicit_setup.rs @@ -0,0 +1,28 @@ +use lean_multisig_api::{Claim, ClaimSigners, Error, MultiClaimProof, SecretKey, Signature, aggregate, setup}; + +#[test] +fn proof_operations_require_explicit_setup_without_initializing_themselves() { + let claim = Claim::new([0u8; 32], 0); + let key = SecretKey::from_seed([1u8; 32], 0..=15).unwrap(); + + assert!(matches!( + aggregate(vec![key.sign(&claim).unwrap()], &claim), + Err(Error::NotInitialized) + )); + assert!(matches!( + Signature::from_bytes(b"LMSI\x01\x01proof", &claim, &[key.public_key()]), + Err(Error::NotInitialized) + )); + assert!(matches!( + MultiClaimProof::from_bytes( + b"LMCM\x01proof", + &[ClaimSigners { + claim, + signers: vec![key.public_key()], + }], + ), + Err(Error::NotInitialized) + )); + setup(); + aggregate(vec![key.sign(&claim).unwrap()], &claim).unwrap(); +} diff --git a/crates/lean_multisig_api/tests/multi_claim.rs b/crates/lean_multisig_api/tests/multi_claim.rs new file mode 100644 index 00000000..c447c3bf --- /dev/null +++ b/crates/lean_multisig_api/tests/multi_claim.rs @@ -0,0 +1,174 @@ +use lean_multisig_api::{ + Claim, ClaimSigners, Error, MAX_CLAIMS, MultiClaimProof, SecretKey, aggregate, merge_claims, setup, + verified_claims, verify_claims, +}; +use std::sync::OnceLock; + +const ATTESTATION: Claim = Claim::new([0xa1; 32], 100); +const PROPOSAL: Claim = Claim::new([0xb2; 32], 101); + +struct Fixture { + proof: MultiClaimProof, + expected: Vec, +} + +fn fixture() -> &'static Fixture { + static FIXTURE: OnceLock = OnceLock::new(); + FIXTURE.get_or_init(|| { + setup(); + let alice = SecretKey::from_seed([1; 32], 100..=115).unwrap(); + let bob = SecretKey::from_seed([2; 32], 100..=115).unwrap(); + let proposer = SecretKey::from_seed([3; 32], 100..=115).unwrap(); + let attestation_child = aggregate(vec![alice.sign(&ATTESTATION).unwrap()], &ATTESTATION).unwrap(); + let proof = merge_claims(vec![ + proposer.sign(&PROPOSAL).unwrap(), + bob.sign(&ATTESTATION).unwrap(), + attestation_child, + ]) + .unwrap(); + let expected = vec![ + ClaimSigners { + claim: ATTESTATION, + signers: vec![alice.public_key(), bob.public_key()], + }, + ClaimSigners { + claim: PROPOSAL, + signers: vec![proposer.public_key()], + }, + ]; + Fixture { proof, expected } + }) +} + +#[test] +fn mixed_signatures_are_grouped_by_claim_and_verified_as_one_bundle() { + let Fixture { proof, expected } = fixture(); + + verify_claims(proof, expected).unwrap(); + let proved = verified_claims(proof).unwrap(); + assert_eq!(proved.len(), 2); + assert!( + proved + .iter() + .any(|group| group.claim == ATTESTATION && group.signers.len() == 2) + ); + assert!( + proved + .iter() + .any(|group| group.claim == PROPOSAL && group.signers.len() == 1) + ); +} + +#[test] +fn bundle_round_trips_with_context_from_the_outer_container() { + let Fixture { proof, expected } = fixture(); + let mut outer_context = expected.clone(); + outer_context.reverse(); + outer_context[1].signers.reverse(); + let duplicate = outer_context[1].signers[0]; + outer_context[1].signers.push(duplicate); + + let restored = MultiClaimProof::from_bytes(&proof.to_bytes(), &outer_context).unwrap(); + + verify_claims(&restored, expected).unwrap(); +} + +#[test] +fn decoded_bundle_is_bound_to_the_supplied_outer_context() { + let Fixture { proof, expected } = fixture(); + let mut wrong = expected.clone(); + wrong[0].claim = Claim::new([0xff; 32], wrong[0].claim.slot()); + + let restored = MultiClaimProof::from_bytes(&proof.to_bytes(), &wrong).unwrap(); + + assert!(verified_claims(&restored).is_err()); +} + +#[test] +fn authorization_rejects_a_wrong_claim_signer_mapping() { + let Fixture { proof, expected } = fixture(); + let mut wrong = expected.clone(); + wrong[0].signers.pop(); + + assert!(matches!(verify_claims(proof, &wrong), Err(Error::ClaimSetMismatch))); +} + +#[test] +fn authorization_is_order_independent_but_rejects_repeated_claim_groups() { + let Fixture { proof, expected } = fixture(); + let mut reordered = expected.clone(); + reordered.reverse(); + reordered[1].signers.reverse(); + let duplicate = reordered[1].signers[0]; + reordered[1].signers.push(duplicate); + verify_claims(proof, &reordered).unwrap(); + + let mut repeated = expected.clone(); + repeated.push(expected[0].clone()); + assert!(matches!(verify_claims(proof, &repeated), Err(Error::ClaimSetMismatch))); +} + +#[test] +fn malformed_multi_claim_envelopes_are_rejected() { + assert!(matches!( + MultiClaimProof::from_bytes(b"not a multi-claim proof", &fixture().expected), + Err(Error::MalformedMultiClaimProof) + )); + assert!(matches!( + MultiClaimProof::from_bytes(b"LMCM\x01", &fixture().expected), + Err(Error::MalformedMultiClaimProof) + )); + let mut unsupported_version = fixture().proof.to_bytes(); + unsupported_version[4] = 0; + assert!(matches!( + MultiClaimProof::from_bytes(&unsupported_version, &fixture().expected), + Err(Error::MalformedMultiClaimProof) + )); +} + +#[test] +fn decoding_is_structural_and_verification_rejects_a_tampered_bundle() { + let mut bytes = fixture().proof.to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + + match MultiClaimProof::from_bytes(&bytes, &fixture().expected) { + Err(Error::MalformedMultiClaimProof) => {} + Ok(proof) => assert!(verified_claims(&proof).is_err()), + Err(other) => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn merging_no_signatures_is_rejected_before_proving() { + assert!(matches!(merge_claims(Vec::new()), Err(Error::Empty))); +} + +#[test] +fn a_proposer_only_bundle_can_contain_one_claim() { + setup(); + let claim = Claim::new([0xc3; 32], 200); + let proposer = SecretKey::from_seed([4; 32], 200..=215).unwrap(); + let proof = merge_claims(vec![proposer.sign(&claim).unwrap()]).unwrap(); + + verify_claims( + &proof, + &[ClaimSigners { + claim, + signers: vec![proposer.public_key()], + }], + ) + .unwrap(); +} + +#[test] +fn too_many_distinct_claims_are_rejected_before_proving() { + let key = SecretKey::from_seed([9; 32], 0..=u32::try_from(MAX_CLAIMS).unwrap()).unwrap(); + let signatures = (0..=u32::try_from(MAX_CLAIMS).unwrap()) + .map(|slot| key.sign(&Claim::new([u8::try_from(slot).unwrap(); 32], slot)).unwrap()) + .collect(); + + assert!(matches!( + merge_claims(signatures), + Err(Error::TooManyClaims { got, max }) if got == MAX_CLAIMS + 1 && max == MAX_CLAIMS + )); +} diff --git a/crates/lean_multisig_api/tests/round_trip.rs b/crates/lean_multisig_api/tests/round_trip.rs new file mode 100644 index 00000000..b8736537 --- /dev/null +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -0,0 +1,245 @@ +//! End-to-end tests for the opaque signature boundary. +//! +//! The two ignored tests pin the planner's 1500-signature leaf boundary and are run explicitly +//! by CI. Run them locally with: +//! +//! ```text +//! cargo test --release -p lean_multisig_api --test round_trip -- --ignored +//! ``` + +use lean_multisig_api::{Claim, Error, PublicKey, SecretKey, Signature, aggregate, setup, verified_signers, verify}; +use ssz::Encode; +use std::collections::BTreeSet; +use std::sync::OnceLock; + +const CLAIM: Claim = Claim::new([42u8; 32], 100); +static BASE: OnceLock = OnceLock::new(); + +fn signers(n: u8) -> Vec { + (0..n) + .map(|seed| SecretKey::from_seed([seed; 32], 100..=115).unwrap()) + .collect() +} + +fn lone_key(seed: u8) -> SecretKey { + assert!(seed >= 200); + SecretKey::from_seed([seed; 32], 100..=115).unwrap() +} + +fn prove(signatures: Vec, claim: &Claim) -> Result { + setup(); + aggregate(signatures, claim) +} + +fn base() -> &'static Signature { + BASE.get_or_init(|| { + let signatures = signers(2).iter().map(|key| key.sign(&CLAIM).unwrap()).collect(); + prove(signatures, &CLAIM).unwrap() + }) +} + +fn base_public_keys() -> Vec { + signers(2).iter().map(SecretKey::public_key).collect() +} + +fn signer_set(signature: &Signature, claim: &Claim) -> BTreeSet { + verified_signers(signature, claim).unwrap().into_iter().collect() +} + +#[test] +fn aggregate_round_trips_through_the_public_wire_format() { + let expected = base_public_keys(); + let aggregate = Signature::from_bytes(&base().to_bytes(), &CLAIM, &expected).unwrap(); + + verify(&aggregate, &expected, &CLAIM).unwrap(); + assert_eq!(signer_set(&aggregate, &CLAIM), expected.into_iter().collect()); +} + +#[test] +fn verification_binds_the_claim_and_signer_set() { + let wrong_claim = Claim::new([7u8; 32], CLAIM.slot()); + assert!(matches!( + verify(base(), &base_public_keys(), &wrong_claim), + Err(Error::MessageMismatch) + )); + + let outsider = lone_key(200).public_key(); + assert!(matches!( + verify(base(), &[base_public_keys()[0], outsider], &CLAIM), + Err(Error::SignerSetMismatch) + )); +} + +#[test] +fn decoded_aggregate_is_bound_to_the_supplied_outer_context() { + let bytes = base().to_bytes(); + let expected = base_public_keys(); + let wrong_claim = Claim::new([7u8; 32], CLAIM.slot()); + let wrong_claim_signature = Signature::from_bytes(&bytes, &wrong_claim, &expected).unwrap(); + assert!(verified_signers(&wrong_claim_signature, &wrong_claim).is_err()); + + let wrong_signers = [expected[0], lone_key(200).public_key()]; + let wrong_signer_signature = Signature::from_bytes(&bytes, &CLAIM, &wrong_signers).unwrap(); + assert!(verified_signers(&wrong_signer_signature, &CLAIM).is_err()); +} + +#[test] +fn decoding_rejects_missing_or_malformed_signer_context() { + assert!(matches!( + Signature::from_bytes(&base().to_bytes(), &CLAIM, &[]), + Err(Error::SignerSetMismatch) + )); + + let raw = lone_key(205).sign(&CLAIM).unwrap(); + assert!(matches!( + Signature::from_bytes(&raw.to_bytes(), &CLAIM, &[[0xff; 32]]), + Err(Error::MalformedPublicKey) + )); +} + +#[test] +fn folding_an_aggregate_with_a_fresh_signature_hides_the_representation_split() { + let fresh = lone_key(201); + let combined = prove(vec![base().clone(), fresh.sign(&CLAIM).unwrap()], &CLAIM).unwrap(); + + let mut expected: BTreeSet = base_public_keys().into_iter().collect(); + expected.insert(fresh.public_key()); + assert_eq!(signer_set(&combined, &CLAIM), expected); +} + +#[test] +fn duplicate_signers_collapse_to_one() { + let keys = signers(2); + let combined = prove( + vec![ + keys[0].sign(&CLAIM).unwrap(), + keys[0].sign(&CLAIM).unwrap(), + keys[1].sign(&CLAIM).unwrap(), + ], + &CLAIM, + ) + .unwrap(); + + assert_eq!(signer_set(&combined, &CLAIM), base_public_keys().into_iter().collect()); +} + +#[test] +fn a_signer_shared_by_a_child_and_fresh_input_appears_once() { + let keys = signers(2); + let fresh = lone_key(202); + let combined = prove( + vec![ + base().clone(), + keys[0].sign(&CLAIM).unwrap(), + fresh.sign(&CLAIM).unwrap(), + ], + &CLAIM, + ) + .unwrap(); + + let mut expected: BTreeSet = base_public_keys().into_iter().collect(); + expected.insert(fresh.public_key()); + assert_eq!(signer_set(&combined, &CLAIM), expected); +} + +#[test] +fn mismatched_inputs_are_rejected_before_a_new_proof() { + let other_claim = Claim::new([9u8; 32], CLAIM.slot()); + let key = lone_key(203); + let other = prove(vec![key.sign(&other_claim).unwrap()], &other_claim).unwrap(); + + assert!(matches!( + prove(vec![other, key.sign(&CLAIM).unwrap()], &CLAIM), + Err(Error::MessageMismatch) + )); +} + +#[test] +fn malformed_and_tampered_envelopes_are_rejected() { + assert!(matches!( + Signature::from_bytes(b"not a signature", &CLAIM, &[]), + Err(Error::MalformedSignature) + )); + + let mut unsupported_version = base().to_bytes(); + unsupported_version[4] = 0; + assert!(matches!( + Signature::from_bytes(&unsupported_version, &CLAIM, &base_public_keys()), + Err(Error::MalformedSignature) + )); + + let mut bytes = base().to_bytes(); + *bytes.last_mut().unwrap() ^= 0xff; + match Signature::from_bytes(&bytes, &CLAIM, &base_public_keys()) { + Err(Error::MalformedSignature) => {} + Ok(signature) => assert!(verified_signers(&signature, &CLAIM).is_err()), + Err(other) => panic!("unexpected error: {other:?}"), + } +} + +#[test] +fn decoding_is_structural_and_verification_rejects_a_tampered_raw_signature() { + let key = lone_key(204); + let mut bytes = key.sign(&CLAIM).unwrap().to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + + let signature = Signature::from_bytes(&bytes, &CLAIM, &[key.public_key()]) + .expect("the tagged envelope is still structurally valid"); + assert!(matches!( + verify(&signature, &[key.public_key()], &CLAIM), + Err(Error::InvalidSignature { index: 0, .. }) + )); +} + +#[test] +fn a_multi_level_tree_round_trips() { + let keys = signers(17); + let children = keys + .iter() + .map(|key| prove(vec![key.sign(&CLAIM).unwrap()], &CLAIM).unwrap()) + .collect(); + let root = prove(children, &CLAIM).unwrap(); + + let expected = keys.iter().map(SecretKey::public_key).collect::>(); + assert_eq!(signer_set(&root, &CLAIM), expected); +} + +const LEAF_TARGET: usize = 1500; + +fn cached_batch(n: usize) -> (Vec, Vec, Claim) { + let cached = xmss::signers_cache::get_benchmark_signatures(); + assert!(cached.len() >= n); + let claim = Claim::new( + xmss::signers_cache::message_for_benchmark(), + xmss::signers_cache::BENCHMARK_SLOT, + ); + let mut public_keys = Vec::with_capacity(n); + let signatures = cached[..n] + .iter() + .map(|(public_key, signature)| { + let public_key_bytes = public_key.as_ssz_bytes(); + public_keys.push(public_key_bytes.as_slice().try_into().unwrap()); + let public_key: PublicKey = public_key_bytes.as_slice().try_into().unwrap(); + let mut bytes = b"LMSI\x01\x00".to_vec(); + bytes.extend(signature.as_ssz_bytes()); + Signature::from_bytes(&bytes, &claim, &[public_key]).unwrap() + }) + .collect(); + (signatures, public_keys, claim) +} + +#[test] +#[ignore = "slow: proves a full 1500-signature leaf"] +fn a_leaf_target_sized_batch_proves() { + let (signatures, public_keys, claim) = cached_batch(LEAF_TARGET); + let aggregate = prove(signatures, &claim).unwrap(); + verify(&aggregate, &public_keys, &claim).unwrap(); +} + +#[test] +#[ignore = "slow: proves two leaves and a root over 1501 signatures"] +fn a_batch_one_past_leaf_target_splits_and_proves() { + let (signatures, public_keys, claim) = cached_batch(LEAF_TARGET + 1); + let aggregate = prove(signatures, &claim).unwrap(); + verify(&aggregate, &public_keys, &claim).unwrap(); +} diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs new file mode 100644 index 00000000..b427848f --- /dev/null +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -0,0 +1,127 @@ +use lean_multisig_api::{ + Claim, ClaimSigners, MultiClaimProof, PublicKey, SecretKey, Signature, aggregate, merge_claims, setup, + verified_signers, verify, verify_claims, +}; +use std::collections::BTreeSet; +use std::sync::Barrier; + +#[test] +fn signatures_and_aggregates_share_one_opaque_api() { + setup(); + let claim = Claim::new([42u8; 32], 100); + let alice = SecretKey::from_seed([1u8; 32], 100..=115).unwrap(); + let bob = SecretKey::from_seed([2u8; 32], 100..=115).unwrap(); + + let alice_signature = alice.sign(&claim).unwrap(); + let bob_signature = bob.sign(&claim).unwrap(); + + let alice_bytes = alice_signature.to_bytes(); + assert_eq!(alice_bytes.len(), 6 + xmss::SIGNATURE_SSZ_LEN); + assert_eq!(alice_bytes[4], 1); + let alice_signature = Signature::from_bytes(&alice_bytes, &claim, &[alice.public_key()]).unwrap(); + let aggregate = aggregate(vec![alice_signature, bob_signature], &claim).unwrap(); + + let _: [u8; 32] = alice.public_key(); + let expected: Vec = vec![alice.public_key(), bob.public_key()]; + let aggregate = Signature::from_bytes(&aggregate.to_bytes(), &claim, &expected).unwrap(); + verify(&aggregate, &expected, &claim).unwrap(); + + assert_eq!( + verified_signers(&aggregate, &claim) + .unwrap() + .into_iter() + .collect::>(), + expected.into_iter().collect() + ); +} + +#[test] +fn multiple_claims_use_context_resolved_from_the_outer_container() { + setup(); + let attestation = Claim::new([0xa1; 32], 100); + let proposal = Claim::new([0xb2; 32], 101); + let alice = SecretKey::from_seed([1; 32], 100..=115).unwrap(); + let bob = SecretKey::from_seed([2; 32], 100..=115).unwrap(); + let proposer = SecretKey::from_seed([3; 32], 100..=115).unwrap(); + + let proof = merge_claims(vec![ + alice.sign(&attestation).unwrap(), + bob.sign(&attestation).unwrap(), + proposer.sign(&proposal).unwrap(), + ]) + .unwrap(); + let groups = [ + ClaimSigners { + claim: attestation, + signers: vec![alice.public_key(), bob.public_key()], + }, + ClaimSigners { + claim: proposal, + signers: vec![proposer.public_key()], + }, + ]; + let proof_bytes = proof.to_bytes(); + assert_eq!(proof_bytes[4], 1); + let proof = MultiClaimProof::from_bytes(&proof_bytes, &groups).unwrap(); + + verify_claims(&proof, &groups).unwrap(); +} + +#[test] +fn a_single_claim_aggregate_can_be_merged_with_another_claim() { + setup(); + let attestation = Claim::new([0xc1; 32], 100); + let proposal = Claim::new([0xd2; 32], 101); + let alice = SecretKey::from_seed([4; 32], 100..=115).unwrap(); + let bob = SecretKey::from_seed([5; 32], 100..=115).unwrap(); + let proposer = SecretKey::from_seed([6; 32], 100..=115).unwrap(); + + let attestation_signature = aggregate( + vec![alice.sign(&attestation).unwrap(), bob.sign(&attestation).unwrap()], + &attestation, + ) + .unwrap(); + let proof = merge_claims(vec![attestation_signature, proposer.sign(&proposal).unwrap()]).unwrap(); + let groups = [ + ClaimSigners { + claim: attestation, + signers: vec![alice.public_key(), bob.public_key()], + }, + ClaimSigners { + claim: proposal, + signers: vec![proposer.public_key()], + }, + ]; + let proof = MultiClaimProof::from_bytes(&proof.to_bytes(), &groups).unwrap(); + + verify_claims(&proof, &groups).unwrap(); +} + +#[test] +fn concurrent_proving_without_the_arena_does_not_panic() { + setup(); + const THREADS: usize = 2; + let barrier = Barrier::new(THREADS); + + std::thread::scope(|scope| { + let handles = (0..THREADS) + .map(|index| { + let barrier = &barrier; + scope.spawn(move || { + let byte = u8::try_from(index + 10).unwrap(); + let slot = u32::try_from(index + 200).unwrap(); + let claim = Claim::new([byte; 32], slot); + let key = SecretKey::from_seed([byte; 32], slot..=slot).unwrap(); + let signature = key.sign(&claim).unwrap(); + + barrier.wait(); + aggregate(vec![signature], &claim).unwrap() + }) + }) + .collect::>(); + + for handle in handles { + handle.join().unwrap(); + } + }); +} diff --git a/crates/rec_aggregation/src/multi_message_aggregation.rs b/crates/rec_aggregation/src/multi_message_aggregation.rs index 40bc0e21..441a6356 100644 --- a/crates/rec_aggregation/src/multi_message_aggregation.rs +++ b/crates/rec_aggregation/src/multi_message_aggregation.rs @@ -18,7 +18,7 @@ use crate::single_message_aggregation::{ extract_merkle_hint_blobs, rebuild_bytecode_claim, verify_single_message_aggregate, }; use crate::verify_inner; -use xmss::XmssPublicKey; +use xmss::{MESSAGE_LEN_BYTES, XmssPublicKey}; /// A bundle of `n` single-message aggregate signatures with potentially distinct (message, slot) per component, attested by a single snark. #[derive(Debug, Clone)] @@ -83,6 +83,52 @@ impl MultiMessageAggregateSignature { }) } + /// Serialize only the cryptographic proof material. Per-component messages, slots, and signer + /// sets must come from the protocol container that carries these bytes. + #[doc(hidden)] + pub fn to_bytes_without_context(&self) -> Vec { + let component_points = self + .info + .iter() + .map(|info| &info.core.bytecode_claim.point) + .collect::>(); + postcard::to_allocvec(&(component_points, &self.bytecode_claim.point, &self.proof)) + .expect("postcard serialization failed") + } + + /// Inverse of [`Self::to_bytes_without_context`]; the caller supplies one protocol context per + /// component, in the same order. Different context makes verification fail. + #[doc(hidden)] + pub fn from_bytes_without_context( + bytes: &[u8], + contexts: Vec<([u8; MESSAGE_LEN_BYTES], u32, Vec)>, + ) -> Option { + let _forbid = parallel::forbid_parallelism(); + let ((component_points, bytecode_claim_point, proof), rest) = + postcard::take_from_bytes::<(Vec>, MultilinearPoint, ExecutionProof)>(bytes) + .ok()?; + if !rest.is_empty() || component_points.len() != contexts.len() { + return None; + } + let info = component_points + .into_iter() + .zip(contexts) + .map(|(point, (message, slot, pubkeys))| { + SingleMessageCore { + message, + slot, + bytecode_claim: rebuild_bytecode_claim(point).ok()?, + } + .with_pubkeys(pubkeys) + }) + .collect::>>()?; + Some(Self { + info, + bytecode_claim: rebuild_bytecode_claim(bytecode_claim_point).ok()?, + proof, + }) + } + pub(crate) fn bytecode_claim_flat(&self) -> Vec { flatten_bytecode_claim(&self.bytecode_claim) } diff --git a/crates/rec_aggregation/src/single_message_aggregation.rs b/crates/rec_aggregation/src/single_message_aggregation.rs index 7efd713c..6f1353b1 100644 --- a/crates/rec_aggregation/src/single_message_aggregation.rs +++ b/crates/rec_aggregation/src/single_message_aggregation.rs @@ -143,6 +143,38 @@ impl SingleMessageAggregateSignature { let info = core.with_pubkeys(pubkeys)?; Some(Self { info, proof }) } + + /// Serialize only the cryptographic proof material. The message, slot, and signer set must + /// come from the protocol container that carries these bytes. + #[doc(hidden)] + pub fn to_bytes_without_context(&self) -> Vec { + postcard::to_allocvec(&(&self.info.core.bytecode_claim.point, &self.proof)) + .expect("postcard serialization failed") + } + + /// Inverse of [`Self::to_bytes_without_context`]; the caller supplies the protocol context. + /// Context different from the one aggregated makes verification fail. + #[doc(hidden)] + pub fn from_bytes_without_context( + bytes: &[u8], + message: [u8; MESSAGE_LEN_BYTES], + slot: u32, + pubkeys: Vec, + ) -> Option { + let _forbid = parallel::forbid_parallelism(); + let ((bytecode_claim_point, proof), rest) = + postcard::take_from_bytes::<(MultilinearPoint, ExecutionProof)>(bytes).ok()?; + if !rest.is_empty() { + return None; + } + let core = SingleMessageCore { + message, + slot, + bytecode_claim: rebuild_bytecode_claim(bytecode_claim_point).ok()?, + }; + let info = core.with_pubkeys(pubkeys)?; + Some(Self { info, proof }) + } } impl SingleMessageInfo { diff --git a/tests/test_multisignatures.rs b/tests/test_multisignatures.rs index c39b2a76..1517fa5a 100644 --- a/tests/test_multisignatures.rs +++ b/tests/test_multisignatures.rs @@ -91,7 +91,8 @@ fn test_single_message_aggregation() { let without_pubkeys = final_sig.to_bytes_without_pubkeys(); assert!(without_pubkeys.len() < serialized_proof.len()); let reattached = - SingleMessageAggregateSignature::from_bytes_without_pubkeys(&without_pubkeys, final_sig.info.pubkeys).unwrap(); + SingleMessageAggregateSignature::from_bytes_without_pubkeys(&without_pubkeys, final_sig.info.pubkeys.clone()) + .unwrap(); verify_single_message_aggregate(&reattached).unwrap(); // A wrong signer set makes verification fail. @@ -99,6 +100,25 @@ fn test_single_message_aggregation() { SingleMessageAggregateSignature::from_bytes_without_pubkeys(&without_pubkeys, vec![signatures[7].0.clone()]) .unwrap(); assert!(verify_single_message_aggregate(&wrong_set).is_err()); + + // Context-free serialization relies on the outer protocol container for all semantics. + let without_context = final_sig.to_bytes_without_context(); + let reattached = SingleMessageAggregateSignature::from_bytes_without_context( + &without_context, + message, + slot, + final_sig.info.pubkeys.clone(), + ) + .unwrap(); + verify_single_message_aggregate(&reattached).unwrap(); + let wrong_context = SingleMessageAggregateSignature::from_bytes_without_context( + &without_context, + [0xff; xmss::MESSAGE_LEN_BYTES], + slot, + final_sig.info.pubkeys, + ) + .unwrap(); + assert!(verify_single_message_aggregate(&wrong_context).is_err()); } #[test] @@ -154,6 +174,16 @@ fn test_multi_message_aggregation() { MultiMessageAggregateSignature::from_bytes_without_pubkeys(&without_pubkeys, pubkeys_per_info).unwrap(); verify_multi_message_aggregate(&reattached).unwrap(); + // Context-free serialization relies on one externally resolved context per component. + let without_context = multi_message.to_bytes_without_context(); + let contexts = multi_message + .info + .iter() + .map(|info| (info.core.message, info.core.slot, info.pubkeys.clone())) + .collect(); + let reattached = MultiMessageAggregateSignature::from_bytes_without_context(&without_context, contexts).unwrap(); + verify_multi_message_aggregate(&reattached).unwrap(); + let time = Instant::now(); let split_a = split_multi_message_aggregate(multi_message.clone(), 0, log_inv_rate).unwrap(); println!("split index 0: {:.2}s", time.elapsed().as_secs_f64());