From cea9fe7bef5bada6ef25cfec3aa12cea264ed697 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 11:58:09 +0100 Subject: [PATCH 01/12] fix(poly): drop wrong packing-width assert on the middle eval slice `base_eval_eq_packed_with_packed_output` asserted `log_packing_width <= eval_points.len()`, but `eval_points` is the *middle* slice produced 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 the packed scalar. Its length is therefore `n - log_packing_width - log_chunks` and has nothing to do with the packing width. The callers only guarantee it is at least 2, while the assert demanded at least `log_packing_width`, so every `n` in `[lpw + log_chunks + 2, 2*lpw + log_chunks)` panicked in debug builds. On a 32-thread AVX512 host that is log_chunks=7, lpw=4, so 13- and 14-variable polynomials aborted `test_packed_eval_eq` and `lean_prover`'s `test_small_memory`. The band is machine-dependent and non-empty on any target with a packing width above 4. The invariant is real, but it belongs to the callers, which already check it against the full point (`compute_eval_eq_base_packed` and `compute_eval_eq_base_packed_batched`). This restores 5cf504a, which removed the same assert for the same reason and was reverted by e45a0ed. It regressed because no test covered the band, so add one that computes the bounds from the runtime thread count and SIMD width rather than hardcoding them. Co-Authored-By: Claude Opus 5 (1M context) --- crates/backend/poly/src/eq_mle.rs | 41 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/backend/poly/src/eq_mle.rs b/crates/backend/poly/src/eq_mle.rs index 3ab98c6e..214138b3 100644 --- a/crates/backend/poly/src/eq_mle.rs +++ b/crates/backend/poly/src/eq_mle.rs @@ -1023,10 +1023,13 @@ fn base_eval_eq_packed_with_packed_output( { // Ensure that the output buffer size is correct: // It should be of size `2^n`, where `n` is the number of variables. - let width = F::Packing::WIDTH; - let log_packing_width = log2_strict_usize(width); + // + // `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()); - debug_assert!(log_packing_width <= eval_points.len()); match eval_points.len() { 0 => { @@ -1320,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}"); + } + } + #[test] fn test_compute_eval_eq_packed_dual() { let packing_width = ::Packing::WIDTH; From 245fcab7586ecce5baef2247c1dc9354c152ca90 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 18:15:40 +0100 Subject: [PATCH 02/12] feat(lean_multisig_api): opinionated facade over XMSS and aggregation Adds `crates/lean_multisig_api`, a facade over `xmss` and `rec_aggregation` exposing signing and single-message aggregation over plain byte slices. Every tuning parameter -- recursion topology and each node's `log_inv_rate` -- is chosen internally, so callers have no knobs. Callers needing the full parameter space keep using `rec_aggregation`. aggregate(proof_or_sig, public_keys, message, slot) -> Vec verify(aggregate, message, slot) -> Vec> verify_with_signers(aggregate, expected, message, slot) SecretKey::{generate, from_seed, sign, public_key, prepare, ...} `proof_or_sig` mixes raw XMSS signatures with prior aggregates, discriminated by length (a signature is exactly SIGNATURE_SSZ_LEN). Aggregates carry their own signer sets, so `public_keys` covers raw entries only and the two vectors are deliberately not index-aligned -- the sharpest edge in the API, documented accordingly. `verify` returns the proved signer set rather than a bool: an aggregate over the wrong validator set is still a valid proof, so ignoring who signed should require discarding a value rather than simply not asking. Five modules: `plan.rs` is a pure tree planner (millisecond tests, no prover), `codec.rs` does length dispatch and pubkey pairing, `key.rs` holds `SecretKey` as an opaque handle so its bottom-subtree cache survives across signatures, `lib.rs` the entry points, `error.rs` one flattened enum. 62 tests: 44 unit, 18 across five integration binaries, plus two `#[ignore]`d boundary tests wired into CI. Three upstream traps are absorbed so callers never meet them: the bytecode `OnceLock` (which panics via `get_aggregation_bytecode` and silently returns `None` from `from_bytes`), the `MAX_XMSS_AGGREGATED` ceiling applying at every node so recursion cannot raise it, and the requirement to serialize proving jobs. `LEAF_TARGET = 1500` is measured rather than inherited: a full leaf proves in ~8s at the slowest rate the planner assigns, and 1501 splits and proves in ~6.7s. The largest leaf that proves is still unknown, so the value is known-good rather than known-optimal. Design and rationale in docs/plans/2026-08-14-lean-sig-facade-design.md; the task breakdown and deferred tuning questions in docs/plans/2026-08-14-lean-sig-implementation.md. The crate was named `lean_sig` while it was built, which is why those filenames and the pre-squash commit scopes say so. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/rust.yml | 17 + .gitignore | 3 +- Cargo.lock | 13 + TODO.md | 10 + crates/lean_multisig_api/Cargo.toml | 26 + crates/lean_multisig_api/src/codec.rs | 247 ++++ crates/lean_multisig_api/src/error.rs | 142 +++ crates/lean_multisig_api/src/key.rs | 389 ++++++ crates/lean_multisig_api/src/lib.rs | 449 +++++++ crates/lean_multisig_api/src/plan.rs | 348 +++++ .../tests/lazy_init_aggregate.rs | 20 + .../tests/lazy_init_verify.rs | 22 + .../tests/lazy_init_verify_with_signers.rs | 16 + crates/lean_multisig_api/tests/round_trip.rs | 525 ++++++++ .../tests/unprovable_child.rs | 90 ++ .../2026-08-14-lean-sig-facade-design.md | 373 ++++++ .../2026-08-14-lean-sig-implementation.md | 1127 +++++++++++++++++ 17 files changed, 3816 insertions(+), 1 deletion(-) create mode 100644 crates/lean_multisig_api/Cargo.toml create mode 100644 crates/lean_multisig_api/src/codec.rs create mode 100644 crates/lean_multisig_api/src/error.rs create mode 100644 crates/lean_multisig_api/src/key.rs create mode 100644 crates/lean_multisig_api/src/lib.rs create mode 100644 crates/lean_multisig_api/src/plan.rs create mode 100644 crates/lean_multisig_api/tests/lazy_init_aggregate.rs create mode 100644 crates/lean_multisig_api/tests/lazy_init_verify.rs create mode 100644 crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs create mode 100644 crates/lean_multisig_api/tests/round_trip.rs create mode 100644 crates/lean_multisig_api/tests/unprovable_child.rs create mode 100644 docs/plans/2026-08-14-lean-sig-facade-design.md create mode 100644 docs/plans/2026-08-14-lean-sig-implementation.md 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..36ea2cbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,6 +1505,19 @@ dependencies = [ "xmss", ] +[[package]] +name = "lean_multisig_api" +version = "0.1.0" +dependencies = [ + "backend", + "ethereum_ssz", + "lean_vm", + "postcard", + "rand 0.10.1", + "rec_aggregation", + "xmss", +] + [[package]] name = "lean_prover" version = "0.1.0" diff --git a/TODO.md b/TODO.md index 5bd14c0e..4a0c0c3c 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,16 @@ - 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. + # 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/lean_multisig_api/Cargo.toml b/crates/lean_multisig_api/Cargo.toml new file mode 100644 index 00000000..c3c39a54 --- /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] +xmss.workspace = true +rec_aggregation.workspace = true +backend.workspace = true +ssz.workspace = true +postcard.workspace = true +rand.workspace = true + +# `tests/unprovable_child.rs` hand-builds an aggregate envelope, which needs the field types its +# wire format is made of. Everything else the tests use comes from `[dependencies]`, which +# integration tests can also see. +[dev-dependencies] +lean_vm.workspace = true +# `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/codec.rs b/crates/lean_multisig_api/src/codec.rs new file mode 100644 index 00000000..fa9dae98 --- /dev/null +++ b/crates/lean_multisig_api/src/codec.rs @@ -0,0 +1,247 @@ +//! Length dispatch and pubkey pairing. +//! +//! `proof_or_sig` deliberately mixes raw XMSS signatures with previously produced aggregates, +//! so a caller can fold an existing aggregate together with fresh signatures. This module +//! splits that vector back apart. + +use crate::Error; +use rec_aggregation::SingleMessageAggregateSignature; +use ssz::Decode; +use xmss::{SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSignature}; + +/// A raw signature paired with the public key that produced it. +type Raw = (XmssPublicKey, XmssSignature); + +/// A raw XMSS signature is exactly `SIGNATURE_SSZ_LEN` bytes; anything else is parsed as an +/// aggregate. Counted once and dispatched on once, so the two cannot drift apart. +/// +/// Drift here would be silent and severe: a predicate that counts more entries than the loop +/// classifies as signatures leaves `signatures` shorter than `pubkeys`, and the `zip` below +/// then truncates and pairs every later signature with the wrong key — a valid proof of the +/// wrong signer set, with no decode error to show for it. +const fn is_raw_signature(entry: &[u8]) -> bool { + entry.len() == SIGNATURE_SSZ_LEN +} + +/// Splits the mixed input vector into raw signatures (paired with their pubkeys) and +/// previously produced aggregates. +/// +/// Entries are classified by length: exactly `SIGNATURE_SSZ_LEN` means a raw signature, +/// anything else is parsed as a postcard aggregate. A correctly sized blob that fails SSZ +/// decode is `MalformedSignature`, never a fallback to the aggregate parser — silent +/// reclassification would surface as a baffling failure much later. +/// +/// Aggregates carry their own signer sets, so `public_keys` covers raw signatures only: +/// the k-th raw entry pairs with `public_keys[k]`. The two vectors are therefore *not* +/// index-aligned whenever an aggregate is present. +/// +/// An empty `proof_or_sig` is `Error::Empty`. The check lives here rather than in the caller +/// because this module owns the input vector, and the planner downstream documents that it +/// expects the empty case to have been rejected already. +/// +/// The index-carrying errors point into *different* vectors, each named by its variant: +/// `MalformedEntry { index }` and `MalformedSignature { index }` index `proof_or_sig`, +/// `MalformedPublicKey { index }` indexes `public_keys`. Reporting a pubkey fault against a +/// `proof_or_sig` position would point the caller at a blob it cannot fix. The two entry +/// faults are kept apart because their remedies differ: a signature-sized blob that fails to +/// decode is damaged data, not data of the wrong kind. +/// +/// Every public key is decoded before any entry is, so when both vectors hold a bad blob the +/// public key is reported whatever the two positions are. That is a stable rule rather than +/// one that shifts with how the two vectors interleave, and it is pinned by test — changing +/// it is therefore a deliberate act rather than a side effect. +/// +/// Both arguments are taken by value and consumed as they are decoded, so the caller's byte +/// buffers (up to tens of megabytes at the signer ceiling) are freed here rather than living +/// on through all the proving that follows. +/// +/// This cannot panic. In particular `SingleMessageAggregateSignature::from_bytes` returns +/// `None` rather than panicking when the aggregation bytecode is uninitialized, which would +/// surface here as `MalformedEntry`; every public entry point calls +/// `init_aggregation_bytecode()` first so that cannot happen. +pub(crate) fn classify( + proof_or_sig: Vec>, + public_keys: Vec>, +) -> Result<(Vec, Vec), Error> { + if proof_or_sig.is_empty() { + return Err(Error::Empty); + } + + let expected = proof_or_sig.iter().filter(|e| is_raw_signature(e.as_slice())).count(); + if expected != public_keys.len() { + return Err(Error::PubkeyCountMismatch { + expected, + got: public_keys.len(), + }); + } + + // `from_ssz_bytes` enforces the fixed length itself, so a short or long blob and one + // holding non-canonical field elements both land on the same variant. + let pubkeys = public_keys + .into_iter() + .enumerate() + .map(|(index, bytes)| XmssPublicKey::from_ssz_bytes(&bytes).map_err(|_| Error::MalformedPublicKey { index })) + .collect::, _>>()?; + + let mut signatures = Vec::with_capacity(expected); + let mut aggregates = Vec::new(); + + for (index, entry) in proof_or_sig.into_iter().enumerate() { + if is_raw_signature(&entry) { + signatures.push(XmssSignature::from_ssz_bytes(&entry).map_err(|_| Error::MalformedSignature { index })?); + } else { + aggregates + .push(SingleMessageAggregateSignature::from_bytes(&entry).ok_or(Error::MalformedEntry { index })?); + } + } + + // `zip` would silently truncate on a length mismatch; the count check above is what makes + // it exact, and it counted `is_raw_signature` — the same function this loop dispatches on. + debug_assert_eq!(pubkeys.len(), signatures.len()); + Ok((pubkeys.into_iter().zip(signatures).collect(), aggregates)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ssz::Encode; + use xmss::{xmss_key_gen_from_seed, xmss_sign}; + + fn sample(seed: u8) -> (Vec, Vec) { + let (pk, sk) = xmss_key_gen_from_seed([seed; 32], 100, 16).unwrap(); + let sig = xmss_sign(&sk, 100, &[7u8; 32]).unwrap(); + (pk.as_ssz_bytes(), sig.as_ssz_bytes()) + } + + #[test] + fn classifies_raw_signatures_by_length() { + let (pk, sig) = sample(1); + assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); + let (raw, aggs) = classify(vec![sig], vec![pk]).unwrap(); + assert_eq!(raw.len(), 1); + assert!(aggs.is_empty()); + } + + #[test] + fn rejects_a_correctly_sized_but_corrupt_signature() { + // A 1208-byte blob that fails SSZ decode is an error rather than a success. This + // cannot show that it was not first tried as an aggregate: that path also returns + // `None` with no bytecode initialized, so a fall-through classifier would produce an + // error here too. Fall-through is ruled out by the if/else in `classify`, not by this. + let (pk, mut sig) = sample(2); + sig[0] = 0xff; + sig[1] = 0xff; + sig[2] = 0xff; + sig[3] = 0xff; // non-canonical field element + assert!(matches!( + classify(vec![sig], vec![pk]), + Err(Error::MalformedSignature { index: 0 }) + )); + } + + #[test] + fn pubkey_count_must_match_raw_count() { + let (pk, sig) = sample(3); + let err = classify(vec![sig.clone(), sig], vec![pk]).unwrap_err(); + assert!(matches!(err, Error::PubkeyCountMismatch { expected: 2, got: 1 })); + } + + #[test] + fn rejects_a_wrong_length_pubkey() { + let (_, sig) = sample(4); + assert!(matches!( + classify(vec![sig], vec![vec![0u8; 8]]), + Err(Error::MalformedPublicKey { index: 0 }) + )); + } + + #[test] + fn rejects_a_right_length_but_non_canonical_pubkey() { + // The other half of what `from_ssz_bytes` covers, and the reason no explicit length + // pre-check is needed here: 0xffffffff exceeds the KoalaBear modulus, so a correctly + // sized blob is still rejected. If canonicality checking is ever lost upstream, this + // is what catches it. + let (_, sig) = sample(9); + assert!(matches!( + classify(vec![sig], vec![vec![0xffu8; xmss::PUB_KEY_SSZ_LEN]]), + Err(Error::MalformedPublicKey { index: 0 }) + )); + } + + #[test] + fn reports_a_malformed_pubkey_ahead_of_a_malformed_signature() { + // Documented ordering: all pubkeys decode before any entry, so the pubkey wins even + // though the corrupt entry sits at a lower position in its own vector. + let (pk, mut sig) = sample(10); + sig[0] = 0xff; + sig[1] = 0xff; + sig[2] = 0xff; + sig[3] = 0xff; + assert!(matches!( + classify(vec![sig.clone(), sig], vec![pk, vec![0xffu8; xmss::PUB_KEY_SSZ_LEN]]), + Err(Error::MalformedPublicKey { index: 1 }) + )); + } + + #[test] + fn pairs_each_pubkey_with_its_own_signature() { + // Counts alone would pass a reversed or off-by-one pairing, which verifies as a proof + // of the wrong signer set rather than as a decode failure. + let (pk_a, sig_a) = sample(5); + let (pk_b, sig_b) = sample(6); + assert_ne!(pk_a, pk_b); + let (raw, _) = classify(vec![sig_a.clone(), sig_b.clone()], vec![pk_a.clone(), pk_b.clone()]).unwrap(); + let expected: Vec = [(pk_a, sig_a), (pk_b, sig_b)] + .into_iter() + .map(|(pk, sig)| { + ( + XmssPublicKey::from_ssz_bytes(&pk).unwrap(), + XmssSignature::from_ssz_bytes(&sig).unwrap(), + ) + }) + .collect(); + assert_eq!(raw, expected); + } + + #[test] + fn too_many_pubkeys_is_rejected() { + // The misuse the module doc predicts: a caller assuming the two vectors ARE + // index-aligned supplies one pubkey per entry, aggregates included. + let (pk_a, sig) = sample(11); + let (pk_b, _) = sample(12); + assert!(matches!( + classify(vec![sig, vec![0u8; 9]], vec![pk_a, pk_b]), + Err(Error::PubkeyCountMismatch { expected: 1, got: 2 }) + )); + } + + #[test] + fn a_non_signature_entry_does_not_consume_a_pubkey() { + // The asymmetry this module exists for: an aggregate carries its own signer set, so + // only raw entries count towards `public_keys.len()`. One raw entry here, so one + // pubkey is expected however many non-raw entries sit beside it. + let (_, sig) = sample(7); + assert!(matches!( + classify(vec![vec![0u8; 9], sig], vec![]), + Err(Error::PubkeyCountMismatch { expected: 1, got: 0 }) + )); + } + + #[test] + fn an_unparseable_non_signature_entry_reports_its_index_in_proof_or_sig() { + // Coverage of the aggregate branch stops here: this only shows that a blob which is no + // aggregate is rejected against its own `proof_or_sig` position (1, not 0, which is + // where it lands among the aggregates). Parsing a *real* aggregate needs a real prover + // and `init_aggregation_bytecode`; that is the integration tests' job. + let (pk, sig) = sample(8); + assert!(matches!( + classify(vec![sig, vec![0u8; 9]], vec![pk]), + Err(Error::MalformedEntry { index: 1 }) + )); + } + + #[test] + fn empty_input_is_rejected() { + assert!(matches!(classify(vec![], vec![]), Err(Error::Empty))); + } +} diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs new file mode 100644 index 00000000..698a7d9b --- /dev/null +++ b/crates/lean_multisig_api/src/error.rs @@ -0,0 +1,142 @@ +use std::fmt::{Display, Formatter}; + +/// Every way a `lean_multisig_api` call can fail. +#[non_exhaustive] +#[derive(Debug)] +pub enum Error { + KeyGen(xmss::XmssKeyGenError), + Sign(xmss::XmssSignatureError), + Verify(xmss::XmssVerifyError), + Aggregation(rec_aggregation::AggregationError), + Proof(backend::ProofError), + /// A `proof_or_sig` entry was not `SIGNATURE_SSZ_LEN` bytes and did not parse as an + /// aggregate. The index is into `proof_or_sig`. + MalformedEntry { + index: usize, + }, + /// A `proof_or_sig` entry was `SIGNATURE_SSZ_LEN` bytes — so it is a signature by the only + /// classification rule there is — but failed to decode: damaged bytes, or non-canonical + /// field elements. The index is into `proof_or_sig`. + MalformedSignature { + index: usize, + }, + /// The bytes handed to `verify` are not a well-formed aggregate. Distinct from + /// [`Self::MalformedEntry`], which names a position in a `proof_or_sig` vector — `verify` + /// takes one blob and has no vector to index into. + MalformedAggregate, + /// A public key blob was not `PUB_KEY_SSZ_LEN` bytes, or held non-canonical field elements. + MalformedPublicKey { + index: usize, + }, + /// Secret key bytes could not be deserialized. + MalformedSecretKey, + /// `public_keys.len()` must equal the number of raw signatures in `proof_or_sig`. + PubkeyCountMismatch { + expected: usize, + got: usize, + }, + /// The deduplicated signer union exceeds `MAX_XMSS_AGGREGATED`. + TooManySigners { + got: usize, + max: usize, + }, + /// `proof_or_sig` was empty. + Empty, + /// An aggregate proves a different (message, slot) than the one supplied: from `verify`, + /// the aggregate under test; from `aggregate`, one of the supplied child aggregates. + MessageMismatch, + /// The proved signer set differs from the expected one. + SignerSetMismatch, +} + +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: xmss::XmssVerifyError) -> Self { + Self::Verify(err) + } +} + +impl From for Error { + fn from(err: rec_aggregation::AggregationError) -> Self { + 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 { + // These carry their cause in `source()`; adding it here too would print it twice + // under any chain-aware reporter. + Self::KeyGen(_) => write!(f, "Key generation failed"), + // Not "Signing failed": `SecretKey::prepare` maps through this variant too, and it + // signs nothing. The wording has to fit every entry point that can raise it. + Self::Sign(_) => write!(f, "XMSS signing operation failed"), + Self::Verify(_) => write!(f, "Signature verification failed"), + Self::Aggregation(_) => write!(f, "Aggregation failed"), + Self::Proof(_) => write!(f, "Proof error"), + Self::MalformedEntry { index } => { + write!(f, "Entry {index} is neither a signature nor an aggregate") + } + Self::MalformedSignature { index } => { + write!(f, "Entry {index} is signature-sized but could not be decoded") + } + Self::MalformedAggregate => write!(f, "The supplied bytes are not a well-formed aggregate"), + Self::MalformedPublicKey { index } => write!(f, "Public key {index} is malformed"), + Self::MalformedSecretKey => write!(f, "Secret key bytes could not be deserialized"), + Self::PubkeyCountMismatch { expected, got } => { + write!(f, "Expected {expected} public keys, got {got}") + } + Self::TooManySigners { got, max } => write!(f, "Too many signers: {got} (max {max})"), + Self::Empty => write!(f, "Nothing to aggregate: no signatures or aggregates were supplied"), + Self::MessageMismatch => { + write!( + f, + "The aggregate proves a different (message, slot) than the one supplied" + ) + } + Self::SignerSetMismatch => write!(f, "The proved signer set differs from the expected one"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::KeyGen(e) => Some(e), + Self::Sign(e) => Some(e), + Self::Verify(e) => Some(e), + Self::Aggregation(e) => Some(e), + Self::Proof(e) => Some(e), + // Spelled out rather than `_`: `#[non_exhaustive]` does not apply inside the + // defining crate, so this match is compile-checked. A new wrapping variant then + // fails to compile here instead of silently truncating the chain. + Self::MalformedEntry { .. } + | Self::MalformedSignature { .. } + | Self::MalformedAggregate + | Self::MalformedPublicKey { .. } + | Self::MalformedSecretKey + | Self::PubkeyCountMismatch { .. } + | Self::TooManySigners { .. } + | Self::Empty + | Self::MessageMismatch + | Self::SignerSetMismatch => 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..f1353fae --- /dev/null +++ b/crates/lean_multisig_api/src/key.rs @@ -0,0 +1,389 @@ +//! The `SecretKey` handle. +//! +//! Every other value crossing this crate's boundary is a `Vec`. This one is not, and that +//! asymmetry is the whole point of the module: see the type's own documentation. + +use crate::Error; +use ssz::Encode; +use std::ops::RangeInclusive; +use xmss::{XmssKeyGenError, XmssSecretKey, xmss_key_gen, xmss_key_gen_from_seed, xmss_sign}; + +// 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. Trailing bytes are rejected rather than ignored so that a + /// key has exactly one encoding. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (key, rest) = postcard::take_from_bytes::(bytes).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. + /// + /// # 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 { + postcard::to_allocvec(&self.0).expect("XmssSecretKey serialization is infallible") + } + + /// The matching public key, SSZ-encoded: exactly `xmss::PUB_KEY_SSZ_LEN` bytes, ready to + /// hand to `aggregate` alongside a signature. + #[must_use] + pub fn public_key(&self) -> Vec { + self.0.public_key().as_ssz_bytes() + } + + /// 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 a 32-byte message at `slot`, returning `xmss::SIGNATURE_SSZ_LEN` SSZ bytes ready + /// for `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 `slot` is outside [`slots`](Self::slots), or if no valid WOTS encoding + /// was found within the attempt budget. + pub fn sign(&self, message: &[u8; 32], slot: u32) -> Result, Error> { + Ok(xmss_sign(&self.0, slot, message)?.as_ssz_bytes()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ssz::Decode; + use xmss::{XmssPublicKey, XmssSignature}; + + #[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. Task 6's `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 sig = sk.sign(&[9u8; 32], 100).unwrap(); + assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); + assert_eq!(sk.public_key().len(), xmss::PUB_KEY_SSZ_LEN); + + let pk = XmssPublicKey::from_ssz_bytes(&sk.public_key()).unwrap(); + let signature = XmssSignature::from_ssz_bytes(&sig).unwrap(); + 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 before = sk.sign(&[4u8; 32], 105).unwrap(); + let restored = SecretKey::from_bytes(&sk.to_bytes()).unwrap(); + assert_eq!(restored.public_key(), sk.public_key()); + assert_eq!(restored.sign(&[4u8; 32], 105).unwrap(), 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 first = sk.sign(&message, 105).unwrap(); + let second = sk.sign(&message, 105).unwrap(); + assert_eq!(first, second); + + // A different message at the same slot must NOT collide; the carve-out is exact. + assert_ne!(sk.sign(&[14u8; 32], 105).unwrap(), 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(&[0u8; 32], 116).is_err()); + assert!(sk.sign(&[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 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() { + // postcard's `from_bytes` stops at the end of the value and ignores whatever follows, + // which would give one key many encodings. `SingleMessageAggregateSignature::from_bytes` + // guards against this with `take_from_bytes`; so does this. + 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..c9ff194a --- /dev/null +++ b/crates/lean_multisig_api/src/lib.rs @@ -0,0 +1,449 @@ +//! An opinionated facade over `xmss` and `rec_aggregation`. +//! +//! Every tuning parameter is chosen internally. Callers needing control over `log_inv_rate` +//! or recursion topology should use `rec_aggregation` directly. +//! +//! # Trusted claims cannot enter through this facade +//! +//! `SingleMessageCore::bytecode_claim` carries a `value` that verification *trusts*, and +//! `rec_aggregation` warns that a claim taken from an untrusted source must be recomputed +//! before use. Every aggregate crossing this boundary is a byte slice, and deserializing one +//! always runs `rebuild_bytecode_claim`, which recomputes that value from the point alone. +//! There is no way to hand this crate a pre-built aggregate struct, so the unsound path is +//! unreachable here by construction rather than by discipline. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod codec; +mod error; +mod key; +mod plan; + +use rec_aggregation::{ + MAX_XMSS_AGGREGATED, SingleMessageAggregateSignature, aggregate_single_message_signatures, + init_aggregation_bytecode, verify_single_message_aggregate, +}; +use ssz::Encode; +use std::borrow::Cow; +use std::collections::BTreeSet; +use xmss::{XmssPublicKey, XmssSignature}; + +pub use error::Error; +pub use key::SecretKey; + +/// A raw signature paired with the public key that produced it. +type Raw = (XmssPublicKey, XmssSignature); + +/// Pays the one-time aggregation-bytecode compile up front. +/// +/// Entirely optional: [`aggregate`], [`verify`] and [`verify_with_signers`] all do this +/// themselves, and it is idempotent, so this only moves *when* the cost lands. A long-running +/// service calls it at startup rather than paying it inside its first real request. +/// +/// # Panics +/// +/// If the bytecode fails to compile. The program source is embedded in the binary, so that is +/// a bug in this workspace rather than anything a caller can provoke. +pub fn warm_up() { + init_aggregation_bytecode(); +} + +/// Aggregates raw XMSS signatures and previously produced aggregates into a single proof, all +/// sharing one `(message, slot)`. +/// +/// `public_keys` covers **raw signatures only** — aggregates carry their own signer sets — so +/// the k-th raw entry of `proof_or_sig` pairs with `public_keys[k]`. The two vectors are +/// therefore *not* index-aligned whenever an aggregate is present. Entries are told apart by +/// length: exactly `xmss::SIGNATURE_SSZ_LEN` bytes is a raw signature, anything else is parsed +/// as an aggregate. +/// +/// The recursion tree, its fan-in, and every `log_inv_rate` are chosen internally. The result +/// is the wire proof, ready for [`verify`] or for feeding back into another `aggregate` call. +/// +/// Repeated signers are dropped: the signer set of the result is the sorted, deduplicated union +/// of the raw pubkeys and every supplied aggregate's pubkeys. Supplying one signer twice is +/// therefore harmless, but wasteful — the same key in two *different* supplied aggregates still +/// costs a duplicate slot at the node that merges them. +/// +/// # Cost +/// +/// Proving is sequential within one call: wall-clock is the **sum** of every node's proving +/// time, with no parallelism across nodes. Seconds per node, at the leaf boundary as much as at +/// small signer counts — measured un-warmed at 19 small nodes in ~8s release and one full +/// 1500-signature leaf in about the same, so an embedder that has called +/// `lean_multisig::setup_prover_without_arena` may see different numbers. No progress reporting, +/// no way to resume. +/// +/// Concurrency across calls is a property of the *process*, not of this crate. Two `aggregate` +/// calls on different threads are safe only while nothing has engaged `zk_alloc`'s arena. An +/// application calling `lean_multisig::setup_prover` engages it, after which `rec_aggregation` +/// asserts that proving phases neither nest nor overlap and the losing call **panics**. The same +/// code in a `lean_multisig_api`-only harness runs fine because the arena is never engaged there — that +/// is an artefact of the harness, not a guarantee. Serialize `aggregate` calls unconditionally. +/// +/// # Errors +/// +/// - [`Error::Empty`] if `proof_or_sig` is empty. +/// - [`Error::PubkeyCountMismatch`] if `public_keys.len()` is not the number of raw entries. +/// - [`Error::MalformedSignature`], [`Error::MalformedEntry`], [`Error::MalformedPublicKey`] +/// for a blob that does not decode; the index names the vector its variant documents. +/// - [`Error::MessageMismatch`] if a supplied aggregate proves a different `(message, slot)`. +/// - [`Error::TooManySigners`] if the deduplicated union exceeds `MAX_XMSS_AGGREGATED` (32768). +/// Recursion does not raise that ceiling — it is re-checked at every node including the root, +/// so the tree exists to get past the ~1500 signatures one node can prove, not past 32768. +/// - [`Error::Proof`] if a supplied aggregate's proof does not verify. Every child is checked, +/// including the lone-aggregate case that is passed straight through: a successful return +/// always means the bytes handed back are a valid aggregate. +/// - [`Error::Aggregation`] or [`Error::Proof`] if a proving job fails. A raw signature that does +/// not verify under the public key it was paired with — the shape a misordered `public_keys` +/// produces, since the counts still match and both blobs still decode — surfaces here as a bare +/// constraint-system mismatch carrying no index. Check the ordering first. +/// +/// Everything except the last two is decided before any proving starts, so a malformed or +/// over-capacity request fails in milliseconds rather than after the whole tree has been proved. +/// +/// The two faults a *supplied aggregate* can raise — [`Error::MessageMismatch`] and +/// [`Error::Proof`] — carry no index, so a caller passing several aggregates learns that one of +/// them is bad but not which, and has to bisect. Naming one cheaply would mean indexing the +/// filtered aggregate list rather than `proof_or_sig`, which is a third index space +/// contradicting the rule above; pointing at `proof_or_sig` needs `classify` to carry original +/// positions. +/// +/// # Panics +/// +/// If another proving job is already running in this process *and* something has engaged +/// `zk_alloc`'s arena — see the concurrency paragraph under [Cost](#cost) for why that condition +/// is not automatic, and why it is no reason to leave calls unserialized. Also if the aggregation +/// bytecode fails to compile — see [`warm_up`]. +pub fn aggregate( + proof_or_sig: Vec>, + public_keys: Vec>, + message: [u8; 32], + slot: u32, +) -> Result, Error> { + // Before `classify`, which parses aggregates: `from_bytes` silently returns `None` with the + // bytecode uninitialized, so a valid aggregate would come back as `MalformedEntry`. + init_aggregation_bytecode(); + let (mut raw, children) = codec::classify(proof_or_sig, public_keys)?; + + // A supplied aggregate over some other (message, slot) is caught here rather than by + // `aggregate_single_message_signatures`, which only sees it at a node that consumes it. + // For a lone aggregate the plan is a `Passthrough` and no node ever consumes it, so + // without this check that call would return an aggregate over the wrong message as a + // *success*. Other shapes reach upstream's own check eventually — immediately if every + // sibling is a passthrough, but only after proving them if any sibling is a node this call + // has to prove first. Checking the flat vector here covers every child wherever the + // planner later puts it. + if children + .iter() + .any(|c| c.info.core.message != message || c.info.core.slot != slot) + { + return Err(Error::MessageMismatch); + } + + dedup_signers(&mut raw); + + // Reject over-capacity before proving anything: failing after the whole tree is cruel. + // Held by reference — cloning 32768 public keys to count them would be its own small waste. + let mut signers: BTreeSet<&XmssPublicKey> = raw.iter().map(|(pk, _)| pk).collect(); + signers.extend(children.iter().flat_map(|c| c.info.pubkeys.iter())); + let got = signers.len(); + if got > MAX_XMSS_AGGREGATED { + return Err(Error::TooManySigners { + got, + max: MAX_XMSS_AGGREGATED, + }); + } + + // The plan is built here, from these exact lengths, and never accepted from outside: its + // ranges and passthrough indices are bare `usize`s into the two slices below, with nothing + // at type level tying them together. + let tree = plan::plan(raw.len(), children.len()); + + // A lone supplied aggregate is planned as a `Passthrough` and consumed by nothing, so this + // is the one shape where no node verifies the child's proof — `classify` only decodes the + // envelope. Every other shape gets it free from `aggregate_single_message_signatures`, + // which verifies each child before folding it in. Without this, `aggregate` returns `Ok` + // for a peer's structurally intact but unprovable aggregate and the caller re-gossips it. + // + // At the root rather than in `execute`'s `Passthrough` arm: the planner puts *every* + // supplied child into the pool as a passthrough, so that arm would fire for all of them and + // each would then be verified a second time by the node that consumes it — 32 verifications + // for 16 supplied aggregates. Only the lone case is unconsumed, and the tree says which + // case this is before any of it runs. + if let plan::Plan::Passthrough(i) = tree { + verify_single_message_aggregate(&children[i])?; + } + Ok(execute(&tree, &raw, &children, message, slot)?.to_bytes()) +} + +/// Drops repeat signers, keeping the earliest signature offered for each public key. +/// +/// Every node deduplicates its own share anyway, so the *signer set* of the result is the same +/// either way. What this adds is that duplicates landing in two different leaves — which survive +/// the per-node dedup and reappear as `dup_pub_keys` at the node merging them — cost neither a +/// wasted leaf slot nor a second ceiling (`MAX_XMSS_DUPLICATES`) to blow through at the very top +/// of the tree, with everything below it already proved. +/// +/// It is not *entirely* result-preserving: where one key is offered twice with different +/// signature bytes and the two would have landed in different leaves, upstream would have proved +/// both and this proves only the first. That is a laxening rather than a soundness hole — the +/// surviving signature is still proved, and the signer set is unchanged. +/// +/// The sort is stable and the dedup drops the later of each equal pair, so "earliest" means +/// earliest in the caller's `proof_or_sig` order. This is exactly what upstream does per node. +fn dedup_signers(raw: &mut Vec) { + raw.sort_by(|(a, _), (b, _)| a.cmp(b)); + raw.dedup_by(|(a, _), (b, _)| a == b); +} + +/// Proves one node of the plan, depth first, recursing into its children first. +/// +/// Returns `Cow` so that a plan which is nothing but a `Passthrough` — a lone supplied +/// aggregate, re-encoded — does not clone a whole `ExecutionProof` on the way out. A +/// passthrough *under* a node still has to be cloned, because +/// `aggregate_single_message_signatures` wants a contiguous slice of owned children. +/// +/// Recursion depth is not a stack concern. `dedup_signers` and the ceiling check together cap +/// `raw.len()` at 32768 before planning, so raw contributes at most 22 leaves; the pool is +/// otherwise supplied children, and the planner folds at a fan-in of 16, so reaching depth `d` +/// needs 16^(d-1) of them. Each is a decoded `ExecutionProof`, so a pathological input runs out +/// of addressable memory several levels before it runs out of stack. +/// +/// Indexing `raw` and `children` cannot panic: `plan` was called with exactly these two +/// lengths, and it covers every index of both exactly once (pinned by its own tests). +fn execute<'a>( + node: &plan::Plan, + raw: &[Raw], + children: &'a [SingleMessageAggregateSignature], + message: [u8; 32], + slot: u32, +) -> Result, Error> { + match node { + // Proof checking is not done here. A passthrough under a node is verified by that node, + // and the one passthrough with no node above it — a lone supplied aggregate — is + // verified by `aggregate` before this is ever called. + plan::Plan::Passthrough(i) => Ok(Cow::Borrowed(&children[*i])), + plan::Plan::Node { + raw: range, + children: kids, + log_inv_rate, + } => { + let proved: Vec = kids + .iter() + .map(|kid| execute(kid, raw, children, message, slot).map(Cow::into_owned)) + .collect::>()?; + Ok(Cow::Owned(aggregate_single_message_signatures( + &proved, + raw[range.clone()].to_vec(), + message, + slot, + *log_inv_rate, + )?)) + } + } +} + +/// Verifies an aggregate and returns the signer set it actually proves, as SSZ-encoded public +/// keys. +/// +/// The signer set is the success value rather than an input on purpose: an aggregate over the +/// wrong validator set is still a perfectly valid proof, so a `bool` return would invite +/// `if verify(..)` while the caller forgets to check *who* signed. Returning the set instead +/// means a caller who ignores who signed has to discard a value to do it, rather than simply +/// not asking. It is not a guarantee — `verify(..)?;` still compiles, because after `?` the +/// type is a plain `Vec>` and nothing on it is `#[must_use]` — which is why +/// [`verify_with_signers`] exists for the common case where the expected set is already known. +/// +/// Keys come back in the library's canonical sorted order, not the order they were aggregated +/// in, and without duplicates. Compare as a set. +/// +/// # Errors +/// +/// - [`Error::MalformedAggregate`] if the bytes are not a well-formed aggregate, including +/// trailing bytes after a complete one. +/// - [`Error::MessageMismatch`] if the aggregate proves a different `(message, slot)`. This is +/// not implied by the proof check below, which validates the aggregate against its *own* +/// message and slot; it is what binds the proof to the pair you asked about. +/// - [`Error::Proof`] if the proof does not verify. +/// +/// # Panics +/// +/// If the aggregation bytecode fails to compile — see [`warm_up`], which is the only way this +/// can happen. Verification runs no proving job of its own, so unlike [`aggregate`] it never +/// takes the process-wide arena phase. +#[must_use = "an aggregate proves nothing until you check who signed it"] +pub fn verify(aggregate: &[u8], message: &[u8; 32], slot: u32) -> Result>, Error> { + // Before parsing: `from_bytes` returns `None` rather than panicking with the bytecode + // uninitialized, which would report a perfectly good aggregate as malformed. + init_aggregation_bytecode(); + let sig = SingleMessageAggregateSignature::from_bytes(aggregate).ok_or(Error::MalformedAggregate)?; + if &sig.info.core.message != message || sig.info.core.slot != slot { + return Err(Error::MessageMismatch); + } + verify_single_message_aggregate(&sig)?; + Ok(sig.info.pubkeys.iter().map(Encode::as_ssz_bytes).collect()) +} + +/// [`verify`], checking the proved signer set against one already known. +/// +/// Both sides are compared as sets, so the order of `expected` is irrelevant and repeats in it +/// are ignored — `[a, a]` matches a proof of `{a}`. +/// +/// # Errors +/// +/// As [`verify`], plus [`Error::SignerSetMismatch`] if the proved set is not exactly +/// `expected`. A signer missing from `expected` fails just as loudly as an unexpected one. +/// +/// Entries of `expected` are compared as opaque bytes and never decoded, so one that is not a +/// `xmss::PUB_KEY_SSZ_LEN` SSZ public key is not reported as malformed input — it simply +/// matches nothing, and surfaces as [`Error::SignerSetMismatch`] like any other wrong set. +pub fn verify_with_signers(aggregate: &[u8], expected: &[Vec], message: &[u8; 32], slot: u32) -> Result<(), Error> { + // Inherits the bytecode initialization from `verify`, which is the first thing this calls + // and which initializes before it parses anything. + let proved = verify(aggregate, message, slot)?; + let proved: BTreeSet<&[u8]> = proved.iter().map(Vec::as_slice).collect(); + let expected: BTreeSet<&[u8]> = expected.iter().map(Vec::as_slice).collect(); + if proved == expected { + Ok(()) + } else { + Err(Error::SignerSetMismatch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ssz::Decode; + + const MSG: [u8; 32] = [42u8; 32]; + const SLOT: u32 = 100; + + /// A distinct, well-formed, entirely fictitious public key. + /// + /// `classify` checks SSZ length and field-element canonicality, never that a key belongs to + /// anybody, and the ceiling counts distinct keys — so a real keygen (milliseconds each, and + /// 32769 of them here) buys nothing these tests need. A small index in the leading field + /// element is canonical for every index these tests use. + fn synthetic_pubkey_bytes(index: u32) -> Vec { + let mut bytes = vec![0u8; xmss::PUB_KEY_SSZ_LEN]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + bytes + } + + fn synthetic_pubkey(index: u32) -> XmssPublicKey { + XmssPublicKey::from_ssz_bytes(&synthetic_pubkey_bytes(index)).unwrap() + } + + /// A decodable signature that is not a valid one. Only its identity matters here. + fn synthetic_signature(tag: u8) -> XmssSignature { + let mut bytes = vec![0u8; xmss::SIGNATURE_SSZ_LEN]; + bytes[0] = tag; + XmssSignature::from_ssz_bytes(&bytes).unwrap() + } + + #[test] + fn aggregate_rejects_empty_input() { + // Returns before the planner, so no prover is involved. + assert!(matches!(aggregate(vec![], vec![], MSG, SLOT), Err(Error::Empty))); + } + + #[test] + fn aggregate_rejects_a_pubkey_count_mismatch() { + // A zero-filled blob of signature length classifies as raw on length alone, and the + // count check fires before anything is decoded — so this needs no real signature. + let entry = vec![0u8; xmss::SIGNATURE_SSZ_LEN]; + assert!(matches!( + aggregate(vec![entry], vec![], MSG, SLOT), + Err(Error::PubkeyCountMismatch { expected: 1, got: 0 }) + )); + } + + #[test] + fn aggregate_rejects_more_signers_than_the_ceiling() { + // Trap #2: the ceiling recursion does *not* raise. `aggregate_single_message_signatures` + // re-checks it at every node including the root, so a tree buys capacity past the ~1500 + // signatures one node can prove, and nothing past 32768 signers. This is the check that + // turns "fails after the whole tree is proved" into "fails in milliseconds", so it needs + // pinned by something. Costs about 3s on top of the shared bytecode compile. + // + // Only the rejecting side of the boundary is testable: exactly 32768 signers gets *past* + // this check and straight into proving 22 leaves, so no test at any tier can assert the + // accepting side cheaply. + let n = MAX_XMSS_AGGREGATED + 1; + let entries = vec![vec![0u8; xmss::SIGNATURE_SSZ_LEN]; n]; + let pubkeys: Vec> = (0..u32::try_from(n).unwrap()).map(synthetic_pubkey_bytes).collect(); + assert!(matches!( + aggregate(entries, pubkeys, MSG, SLOT), + Err(Error::TooManySigners { got, max }) if got == n && max == MAX_XMSS_AGGREGATED + )); + } + + #[test] + fn dedup_keeps_the_earliest_signature_offered_for_a_key() { + // The property `dedup_signers` documents, and previously only claimed in a comment: a + // stable sort plus `dedup_by` (which drops the *later* of each equal pair) means the + // survivor is the earliest in the caller's order, matching what upstream does per node. + // Reachable only through `aggregate`, and so only at proving cost, until it was extracted. + let repeated = synthetic_pubkey(1); + let other = synthetic_pubkey(2); + let (first, second) = (synthetic_signature(1), synthetic_signature(2)); + assert_ne!(first, second, "the two signatures must be distinguishable"); + + let mut raw = vec![ + (other.clone(), second.clone()), + (repeated.clone(), first.clone()), + (repeated.clone(), second.clone()), + ]; + dedup_signers(&mut raw); + + assert_eq!(raw.len(), 2, "the repeated key must collapse to one entry"); + let kept = raw + .iter() + .find(|(pk, _)| *pk == repeated) + .expect("the key must survive"); + assert_eq!(kept.1, first, "the earliest signature offered must be the survivor"); + // The other key is untouched, so dedup is not merely truncating. + let kept_other = raw.iter().find(|(pk, _)| *pk == other).expect("the key must survive"); + assert_eq!(kept_other.1, second); + } + + #[test] + fn dedup_leaves_distinct_signers_alone() { + let mut raw: Vec = (0..8).map(|i| (synthetic_pubkey(i), synthetic_signature(1))).collect(); + dedup_signers(&mut raw); + assert_eq!(raw.len(), 8); + } + + #[test] + fn verify_rejects_garbage() { + // Not signature-length, not a postcard aggregate: rejected before any proof work. + assert!(matches!( + verify(&[0xffu8; 64], &MSG, SLOT), + Err(Error::MalformedAggregate) + )); + } + + #[test] + fn verify_rejects_empty_bytes() { + assert!(matches!(verify(&[], &MSG, SLOT), Err(Error::MalformedAggregate))); + } + + #[test] + fn warm_up_is_idempotent() { + // The bytecode lives in a `OnceLock`; a second init must be a no-op, not a panic. + warm_up(); + warm_up(); + } + + #[test] + fn every_entry_point_survives_without_an_explicit_warm_up() { + // This pins only that none of the three entry points panics on a caller that never + // called `warm_up`. It cannot pin that they *initialize* the bytecode: unit tests share + // a process, so by the time this runs another test has very likely filled the + // `OnceLock` already. That half is pinned by `tests/lazy_init_*.rs`, one file per entry + // point so that each gets a process where it is the first thing to run. + assert!(aggregate(vec![], vec![], MSG, SLOT).is_err()); + assert!(verify(&[0xffu8; 64], &MSG, SLOT).is_err()); + assert!(verify_with_signers(&[0xffu8; 64], &[], &MSG, SLOT).is_err()); + } +} diff --git a/crates/lean_multisig_api/src/plan.rs b/crates/lean_multisig_api/src/plan.rs new file mode 100644 index 00000000..9d6f3f2c --- /dev/null +++ b/crates/lean_multisig_api/src/plan.rs @@ -0,0 +1,348 @@ +//! 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. Taken from `src/main.rs`'s tuned topology (leaves of 508..1550): +/// 1500 sits just under the largest leaf that topology proves successfully (1550); the 2^22 +/// table height is the underlying bound but has not been computed against. See the design +/// doc's open questions: this wants measuring. +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; + +/// 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 + /// open as a Task 6 tuning question. + 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; a Task 6 tuning question. + 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/tests/lazy_init_aggregate.rs b/crates/lean_multisig_api/tests/lazy_init_aggregate.rs new file mode 100644 index 00000000..be52e2e2 --- /dev/null +++ b/crates/lean_multisig_api/tests/lazy_init_aggregate.rs @@ -0,0 +1,20 @@ +//! `aggregate` must initialize the aggregation bytecode itself, before `codec::classify` runs. +//! +//! In its own file so that it owns its process; see `lazy_init_verify.rs` for why that is the +//! only arrangement that can observe a `OnceLock`. +//! +//! The ordering matters here as much as the call: `classify` parses supplied aggregates, and +//! with the lock empty every one of them decodes as `None` and is reported as +//! `Error::MalformedEntry`. Initializing after `classify` would therefore look perfectly correct +//! to every test that supplies no aggregate. + +#[test] +fn aggregate_initializes_the_bytecode() { + // Empty input, so this returns `Error::Empty` from inside `classify` — before the planner + // and before any proving. That early return is exactly what makes this discriminating: an + // `init` placed after `classify` would never run here. + let _ = lean_multisig_api::aggregate(vec![], vec![], [0u8; 32], 0); + + // Panics if the lock is still empty. + let _ = rec_aggregation::get_aggregation_bytecode(); +} diff --git a/crates/lean_multisig_api/tests/lazy_init_verify.rs b/crates/lean_multisig_api/tests/lazy_init_verify.rs new file mode 100644 index 00000000..d16fb9e0 --- /dev/null +++ b/crates/lean_multisig_api/tests/lazy_init_verify.rs @@ -0,0 +1,22 @@ +//! `verify` must initialize the aggregation bytecode itself. +//! +//! This file exists to get its own process. The bytecode lives in a `OnceLock`, so only the +//! *first* call in a process can observe whether an entry point initializes it; any test sharing +//! a process with another entry point — or with `warm_up` — finds the lock already filled and +//! proves nothing. One file per entry point is the only way to keep each one first, which is why +//! these are three near-identical files rather than one with three tests. +//! +//! Getting this wrong is invisible until it is expensive: with the lock empty, +//! `SingleMessageAggregateSignature::from_bytes` silently returns `None`, so a perfectly good +//! aggregate is reported as malformed by the first `verify` in a fresh process and by no other. + +#[test] +fn verify_initializes_the_bytecode() { + // Garbage in, so this returns `Err` long before any proof work. The return value is not the + // point; what happens to the `OnceLock` on the way is. + let _ = lean_multisig_api::verify(&[0xffu8; 64], &[0u8; 32], 0); + + // The assertion. `get_aggregation_bytecode` panics when the lock is empty, so this fails + // loudly if `verify` ever stops initializing, or starts doing it after it parses. + let _ = rec_aggregation::get_aggregation_bytecode(); +} diff --git a/crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs b/crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs new file mode 100644 index 00000000..296a3489 --- /dev/null +++ b/crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs @@ -0,0 +1,16 @@ +//! `verify_with_signers` must initialize the aggregation bytecode too. +//! +//! In its own file so that it owns its process; see `lazy_init_verify.rs` for why. +//! +//! It has no `init` call of its own — it inherits one by delegating to `verify` before it +//! touches anything else. That is a claim about a call this function makes first, not a +//! property of its own body, so it is worth pinning separately: reordering +//! `verify_with_signers` to do any parsing of its own before delegating would break it silently. + +#[test] +fn verify_with_signers_initializes_the_bytecode() { + let _ = lean_multisig_api::verify_with_signers(&[0xffu8; 64], &[], &[0u8; 32], 0); + + // Panics if the lock is still empty. + let _ = rec_aggregation::get_aggregation_bytecode(); +} 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..4117ed40 --- /dev/null +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -0,0 +1,525 @@ +//! The only tests that run a real aggregation. +//! +//! Everything else in this crate's suite stops at an early return, a pure function, or a +//! hand-built fixture; these produce genuine proofs and feed them back through the public API. +//! That makes them the sole home of several claims the cheaper tiers cannot even phrase — the +//! mixed raw+aggregate input path, the passthrough of a *valid* aggregate, and the discrimination +//! between `MalformedSignature` and `MalformedEntry` — each marked below. +//! +//! # Cost +//! +//! Proving is the entire runtime, so this file spends it deliberately: one 2-signer aggregate is +//! built once and shared by every test that only needs *some* valid aggregate. Two signers is as +//! structurally meaningful as two hundred for everything asserted here, and proportionally +//! cheaper. Seven of the twelve default tests run no proving job at all — four never call +//! `aggregate`, and three more are rejected before the planner or planned as a passthrough — so +//! the binary's ten-odd seconds belongs to the other five, most of it to +//! `a_multi_level_tree_round_trips`. +//! +//! Release, in practice: measured at 10-12s with `--release` against 317s without, on the same +//! machine. Both pass, so an unoptimized run is a patience problem rather than a broken one. CI +//! runs `cargo test --release --all` (`.github/workflows/rust.yml`), so release is the figure CI +//! pays and debug is what a local `cargo test` costs. +//! +//! # Why the two boundary tests are `#[ignore]`d +//! +//! `a_leaf_target_sized_batch_proves` and `a_batch_one_past_leaf_target_splits_and_proves` prove +//! real 1500- and 1501-signature batches, to check that `plan::LEAF_TARGET` is a leaf size the +//! prover accepts rather than a number inherited from a benchmark topology. They are the only +//! tests here whose *size* is the point rather than an incidental cost. +//! +//! Not gated because they are expensive in CI, which they are not. ~12s of proving in a job that +//! already spends minutes on a release build, and the 10,000-signer cache they load is already +//! paid for: `tests/test_multisignatures.rs` calls `get_benchmark_signatures` from tests that are +//! *not* ignored, so every CI run generates or loads it before this file is reached. The marginal +//! cost is the proving alone. +//! +//! **CI runs them.** `.github/workflows/rust.yml` has an `Ignored slow tests` step immediately +//! after `Test`, in the same job so it inherits the matrix condition and `SIGNERS_CACHE_DIR`. It +//! names this binary explicitly rather than passing `--include-ignored`, which would also run six +//! unrelated ignored tests elsewhere in the workspace, several of them benchmarks. So `#[ignore]` +//! here means "not in a local `cargo test`", not "unverified". +//! +//! They stay gated to keep *local* iteration bearable, where the debug suite already costs 317s +//! without them. Run them by hand after touching the planner: +//! +//! ```text +//! cargo test --release -p lean_multisig_api --test round_trip -- --ignored +//! ``` +//! +//! No test here calls [`lean_multisig_api::warm_up`]: the lazy initialization inside each entry point is +//! what the whole file leans on, and `tests/lazy_init_*.rs` is where that is pinned per entry +//! point in a process each owns. + +use lean_multisig_api::{Error, SecretKey, aggregate, verify, verify_with_signers}; +use std::collections::BTreeSet; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +const MSG: [u8; 32] = [42u8; 32]; +const SLOT: u32 = 100; + +/// Serializes proving across the test binary's threads. +/// +/// Not currently load-bearing: `zk_alloc`'s phase assertion — the one that panics when two +/// proving jobs overlap — is a no-op until `enable_arena`, which only `lean-multisig`'s +/// `setup_prover` calls, and nothing in `lean_multisig_api`'s dependency path does. So concurrent +/// `aggregate` calls would today run rather than panic. They would still be two provers fighting +/// over the machine, and the day `lean_multisig_api` (or anything under it) engages the arena the failure +/// mode is a panic in whichever test loses the race. Following `tests/test_multisignatures.rs`'s +/// `ARENA_TEST_LOCK` precedent costs nothing and removes the question. +static PROVE_LOCK: Mutex<()> = Mutex::new(()); + +/// The shared 2-signer aggregate over `(MSG, SLOT)`, proved at most once per process. +/// +/// Tests are independent in what they *assert* — this is a fixture, not a channel between them — +/// but sharing it means one proving job instead of six. +static BASE: OnceLock> = OnceLock::new(); + +/// `n` distinct keys, seeded `0..n`, each active over `SLOT`. +/// +/// One-off keys elsewhere in this file are seeded from 200 up, deliberately disjoint from these. +/// XMSS is stateful: signing two *different* messages at one slot with one key leaks that slot's +/// WOTS key, and this file signs a second message in +/// `a_lone_aggregate_over_another_message_or_slot_is_rejected`. Nothing here would fail if the +/// seeds collided — the proofs would still verify — which is exactly why the separation has to be +/// deliberate rather than noticed later by a reader copying the pattern. +fn signers(n: u8) -> Vec { + (0..n) + .map(|i| SecretKey::from_seed([i; 32], 100..=115).unwrap()) + .collect() +} + +/// A key belonging to no `signers` set. See that function for why the ranges are kept apart. +fn lone_key(seed: u8) -> SecretKey { + assert!(seed >= 200, "one-off seeds live at 200 and up"); + SecretKey::from_seed([seed; 32], 100..=115).unwrap() +} + +/// Runs one aggregation with the prover to itself. +/// +/// The lock is taken and released entirely inside this function. Nothing else acquires it, so no +/// caller can be holding it when it blocks on `BASE`'s initialization — which is exactly the +/// cycle that would deadlock, since `base` proves while holding the `OnceLock`. +fn prove(entries: Vec>, pubkeys: Vec>, message: [u8; 32], slot: u32) -> Result, Error> { + let _guard: MutexGuard<'_, ()> = PROVE_LOCK.lock().unwrap(); + aggregate(entries, pubkeys, message, slot) +} + +/// The two public keys `base` proves, in the caller's order (aggregation sorts them; compare as +/// sets). +fn base_pubkeys() -> Vec> { + signers(2).iter().map(SecretKey::public_key).collect() +} + +/// A real aggregate over `(MSG, SLOT)` signed by `base_pubkeys()`. +fn base() -> &'static [u8] { + BASE.get_or_init(|| { + let keys = signers(2); + let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); + let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); + prove(sigs, pks, MSG, SLOT).expect("aggregating two honest signatures must succeed") + }) +} + +/// The proved signer set as a set, since `verify` returns the library's canonical sorted order +/// rather than the order anything was aggregated in. +fn proved_set(aggregate_bytes: &[u8]) -> BTreeSet> { + verify(aggregate_bytes, &MSG, SLOT).unwrap().into_iter().collect() +} + +#[test] +fn aggregate_then_verify_returns_the_signer_set() { + // The end-to-end claim the whole crate exists to make: keys sign, `aggregate` proves, and + // `verify` reports exactly who signed. Everything below is a variation on this failing. + let pks = base_pubkeys(); + let agg = base(); + + assert_eq!(proved_set(agg), pks.iter().cloned().collect::>()); + + // The same claim through the API that checks the set for you. + verify_with_signers(agg, &pks, &MSG, SLOT).unwrap(); +} + +#[test] +fn verify_rejects_the_wrong_message_and_slot() { + // The proof is valid; it just does not prove what is being asked about. This is the check + // that binds an aggregate to a `(message, slot)` — `verify_single_message_aggregate` on its + // own validates the aggregate against its *own* pair and would happily pass. + let agg = base(); + assert!(matches!(verify(agg, &[0u8; 32], SLOT), Err(Error::MessageMismatch))); + assert!(matches!(verify(agg, &MSG, SLOT + 1), Err(Error::MessageMismatch))); + assert!(matches!( + verify_with_signers(agg, &base_pubkeys(), &[0u8; 32], SLOT), + Err(Error::MessageMismatch) + )); +} + +#[test] +fn verify_rejects_a_tampered_proof() { + // A real proof with one byte changed, in the two classes that turn out to exist. Which class + // a mutation falls into is decided by postcard, not by the prover: field elements are LEB128 + // varints, so most edits move a continuation bit and desynchronize every value after it. + // + // Measured by hand over 64 evenly spaced offsets — that probe is not committed; the sweep at + // the end of this test is a different, smaller one of 15 offsets and 30 mutations. `^= 0xff` + // gives `MalformedAggregate` at 63 of the 64 and never reaches the proof check (the odd one + // out is byte 0, the message's first byte, which gives `MessageMismatch`). `^= 0x01` gives + // `Error::Proof` at all 63 and `MalformedAggregate` at none. Neither mask is ever accepted at + // any offset. + // + // Even spacing tops out at `len * 63/64`, so the probe never lands on the last byte — which + // is precisely the byte the class-1 assertion below flips. That assertion is its own evidence; + // the sweep above it is not. + let agg = base(); + + // Class 1: framing broken, so the blob stops being an aggregate before any proof work. + let mut framing = agg.to_vec(); + *framing.last_mut().unwrap() ^= 0xff; + let result = verify(&framing, &MSG, SLOT); + assert!( + matches!(result, Err(Error::MalformedAggregate)), + "a wholesale byte flip breaks postcard framing, got {result:?}" + ); + + // Class 2: the interesting one. Flipping the *low* bit preserves every varint's length and + // leaves the field element canonical, so the aggregate parses and the proof itself is what + // rejects it. This is the only place a genuine `Error::Proof` comes out of a real proof — + // `tests/unprovable_child.rs` reaches that variant with a hand-built empty transcript, which + // says nothing about what a prover actually emits. + let mut tampered = agg.to_vec(); + tampered[agg.len() / 2] ^= 0x01; + let result = verify(&tampered, &MSG, SLOT); + assert!( + matches!(result, Err(Error::Proof(_))), + "a framing-preserving edit must reach the proof check, got {result:?}" + ); + + // The claim both classes serve: no single-byte change anywhere is *accepted*. Verify-only, so + // this sweep is milliseconds. If the encoding ever drifts and class 2 stops reaching the proof + // check, this still holds and the assertion above is what fails — which is the right place to + // find out. + for k in 1..16 { + let offset = agg.len() * k / 16; + for mask in [0x01u8, 0xff] { + let mut bytes = agg.to_vec(); + bytes[offset] ^= mask; + let result = verify(&bytes, &MSG, SLOT); + assert!( + result.is_err(), + "flipping {mask:#04x} at byte {offset} verified: {result:?}" + ); + } + } +} + +#[test] +fn verify_rejects_a_different_expected_signer_set() { + // `verify_with_signers` exists so a caller cannot forget to check *who* signed; a set that + // is merely plausible must fail as loudly as garbage. + let agg = base(); + let pks = base_pubkeys(); + let outsider = lone_key(200).public_key(); + + // One real signer swapped for someone who never signed. + assert!(matches!( + verify_with_signers(agg, &[pks[0].clone(), outsider.clone()], &MSG, SLOT), + Err(Error::SignerSetMismatch) + )); + // A subset fails too: a signer missing from `expected` is as wrong as an extra one. + assert!(matches!( + verify_with_signers(agg, &[pks[0].clone()], &MSG, SLOT), + Err(Error::SignerSetMismatch) + )); + // And a superset, which is the shape a caller checking "did my validators sign?" gets wrong. + let superset = [pks[0].clone(), pks[1].clone(), outsider]; + assert!(matches!( + verify_with_signers(agg, &superset, &MSG, SLOT), + Err(Error::SignerSetMismatch) + )); + // Repeats in `expected` are ignored, as documented — this is the *passing* side. + verify_with_signers(agg, &[pks[0].clone(), pks[1].clone(), pks[0].clone()], &MSG, SLOT).unwrap(); +} + +#[test] +fn folding_an_aggregate_with_fresh_signatures_unions_the_signers() { + // The mixed-input path, and the only test anywhere that sees `codec::classify` return + // successfully with its `aggregates` vector non-empty. Unit tests cannot reach it: parsing a + // real aggregate needs the bytecode `OnceLock` filled and a genuine `ExecutionProof`. + // + // Also the shape where the two input vectors are deliberately *not* index-aligned: + // `proof_or_sig` holds an aggregate and one raw signature, and `public_keys` holds exactly + // one key — the raw one's. A caller who assumed alignment would pass two keys and get + // `PubkeyCountMismatch`. + // + // The assertion is the *union*, not merely `Ok`: pairing the third signature with the wrong + // key, or dropping the child's signers, both produce a perfectly valid proof of the wrong + // signer set, which no `is_ok` check would notice. + let fresh = lone_key(201); + let outer = prove( + vec![base().to_vec(), fresh.sign(&MSG, SLOT).unwrap()], + vec![fresh.public_key()], + MSG, + SLOT, + ) + .unwrap(); + + let mut expected: BTreeSet> = base_pubkeys().into_iter().collect(); + expected.insert(fresh.public_key()); + assert_eq!(expected.len(), 3, "the fresh signer must be a genuinely new key"); + assert_eq!(proved_set(&outer), expected); + verify_with_signers(&outer, &expected.iter().cloned().collect::>(), &MSG, SLOT).unwrap(); +} + +#[test] +fn duplicate_raw_signatures_collapse_to_one_signer() { + // `dedup_signers` is pinned as a pure function in the unit suite, which is where its + // "earliest signature wins" tie-break belongs. What that cannot show is that the deduplicated + // batch is still something the prover accepts, and that the *proved* set is the deduplicated + // one rather than a set with a repeat in it — a repeat that upstream would later charge + // against `MAX_XMSS_DUPLICATES`. + let keys = signers(2); + let (a, b) = (&keys[0], &keys[1]); + // The same key offered twice, beside a second key that must survive untouched. Signing the + // same (message, slot) twice is derandomized and byte-identical, so this is the innocent + // shape a caller hits by merging two overlapping gossip batches. + let entries = vec![ + a.sign(&MSG, SLOT).unwrap(), + a.sign(&MSG, SLOT).unwrap(), + b.sign(&MSG, SLOT).unwrap(), + ]; + let pks = vec![a.public_key(), a.public_key(), b.public_key()]; + + let agg = prove(entries, pks, MSG, SLOT).unwrap(); + assert_eq!( + proved_set(&agg), + base_pubkeys().into_iter().collect::>(), + "a repeated signer must prove exactly once, and must not take the other one down with it" + ); +} + +#[test] +fn a_signer_present_in_both_a_child_and_a_raw_batch_appears_once() { + // The overlap `aggregate`'s rustdoc documents: the result's signer set is the *union* of the + // raw pubkeys and every child's. `dedup_signers` cannot help here — it only sees the raw + // vector — so this is upstream's per-node deduplication being relied on across the boundary + // between a supplied aggregate and fresh signatures. + // + // Distinct from the excluded `MAX_XMSS_DUPLICATES` ceiling: this asserts that one overlapping + // signer is *correct*, not how many the node tolerates before refusing. + let keys = signers(2); + let fresh = lone_key(204); + let entries = vec![ + base().to_vec(), + keys[0].sign(&MSG, SLOT).unwrap(), // already inside `base` + fresh.sign(&MSG, SLOT).unwrap(), // genuinely new + ]; + let pks = vec![keys[0].public_key(), fresh.public_key()]; + + let agg = prove(entries, pks, MSG, SLOT).unwrap(); + + let mut expected: BTreeSet> = base_pubkeys().into_iter().collect(); + expected.insert(fresh.public_key()); + assert_eq!(expected.len(), 3, "two from the child plus one new one"); + assert_eq!( + proved_set(&agg), + expected, + "the overlapping signer must appear once, and the new one must appear at all" + ); +} + +#[test] +fn a_corrupt_signature_sized_blob_is_a_malformed_signature_not_a_malformed_entry() { + // `codec`'s unit test of the same shape cannot discriminate what its name says: with no + // bytecode initialized, a fall-through to the aggregate parser also returns `None`, so a + // classifier that tried both would produce an error there too — just a different variant + // nobody could distinguish from the right one. + // + // Here `aggregate` initializes the bytecode before `classify` runs, so the aggregate parser + // is genuinely live: a fall-through would report `MalformedEntry`, and this asserts it does + // not. The two variants have different remedies — damaged data versus data of the wrong kind + // — which is why they are kept apart at all. + let key = lone_key(202); + let mut sig = key.sign(&MSG, SLOT).unwrap(); + assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN, "the classifier dispatches on this"); + sig[..4].copy_from_slice(&[0xff; 4]); // non-canonical field element + + // No proving: this fails inside `classify`, before the planner. + let result = prove(vec![sig], vec![key.public_key()], MSG, SLOT); + assert!( + matches!(result, Err(Error::MalformedSignature { index: 0 })), + "a 1208-byte blob must never fall through to the aggregate parser, got {result:?}" + ); +} + +#[test] +fn a_signature_paired_with_the_wrong_key_fails_in_the_prover() { + // The mistake this API's shape invites: the right *number* of pubkeys in the wrong order. + // Nothing before proving can catch it — the counts match and both blobs decode — so the + // constraint system is what rejects it, as `Error::Aggregation(Prover(Runner(..)))`. + // + // Pinned because the alternative to an error here is a *panic*: this is a public entry point + // fed by gossip, and a constraint failure on attacker-supplied bytes must stay a `Result`. + // Nothing else in the suite reaches the runner's failure path, so a change to how it reports + // unsatisfied constraints could turn this into an abort with every other test still green. + // + // The diagnostic is a bare constraint mismatch carrying no index and no hint that pubkey + // ordering is the thing to check — see `aggregate`'s `# Errors`, which now says so. + let a = lone_key(205); + let b = lone_key(206); + let result = prove(vec![a.sign(&MSG, SLOT).unwrap()], vec![b.public_key()], MSG, SLOT); + assert!(matches!(result, Err(Error::Aggregation(_))), "got {result:?}"); +} + +#[test] +fn a_lone_valid_aggregate_is_passed_through_unchanged() { + // `plan(0, 1)` is `Passthrough`, the one shape where `aggregate` proves nothing and hands + // back what it was given. Cheap, and therefore the shape most likely to quietly return the + // wrong thing: it is the only path where no node re-derives the signer set, and the only one + // where no node verifies the child's proof either (`aggregate` does that itself at the root + // — `tests/unprovable_child.rs` is the negative side of that check; this is the positive one, + // which is what shows the check does not reject *valid* aggregates too). + let out = prove(vec![base().to_vec()], vec![], MSG, SLOT).unwrap(); + + assert_eq!( + proved_set(&out), + base_pubkeys().into_iter().collect::>(), + "a passthrough must not change who signed" + ); + // Decode/re-encode is the identity, so the passthrough is a passthrough in bytes and not + // merely in meaning. `rebuild_bytecode_claim` recomputes the claim's value on the way in and + // `to_bytes` never writes it, so the round trip has nothing to drift on. + assert_eq!( + out, + base(), + "re-encoding a lone aggregate must reproduce it byte for byte" + ); +} + +#[test] +fn a_multi_level_tree_round_trips() { + // The planner's fold loop and `execute`'s nested recursion, on real proofs. Every other test + // here plans a single node or a root over two children, so the `while pool.len() > MAX_FAN_IN` + // branch runs nowhere else — and with it the only `Passthrough` under a *non-root* node, + // which is what makes `execute` recurse into a child that is itself a folded internal node. + // (`Passthrough` under the root is not unique to this test: + // `folding_an_aggregate_with_fresh_signatures_unions_the_signers` plans `Node { children: + // [leaf, Passthrough(0)] }` and so also takes the `Cow::into_owned` clone.) + // + // Depth comes from the fan-in, not from `LEAF_TARGET`: 1501 raw signatures would split into + // two leaves, but 17 supplied children fold into an internal node over 16 plus a leftover, + // which is a three-level tree. Reaching it through raw signatures alone would need 1500 * 17 + // of them, which is not a test at any budget. + // + // The plan for this crate expected it to be `#[ignore]`d as unaffordably slow. Measured, it + // is 19 proving jobs in ~8s — the whole of the rest of this file is ~4s — so it runs in CI + // like everything else. It is still by far the most expensive test here, and the first place + // to look if this binary's runtime ever becomes a problem. + let keys = signers(17); + let children: Vec> = keys + .iter() + .map(|k| prove(vec![k.sign(&MSG, SLOT).unwrap()], vec![k.public_key()], MSG, SLOT).unwrap()) + .collect(); + + let root = prove(children, vec![], MSG, SLOT).unwrap(); + + let expected: BTreeSet> = keys.iter().map(SecretKey::public_key).collect(); + assert_eq!(expected.len(), 17, "every child must contribute a distinct signer"); + assert_eq!(proved_set(&root), expected); +} + +#[test] +fn a_lone_aggregate_over_another_message_or_slot_is_rejected() { + // The check that has to live in `aggregate` itself. Upstream compares a child's `(message, + // slot)` only at the node that *consumes* it, and a lone aggregate is consumed by nothing — + // so without this, `aggregate` returns an aggregate over MSG as a success for a caller who + // asked for a different message entirely, and the caller then gossips it as proof of the + // wrong thing. + let other = [7u8; 32]; + assert_ne!(other, MSG); + let result = prove(vec![base().to_vec()], vec![], other, SLOT); + assert!(matches!(result, Err(Error::MessageMismatch)), "got {result:?}"); + + let result = prove(vec![base().to_vec()], vec![], MSG, SLOT + 1); + assert!(matches!(result, Err(Error::MessageMismatch)), "got {result:?}"); + + // The same fault with a sibling present, which is the shape upstream would eventually catch + // on its own — but only after proving the sibling's leaf. Fails here in milliseconds instead. + let fresh = lone_key(203); + let result = prove( + vec![base().to_vec(), fresh.sign(&other, SLOT).unwrap()], + vec![fresh.public_key()], + other, + SLOT, + ); + assert!(matches!(result, Err(Error::MessageMismatch)), "got {result:?}"); +} + +/// Mirror of `plan::LEAF_TARGET`, which is `pub(crate)` in a private module and so invisible +/// here. Nothing enforces that these agree — if the constant moves, the tests below quietly stop +/// testing the boundary they are named for, which is the cost of not exposing it. +const LEAF_TARGET: usize = 1500; + +/// The first `n` pre-generated benchmark signatures, as `(entries, pubkeys)` in `aggregate`'s +/// argument shapes. +/// +/// Real keygen for 1501 signers would dwarf the proving these tests exist to measure; +/// `xmss::signers_cache` generates 10,000 once and caches them on disk (`target/signers-cache`, +/// or `$SIGNERS_CACHE_DIR`), which is the same cache `tests/test_multisignatures.rs` uses. They +/// are signed over `message_for_benchmark()` at `BENCHMARK_SLOT`, not this file's `MSG`/`SLOT`. +fn benchmark_batch(n: usize) -> (Vec>, Vec>) { + use ssz::Encode; + let signatures = xmss::signers_cache::get_benchmark_signatures(); + assert!(signatures.len() >= n, "the cache holds {} signatures", signatures.len()); + signatures[..n] + .iter() + .map(|(pk, sig)| (sig.as_ssz_bytes(), pk.as_ssz_bytes())) + .unzip() +} + +#[test] +#[ignore = "slow: proves a full 1500-signature leaf"] +fn a_leaf_target_sized_batch_proves() { + // The crate's biggest untested assumption. `plan::LEAF_TARGET` is 1500 because `src/main.rs`'s + // tuned topology proves leaves of 508..1550 — it was inherited, not computed against the 2^22 + // table height. If a 1500-signature node does not actually prove, every aggregation past that + // many signers fails at runtime, and nothing in the default suite would notice: its largest + // single node holds three signatures. + // + // `plan(LEAF_TARGET, 0)` is one node at `RATE_ROOT`, which is also the slowest rate the + // planner uses — so this is the worst case for the boundary, not a favourable reading of it. + // + // Measured: it proves, in ~8s release including the cache load. So 1500 is a real leaf size + // and not merely an inherited one. That is a statement about this one shape at this one rate; + // the largest leaf that proves is still unmeasured, and the constant sits below 1550 for + // reasons `plan.rs` records rather than reasons anything checks. + let message = xmss::signers_cache::message_for_benchmark(); + let slot = xmss::signers_cache::BENCHMARK_SLOT; + let (entries, pubkeys) = benchmark_batch(LEAF_TARGET); + + let agg = prove(entries, pubkeys, message, slot).expect("a LEAF_TARGET-sized leaf must prove"); + assert_eq!( + verify(&agg, &message, slot).unwrap().len(), + LEAF_TARGET, + "every signer must survive to the wire proof" + ); +} + +#[test] +#[ignore = "slow: proves two leaves and a root over 1501 signatures"] +fn a_batch_one_past_leaf_target_splits_and_proves() { + // One signature past the boundary, which is where `plan` stops returning a single node and + // starts returning `[leaf(0..1500), leaf(1500..1501)]` under a root — three proving jobs, and + // the degenerate one-signature leaf the planner's own test records as a shape it tolerates. + // Neither the split nor that leaf has ever been proved for real. + // + // Measured at ~7s against the single 1500-node's ~8s — one more signature, three proving jobs + // instead of one, and *less* wall-clock, because the two leaves run at `RATE_LEAF` and only + // the root pays `RATE_ROOT`. Worth knowing before optimizing node counts: the rate the + // planner assigns dominates how many nodes it creates. + let message = xmss::signers_cache::message_for_benchmark(); + let slot = xmss::signers_cache::BENCHMARK_SLOT; + let (entries, pubkeys) = benchmark_batch(LEAF_TARGET + 1); + + let agg = prove(entries, pubkeys, message, slot).expect("a split batch must prove"); + assert_eq!(verify(&agg, &message, slot).unwrap().len(), LEAF_TARGET + 1); +} diff --git a/crates/lean_multisig_api/tests/unprovable_child.rs b/crates/lean_multisig_api/tests/unprovable_child.rs new file mode 100644 index 00000000..31fb0b8a --- /dev/null +++ b/crates/lean_multisig_api/tests/unprovable_child.rs @@ -0,0 +1,90 @@ +//! A supplied aggregate whose envelope decodes but whose proof is worthless must be rejected. +//! +//! `codec::classify` only decodes the envelope — postcard, then `rebuild_bytecode_claim`, then +//! the pubkey well-formedness check. Nothing in it looks at the proof. Every plan shape that +//! *consumes* a child gets the proof checked for free, because +//! `aggregate_single_message_signatures` verifies each child before folding it in — but a lone +//! supplied aggregate is planned as a `Passthrough`, which is consumed by nothing. That one path +//! has to check the proof itself, and these tests are what say so. +//! +//! The blob below is the cheapest thing that gets past the envelope: a real bytecode-claim point +//! of the right length, one well-formed public key, and an empty proof. Building it by hand +//! avoids the minutes of proving a genuine aggregate would cost, and an empty transcript fails +//! verification for the most unambiguous reason available (`ExceededTranscript`). + +use lean_vm::{EF, F}; +use ssz::Decode; +use xmss::XmssPublicKey; + +const MSG: [u8; 32] = [0u8; 32]; +const SLOT: u32 = 0; + +/// The postcard encoding of a `SingleMessageAggregateSignature`, field by field. +/// +/// The type cannot be constructed directly from outside `rec_aggregation` — `Proof`'s fields are +/// `pub(crate)` — so this writes the wire format instead. Postcard encodes structs and tuples as +/// their fields back to back with no framing, so a tuple of the right leaves in the right order +/// is byte-identical to the real thing: +/// +/// `SingleMessageAggregateSignature { info: { core: (message, slot, point), pubkeys }, proof }`, +/// where `ExecutionProof`'s only serialized field is `Proof { transcript, merkle_paths }`. +fn unprovable_aggregate() -> Vec { + // The point must match the bytecode's variable count or `rebuild_bytecode_claim` rejects it + // and the blob never gets past parsing — which would make these tests pass for the wrong + // reason. Its *value* is recomputed on deserialize, so zeros are fine. + let n_vars = rec_aggregation::get_aggregation_bytecode().cumulated_n_vars(); + let point: Vec = vec![EF::default(); n_vars]; + + // One public key, non-empty and trivially sorted, as `check_single_message_pubkeys` demands. + let mut pk_bytes = vec![0u8; xmss::PUB_KEY_SSZ_LEN]; + pk_bytes[0] = 1; + let pubkeys: Vec = vec![XmssPublicKey::from_ssz_bytes(&pk_bytes).unwrap()]; + + postcard::to_allocvec(&((MSG, SLOT, point), pubkeys, (Vec::::new(), Vec::::new()))) + .expect("the fixture serializes infallibly") +} + +#[test] +fn aggregate_rejects_an_unprovable_lone_aggregate() { + lean_multisig_api::warm_up(); + let blob = unprovable_aggregate(); + + // No raw signatures and one child, so the planner returns `Passthrough(0)` — the shape where + // no node ever consumes the child. Without the check in that arm this returns `Ok`, and the + // caller goes on to re-gossip a blob that fails everywhere downstream. + let result = lean_multisig_api::aggregate(vec![blob], vec![], MSG, SLOT); + assert!( + matches!(result, Err(lean_multisig_api::Error::Proof(_))), + "a passthrough must verify its child's proof, got {result:?}" + ); +} + +#[test] +fn verify_rejects_an_unprovable_aggregate() { + lean_multisig_api::warm_up(); + let blob = unprovable_aggregate(); + + // Distinct from the malformed-bytes tests in the unit suite: those stop at parsing, so they + // never reach `verify_single_message_aggregate`. This one is well-formed all the way to the + // proof, so `Error::Proof` rather than `Error::MalformedAggregate` is the whole assertion. + let result = lean_multisig_api::verify(&blob, &MSG, SLOT); + assert!( + matches!(result, Err(lean_multisig_api::Error::Proof(_))), + "expected the proof check to reject this, got {result:?}" + ); +} + +#[test] +fn the_fixture_really_does_get_past_the_envelope() { + // If the wire format ever drifts, the two tests above would still pass — on + // `MalformedAggregate`, having proved nothing about proof checking. This is what tells the + // difference: a wrong (message, slot) has to be reported as a mismatch, which is only + // reachable once the blob has parsed. + lean_multisig_api::warm_up(); + let blob = unprovable_aggregate(); + let result = lean_multisig_api::verify(&blob, &[9u8; 32], SLOT); + assert!( + matches!(result, Err(lean_multisig_api::Error::MessageMismatch)), + "the fixture must parse, or the tests above prove nothing, got {result:?}" + ); +} diff --git a/docs/plans/2026-08-14-lean-sig-facade-design.md b/docs/plans/2026-08-14-lean-sig-facade-design.md new file mode 100644 index 00000000..7cbe276b --- /dev/null +++ b/docs/plans/2026-08-14-lean-sig-facade-design.md @@ -0,0 +1,373 @@ +# `lean_multisig_api`: an opinionated facade over XMSS and aggregation + +**Status:** implemented (Tasks 1-8 complete) +**Date:** 2026-08-14 + +> **Crate renamed.** This file is named `2026-08-14-lean-sig-*` and the commits from Tasks 1-7 +> are scoped `feat(lean_sig)` / `test(lean_sig)`, because `lean_sig` was the working name until +> Task 8 renamed the crate to **`lean_multisig_api`**. The filename and those commit scopes are +> left alone deliberately: they are how existing commit messages refer to this work. Everything +> else here says `lean_multisig_api`. + +## Purpose + +`xmss` and `rec_aggregation` expose the full parameter space: `log_inv_rate`, recursion +topology, bytecode claims, field elements. Callers who just want to sign and aggregate must +first learn which of those choices matter. + +`lean_multisig_api` removes the choices. It exposes signing and single-message aggregation over +byte slices, and picks every tuning parameter internally. Callers who need the full +parameter space keep using `rec_aggregation` directly; this crate does not try to replace it. + +## Scope + +In scope: + +- XMSS keygen, signing, verification. +- Single-message aggregation: one `(message, slot)` shared by every signer. +- Verification of such an aggregate. + +Out of scope: + +- Multi-message aggregation (`merge_single_message_aggregates`, + `split_multi_message_aggregate`). Callers needing cross-message merging use + `rec_aggregation`. +- Any control over `log_inv_rate` or recursion topology. + +## Public surface + +Everything crossing the boundary is either a `Vec` wire format or an opaque handle. +No `KoalaBear`, `Evaluation`, or `MultilinearPoint` appears in a signature. + +### Handles + +State expensive enough to hold across operations gets a real type. + +```rust +pub struct SecretKey(xmss::XmssSecretKey); + +impl SecretKey { + // As built: one inclusive slot range, not the (activation_slot, num_active_slots) pair + // upstream takes. The range round-trips through `slots()`, and an empty one is an error. + pub fn generate(slots: RangeInclusive) -> Result; + pub fn from_seed(seed: [u8; 32], slots: RangeInclusive) -> Result; + pub fn from_bytes(b: &[u8]) -> Result; + pub fn to_bytes(&self) -> Vec; + + pub fn public_key(&self) -> Vec; // PUB_KEY_SSZ_LEN = 32 + pub const fn slots(&self) -> RangeInclusive; + pub fn prepare(&self, slot: u32) -> Result<(), Error>; + pub fn sign(&self, message: &[u8; 32], slot: u32) -> Result, Error>; + // SIGNATURE_SSZ_LEN = 1208 +} +``` + +`XmssSecretKey` holds a `top: Vec>` tree and a `Mutex>` +cache warmed by `prepare`. Serialization persists the seed, slot range, and top tree, but +drops the cache. A pure `sign(sk_bytes, msg, slot)` function would therefore rebuild a +bottom subtree on every call. The handle keeps the cache warm across signatures. + +`prepare` survives into the facade. It is the one piece of tuning the library cannot infer, +because only the caller knows which slot is coming next. + +`sign` returns SSZ bytes rather than a handle so its output drops straight into +`aggregate`'s first argument. + +### Bytes + +Public keys, signatures, and aggregates are inert blobs. They get no newtype; wrapping them +would buy nothing but conversions. + +### Aggregation + +```rust +pub fn aggregate( + proof_or_sig: Vec>, + public_keys: Vec>, + message: [u8; 32], + slot: u32, +) -> Result, Error>; + +#[must_use] +pub fn verify( + aggregate: &[u8], + message: &[u8; 32], + slot: u32, +) -> Result>, Error>; // Ok holds the signer set actually proved + +pub fn verify_with_signers( + aggregate: &[u8], + expected: &[Vec], + message: &[u8; 32], + slot: u32, +) -> Result<(), Error>; + +pub fn warm_up(); +``` + +## Dispatch and pubkey pairing + +`proof_or_sig` mixes raw XMSS signatures and previously produced aggregates. Entries are +classified by length: exactly `SIGNATURE_SSZ_LEN` (1208) means a raw signature; any other +length is parsed as a postcard aggregate via `SingleMessageAggregateSignature::from_bytes`. + +A 1208-byte blob that fails SSZ decode is an error, never a fallback to the aggregate parse. +Silent reclassification would surface as a baffling failure much later. + +Aggregates carry their own signer sets (`info.pubkeys`), so `public_keys` covers raw +signatures only: the *k*-th raw entry, in order, pairs with `public_keys[k]`. When the counts +disagree, `Error::PubkeyCountMismatch { expected, got }`. The two vectors are deliberately +not index-aligned, which is the main thing to document loudly. + +## Tree planning + +`aggregate` builds the whole recursion tree in one call and chooses `log_inv_rate` per level. +Lower rate means faster proving and a bigger proof; the useful range is 1 to 4. + +- Raw signatures partition into leaves of `LEAF_TARGET` (1500), proved at rate 1. Leaf proofs + are consumed immediately, so their size does not matter. +- Supplied child aggregates enter at the level above, fanning in at most `MAX_RECURSIONS` + (16) per node, at rate 2. +- The root is proved at rate 4. It is what goes on the wire, so it gets the smallest proof. +- Special case: when everything fits in one node it is proved at rate 4 directly, because that + node *is* the wire proof. Proving it at rate 1 would hand the caller a needlessly large one. +- A leftover group of one is passed through rather than wrapped in a single-child node, which + would prove its only child a second time for nothing. + +`LEAF_TARGET` was taken from the hand-tuned topology in `src/main.rs`, whose leaves hold 508 +to 1550 raw signatures. An earlier draft of this document said it was "tuned against the 2^22 +table-height limit" — it was not; no such calculation was ever done, and that clause overclaimed +rigor the number did not have. It has since been *measured* instead: see Resolved questions. + +### Capacity + +`aggregate_single_message_signatures` computes `global_pub_keys` as the sorted, deduplicated +union of raw pubkeys and every child's pubkeys, and rejects it above `MAX_XMSS_AGGREGATED` +(2^15 = 32768). That check applies at every node including the root, so **recursion does not +extend signer capacity**: 32768 distinct signers is the ceiling for the entire tree. The tree +exists to get past the roughly 1500 signatures a single node can prove, not past 32768. + +The facade checks this ceiling up front, before proving anything, and returns +`Error::TooManySigners { got, max }`. Failing after minutes of proving would be needlessly +cruel. + +### Sequential cost + +A whole-tree call is strictly sequential: `execute` proves nodes one after another, so +wall-clock is the sum of every node's proving time with no intra-call parallelism. That much +is unconditional, and it is documented on `aggregate` itself. + +The *cross-call* claim needs more care than this document originally gave it. It said +concurrent calls panic, citing `rec_aggregation`. That is only conditionally true. +`zk_alloc::begin_phase` returns early unless `enable_arena` has run, and `enable_arena` is +called in exactly one place — `lean_multisig::setup_prover`. An application on +`setup_prover_without_arena` never engages it either. So two concurrent `aggregate` calls +panic under an embedder that called `setup_prover`, and quietly succeed in a +`lean_multisig_api`-only harness. + +That asymmetry is a trap worth naming: a caller who tests concurrency in isolation sees it +work and concludes the warning is stale. Serialize `aggregate` calls unconditionally. + +**Measured cost.** An earlier draft of this document said "minutes per node", which was wrong +by about two orders of magnitude at the sizes anyone tests. Release measurements: 19 small +nodes in ~8s; one full 1500-signature leaf in ~8s; a 2-signer aggregate plus verify in ~3s. +The framing had propagated to seven doc sites in the crate and was corrected in all of them. + +## Initialization + +`get_aggregation_bytecode()` panics with `"call init_aggregation_bytecode() first"`, and +`SingleMessageAggregateSignature::from_bytes` silently returns `None` when the `OnceLock` is +unset. + +Every public entry point calls `init_aggregation_bytecode()` first. It is a `OnceLock`, so +this is idempotent and free after the first call, and both sharp edges disappear. The caller +never learns the bytecode exists. + +The first `aggregate` or `verify` in a process absorbs the one-time compile. `warm_up()` lets +long-running services pay that at startup instead. + +## Why `verify` returns the signer set + +An aggregate over the wrong validator set is still a perfectly valid proof. A `bool` return +invites `if verify(..) { .. }` while the caller forgets to check *who* signed. + +An earlier draft of this document claimed returning the signer set means the API "cannot be +used without confronting it". That overclaims, and it is worth correcting precisely because it +is the kind of sentence a security reviewer leans on: `verify(..)?;` compiles silently, since +after `?` the type is a plain `Vec>` with no `#[must_use]`. What the design actually +buys is that ignoring the signer set requires *discarding a value* rather than simply not +asking for one — a real improvement over `bool`, but not a guarantee the type system enforces. +`verify_with_signers` exists for the common case where the expected set is already known. + +Because every input is bytes, deserialization always runs `rebuild_bytecode_claim`, which +recomputes the trusted `bytecode_claim.value`. The unsound path that `rec_aggregation` warns +about, a trusted claim taken from an untrusted source, is unreachable through this facade. + +## Errors + +One `#[non_exhaustive] pub enum Error` implementing `std::error::Error`, flattening +`XmssKeyGenError`, `XmssSignatureError`, `XmssVerifyError`, `AggregationError`, `ProofError`, +and the facade's own parse and pairing variants. No generics; no source-crate types leak. + +## Layout + +`crates/lean_multisig_api/src/`: + +| File | Contents | +| --- | --- | +| `lib.rs` | Public surface only. No `pub mod`. | +| `key.rs` | `SecretKey` handle; SSZ codecs at the boundary. | +| `codec.rs` | Length dispatch, pubkey pairing. | +| `plan.rs` | Tree planner: `LEAF_TARGET`, fan-in, per-level rate. Pure. | +| `error.rs` | The flattened `Error` enum. | + +As built: no workspace manifest edit was needed. The root `members` is `["crates/*", ...]`, so +creating the directory registers the crate. It is deliberately *not* in +`[workspace.dependencies]` — nothing in the workspace depends on it, and an entry there would +be dead weight until something does. + +## Testing + +The layers differ enormously in cost, so they are tested separately. + +**Unit, no proving.** `plan.rs` is pure so the planner is testable without a prover: for 1, +500, 1550, and 32768 signatures, assert leaf count, fan-in at most 16, and rates descending +1 → 2 → 4 toward the root. For `codec.rs`: a 1208-byte blob classifies as raw, a malformed +one errors rather than reclassifying, and pairing arithmetic holds when aggregates are +interleaved among raw signatures. + +**Integration, slow.** `crates/lean_multisig_api/tests/`, modelled on `tests/test_multisignatures.rs`, +with `parallel`'s `forbid-parallelism` and `xmss`'s `test-utils` as dev-dependencies. Round +trip: keygen, sign, `aggregate`, then `verify` returns exactly the input signer set. Keep +these to single-digit signers and one leaf so CI stays usable; gate a multi-level tree test +behind `#[ignore]`. + +As built, the multi-level tree test is *not* gated. Tree depth comes from fan-in +(`MAX_FAN_IN = 16`), not from `LEAF_TARGET`, so 17 supplied children give a three-level tree — +19 proving jobs in ~8s release, which is affordable. The two tests that *are* gated are the +`LEAF_TARGET` boundary pair, and CI runs those in a scoped step; see +[Resolved questions](#resolved-questions). + +**Negative tests.** Wrong message; wrong slot; tampered proof bytes; a signer set that +verifies as a proof but differs from the expected set; and `verify` called before any +`warm_up`, which must succeed through lazy init rather than panic. + +## Resolved questions + +All three questions this document opened with are now settled. + +### `LEAF_TARGET` — measured, and it holds + +1500 was inherited from `src/main.rs`'s tuned topology (leaves of 508..1550) rather than +computed against the 2^22 table height. It has now been proved for real, by two `#[ignore]`d +tests in `crates/lean_multisig_api/tests/round_trip.rs` that CI runs in a scoped step: + +| Shape | Plan | Proving jobs | Wall-clock (release) | Verified signers | +| --- | --- | --- | --- | --- | +| 1500 raw signatures | one node at `RATE_ROOT` | 1 | 8.16s | 1500 | +| 1501 raw signatures | two leaves at `RATE_LEAF` under a root at `RATE_ROOT` | 3 | 6.74s | 1501 | + +1500 is measured at `RATE_ROOT`, which is the *slowest* rate the planner ever assigns — so this +is the worst case for the boundary, not a favourable reading of it. + +**The 3-node split is cheaper than the single node, despite proving one more signature.** The +two leaves run at `RATE_LEAF` and only the root pays `RATE_ROOT`. This is direct evidence +bearing on the greedy-vs-balanced tuning question deferred from Task 3 (see the implementation +plan's "Tuning questions" section), and it points *against* the intuition that motivated it: +that section reasons about minimizing node count, on the grounds that wall-clock is the sum +over nodes rather than a critical path. That reasoning is correct as far as it goes, but the +measurement says the rate the planner assigns dominates the node count it creates. A topology +change that removes a node while pushing work up to a higher rate can be a net loss. Anyone +tuning the planner should measure rate assignment first and node count second. + +**Still unmeasured: the largest leaf that proves.** 1500 sits below `main.rs`'s observed 1550 +for inherited reasons, not checked ones. Nothing here probes where the table-height limit +actually bites. So `LEAF_TARGET = 1500` is **known-good, not known-optimal**, and the headroom +above it is unknown. Raising it is a measurement task, not a guess; lowering it needs no +evidence beyond a failure. + +### The crate name — `lean_multisig_api` + +`lean_sig` was a placeholder. The crate was renamed in Task 8; the plan-doc filenames and the +Task 1-7 commit scopes still say `lean_sig`, deliberately (see the note at the top). + +### `SecretKey::generate` takes no RNG + +Settled as-is: it does not, and will not. `from_seed` covers deterministic testing completely — +it is the same key derivation with the entropy supplied by the caller — so an RNG parameter +would buy only the ability to inject a *non-default* CSPRNG, which is not a use case this +facade exists to serve. Threading an `R: CryptoRng` through a "no knobs" API is exactly the +kind of parameter the crate was built to remove. + +`generate_is_randomized` (in `key.rs`) pins the other half: that `generate` genuinely differs +run to run, and is not `from_seed` with a constant hiding in it. + +## Knowingly untested + +Absence of a test here is not absence of risk. These are the gaps that are known and were +judged not worth closing, rather than gaps nobody noticed. + +- **The accepting side of the 32768-signer ceiling.** Exactly `MAX_XMSS_AGGREGATED` signers + *passes* the up-front check and proceeds to prove 22 leaves. That is untestable at any tier — + the check is cheap but what follows it is not. The **rejecting** side is tested + (`aggregate_rejects_more_signers_than_the_ceiling`, 32769 synthetic pubkeys, ~3s), which is + the side that turns "fails after the whole tree is proved" into "fails in milliseconds". + +- **`MAX_XMSS_DUPLICATES`.** Reachable only *after* everything below the root has been proved, + because the duplicate count depends on which node merges which children — so an exact + pre-check would mean simulating the tree. `dedup_signers` removes the case a caller can + trigger directly (the same key offered twice in one call). Several *supplied aggregates* with + heavily overlapping signer sets can still hit the ceiling late, after minutes of proving. + +- **Whether the bottom-subtree cache actually saves work.** `SecretKey` is a handle rather than + a bytes-in/bytes-out `sign` precisely so `XmssSecretKey`'s `Mutex>` + survives across signatures. That the cache is *preserved* is structural; that it *pays* is a + timing property, and there is no benchmark harness in this crate to assert it without + inventing one. + +- **The largest leaf that proves.** See `LEAF_TARGET` above. + +## Operational note: the runner prints to stdout on a constraint failure + +Not a `lean_multisig_api` defect, but it affects anyone embedding it. When a raw signature +fails to verify under the public key it was paired with — the shape a misordered `public_keys` +produces — the zkVM runner **prints a diagnostic to stdout** on its way to returning +`Err(Error::Aggregation(..))`. Observed shape: + +```text +ERROR + + at xmss_aggregate.py:109 in xmss_verify + + 106 │ ) + 107 │ target_sum += pair_sum_ptr[0] + 108 │ + 109 │ assert target_sum == TARGET_SUM + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 110 │ + +CALL STACK + + → xmss_verify() at main.py:176 + main() at main.py:35 +``` + +The frames are labelled with line numbers from the aggregation program's own source, which +will mean nothing to a reader of the embedding application's logs. The error is returned +normally and nothing panics — but a node that captures stdout will emit this block for **every +malformed gossip batch**, which is attacker-controllable volume. Redirect or filter it if that +matters. + +## Follow-up + +- **Move the wire-format fixture upstream.** `tests/unprovable_child.rs` hand-encodes + `SingleMessageAggregateSignature`'s postcard layout, relying on a tuple of the right leaves + being byte-identical to a struct whose fields are `pub(crate)` and unconstructable from + outside. `the_fixture_really_does_get_past_the_envelope` keeps it from going silently vacuous, + which is what makes the technique safe today — but the knowledge lives in the wrong crate. A + `rec_aggregation` `test-utils` feature exposing a constructor for a structurally valid, + unprovable aggregate would put it where a field reorder is a compile error rather than a + downstream fixture that parses by luck. `xmss` already gates `signers_cache` this way, so the + pattern exists. **Deferred:** it changes another crate's public surface, which is outside the + scope of a facade that is meant to depend on `rec_aggregation` rather than reshape it. Do it + when `rec_aggregation` is next opened for its own reasons. diff --git a/docs/plans/2026-08-14-lean-sig-implementation.md b/docs/plans/2026-08-14-lean-sig-implementation.md new file mode 100644 index 00000000..d53ebe08 --- /dev/null +++ b/docs/plans/2026-08-14-lean-sig-implementation.md @@ -0,0 +1,1127 @@ +# `lean_multisig_api` Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +> **Crate renamed.** This file is named `2026-08-14-lean-sig-implementation.md` and the `git +> commit` lines below are scoped `feat(lean_sig)` / `test(lean_sig)`, because `lean_sig` was the +> working name until Task 8 renamed the crate to **`lean_multisig_api`**. The filename and those +> commit scopes are left verbatim on purpose: they are the strings that appear in `git log` and +> are how a reader finds the commits for Tasks 1-7. Every other mention here says +> `lean_multisig_api`. + +**Goal:** Build `crates/lean_multisig_api`, a facade over `xmss` + `rec_aggregation` that exposes signing +and single-message aggregation over byte slices, choosing every tuning parameter internally. + +**Architecture:** Five modules. `plan.rs` is a pure tree planner (fast unit tests, no prover). +`codec.rs` does length-based dispatch of the mixed input vector. `key.rs` wraps `XmssSecretKey` +as an opaque handle so its bottom-subtree cache survives across signatures. `lib.rs` holds the +four public functions; `error.rs` flattens every upstream error into one enum. + +**Tech Stack:** Rust 2024, `xmss`, `rec_aggregation`, `ssz` (ethereum_ssz), `postcard`, `serde`. + +**Design doc:** `docs/plans/2026-08-14-lean-sig-facade-design.md`. Read it first. + +--- + +## Background you need + +Facts established by reading the existing code. Do not re-derive these. + +**`xmss` crate:** + +```rust +xmss_key_gen(rng: &mut R, activation_slot: u64, num_active_slots: u64) + -> Result<(XmssPublicKey, XmssSecretKey), XmssKeyGenError> +xmss_key_gen_from_seed(seed: [u8; 32], activation_slot: u64, num_active_slots: u64) + -> Result<(XmssPublicKey, XmssSecretKey), XmssKeyGenError> +xmss_sign(secret_key: &XmssSecretKey, slot: u32, message: &[u8; 32]) + -> Result +xmss_verify(pub_key: &XmssPublicKey, slot: u32, message: &[u8; 32], signature: &XmssSignature) + -> Result<(), XmssVerifyError> + +impl XmssSecretKey { + fn public_key(&self) -> XmssPublicKey; + const fn activation_slots(&self) -> std::ops::RangeInclusive; + fn prepare(&self, slot: u32) -> Result<(), XmssSignatureError>; +} +``` + +`XmssPublicKey` and `XmssSignature` implement `ssz::Encode` / `ssz::Decode` with **fixed** +lengths `PUB_KEY_SSZ_LEN` (32) and `SIGNATURE_SSZ_LEN` (1208). `XmssSecretKey` implements +serde `Serialize`/`Deserialize` (seed + slot range + top tree; the cache is dropped) but +**not** SSZ. `XmssPublicKey` derives `Ord`. + +**`rec_aggregation` crate:** + +```rust +aggregate_single_message_signatures( + children: &[SingleMessageAggregateSignature], + raw_xmss: Vec<(XmssPublicKey, XmssSignature)>, + message: [u8; 32], + slot: u32, + log_inv_rate: usize, +) -> Result + +verify_single_message_aggregate(sig: &SingleMessageAggregateSignature) + -> Result + +init_aggregation_bytecode(); + +impl SingleMessageAggregateSignature { + fn to_bytes(&self) -> Vec; // postcard, includes pubkeys + fn from_bytes(bytes: &[u8]) -> Option; +} +// sig.info.pubkeys: Vec — the signer set +// sig.info.core.message / .slot +``` + +Constants: `MAX_RECURSIONS = 16`, `MAX_XMSS_AGGREGATED = 1 << 15`. Valid `log_inv_rate` is +`1..=4` (`MIN_WHIR_LOG_INV_RATE`..`MAX_WHIR_LOG_INV_RATE`), lower = faster proving, bigger proof. + +**Three traps:** + +1. `get_aggregation_bytecode()` **panics** if `init_aggregation_bytecode()` was never called, + and `SingleMessageAggregateSignature::from_bytes` silently returns `None` in that state. + Every public entry point must call `init_aggregation_bytecode()` first. It is a `OnceLock`, + so this is idempotent and cheap. +2. `aggregate_single_message_signatures` computes the signer set as the sorted, deduplicated + **union** of raw pubkeys and all children's pubkeys, and rejects it above + `MAX_XMSS_AGGREGATED` at **every** node. Recursion does not raise that ceiling. +3. Only one proving job may run per process; a concurrent call panics. Everything is sequential. + +Because aggregation sorts and dedups, `verify` returns pubkeys in `XmssPublicKey`'s `Ord` +order, which is **not** the caller's input order and **not** SSZ-byte order. Compare as sets. + +--- + +## Task 1: Scaffold the crate + +**Files:** +- Create: `crates/lean_multisig_api/Cargo.toml` +- Create: `crates/lean_multisig_api/src/lib.rs` + +The root `Cargo.toml` already has `members = ["crates/*", ...]`, so no workspace edit is needed. + +**Step 1: Write the manifest** + +```toml +[package] +name = "lean_multisig_api" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +xmss.workspace = true +rec_aggregation.workspace = true +backend.workspace = true +ssz.workspace = true +postcard.workspace = true +serde.workspace = true + +[dev-dependencies] +rand.workspace = true +parallel = { workspace = true, features = ["forbid-parallelism"] } +``` + +**Step 2: Write a placeholder lib.rs** + +```rust +//! An opinionated facade over `xmss` and `rec_aggregation`. +//! +//! Every tuning parameter is chosen internally. Callers needing control over `log_inv_rate` +//! or recursion topology should use `rec_aggregation` directly. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod error; +pub use error::Error; +``` + +Create an empty `crates/lean_multisig_api/src/error.rs` so this compiles. + +**Step 3: Verify it builds** + +Run: `cargo build -p lean_multisig_api` +Expected: success (warnings about unused deps are fine at this stage). + +**Step 4: Commit** + +```bash +git add crates/lean_multisig_api +git commit -m "feat(lean_sig): scaffold facade crate" +``` + +--- + +## Task 2: The error enum + +**Files:** +- Modify: `crates/lean_multisig_api/src/error.rs` + +**Step 1: Write the enum** + +```rust +use std::fmt::{Display, Formatter}; + +/// Every way a `lean_multisig_api` call can fail. +#[non_exhaustive] +#[derive(Debug)] +pub enum Error { + KeyGen(xmss::XmssKeyGenError), + Sign(xmss::XmssSignatureError), + Verify(xmss::XmssVerifyError), + Aggregation(rec_aggregation::AggregationError), + Proof(backend::ProofError), + /// A `proof_or_sig` entry decoded as neither a signature nor an aggregate. + MalformedEntry { index: usize }, + /// A public key blob was not `PUB_KEY_SSZ_LEN` bytes, or held non-canonical field elements. + MalformedPublicKey { index: usize }, + /// Secret key bytes could not be deserialized. + MalformedSecretKey, + /// `public_keys.len()` must equal the number of raw signatures in `proof_or_sig`. + PubkeyCountMismatch { expected: usize, got: usize }, + /// The deduplicated signer union exceeds `MAX_XMSS_AGGREGATED`. + TooManySigners { got: usize, max: usize }, + /// `proof_or_sig` was empty. + Empty, + /// The proved signer set differs from the expected one. + SignerSetMismatch, +} +``` + +Add `Display` with one arm per variant, then `impl std::error::Error for Error {}`, then +`From` impls for the five wrapped types so `?` works. + +**Step 2: Verify** + +Run: `cargo build -p lean_multisig_api` +Expected: success. + +**Step 3: Commit** + +```bash +git commit -am "feat(lean_sig): flattened error enum" +``` + +--- + +## Task 3: The tree planner (pure, fast tests) + +This is the highest-value module to get right, and the only one testable without a prover. +Keep it free of any `rec_aggregation` calls. + +**Files:** +- Create: `crates/lean_multisig_api/src/plan.rs` +- Modify: `crates/lean_multisig_api/src/lib.rs` (add `mod plan;`) + +**Step 1: Write the failing tests first** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_child_alone_is_passed_through() { + // Re-proving a lone aggregate would burn minutes 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 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 } + ); + } + + #[test] + fn fan_in_never_exceeds_max_recursions() { + for n in [1, 2, LEAF_TARGET, LEAF_TARGET * 40, MAX_XMSS_AGGREGATED] { + assert_fan_in_ok(&plan(n, 0)); + } + } + + fn assert_fan_in_ok(p: &Plan) { + if let Plan::Node { children, .. } = p { + assert!(children.len() <= MAX_FAN_IN, "fan-in {} too wide", children.len()); + children.iter().for_each(assert_fan_in_ok); + } + } + + #[test] + fn every_raw_signature_is_covered_exactly_once() { + // The planner returning index ranges makes off-by-ones silent otherwise. + let n = LEAF_TARGET * 3 + 7; + let mut seen = vec![0u8; n]; + collect(&plan(n, 0), &mut seen); + assert!(seen.iter().all(|&c| c == 1), "each raw sig must appear exactly once"); + } + + fn collect(p: &Plan, seen: &mut [u8]) { + if let Plan::Node { raw, children, .. } = p { + for i in raw.clone() { seen[i] += 1; } + children.iter().for_each(|c| collect(c, seen)); + } + } + + #[test] + fn rates_descend_toward_the_root() { + let p = plan(LEAF_TARGET * 40, 0); + let Plan::Node { log_inv_rate, children, .. } = &p else { panic!() }; + assert_eq!(*log_inv_rate, RATE_ROOT); + // Every non-root internal node proves at RATE_INTERNAL, every leaf at RATE_LEAF. + for c in children { + if let Plan::Node { log_inv_rate, children: gc, .. } = c { + let expected = if gc.is_empty() { RATE_LEAF } else { RATE_INTERNAL }; + assert_eq!(*log_inv_rate, expected); + } + } + } +} +``` + +**Step 2: Run to verify they fail** + +Run: `cargo test -p lean_multisig_api --lib plan` +Expected: FAIL, `cannot find function plan`. + +**Step 3: Write the implementation** + +```rust +use rec_aggregation::{MAX_RECURSIONS, MAX_XMSS_AGGREGATED}; +use std::ops::Range; + +/// Raw signatures per leaf. Taken from `src/main.rs`'s tuned topology (leaves of 508..1550), +/// bounded by the 2^22 table height. See the design doc's open questions: this wants measuring. +pub(crate) const LEAF_TARGET: usize = 1500; +pub(crate) const MAX_FAN_IN: usize = MAX_RECURSIONS; + +/// Fast proving, large proof. Leaf proofs are consumed immediately, so size is irrelevant. +pub(crate) const RATE_LEAF: usize = 1; +pub(crate) const RATE_INTERNAL: usize = 2; +/// Smallest proof. Only the root goes on the wire. +pub(crate) const RATE_ROOT: usize = 4; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Plan { + /// Return a caller-supplied aggregate unchanged; the index is into the supplied children. + Passthrough(usize), + Node { + /// Range into the raw-signature vector. May be empty for internal nodes. + raw: Range, + children: Vec, + log_inv_rate: usize, + }, +} + +pub(crate) fn plan(n_raw: usize, n_children: usize) -> Plan { + // A lone aggregate is already a valid proof. + 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 }; + } + + 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)); + + while pool.len() > MAX_FAN_IN { + pool = pool + .chunks(MAX_FAN_IN) + .map(|group| 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 } +} +``` + +Note `chunks` on a `Vec` needs `Plan: Clone`, which the derive provides. + +**Step 4: Run tests** + +Run: `cargo test -p lean_multisig_api --lib plan` +Expected: PASS, 6 tests. + +**Step 5: Commit** + +```bash +git commit -am "feat(lean_sig): pure recursion-tree planner" +``` + +--- + +## Task 4: Codec and pubkey pairing + +**Files:** +- Create: `crates/lean_multisig_api/src/codec.rs` +- Modify: `crates/lean_multisig_api/src/lib.rs` (add `mod codec;`) + +**Step 1: Write the failing tests** + +These need real signatures, so add a small helper. Keygen over a 16-slot range is fast +(no proving involved). + +```rust +#[cfg(test)] +mod tests { + use super::*; + use xmss::{xmss_key_gen_from_seed, xmss_sign}; + use ssz::Encode; + + fn sample(seed: u8) -> (Vec, Vec) { + let (pk, sk) = xmss_key_gen_from_seed([seed; 32], 100, 16).unwrap(); + let sig = xmss_sign(&sk, 100, &[7u8; 32]).unwrap(); + (pk.as_ssz_bytes(), sig.as_ssz_bytes()) + } + + #[test] + fn classifies_raw_signatures_by_length() { + let (pk, sig) = sample(1); + assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); + let (raw, aggs) = classify(vec![sig], vec![pk]).unwrap(); + assert_eq!(raw.len(), 1); + assert!(aggs.is_empty()); + } + + #[test] + fn rejects_a_correctly_sized_but_corrupt_signature() { + // Must NOT silently fall through to the aggregate parser. + let (pk, mut sig) = sample(2); + sig[0] = 0xff; sig[1] = 0xff; sig[2] = 0xff; sig[3] = 0xff; // non-canonical field element + assert!(matches!( + classify(vec![sig], vec![pk]), + Err(Error::MalformedEntry { index: 0 }) + )); + } + + #[test] + fn pubkey_count_must_match_raw_count() { + let (pk, sig) = sample(3); + let err = classify(vec![sig.clone(), sig], vec![pk]).unwrap_err(); + assert!(matches!(err, Error::PubkeyCountMismatch { expected: 2, got: 1 })); + } + + #[test] + fn rejects_a_wrong_length_pubkey() { + let (_, sig) = sample(4); + assert!(matches!( + classify(vec![sig], vec![vec![0u8; 8]]), + Err(Error::MalformedPublicKey { index: 0 }) + )); + } + + #[test] + fn empty_input_is_rejected() { + assert!(matches!(classify(vec![], vec![]), Err(Error::Empty))); + } +} +``` + +**Step 2: Run to verify failure** + +Run: `cargo test -p lean_multisig_api --lib codec` +Expected: FAIL, `cannot find function classify`. + +**Step 3: Implement** + +```rust +use crate::Error; +use rec_aggregation::SingleMessageAggregateSignature; +use ssz::Decode; +use xmss::{PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSignature}; + +type Raw = (XmssPublicKey, XmssSignature); + +/// Splits the mixed input vector into raw signatures (paired with their pubkeys) and +/// previously produced aggregates. +/// +/// Entries are classified by length: exactly `SIGNATURE_SSZ_LEN` means a raw signature, +/// anything else is parsed as a postcard aggregate. A correctly sized blob that fails SSZ +/// decode is an error, never a fallback to the aggregate parser — silent reclassification +/// would surface as a baffling failure much later. +/// +/// Aggregates carry their own signer sets, so `public_keys` covers raw signatures only: +/// the k-th raw entry pairs with `public_keys[k]`. +pub(crate) fn classify( + proof_or_sig: Vec>, + public_keys: Vec>, +) -> Result<(Vec, Vec), Error> { + if proof_or_sig.is_empty() { + return Err(Error::Empty); + } + + let expected = proof_or_sig.iter().filter(|e| e.len() == SIGNATURE_SSZ_LEN).count(); + if expected != public_keys.len() { + return Err(Error::PubkeyCountMismatch { expected, got: public_keys.len() }); + } + + let mut raw = Vec::with_capacity(expected); + let mut aggregates = Vec::new(); + let mut next_pk = 0usize; + + for (index, entry) in proof_or_sig.iter().enumerate() { + if entry.len() == SIGNATURE_SSZ_LEN { + let sig = XmssSignature::from_ssz_bytes(entry) + .map_err(|_| Error::MalformedEntry { index })?; + let pk_bytes = &public_keys[next_pk]; + if pk_bytes.len() != PUB_KEY_SSZ_LEN { + return Err(Error::MalformedPublicKey { index: next_pk }); + } + let pk = XmssPublicKey::from_ssz_bytes(pk_bytes) + .map_err(|_| Error::MalformedPublicKey { index: next_pk })?; + next_pk += 1; + raw.push((pk, sig)); + } else { + let agg = SingleMessageAggregateSignature::from_bytes(entry) + .ok_or(Error::MalformedEntry { index })?; + aggregates.push(agg); + } + } + + Ok((raw, aggregates)) +} +``` + +**Important:** `from_bytes` needs the bytecode initialized. Task 6 puts +`init_aggregation_bytecode()` in the public entry points; until then, codec tests must only +use raw signatures (as written above). + +**Step 4: Run tests** + +Run: `cargo test -p lean_multisig_api --lib codec` +Expected: PASS, 5 tests. + +**Step 5: Commit** + +```bash +git commit -am "feat(lean_sig): length-based entry dispatch and pubkey pairing" +``` + +--- + +## Task 5: The `SecretKey` handle + +**Files:** +- Create: `crates/lean_multisig_api/src/key.rs` +- Modify: `crates/lean_multisig_api/src/lib.rs` (add `mod key; pub use key::SecretKey;`) + +**Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_then_verify_round_trips() { + let sk = SecretKey::from_seed([1u8; 32], 100, 16).unwrap(); + let sig = sk.sign(&[9u8; 32], 100).unwrap(); + assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); + assert_eq!(sk.public_key().len(), xmss::PUB_KEY_SSZ_LEN); + } + + #[test] + fn from_seed_is_deterministic() { + let a = SecretKey::from_seed([2u8; 32], 100, 16).unwrap(); + let b = SecretKey::from_seed([2u8; 32], 100, 16).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, 16).unwrap(); + let before = sk.sign(&[4u8; 32], 105).unwrap(); + let restored = SecretKey::from_bytes(&sk.to_bytes()).unwrap(); + assert_eq!(restored.public_key(), sk.public_key()); + assert_eq!(restored.sign(&[4u8; 32], 105).unwrap(), before); + } + + #[test] + fn signing_outside_the_slot_range_fails() { + let sk = SecretKey::from_seed([5u8; 32], 100, 16).unwrap(); + assert_eq!(sk.slots(), 100..=115); + assert!(sk.sign(&[0u8; 32], 116).is_err()); + assert!(sk.sign(&[0u8; 32], 99).is_err()); + } + + #[test] + fn malformed_bytes_are_rejected() { + assert!(matches!( + SecretKey::from_bytes(&[0u8; 3]), + Err(Error::MalformedSecretKey) + )); + } +} +``` + +**Step 2: Run to verify failure** + +Run: `cargo test -p lean_multisig_api --lib key` +Expected: FAIL, `cannot find type SecretKey`. + +**Step 3: Implement** + +```rust +use crate::Error; +use ssz::Encode; +use xmss::{XmssSecretKey, xmss_key_gen, xmss_key_gen_from_seed, xmss_sign}; + +/// An XMSS secret key, active for a fixed slot range. +/// +/// This is a handle rather than a byte slice on purpose: the key holds a bottom-subtree cache +/// that `sign` warms and reuses. Serializing drops that cache, so a bytes-in/bytes-out `sign` +/// would rebuild a subtree on every call. +/// +/// WARNING: XMSS is stateful. Never sign two different messages at the same slot. Signing is +/// derandomized, so repeating the same (slot, message) is harmless and returns identical bytes. +#[derive(Debug)] +pub struct SecretKey(XmssSecretKey); + +impl SecretKey { + /// Generates a key active for `num_active_slots` slots starting at `activation_slot`. + pub fn generate(activation_slot: u64, num_active_slots: u64) -> Result { + 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. + pub fn from_seed(seed: [u8; 32], activation_slot: u64, num_active_slots: u64) + -> Result + { + let (_, sk) = xmss_key_gen_from_seed(seed, activation_slot, num_active_slots)?; + Ok(Self(sk)) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + postcard::from_bytes::(bytes) + .map(Self) + .map_err(|_| Error::MalformedSecretKey) + } + + pub fn to_bytes(&self) -> Vec { + postcard::to_allocvec(&self.0).expect("postcard serialization failed") + } + + /// SSZ-encoded public key, `PUB_KEY_SSZ_LEN` bytes. + pub fn public_key(&self) -> Vec { + self.0.public_key().as_ssz_bytes() + } + + pub fn slots(&self) -> std::ops::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 choice the library cannot make for you. + pub fn prepare(&self, slot: u32) -> Result<(), Error> { + self.0.prepare(slot).map_err(Into::into) + } + + /// SSZ-encoded signature, `SIGNATURE_SSZ_LEN` bytes, ready for `aggregate`. + pub fn sign(&self, message: &[u8; 32], slot: u32) -> Result, Error> { + Ok(xmss_sign(&self.0, slot, message)?.as_ssz_bytes()) + } +} +``` + +Add `rand.workspace = true` to `[dependencies]` (it is currently only a dev-dependency). + +**Step 4: Run tests** + +Run: `cargo test -p lean_multisig_api --lib key` +Expected: PASS, 5 tests. + +**Step 5: Commit** + +```bash +git commit -am "feat(lean_sig): SecretKey handle with warm signing cache" +``` + +--- + +## Task 6: `aggregate` and `verify` + +**Files:** +- Modify: `crates/lean_multisig_api/src/lib.rs` + +No unit tests here — every path needs a real prover. Task 7 covers it with integration tests. + +**Step 1: Implement the public functions** + +```rust +use rec_aggregation::{ + MAX_XMSS_AGGREGATED, SingleMessageAggregateSignature, aggregate_single_message_signatures, + init_aggregation_bytecode, verify_single_message_aggregate, +}; +use ssz::Encode; +use std::collections::BTreeSet; + +/// Pays the one-time bytecode compile up front. Optional: every entry point does this lazily. +pub fn warm_up() { + init_aggregation_bytecode(); +} + +/// Aggregates raw XMSS signatures and previously produced aggregates into a single proof, +/// all sharing one `(message, slot)`. +/// +/// `public_keys` covers **raw signatures only** — aggregates carry their own signer sets — so +/// the k-th raw entry of `proof_or_sig` pairs with `public_keys[k]`. The two vectors are +/// therefore not index-aligned when aggregates are present. +/// +/// The recursion tree and every `log_inv_rate` are chosen internally. +/// +/// Runs entirely sequentially: only one proving job may run per process, so wall-clock is the +/// sum of every node's proving time. Expect this to be slow for large inputs. +pub fn aggregate( + proof_or_sig: Vec>, + public_keys: Vec>, + message: [u8; 32], + slot: u32, +) -> Result, Error> { + init_aggregation_bytecode(); + let (raw, children) = codec::classify(proof_or_sig, public_keys)?; + + // Reject over-capacity before proving anything: failing after minutes of work is cruel. + let mut signers: BTreeSet<_> = raw.iter().map(|(pk, _)| pk.clone()).collect(); + for child in &children { + signers.extend(child.info.pubkeys.iter().cloned()); + } + if signers.len() > MAX_XMSS_AGGREGATED { + return Err(Error::TooManySigners { got: signers.len(), max: MAX_XMSS_AGGREGATED }); + } + + let tree = plan::plan(raw.len(), children.len()); + Ok(execute(&tree, &raw, &children, message, slot)?.to_bytes()) +} + +fn execute( + node: &plan::Plan, + raw: &[(xmss::XmssPublicKey, xmss::XmssSignature)], + children: &[SingleMessageAggregateSignature], + message: [u8; 32], + slot: u32, +) -> Result { + match node { + plan::Plan::Passthrough(i) => Ok(children[*i].clone()), + plan::Plan::Node { raw: range, children: kids, log_inv_rate } => { + let proved: Vec<_> = kids + .iter() + .map(|k| execute(k, raw, children, message, slot)) + .collect::>()?; + let mine = raw[range.clone()].to_vec(); + Ok(aggregate_single_message_signatures(&proved, mine, message, slot, *log_inv_rate)?) + } + } +} + +/// Verifies an aggregate and returns the signer set it actually proves, as SSZ-encoded +/// public keys. +/// +/// The signer set is the success value rather than an input on purpose: an aggregate over the +/// wrong validator set is still a valid proof, so a `bool` would let callers forget to check +/// who signed. Order is the library's canonical sorted order, not the order you aggregated in +/// — compare as a set. +#[must_use = "an aggregate proves nothing until you check who signed it"] +pub fn verify(aggregate: &[u8], message: &[u8; 32], slot: u32) -> Result>, Error> { + init_aggregation_bytecode(); + let sig = SingleMessageAggregateSignature::from_bytes(aggregate) + .ok_or(Error::MalformedEntry { index: 0 })?; + if &sig.info.core.message != message || sig.info.core.slot != slot { + return Err(Error::SignerSetMismatch); + } + verify_single_message_aggregate(&sig)?; + Ok(sig.info.pubkeys.iter().map(Encode::as_ssz_bytes).collect()) +} + +/// [`verify`], checking the proved signer set against one you already know. +pub fn verify_with_signers( + aggregate: &[u8], + expected: &[Vec], + message: &[u8; 32], + slot: u32, +) -> Result<(), Error> { + let proved = verify(aggregate, message, slot)?; + let proved: BTreeSet<_> = proved.into_iter().collect(); + let expected: BTreeSet<_> = expected.iter().cloned().collect(); + if proved == expected { Ok(()) } else { Err(Error::SignerSetMismatch) } +} +``` + +Note the message/slot mismatch currently reuses `SignerSetMismatch`. Add a distinct +`Error::MessageMismatch` variant instead — a wrong message is not a wrong signer set. + +**Step 2: Verify it builds** + +Run: `cargo build -p lean_multisig_api && cargo clippy -p lean_multisig_api --all-targets` +Expected: no warnings (the workspace denies a lot; fix what it flags). + +**Step 3: Commit** + +```bash +git commit -am "feat(lean_sig): aggregate and verify entry points" +``` + +--- + +## Task 7: Integration tests + +**Files:** +- Create: `crates/lean_multisig_api/tests/round_trip.rs` + +These invoke the real prover and are slow. Keep counts tiny. + +**Step 1: Write the tests** + +```rust +use lean_multisig_api::{SecretKey, aggregate, verify, verify_with_signers}; +use std::collections::BTreeSet; + +const MSG: [u8; 32] = [42u8; 32]; +const SLOT: u32 = 100; + +fn signers(n: u8) -> Vec { + (0..n).map(|i| SecretKey::from_seed([i; 32], 100, 16).unwrap()).collect() +} + +#[test] +fn aggregate_then_verify_returns_the_signer_set() { + let keys = signers(2); + let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); + let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); + + let agg = aggregate(sigs, pks.clone(), MSG, SLOT).unwrap(); + + // Aggregation sorts and dedups, so compare as sets. + let proved: BTreeSet<_> = verify(&agg, &MSG, SLOT).unwrap().into_iter().collect(); + assert_eq!(proved, pks.iter().cloned().collect::>()); + + verify_with_signers(&agg, &pks, &MSG, SLOT).unwrap(); +} + +#[test] +fn verify_rejects_the_wrong_message() { + let keys = signers(2); + let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); + let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); + let agg = aggregate(sigs, pks, MSG, SLOT).unwrap(); + + assert!(verify(&agg, &[0u8; 32], SLOT).is_err()); + assert!(verify(&agg, &MSG, SLOT + 1).is_err()); +} + +#[test] +fn verify_rejects_a_tampered_proof() { + let keys = signers(2); + let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); + let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); + let mut agg = aggregate(sigs, pks, MSG, SLOT).unwrap(); + + let last = agg.len() - 1; + agg[last] ^= 0xff; + assert!(verify(&agg, &MSG, SLOT).is_err()); +} + +#[test] +fn verify_rejects_a_different_expected_signer_set() { + let keys = signers(2); + let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); + let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); + let agg = aggregate(sigs, pks.clone(), MSG, SLOT).unwrap(); + + let outsider = SecretKey::from_seed([99u8; 32], 100, 16).unwrap().public_key(); + assert!(verify_with_signers(&agg, &[pks[0].clone(), outsider], &MSG, SLOT).is_err()); +} + +#[test] +fn works_without_an_explicit_warm_up() { + // Lazy init must make the bytecode OnceLock invisible; this must not panic. + let keys = signers(1); + let sigs = vec![keys[0].sign(&MSG, SLOT).unwrap()]; + let pks = vec![keys[0].public_key()]; + let agg = aggregate(sigs, pks, MSG, SLOT).unwrap(); + verify(&agg, &MSG, SLOT).unwrap(); +} + +#[test] +#[ignore = "slow: builds a multi-level recursion tree"] +fn multi_level_tree_round_trips() { + // Feed a prior aggregate back in alongside fresh raw signatures. + let keys = signers(3); + let first: Vec<_> = keys[..2].iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); + let first_pks: Vec<_> = keys[..2].iter().map(SecretKey::public_key).collect(); + let inner = aggregate(first, first_pks, MSG, SLOT).unwrap(); + + let outer = aggregate( + vec![inner, keys[2].sign(&MSG, SLOT).unwrap()], + vec![keys[2].public_key()], // raw signatures only — the aggregate carries its own + MSG, + SLOT, + ).unwrap(); + + let proved = verify(&outer, &MSG, SLOT).unwrap(); + assert_eq!(proved.len(), 3); +} +``` + +### Required: the assertions Task 4 could not make + +`codec::classify` has two coverage holes that are impossible to close at unit level, because +building a parseable aggregate needs both the bytecode `OnceLock` populated and a real +`ExecutionProof`. Both must be closed here, and `multi_level_tree_round_trips` is the natural +home since it already feeds an aggregate back in: + +1. **No test has ever observed `classify` return successfully with a non-raw entry present** — + the `aggregates` vector has never been seen non-empty. Assert that folding a real aggregate + together with fresh raw signatures both succeeds *and* yields the union of signers, not just + that it returns `Ok`. Note pairing-by-raw-order is structural in the `zip`, so this is + confirming an untested path rather than guarding a fragile one. + +2. **`rejects_a_correctly_sized_but_corrupt_signature` cannot discriminate what its name says.** + A hypothetical fall-through to the aggregate parser produces the identical error at unit + level, since `from_bytes` returns `None` with no bytecode initialized. Fall-through is + prevented by construction (the `if`/`else` in `classify`), but with the bytecode genuinely + initialized here, a corrupt 1208-byte blob can be shown to give `MalformedSignature` rather + than `MalformedEntry` — which does discriminate. + +Since this test is `#[ignore]`d, run it manually at least once and record that you did. + +### Correction (Task 8): the multi-level tree is neither ignored nor impractical + +**The paragraphs above are wrong about cost, and were wrong when written.** They assume a real +multi-level tree is unaffordable and gate `multi_level_tree_round_trips` behind `#[ignore]`. +Tree depth does not come from `LEAF_TARGET`; it comes from **fan-in** (`MAX_FAN_IN = +MAX_RECURSIONS = 16`). Reaching depth 3 through raw signatures alone would need `1500 * 17` of +them, which is indeed not a test at any budget — but 17 *supplied children* fold into an +internal node over 16 plus a leftover, which is a three-level tree for 19 small proving jobs in +**~8s release**. + +As built, `a_multi_level_tree_round_trips` takes 17 single-signer children, is **not** +`#[ignore]`d, and runs in CI with everything else. It is the only test that exercises the +planner's `while pool.len() > MAX_FAN_IN` fold loop, and with it the only `Passthrough` under a +*non-root* node — so gating it would have left the planner's most interesting branch unproved. +It is still the most expensive default test in the file and the first place to look if that +binary's runtime becomes a problem. + +The two tests that *are* `#[ignore]`d are the `LEAF_TARGET` boundary pair added later +(`a_leaf_target_sized_batch_proves`, `a_batch_one_past_leaf_target_splits_and_proves`), and CI +runs those too, in a scoped `Ignored slow tests` step. + +The coverage holes 1 and 2 above are genuine and were closed — but in +`folding_an_aggregate_with_fresh_signatures_unions_the_signers` and +`a_corrupt_signature_sized_blob_is_a_malformed_signature_not_a_malformed_entry` respectively, +both of which run by default. + +**Step 2: Run** + +Run: `cargo test -p lean_multisig_api --test round_trip` +Expected: 5 pass, 1 ignored. Expect minutes, not seconds. +*As built:* 12 pass, 2 ignored, ~11s in release (~317s in debug). The "minutes" estimate was +for debug; use `--release`. + +If a small signer count trips a prover edge case, raise the count rather than shrinking the +test, and record the working count in the design doc's open questions. + +**Step 3: Run the ignored one once manually** + +Run: `cargo test -p lean_multisig_api --test round_trip -- --ignored` +Expected: PASS. *As built:* this runs the two `LEAF_TARGET` boundary tests, not the multi-level +one, and CI runs it too — see Task 8. The mixed raw+aggregate input path is covered by a +default test. + +**Step 4: Commit** + +```bash +git commit -am "test(lean_sig): round-trip and negative integration tests" +``` + +--- + +## Task 8: Rename, CI, and final verification + +Task 8 also renamed the crate from `lean_sig` to `lean_multisig_api` and added a scoped CI step +for the two `#[ignore]`d `LEAF_TARGET` boundary tests. See `.github/workflows/rust.yml`'s +`Ignored slow tests` step; note `--verbose` has to go *before* the `--`, since libtest rejects +it (`error: Unrecognized option: 'verbose'`) and would fail the step before running anything. + +**Step 1: Full workspace** + +Run: `cargo test --workspace --lib --no-fail-fast` +Expected: 0 failures across all targets. +*As built:* run without `--lib` and in release — `cargo test --release --workspace +--no-fail-fast` — since `--lib` skips every integration binary, which is where this crate's +proving tests live. Result: 130 passed, 0 failed, 18 ignored. + +**Step 2: Lints and formatting** + +Run: `cargo clippy --workspace --all-targets && cargo fmt --all -- --check` +Expected: clean. +*As built:* `-Dwarnings` added to match CI, plus a pedantic/nursery pass over this crate alone. + +**Step 3: Docs** + +Run: `cargo doc -p lean_multisig_api --no-deps` +Expected: no warnings (`rustdoc.all = "warn"` is set workspace-wide). + +**Step 4: Two decisions carried over from Task 6** + +*Move the wire-format fixture upstream.* `tests/unprovable_child.rs` hand-encodes +`SingleMessageAggregateSignature`'s postcard layout, exploiting the fact that a tuple of the +right leaves is byte-identical to a struct whose fields are `pub(crate)` and unconstructable +from outside. `the_fixture_really_does_get_past_the_envelope` keeps it from silently going +vacuous, which is what makes the technique safe — but the knowledge lives in the wrong crate. +A `rec_aggregation` `test-utils` feature exposing a constructor for a structurally valid, +unprovable aggregate would put it where a field reorder is a compile error instead of a +downstream fixture that parses by luck. `xmss` already gates `signers_cache` this way, so the +pattern exists. Decide whether to do it here or file it as follow-up. + +**DECIDED: defer, filed as follow-up** in the design doc's "Follow-up" section. Three reasons. +(1) It changes `rec_aggregation`'s public surface — a new feature and a new exported +constructor — which is outside the scope of a facade whose whole premise is depending on +`rec_aggregation` rather than reshaping it. (2) The stated risk is bounded today: +`the_fixture_really_does_get_past_the_envelope` asserts that the blob parses far enough to +reach `MessageMismatch`, so a field reorder upstream turns the two real tests from +"passing for the right reason" into a *failing* guard test, not a silently vacuous suite. The +failure would be confusing rather than invisible, which is a much smaller problem than the one +the move is meant to solve. (3) It is not cheap. A useful upstream constructor has to keep the +"structurally valid but unprovable" property, which means it also has to know +`cumulated_n_vars()` and `check_single_message_pubkeys`'s requirements — the same knowledge, +relocated, plus a feature flag and its CI configuration. Worth doing when `rec_aggregation` is +next opened for its own reasons; not worth opening it for. + +*Decide whether `lazy_init_verify_with_signers` earns its ~24s.* All three lazy-init binaries +are separate files purely so each gets its own process — a shared one would let one test's +init satisfy another's assertion. But they are not equal value: the `aggregate` and `verify` +ones pin ordering claims no other test can observe, while this one pins that a four-line +function calls `verify` first, which is visible by reading it. With Task 7's costs now on the +table, decide once whether to keep, `#[ignore]`, or drop it. + +**DECIDED: keep, unchanged.** The ~24s was a debug figure and the premise it rested on is +gone. Measured in release, the binary costs **2.83s** — and the other two lazy-init binaries +cost 2.83s each as well, so this is not the expensive one; all three are dominated by the same +one-time bytecode compile. A cost-based argument for singling it out no longer exists. + +The value argument stands as written — it is the weakest of the three — but "weakest of three" +is not "worthless". `verify_with_signers` calling `verify` first is visible by reading it +*today*; the test is what keeps it true after someone adds a length or set-size pre-check +above that call, which is exactly the plausible edit that would move a public entry point back +in front of the `OnceLock`. At 2.83s for a claim about a public entry point fed by gossip, and +with the alternative being an asymmetry a reader would have to be told about, keeping it is +the cheaper option in every sense that was actually measured. + +**Step 5: Update the design doc** + +Resolve the open questions in `docs/plans/2026-08-14-lean-sig-facade-design.md`: record the +measured `LEAF_TARGET`, the final crate name, and the `generate`-without-RNG decision (now +resolved — `generate` takes no RNG; `from_seed` covers deterministic testing). + +Also record what is knowingly untested, so nobody later mistakes absence for coverage: +the **accepting** side of the 32768 ceiling (exactly 32768 signers passes the check and goes +on to prove 22 leaves — untestable at any tier), `MAX_XMSS_DUPLICATES` (reachable only after +proving everything below the root, since an exact pre-check means simulating the tree), and +whether the bottom-subtree cache actually saves work (a timing property, with no benchmark +harness here to assert it without inventing one). + +**Done.** The design doc's "Open questions" is now a "Resolved questions" section, followed by +"Knowingly untested", an operational note on the runner's stdout diagnostic, and "Follow-up". +It also records one item this step did not anticipate: the **largest leaf that proves** is +still unmeasured, so `LEAF_TARGET = 1500` is known-good rather than known-optimal. + +**Step 6: Commit** + +```bash +git commit -am "docs: resolve lean_multisig_api design open questions" +``` + +--- + +## Tuning questions deferred from Task 3 to Task 6 + +The planner (`plan.rs`) is deliberately conservative. Three shape decisions were left +unmeasured rather than guessed at; all three are measurement work, not redesign. + +**Wall-clock is the sum over nodes, not a critical path.** `crates/backend/zk-alloc/src/lib.rs:99` +asserts *"only one proving job runs at a time"*. So any reasoning of the form "the widest node +dominates" is wrong here — minimizing total node count is what matters. Greedy `chunks(16)` already +does that (`ceil(L/16)` is minimal). The genuine open question is whether per-node trace **padding** +makes a 16+1 split cost more than a balanced 9+8, which only measurement settles. + +> **Task 8 measurement, bearing directly on this.** 1500 raw signatures is one node at +> `RATE_ROOT` and takes **8.16s**. 1501 is two leaves at `RATE_LEAF` under a root at +> `RATE_ROOT` — three proving jobs, one more signature — and takes **6.74s**. The 3-node plan +> is *cheaper*. Minimizing node count is therefore the wrong primary objective: the **rate** +> the planner assigns dominates the node count it creates, because only the root pays +> `RATE_ROOT`. This points against the intuition that motivated the paragraph above. Anyone +> picking this up should measure rate assignment first and node count second. (Both figures are +> from `round_trip.rs`'s two `#[ignore]`d boundary tests, which CI now runs.) + +**Mixed raw+child nodes are unused but supported.** The planner never emits a node holding both +raw signatures and child proofs: `raw` is non-empty if and only if `children` is empty. But +`aggregate_single_message_signatures(children, raw_xmss, ...)` accepts both, and `src/main.rs:111-117` +— the same topology `LEAF_TARGET` is derived from — mixes at raw counts of 10 and 25. + +The cost of not mixing falls on what is likely the most common call: for `n_raw <= LEAF_TARGET` +with 1..=15 supplied children ("add my batch of signatures to an existing aggregate"), the current +plan is **two** proving jobs — a leaf, then a root merging it with the passthroughs — where one node +holding both would be **one**. At minutes per job that is roughly 2x on the incremental path. + +Before implementing it, measure whether `LEAF_TARGET` raw plus a full fan-in of children fits the +trace bound. `main.rs` only ever mixes at small raw counts, which is consistent with a conservative +rule that would itself need measuring. Getting this wrong means a failed proof after minutes of work. + +**The degenerate leaf.** `step_by(LEAF_TARGET)` means `plan(LEAF_TARGET + 1, 0)` produces leaves of +1500 and **1** — a full proving job for a single signature. Same greedy-vs-balanced question as +above, with a more extreme worst case. + +--- + +## Notes for the implementer + +- **Do not** add multi-message aggregation. It was explicitly ruled out of scope. +- Task 6's entry point must call `plan(raw.len(), children.len())` itself and never accept an + externally-constructed `Plan`. The plan's `Range`s and `Passthrough` indices point into + caller-owned slices with nothing type-level tying them together, so constructing a plan away + from its slices is the one way to misuse this module. +- **Do not** expose `log_inv_rate`, topology, or any `rec_aggregation` type in a public + signature. The entire point is that callers have no knobs. +- `LEAF_TARGET = 1500` is inherited from a tuned benchmark topology, not derived. If a leaf + ever exceeds the table-height limit, lower it and say so in the design doc. +- Prefer widening a test's signer count over deleting a test if the prover misbehaves at small + sizes. A recently fixed bug (`cea9fe7`) lived exactly in that small-instance regime. From 8abd87b16bc03dc076f55b86a3e20cec26c84a58 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 19:00:40 +0100 Subject: [PATCH 03/12] refactor(lean_multisig_api): cleanup pass over the facade Quality-only changes from a four-angle review (reuse, simplification, efficiency, altitude). No behavior changes except the two noted below, both of which only remove work. - Drop `Error::Verify`. Nothing in the crate could construct it: the facade never verifies a raw XMSS signature outside the prover, so that fault surfaces as `Error::Aggregation`. A public enum variant for an unreachable case costs consumers a match arm and weakened the deliberately-exhaustive `source()` match by seeding it with a variant nobody decided about. - Share `codec::Raw` instead of restating the same tuple alias in `lib.rs`. The two only ever typechecked against each other because they happened to be written identically. - Extract `proves()` for the "does this aggregate prove (message, slot)?" test, which `aggregate` and `verify` spelled two different ways. Also the only place reaching two levels into `rec_aggregation`'s struct. - `verify_with_signers` no longer collects the proved set into a second `BTreeSet`. `verify` returns a set `check_single_message_pubkeys` already enforced to be strictly sorted, so a length match plus containment is exact. Saves building a tree of up to 32768 nodes on the path a node runs per gossiped aggregate. - Guard the three `log_inv_rate` literals against `lean_vm::{MIN,MAX}_WHIR_LOG_INV_RATE`. `default_whir_config` asserts rather than errors, so a narrowed band upstream would abort every aggregation at proving time with no compile- or test-time signal. Same treatment `MAX_FAN_IN` and `LOG_LIFETIME` already get, for the same reason: this crate does not own the value. `lean_vm` moves from dev- to normal dependency for it, which also retires a dev-dep entry that was redundant (integration tests see `[dependencies]` too). - `warm_up`'s doc said what it warms but not what it does not. The worker pool and DFT twiddle table are also lazy and it touches neither, so a service following the doc still paid both inside its first request. Points at `setup_prover_without_arena`. - `base_set()` in round_trip.rs, replacing four hand-rolled collects. TODO.md records that `#[ignore]` carries two meanings, which is why CI selects slow tests by naming a binary rather than `--include-ignored`. Co-Authored-By: Claude Opus 5 (1M context) --- TODO.md | 10 ++++++ crates/lean_multisig_api/Cargo.toml | 10 +++--- crates/lean_multisig_api/src/codec.rs | 5 ++- crates/lean_multisig_api/src/error.rs | 9 ------ crates/lean_multisig_api/src/lib.rs | 33 ++++++++++++++------ crates/lean_multisig_api/src/plan.rs | 11 +++++++ crates/lean_multisig_api/tests/round_trip.rs | 18 ++++++----- 7 files changed, 64 insertions(+), 32 deletions(-) diff --git a/TODO.md b/TODO.md index 4a0c0c3c..3eb93b0c 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,16 @@ 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/lean_multisig_api/Cargo.toml b/crates/lean_multisig_api/Cargo.toml index c3c39a54..fccb0222 100644 --- a/crates/lean_multisig_api/Cargo.toml +++ b/crates/lean_multisig_api/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true 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 @@ -14,11 +17,10 @@ ssz.workspace = true postcard.workspace = true rand.workspace = true -# `tests/unprovable_child.rs` hand-builds an aggregate envelope, which needs the field types its -# wire format is made of. Everything else the tests use comes from `[dependencies]`, which -# integration tests can also see. +# Integration tests also see `[dependencies]`, so only feature additions belong here. +# `tests/unprovable_child.rs` hand-builds an aggregate envelope from `lean_vm`'s field types, +# which `[dependencies]` already provides. [dev-dependencies] -lean_vm.workspace = true # `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 diff --git a/crates/lean_multisig_api/src/codec.rs b/crates/lean_multisig_api/src/codec.rs index fa9dae98..3e89e014 100644 --- a/crates/lean_multisig_api/src/codec.rs +++ b/crates/lean_multisig_api/src/codec.rs @@ -10,7 +10,10 @@ use ssz::Decode; use xmss::{SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSignature}; /// A raw signature paired with the public key that produced it. -type Raw = (XmssPublicKey, XmssSignature); +/// +/// Shared with `lib.rs` rather than restated there: the two only ever typechecked against each +/// other because the tuples happened to be written identically. +pub(crate) type Raw = (XmssPublicKey, XmssSignature); /// A raw XMSS signature is exactly `SIGNATURE_SSZ_LEN` bytes; anything else is parsed as an /// aggregate. Counted once and dispatched on once, so the two cannot drift apart. diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs index 698a7d9b..288a484d 100644 --- a/crates/lean_multisig_api/src/error.rs +++ b/crates/lean_multisig_api/src/error.rs @@ -6,7 +6,6 @@ use std::fmt::{Display, Formatter}; pub enum Error { KeyGen(xmss::XmssKeyGenError), Sign(xmss::XmssSignatureError), - Verify(xmss::XmssVerifyError), Aggregation(rec_aggregation::AggregationError), Proof(backend::ProofError), /// A `proof_or_sig` entry was not `SIGNATURE_SSZ_LEN` bytes and did not parse as an @@ -61,12 +60,6 @@ impl From for Error { } } -impl From for Error { - fn from(err: xmss::XmssVerifyError) -> Self { - Self::Verify(err) - } -} - impl From for Error { fn from(err: rec_aggregation::AggregationError) -> Self { Self::Aggregation(err) @@ -88,7 +81,6 @@ impl Display for Error { // Not "Signing failed": `SecretKey::prepare` maps through this variant too, and it // signs nothing. The wording has to fit every entry point that can raise it. Self::Sign(_) => write!(f, "XMSS signing operation failed"), - Self::Verify(_) => write!(f, "Signature verification failed"), Self::Aggregation(_) => write!(f, "Aggregation failed"), Self::Proof(_) => write!(f, "Proof error"), Self::MalformedEntry { index } => { @@ -121,7 +113,6 @@ impl std::error::Error for Error { match self { Self::KeyGen(e) => Some(e), Self::Sign(e) => Some(e), - Self::Verify(e) => Some(e), Self::Aggregation(e) => Some(e), Self::Proof(e) => Some(e), // Spelled out rather than `_`: `#[non_exhaustive]` does not apply inside the diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index c9ff194a..7c51f52a 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -25,13 +25,20 @@ use rec_aggregation::{ use ssz::Encode; use std::borrow::Cow; use std::collections::BTreeSet; -use xmss::{XmssPublicKey, XmssSignature}; +use xmss::XmssPublicKey; +use crate::codec::Raw; pub use error::Error; pub use key::SecretKey; -/// A raw signature paired with the public key that produced it. -type Raw = (XmssPublicKey, XmssSignature); +/// Whether `sig` proves the `(message, slot)` asked for. +/// +/// One rule, one spelling: `aggregate` applies it to every supplied child and `verify` to the +/// aggregate handed in, and both raise [`Error::MessageMismatch`] from it. Also the only place +/// reaching two levels into `rec_aggregation`'s struct, so a layout change lands here alone. +fn proves(sig: &SingleMessageAggregateSignature, message: &[u8; 32], slot: u32) -> bool { + &sig.info.core.message == message && sig.info.core.slot == slot +} /// Pays the one-time aggregation-bytecode compile up front. /// @@ -39,6 +46,11 @@ type Raw = (XmssPublicKey, XmssSignature); /// themselves, and it is idempotent, so this only moves *when* the cost lands. A long-running /// service calls it at startup rather than paying it inside its first real request. /// +/// It warms the bytecode compile and nothing else. The worker pool and the DFT twiddle table +/// are also built lazily on first use, and this does not touch either — an embedder that wants +/// those paid at startup too calls `lean_multisig::setup_prover_without_arena` (or +/// `setup_prover`, which additionally engages the arena; see [`aggregate`]'s `# Cost`). +/// /// # Panics /// /// If the bytecode fails to compile. The program source is embedded in the binary, so that is @@ -133,10 +145,7 @@ pub fn aggregate( // sibling is a passthrough, but only after proving them if any sibling is a node this call // has to prove first. Checking the flat vector here covers every child wherever the // planner later puts it. - if children - .iter() - .any(|c| c.info.core.message != message || c.info.core.slot != slot) - { + if !children.iter().all(|c| proves(c, &message, slot)) { return Err(Error::MessageMismatch); } @@ -277,7 +286,7 @@ pub fn verify(aggregate: &[u8], message: &[u8; 32], slot: u32) -> Result], message: &[u8 // Inherits the bytecode initialization from `verify`, which is the first thing this calls // and which initializes before it parses anything. let proved = verify(aggregate, message, slot)?; - let proved: BTreeSet<&[u8]> = proved.iter().map(Vec::as_slice).collect(); + // Only `expected` needs collecting: `verify` returns a set that `check_single_message_pubkeys` + // already enforced to be strictly sorted, so it holds no repeats and a length match plus + // containment is exact. Building a second tree of up to 32768 nodes only to compare it + // against the first is work this path runs per gossiped aggregate. let expected: BTreeSet<&[u8]> = expected.iter().map(Vec::as_slice).collect(); - if proved == expected { + if proved.len() == expected.len() && proved.iter().all(|p| expected.contains(p.as_slice())) { Ok(()) } else { Err(Error::SignerSetMismatch) @@ -314,6 +326,7 @@ pub fn verify_with_signers(aggregate: &[u8], expected: &[Vec], message: &[u8 mod tests { use super::*; use ssz::Decode; + use xmss::XmssSignature; const MSG: [u8; 32] = [42u8; 32]; const SLOT: u32 = 100; diff --git a/crates/lean_multisig_api/src/plan.rs b/crates/lean_multisig_api/src/plan.rs index 9d6f3f2c..ee28d407 100644 --- a/crates/lean_multisig_api/src/plan.rs +++ b/crates/lean_multisig_api/src/plan.rs @@ -34,6 +34,17 @@ pub(crate) const RATE_INTERNAL: usize = 2; /// Smallest proof. Only the root goes on the wire. pub(crate) const RATE_ROOT: usize = 4; +// The three rates above are literals restating the band `lean_prover::default_whir_config` +// accepts, and it `assert!`s rather than erroring — so a narrowed band upstream would abort +// every aggregation at proving time with no compile-time or test-time signal. Same guard the +// two constants above get, for the same reason: this crate does not own the value. +const _: () = assert!( + RATE_LEAF >= lean_vm::MIN_WHIR_LOG_INV_RATE + && RATE_ROOT <= lean_vm::MAX_WHIR_LOG_INV_RATE + && RATE_LEAF <= RATE_INTERNAL + && RATE_INTERNAL <= RATE_ROOT +); + /// One node of the recursion tree, or a caller-supplied aggregate reused as-is. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Plan { diff --git a/crates/lean_multisig_api/tests/round_trip.rs b/crates/lean_multisig_api/tests/round_trip.rs index 4117ed40..861a1d3e 100644 --- a/crates/lean_multisig_api/tests/round_trip.rs +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -111,6 +111,12 @@ fn base_pubkeys() -> Vec> { signers(2).iter().map(SecretKey::public_key).collect() } +/// [`base_pubkeys`] as a set, which is how every comparison against a proved signer set wants +/// it — `proved_set` is the other half of the same pairing. +fn base_set() -> BTreeSet> { + base_pubkeys().into_iter().collect() +} + /// A real aggregate over `(MSG, SLOT)` signed by `base_pubkeys()`. fn base() -> &'static [u8] { BASE.get_or_init(|| { @@ -263,7 +269,7 @@ fn folding_an_aggregate_with_fresh_signatures_unions_the_signers() { ) .unwrap(); - let mut expected: BTreeSet> = base_pubkeys().into_iter().collect(); + let mut expected = base_set(); expected.insert(fresh.public_key()); assert_eq!(expected.len(), 3, "the fresh signer must be a genuinely new key"); assert_eq!(proved_set(&outer), expected); @@ -292,7 +298,7 @@ fn duplicate_raw_signatures_collapse_to_one_signer() { let agg = prove(entries, pks, MSG, SLOT).unwrap(); assert_eq!( proved_set(&agg), - base_pubkeys().into_iter().collect::>(), + base_set(), "a repeated signer must prove exactly once, and must not take the other one down with it" ); } @@ -317,7 +323,7 @@ fn a_signer_present_in_both_a_child_and_a_raw_batch_appears_once() { let agg = prove(entries, pks, MSG, SLOT).unwrap(); - let mut expected: BTreeSet> = base_pubkeys().into_iter().collect(); + let mut expected = base_set(); expected.insert(fresh.public_key()); assert_eq!(expected.len(), 3, "two from the child plus one new one"); assert_eq!( @@ -380,11 +386,7 @@ fn a_lone_valid_aggregate_is_passed_through_unchanged() { // which is what shows the check does not reject *valid* aggregates too). let out = prove(vec![base().to_vec()], vec![], MSG, SLOT).unwrap(); - assert_eq!( - proved_set(&out), - base_pubkeys().into_iter().collect::>(), - "a passthrough must not change who signed" - ); + assert_eq!(proved_set(&out), base_set(), "a passthrough must not change who signed"); // Decode/re-encode is the identity, so the passthrough is a passthrough in bytes and not // merely in meaning. `rebuild_bytecode_claim` recomputes the claim's value on the way in and // `to_bytes` never writes it, so the round trip has nothing to drift on. From 49412509bf465e0858ad5b26d745336f1b21794b Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 19:04:55 +0100 Subject: [PATCH 04/12] docs: drop the plan documents, folding their live content into the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/` did not exist before this work and held only the two planning artifacts. Removing them, but four code comments referenced them and the substance was worth keeping, so it moves into the code that depends on it rather than disappearing: - `LEAF_TARGET`'s doc now records the measurement itself: the two `#[ignore]`d boundary tests prove a full 1500-signature leaf and the 1501 split, so it is a size the prover demonstrably accepts. Also records what is still unmeasured — the *largest* leaf that proves — so the value reads as known-good rather than known-optimal. - `Plan::Node.raw` states what blocks the mixed raw+child node: roughly 2x on the incremental fold path, blocked only on whether that trace size fits, which nobody has measured. - The greedy-split comment gains the data point that argues against balancing: 1501 as [1500, 1] plus a root measures faster than 1500 as one node, because only the root pays `RATE_ROOT`. - Two "Task N" references dropped; the task numbering they pointed at no longer exists anywhere. Co-Authored-By: Claude Opus 5 (1M context) --- crates/lean_multisig_api/src/key.rs | 2 +- crates/lean_multisig_api/src/plan.rs | 26 +- .../2026-08-14-lean-sig-facade-design.md | 373 ------ .../2026-08-14-lean-sig-implementation.md | 1127 ----------------- 4 files changed, 21 insertions(+), 1507 deletions(-) delete mode 100644 docs/plans/2026-08-14-lean-sig-facade-design.md delete mode 100644 docs/plans/2026-08-14-lean-sig-implementation.md diff --git a/crates/lean_multisig_api/src/key.rs b/crates/lean_multisig_api/src/key.rs index f1353fae..2165f5c4 100644 --- a/crates/lean_multisig_api/src/key.rs +++ b/crates/lean_multisig_api/src/key.rs @@ -207,7 +207,7 @@ mod tests { // 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. Task 6's `aggregate` consumes + // 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(); diff --git a/crates/lean_multisig_api/src/plan.rs b/crates/lean_multisig_api/src/plan.rs index ee28d407..fd353d46 100644 --- a/crates/lean_multisig_api/src/plan.rs +++ b/crates/lean_multisig_api/src/plan.rs @@ -10,10 +10,17 @@ use rec_aggregation::MAX_RECURSIONS; use std::ops::Range; -/// Raw signatures per leaf. Taken from `src/main.rs`'s tuned topology (leaves of 508..1550): -/// 1500 sits just under the largest leaf that topology proves successfully (1550); the 2^22 -/// table height is the underlying bound but has not been computed against. See the design -/// doc's open questions: this wants measuring. +/// 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`, @@ -57,7 +64,10 @@ pub(crate) enum Plan { /// `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 - /// open as a Task 6 tuning question. + /// 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, @@ -92,7 +102,11 @@ pub(crate) fn plan(n_raw: usize, n_children: usize) -> Plan { // 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; a Task 6 tuning question. + // 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 { diff --git a/docs/plans/2026-08-14-lean-sig-facade-design.md b/docs/plans/2026-08-14-lean-sig-facade-design.md deleted file mode 100644 index 7cbe276b..00000000 --- a/docs/plans/2026-08-14-lean-sig-facade-design.md +++ /dev/null @@ -1,373 +0,0 @@ -# `lean_multisig_api`: an opinionated facade over XMSS and aggregation - -**Status:** implemented (Tasks 1-8 complete) -**Date:** 2026-08-14 - -> **Crate renamed.** This file is named `2026-08-14-lean-sig-*` and the commits from Tasks 1-7 -> are scoped `feat(lean_sig)` / `test(lean_sig)`, because `lean_sig` was the working name until -> Task 8 renamed the crate to **`lean_multisig_api`**. The filename and those commit scopes are -> left alone deliberately: they are how existing commit messages refer to this work. Everything -> else here says `lean_multisig_api`. - -## Purpose - -`xmss` and `rec_aggregation` expose the full parameter space: `log_inv_rate`, recursion -topology, bytecode claims, field elements. Callers who just want to sign and aggregate must -first learn which of those choices matter. - -`lean_multisig_api` removes the choices. It exposes signing and single-message aggregation over -byte slices, and picks every tuning parameter internally. Callers who need the full -parameter space keep using `rec_aggregation` directly; this crate does not try to replace it. - -## Scope - -In scope: - -- XMSS keygen, signing, verification. -- Single-message aggregation: one `(message, slot)` shared by every signer. -- Verification of such an aggregate. - -Out of scope: - -- Multi-message aggregation (`merge_single_message_aggregates`, - `split_multi_message_aggregate`). Callers needing cross-message merging use - `rec_aggregation`. -- Any control over `log_inv_rate` or recursion topology. - -## Public surface - -Everything crossing the boundary is either a `Vec` wire format or an opaque handle. -No `KoalaBear`, `Evaluation`, or `MultilinearPoint` appears in a signature. - -### Handles - -State expensive enough to hold across operations gets a real type. - -```rust -pub struct SecretKey(xmss::XmssSecretKey); - -impl SecretKey { - // As built: one inclusive slot range, not the (activation_slot, num_active_slots) pair - // upstream takes. The range round-trips through `slots()`, and an empty one is an error. - pub fn generate(slots: RangeInclusive) -> Result; - pub fn from_seed(seed: [u8; 32], slots: RangeInclusive) -> Result; - pub fn from_bytes(b: &[u8]) -> Result; - pub fn to_bytes(&self) -> Vec; - - pub fn public_key(&self) -> Vec; // PUB_KEY_SSZ_LEN = 32 - pub const fn slots(&self) -> RangeInclusive; - pub fn prepare(&self, slot: u32) -> Result<(), Error>; - pub fn sign(&self, message: &[u8; 32], slot: u32) -> Result, Error>; - // SIGNATURE_SSZ_LEN = 1208 -} -``` - -`XmssSecretKey` holds a `top: Vec>` tree and a `Mutex>` -cache warmed by `prepare`. Serialization persists the seed, slot range, and top tree, but -drops the cache. A pure `sign(sk_bytes, msg, slot)` function would therefore rebuild a -bottom subtree on every call. The handle keeps the cache warm across signatures. - -`prepare` survives into the facade. It is the one piece of tuning the library cannot infer, -because only the caller knows which slot is coming next. - -`sign` returns SSZ bytes rather than a handle so its output drops straight into -`aggregate`'s first argument. - -### Bytes - -Public keys, signatures, and aggregates are inert blobs. They get no newtype; wrapping them -would buy nothing but conversions. - -### Aggregation - -```rust -pub fn aggregate( - proof_or_sig: Vec>, - public_keys: Vec>, - message: [u8; 32], - slot: u32, -) -> Result, Error>; - -#[must_use] -pub fn verify( - aggregate: &[u8], - message: &[u8; 32], - slot: u32, -) -> Result>, Error>; // Ok holds the signer set actually proved - -pub fn verify_with_signers( - aggregate: &[u8], - expected: &[Vec], - message: &[u8; 32], - slot: u32, -) -> Result<(), Error>; - -pub fn warm_up(); -``` - -## Dispatch and pubkey pairing - -`proof_or_sig` mixes raw XMSS signatures and previously produced aggregates. Entries are -classified by length: exactly `SIGNATURE_SSZ_LEN` (1208) means a raw signature; any other -length is parsed as a postcard aggregate via `SingleMessageAggregateSignature::from_bytes`. - -A 1208-byte blob that fails SSZ decode is an error, never a fallback to the aggregate parse. -Silent reclassification would surface as a baffling failure much later. - -Aggregates carry their own signer sets (`info.pubkeys`), so `public_keys` covers raw -signatures only: the *k*-th raw entry, in order, pairs with `public_keys[k]`. When the counts -disagree, `Error::PubkeyCountMismatch { expected, got }`. The two vectors are deliberately -not index-aligned, which is the main thing to document loudly. - -## Tree planning - -`aggregate` builds the whole recursion tree in one call and chooses `log_inv_rate` per level. -Lower rate means faster proving and a bigger proof; the useful range is 1 to 4. - -- Raw signatures partition into leaves of `LEAF_TARGET` (1500), proved at rate 1. Leaf proofs - are consumed immediately, so their size does not matter. -- Supplied child aggregates enter at the level above, fanning in at most `MAX_RECURSIONS` - (16) per node, at rate 2. -- The root is proved at rate 4. It is what goes on the wire, so it gets the smallest proof. -- Special case: when everything fits in one node it is proved at rate 4 directly, because that - node *is* the wire proof. Proving it at rate 1 would hand the caller a needlessly large one. -- A leftover group of one is passed through rather than wrapped in a single-child node, which - would prove its only child a second time for nothing. - -`LEAF_TARGET` was taken from the hand-tuned topology in `src/main.rs`, whose leaves hold 508 -to 1550 raw signatures. An earlier draft of this document said it was "tuned against the 2^22 -table-height limit" — it was not; no such calculation was ever done, and that clause overclaimed -rigor the number did not have. It has since been *measured* instead: see Resolved questions. - -### Capacity - -`aggregate_single_message_signatures` computes `global_pub_keys` as the sorted, deduplicated -union of raw pubkeys and every child's pubkeys, and rejects it above `MAX_XMSS_AGGREGATED` -(2^15 = 32768). That check applies at every node including the root, so **recursion does not -extend signer capacity**: 32768 distinct signers is the ceiling for the entire tree. The tree -exists to get past the roughly 1500 signatures a single node can prove, not past 32768. - -The facade checks this ceiling up front, before proving anything, and returns -`Error::TooManySigners { got, max }`. Failing after minutes of proving would be needlessly -cruel. - -### Sequential cost - -A whole-tree call is strictly sequential: `execute` proves nodes one after another, so -wall-clock is the sum of every node's proving time with no intra-call parallelism. That much -is unconditional, and it is documented on `aggregate` itself. - -The *cross-call* claim needs more care than this document originally gave it. It said -concurrent calls panic, citing `rec_aggregation`. That is only conditionally true. -`zk_alloc::begin_phase` returns early unless `enable_arena` has run, and `enable_arena` is -called in exactly one place — `lean_multisig::setup_prover`. An application on -`setup_prover_without_arena` never engages it either. So two concurrent `aggregate` calls -panic under an embedder that called `setup_prover`, and quietly succeed in a -`lean_multisig_api`-only harness. - -That asymmetry is a trap worth naming: a caller who tests concurrency in isolation sees it -work and concludes the warning is stale. Serialize `aggregate` calls unconditionally. - -**Measured cost.** An earlier draft of this document said "minutes per node", which was wrong -by about two orders of magnitude at the sizes anyone tests. Release measurements: 19 small -nodes in ~8s; one full 1500-signature leaf in ~8s; a 2-signer aggregate plus verify in ~3s. -The framing had propagated to seven doc sites in the crate and was corrected in all of them. - -## Initialization - -`get_aggregation_bytecode()` panics with `"call init_aggregation_bytecode() first"`, and -`SingleMessageAggregateSignature::from_bytes` silently returns `None` when the `OnceLock` is -unset. - -Every public entry point calls `init_aggregation_bytecode()` first. It is a `OnceLock`, so -this is idempotent and free after the first call, and both sharp edges disappear. The caller -never learns the bytecode exists. - -The first `aggregate` or `verify` in a process absorbs the one-time compile. `warm_up()` lets -long-running services pay that at startup instead. - -## Why `verify` returns the signer set - -An aggregate over the wrong validator set is still a perfectly valid proof. A `bool` return -invites `if verify(..) { .. }` while the caller forgets to check *who* signed. - -An earlier draft of this document claimed returning the signer set means the API "cannot be -used without confronting it". That overclaims, and it is worth correcting precisely because it -is the kind of sentence a security reviewer leans on: `verify(..)?;` compiles silently, since -after `?` the type is a plain `Vec>` with no `#[must_use]`. What the design actually -buys is that ignoring the signer set requires *discarding a value* rather than simply not -asking for one — a real improvement over `bool`, but not a guarantee the type system enforces. -`verify_with_signers` exists for the common case where the expected set is already known. - -Because every input is bytes, deserialization always runs `rebuild_bytecode_claim`, which -recomputes the trusted `bytecode_claim.value`. The unsound path that `rec_aggregation` warns -about, a trusted claim taken from an untrusted source, is unreachable through this facade. - -## Errors - -One `#[non_exhaustive] pub enum Error` implementing `std::error::Error`, flattening -`XmssKeyGenError`, `XmssSignatureError`, `XmssVerifyError`, `AggregationError`, `ProofError`, -and the facade's own parse and pairing variants. No generics; no source-crate types leak. - -## Layout - -`crates/lean_multisig_api/src/`: - -| File | Contents | -| --- | --- | -| `lib.rs` | Public surface only. No `pub mod`. | -| `key.rs` | `SecretKey` handle; SSZ codecs at the boundary. | -| `codec.rs` | Length dispatch, pubkey pairing. | -| `plan.rs` | Tree planner: `LEAF_TARGET`, fan-in, per-level rate. Pure. | -| `error.rs` | The flattened `Error` enum. | - -As built: no workspace manifest edit was needed. The root `members` is `["crates/*", ...]`, so -creating the directory registers the crate. It is deliberately *not* in -`[workspace.dependencies]` — nothing in the workspace depends on it, and an entry there would -be dead weight until something does. - -## Testing - -The layers differ enormously in cost, so they are tested separately. - -**Unit, no proving.** `plan.rs` is pure so the planner is testable without a prover: for 1, -500, 1550, and 32768 signatures, assert leaf count, fan-in at most 16, and rates descending -1 → 2 → 4 toward the root. For `codec.rs`: a 1208-byte blob classifies as raw, a malformed -one errors rather than reclassifying, and pairing arithmetic holds when aggregates are -interleaved among raw signatures. - -**Integration, slow.** `crates/lean_multisig_api/tests/`, modelled on `tests/test_multisignatures.rs`, -with `parallel`'s `forbid-parallelism` and `xmss`'s `test-utils` as dev-dependencies. Round -trip: keygen, sign, `aggregate`, then `verify` returns exactly the input signer set. Keep -these to single-digit signers and one leaf so CI stays usable; gate a multi-level tree test -behind `#[ignore]`. - -As built, the multi-level tree test is *not* gated. Tree depth comes from fan-in -(`MAX_FAN_IN = 16`), not from `LEAF_TARGET`, so 17 supplied children give a three-level tree — -19 proving jobs in ~8s release, which is affordable. The two tests that *are* gated are the -`LEAF_TARGET` boundary pair, and CI runs those in a scoped step; see -[Resolved questions](#resolved-questions). - -**Negative tests.** Wrong message; wrong slot; tampered proof bytes; a signer set that -verifies as a proof but differs from the expected set; and `verify` called before any -`warm_up`, which must succeed through lazy init rather than panic. - -## Resolved questions - -All three questions this document opened with are now settled. - -### `LEAF_TARGET` — measured, and it holds - -1500 was inherited from `src/main.rs`'s tuned topology (leaves of 508..1550) rather than -computed against the 2^22 table height. It has now been proved for real, by two `#[ignore]`d -tests in `crates/lean_multisig_api/tests/round_trip.rs` that CI runs in a scoped step: - -| Shape | Plan | Proving jobs | Wall-clock (release) | Verified signers | -| --- | --- | --- | --- | --- | -| 1500 raw signatures | one node at `RATE_ROOT` | 1 | 8.16s | 1500 | -| 1501 raw signatures | two leaves at `RATE_LEAF` under a root at `RATE_ROOT` | 3 | 6.74s | 1501 | - -1500 is measured at `RATE_ROOT`, which is the *slowest* rate the planner ever assigns — so this -is the worst case for the boundary, not a favourable reading of it. - -**The 3-node split is cheaper than the single node, despite proving one more signature.** The -two leaves run at `RATE_LEAF` and only the root pays `RATE_ROOT`. This is direct evidence -bearing on the greedy-vs-balanced tuning question deferred from Task 3 (see the implementation -plan's "Tuning questions" section), and it points *against* the intuition that motivated it: -that section reasons about minimizing node count, on the grounds that wall-clock is the sum -over nodes rather than a critical path. That reasoning is correct as far as it goes, but the -measurement says the rate the planner assigns dominates the node count it creates. A topology -change that removes a node while pushing work up to a higher rate can be a net loss. Anyone -tuning the planner should measure rate assignment first and node count second. - -**Still unmeasured: the largest leaf that proves.** 1500 sits below `main.rs`'s observed 1550 -for inherited reasons, not checked ones. Nothing here probes where the table-height limit -actually bites. So `LEAF_TARGET = 1500` is **known-good, not known-optimal**, and the headroom -above it is unknown. Raising it is a measurement task, not a guess; lowering it needs no -evidence beyond a failure. - -### The crate name — `lean_multisig_api` - -`lean_sig` was a placeholder. The crate was renamed in Task 8; the plan-doc filenames and the -Task 1-7 commit scopes still say `lean_sig`, deliberately (see the note at the top). - -### `SecretKey::generate` takes no RNG - -Settled as-is: it does not, and will not. `from_seed` covers deterministic testing completely — -it is the same key derivation with the entropy supplied by the caller — so an RNG parameter -would buy only the ability to inject a *non-default* CSPRNG, which is not a use case this -facade exists to serve. Threading an `R: CryptoRng` through a "no knobs" API is exactly the -kind of parameter the crate was built to remove. - -`generate_is_randomized` (in `key.rs`) pins the other half: that `generate` genuinely differs -run to run, and is not `from_seed` with a constant hiding in it. - -## Knowingly untested - -Absence of a test here is not absence of risk. These are the gaps that are known and were -judged not worth closing, rather than gaps nobody noticed. - -- **The accepting side of the 32768-signer ceiling.** Exactly `MAX_XMSS_AGGREGATED` signers - *passes* the up-front check and proceeds to prove 22 leaves. That is untestable at any tier — - the check is cheap but what follows it is not. The **rejecting** side is tested - (`aggregate_rejects_more_signers_than_the_ceiling`, 32769 synthetic pubkeys, ~3s), which is - the side that turns "fails after the whole tree is proved" into "fails in milliseconds". - -- **`MAX_XMSS_DUPLICATES`.** Reachable only *after* everything below the root has been proved, - because the duplicate count depends on which node merges which children — so an exact - pre-check would mean simulating the tree. `dedup_signers` removes the case a caller can - trigger directly (the same key offered twice in one call). Several *supplied aggregates* with - heavily overlapping signer sets can still hit the ceiling late, after minutes of proving. - -- **Whether the bottom-subtree cache actually saves work.** `SecretKey` is a handle rather than - a bytes-in/bytes-out `sign` precisely so `XmssSecretKey`'s `Mutex>` - survives across signatures. That the cache is *preserved* is structural; that it *pays* is a - timing property, and there is no benchmark harness in this crate to assert it without - inventing one. - -- **The largest leaf that proves.** See `LEAF_TARGET` above. - -## Operational note: the runner prints to stdout on a constraint failure - -Not a `lean_multisig_api` defect, but it affects anyone embedding it. When a raw signature -fails to verify under the public key it was paired with — the shape a misordered `public_keys` -produces — the zkVM runner **prints a diagnostic to stdout** on its way to returning -`Err(Error::Aggregation(..))`. Observed shape: - -```text -ERROR - - at xmss_aggregate.py:109 in xmss_verify - - 106 │ ) - 107 │ target_sum += pair_sum_ptr[0] - 108 │ - 109 │ assert target_sum == TARGET_SUM - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 110 │ - -CALL STACK - - → xmss_verify() at main.py:176 - main() at main.py:35 -``` - -The frames are labelled with line numbers from the aggregation program's own source, which -will mean nothing to a reader of the embedding application's logs. The error is returned -normally and nothing panics — but a node that captures stdout will emit this block for **every -malformed gossip batch**, which is attacker-controllable volume. Redirect or filter it if that -matters. - -## Follow-up - -- **Move the wire-format fixture upstream.** `tests/unprovable_child.rs` hand-encodes - `SingleMessageAggregateSignature`'s postcard layout, relying on a tuple of the right leaves - being byte-identical to a struct whose fields are `pub(crate)` and unconstructable from - outside. `the_fixture_really_does_get_past_the_envelope` keeps it from going silently vacuous, - which is what makes the technique safe today — but the knowledge lives in the wrong crate. A - `rec_aggregation` `test-utils` feature exposing a constructor for a structurally valid, - unprovable aggregate would put it where a field reorder is a compile error rather than a - downstream fixture that parses by luck. `xmss` already gates `signers_cache` this way, so the - pattern exists. **Deferred:** it changes another crate's public surface, which is outside the - scope of a facade that is meant to depend on `rec_aggregation` rather than reshape it. Do it - when `rec_aggregation` is next opened for its own reasons. diff --git a/docs/plans/2026-08-14-lean-sig-implementation.md b/docs/plans/2026-08-14-lean-sig-implementation.md deleted file mode 100644 index d53ebe08..00000000 --- a/docs/plans/2026-08-14-lean-sig-implementation.md +++ /dev/null @@ -1,1127 +0,0 @@ -# `lean_multisig_api` Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -> **Crate renamed.** This file is named `2026-08-14-lean-sig-implementation.md` and the `git -> commit` lines below are scoped `feat(lean_sig)` / `test(lean_sig)`, because `lean_sig` was the -> working name until Task 8 renamed the crate to **`lean_multisig_api`**. The filename and those -> commit scopes are left verbatim on purpose: they are the strings that appear in `git log` and -> are how a reader finds the commits for Tasks 1-7. Every other mention here says -> `lean_multisig_api`. - -**Goal:** Build `crates/lean_multisig_api`, a facade over `xmss` + `rec_aggregation` that exposes signing -and single-message aggregation over byte slices, choosing every tuning parameter internally. - -**Architecture:** Five modules. `plan.rs` is a pure tree planner (fast unit tests, no prover). -`codec.rs` does length-based dispatch of the mixed input vector. `key.rs` wraps `XmssSecretKey` -as an opaque handle so its bottom-subtree cache survives across signatures. `lib.rs` holds the -four public functions; `error.rs` flattens every upstream error into one enum. - -**Tech Stack:** Rust 2024, `xmss`, `rec_aggregation`, `ssz` (ethereum_ssz), `postcard`, `serde`. - -**Design doc:** `docs/plans/2026-08-14-lean-sig-facade-design.md`. Read it first. - ---- - -## Background you need - -Facts established by reading the existing code. Do not re-derive these. - -**`xmss` crate:** - -```rust -xmss_key_gen(rng: &mut R, activation_slot: u64, num_active_slots: u64) - -> Result<(XmssPublicKey, XmssSecretKey), XmssKeyGenError> -xmss_key_gen_from_seed(seed: [u8; 32], activation_slot: u64, num_active_slots: u64) - -> Result<(XmssPublicKey, XmssSecretKey), XmssKeyGenError> -xmss_sign(secret_key: &XmssSecretKey, slot: u32, message: &[u8; 32]) - -> Result -xmss_verify(pub_key: &XmssPublicKey, slot: u32, message: &[u8; 32], signature: &XmssSignature) - -> Result<(), XmssVerifyError> - -impl XmssSecretKey { - fn public_key(&self) -> XmssPublicKey; - const fn activation_slots(&self) -> std::ops::RangeInclusive; - fn prepare(&self, slot: u32) -> Result<(), XmssSignatureError>; -} -``` - -`XmssPublicKey` and `XmssSignature` implement `ssz::Encode` / `ssz::Decode` with **fixed** -lengths `PUB_KEY_SSZ_LEN` (32) and `SIGNATURE_SSZ_LEN` (1208). `XmssSecretKey` implements -serde `Serialize`/`Deserialize` (seed + slot range + top tree; the cache is dropped) but -**not** SSZ. `XmssPublicKey` derives `Ord`. - -**`rec_aggregation` crate:** - -```rust -aggregate_single_message_signatures( - children: &[SingleMessageAggregateSignature], - raw_xmss: Vec<(XmssPublicKey, XmssSignature)>, - message: [u8; 32], - slot: u32, - log_inv_rate: usize, -) -> Result - -verify_single_message_aggregate(sig: &SingleMessageAggregateSignature) - -> Result - -init_aggregation_bytecode(); - -impl SingleMessageAggregateSignature { - fn to_bytes(&self) -> Vec; // postcard, includes pubkeys - fn from_bytes(bytes: &[u8]) -> Option; -} -// sig.info.pubkeys: Vec — the signer set -// sig.info.core.message / .slot -``` - -Constants: `MAX_RECURSIONS = 16`, `MAX_XMSS_AGGREGATED = 1 << 15`. Valid `log_inv_rate` is -`1..=4` (`MIN_WHIR_LOG_INV_RATE`..`MAX_WHIR_LOG_INV_RATE`), lower = faster proving, bigger proof. - -**Three traps:** - -1. `get_aggregation_bytecode()` **panics** if `init_aggregation_bytecode()` was never called, - and `SingleMessageAggregateSignature::from_bytes` silently returns `None` in that state. - Every public entry point must call `init_aggregation_bytecode()` first. It is a `OnceLock`, - so this is idempotent and cheap. -2. `aggregate_single_message_signatures` computes the signer set as the sorted, deduplicated - **union** of raw pubkeys and all children's pubkeys, and rejects it above - `MAX_XMSS_AGGREGATED` at **every** node. Recursion does not raise that ceiling. -3. Only one proving job may run per process; a concurrent call panics. Everything is sequential. - -Because aggregation sorts and dedups, `verify` returns pubkeys in `XmssPublicKey`'s `Ord` -order, which is **not** the caller's input order and **not** SSZ-byte order. Compare as sets. - ---- - -## Task 1: Scaffold the crate - -**Files:** -- Create: `crates/lean_multisig_api/Cargo.toml` -- Create: `crates/lean_multisig_api/src/lib.rs` - -The root `Cargo.toml` already has `members = ["crates/*", ...]`, so no workspace edit is needed. - -**Step 1: Write the manifest** - -```toml -[package] -name = "lean_multisig_api" -version.workspace = true -edition.workspace = true - -[lints] -workspace = true - -[dependencies] -xmss.workspace = true -rec_aggregation.workspace = true -backend.workspace = true -ssz.workspace = true -postcard.workspace = true -serde.workspace = true - -[dev-dependencies] -rand.workspace = true -parallel = { workspace = true, features = ["forbid-parallelism"] } -``` - -**Step 2: Write a placeholder lib.rs** - -```rust -//! An opinionated facade over `xmss` and `rec_aggregation`. -//! -//! Every tuning parameter is chosen internally. Callers needing control over `log_inv_rate` -//! or recursion topology should use `rec_aggregation` directly. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod error; -pub use error::Error; -``` - -Create an empty `crates/lean_multisig_api/src/error.rs` so this compiles. - -**Step 3: Verify it builds** - -Run: `cargo build -p lean_multisig_api` -Expected: success (warnings about unused deps are fine at this stage). - -**Step 4: Commit** - -```bash -git add crates/lean_multisig_api -git commit -m "feat(lean_sig): scaffold facade crate" -``` - ---- - -## Task 2: The error enum - -**Files:** -- Modify: `crates/lean_multisig_api/src/error.rs` - -**Step 1: Write the enum** - -```rust -use std::fmt::{Display, Formatter}; - -/// Every way a `lean_multisig_api` call can fail. -#[non_exhaustive] -#[derive(Debug)] -pub enum Error { - KeyGen(xmss::XmssKeyGenError), - Sign(xmss::XmssSignatureError), - Verify(xmss::XmssVerifyError), - Aggregation(rec_aggregation::AggregationError), - Proof(backend::ProofError), - /// A `proof_or_sig` entry decoded as neither a signature nor an aggregate. - MalformedEntry { index: usize }, - /// A public key blob was not `PUB_KEY_SSZ_LEN` bytes, or held non-canonical field elements. - MalformedPublicKey { index: usize }, - /// Secret key bytes could not be deserialized. - MalformedSecretKey, - /// `public_keys.len()` must equal the number of raw signatures in `proof_or_sig`. - PubkeyCountMismatch { expected: usize, got: usize }, - /// The deduplicated signer union exceeds `MAX_XMSS_AGGREGATED`. - TooManySigners { got: usize, max: usize }, - /// `proof_or_sig` was empty. - Empty, - /// The proved signer set differs from the expected one. - SignerSetMismatch, -} -``` - -Add `Display` with one arm per variant, then `impl std::error::Error for Error {}`, then -`From` impls for the five wrapped types so `?` works. - -**Step 2: Verify** - -Run: `cargo build -p lean_multisig_api` -Expected: success. - -**Step 3: Commit** - -```bash -git commit -am "feat(lean_sig): flattened error enum" -``` - ---- - -## Task 3: The tree planner (pure, fast tests) - -This is the highest-value module to get right, and the only one testable without a prover. -Keep it free of any `rec_aggregation` calls. - -**Files:** -- Create: `crates/lean_multisig_api/src/plan.rs` -- Modify: `crates/lean_multisig_api/src/lib.rs` (add `mod plan;`) - -**Step 1: Write the failing tests first** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn single_child_alone_is_passed_through() { - // Re-proving a lone aggregate would burn minutes 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 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 } - ); - } - - #[test] - fn fan_in_never_exceeds_max_recursions() { - for n in [1, 2, LEAF_TARGET, LEAF_TARGET * 40, MAX_XMSS_AGGREGATED] { - assert_fan_in_ok(&plan(n, 0)); - } - } - - fn assert_fan_in_ok(p: &Plan) { - if let Plan::Node { children, .. } = p { - assert!(children.len() <= MAX_FAN_IN, "fan-in {} too wide", children.len()); - children.iter().for_each(assert_fan_in_ok); - } - } - - #[test] - fn every_raw_signature_is_covered_exactly_once() { - // The planner returning index ranges makes off-by-ones silent otherwise. - let n = LEAF_TARGET * 3 + 7; - let mut seen = vec![0u8; n]; - collect(&plan(n, 0), &mut seen); - assert!(seen.iter().all(|&c| c == 1), "each raw sig must appear exactly once"); - } - - fn collect(p: &Plan, seen: &mut [u8]) { - if let Plan::Node { raw, children, .. } = p { - for i in raw.clone() { seen[i] += 1; } - children.iter().for_each(|c| collect(c, seen)); - } - } - - #[test] - fn rates_descend_toward_the_root() { - let p = plan(LEAF_TARGET * 40, 0); - let Plan::Node { log_inv_rate, children, .. } = &p else { panic!() }; - assert_eq!(*log_inv_rate, RATE_ROOT); - // Every non-root internal node proves at RATE_INTERNAL, every leaf at RATE_LEAF. - for c in children { - if let Plan::Node { log_inv_rate, children: gc, .. } = c { - let expected = if gc.is_empty() { RATE_LEAF } else { RATE_INTERNAL }; - assert_eq!(*log_inv_rate, expected); - } - } - } -} -``` - -**Step 2: Run to verify they fail** - -Run: `cargo test -p lean_multisig_api --lib plan` -Expected: FAIL, `cannot find function plan`. - -**Step 3: Write the implementation** - -```rust -use rec_aggregation::{MAX_RECURSIONS, MAX_XMSS_AGGREGATED}; -use std::ops::Range; - -/// Raw signatures per leaf. Taken from `src/main.rs`'s tuned topology (leaves of 508..1550), -/// bounded by the 2^22 table height. See the design doc's open questions: this wants measuring. -pub(crate) const LEAF_TARGET: usize = 1500; -pub(crate) const MAX_FAN_IN: usize = MAX_RECURSIONS; - -/// Fast proving, large proof. Leaf proofs are consumed immediately, so size is irrelevant. -pub(crate) const RATE_LEAF: usize = 1; -pub(crate) const RATE_INTERNAL: usize = 2; -/// Smallest proof. Only the root goes on the wire. -pub(crate) const RATE_ROOT: usize = 4; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Plan { - /// Return a caller-supplied aggregate unchanged; the index is into the supplied children. - Passthrough(usize), - Node { - /// Range into the raw-signature vector. May be empty for internal nodes. - raw: Range, - children: Vec, - log_inv_rate: usize, - }, -} - -pub(crate) fn plan(n_raw: usize, n_children: usize) -> Plan { - // A lone aggregate is already a valid proof. - 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 }; - } - - 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)); - - while pool.len() > MAX_FAN_IN { - pool = pool - .chunks(MAX_FAN_IN) - .map(|group| 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 } -} -``` - -Note `chunks` on a `Vec` needs `Plan: Clone`, which the derive provides. - -**Step 4: Run tests** - -Run: `cargo test -p lean_multisig_api --lib plan` -Expected: PASS, 6 tests. - -**Step 5: Commit** - -```bash -git commit -am "feat(lean_sig): pure recursion-tree planner" -``` - ---- - -## Task 4: Codec and pubkey pairing - -**Files:** -- Create: `crates/lean_multisig_api/src/codec.rs` -- Modify: `crates/lean_multisig_api/src/lib.rs` (add `mod codec;`) - -**Step 1: Write the failing tests** - -These need real signatures, so add a small helper. Keygen over a 16-slot range is fast -(no proving involved). - -```rust -#[cfg(test)] -mod tests { - use super::*; - use xmss::{xmss_key_gen_from_seed, xmss_sign}; - use ssz::Encode; - - fn sample(seed: u8) -> (Vec, Vec) { - let (pk, sk) = xmss_key_gen_from_seed([seed; 32], 100, 16).unwrap(); - let sig = xmss_sign(&sk, 100, &[7u8; 32]).unwrap(); - (pk.as_ssz_bytes(), sig.as_ssz_bytes()) - } - - #[test] - fn classifies_raw_signatures_by_length() { - let (pk, sig) = sample(1); - assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); - let (raw, aggs) = classify(vec![sig], vec![pk]).unwrap(); - assert_eq!(raw.len(), 1); - assert!(aggs.is_empty()); - } - - #[test] - fn rejects_a_correctly_sized_but_corrupt_signature() { - // Must NOT silently fall through to the aggregate parser. - let (pk, mut sig) = sample(2); - sig[0] = 0xff; sig[1] = 0xff; sig[2] = 0xff; sig[3] = 0xff; // non-canonical field element - assert!(matches!( - classify(vec![sig], vec![pk]), - Err(Error::MalformedEntry { index: 0 }) - )); - } - - #[test] - fn pubkey_count_must_match_raw_count() { - let (pk, sig) = sample(3); - let err = classify(vec![sig.clone(), sig], vec![pk]).unwrap_err(); - assert!(matches!(err, Error::PubkeyCountMismatch { expected: 2, got: 1 })); - } - - #[test] - fn rejects_a_wrong_length_pubkey() { - let (_, sig) = sample(4); - assert!(matches!( - classify(vec![sig], vec![vec![0u8; 8]]), - Err(Error::MalformedPublicKey { index: 0 }) - )); - } - - #[test] - fn empty_input_is_rejected() { - assert!(matches!(classify(vec![], vec![]), Err(Error::Empty))); - } -} -``` - -**Step 2: Run to verify failure** - -Run: `cargo test -p lean_multisig_api --lib codec` -Expected: FAIL, `cannot find function classify`. - -**Step 3: Implement** - -```rust -use crate::Error; -use rec_aggregation::SingleMessageAggregateSignature; -use ssz::Decode; -use xmss::{PUB_KEY_SSZ_LEN, SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSignature}; - -type Raw = (XmssPublicKey, XmssSignature); - -/// Splits the mixed input vector into raw signatures (paired with their pubkeys) and -/// previously produced aggregates. -/// -/// Entries are classified by length: exactly `SIGNATURE_SSZ_LEN` means a raw signature, -/// anything else is parsed as a postcard aggregate. A correctly sized blob that fails SSZ -/// decode is an error, never a fallback to the aggregate parser — silent reclassification -/// would surface as a baffling failure much later. -/// -/// Aggregates carry their own signer sets, so `public_keys` covers raw signatures only: -/// the k-th raw entry pairs with `public_keys[k]`. -pub(crate) fn classify( - proof_or_sig: Vec>, - public_keys: Vec>, -) -> Result<(Vec, Vec), Error> { - if proof_or_sig.is_empty() { - return Err(Error::Empty); - } - - let expected = proof_or_sig.iter().filter(|e| e.len() == SIGNATURE_SSZ_LEN).count(); - if expected != public_keys.len() { - return Err(Error::PubkeyCountMismatch { expected, got: public_keys.len() }); - } - - let mut raw = Vec::with_capacity(expected); - let mut aggregates = Vec::new(); - let mut next_pk = 0usize; - - for (index, entry) in proof_or_sig.iter().enumerate() { - if entry.len() == SIGNATURE_SSZ_LEN { - let sig = XmssSignature::from_ssz_bytes(entry) - .map_err(|_| Error::MalformedEntry { index })?; - let pk_bytes = &public_keys[next_pk]; - if pk_bytes.len() != PUB_KEY_SSZ_LEN { - return Err(Error::MalformedPublicKey { index: next_pk }); - } - let pk = XmssPublicKey::from_ssz_bytes(pk_bytes) - .map_err(|_| Error::MalformedPublicKey { index: next_pk })?; - next_pk += 1; - raw.push((pk, sig)); - } else { - let agg = SingleMessageAggregateSignature::from_bytes(entry) - .ok_or(Error::MalformedEntry { index })?; - aggregates.push(agg); - } - } - - Ok((raw, aggregates)) -} -``` - -**Important:** `from_bytes` needs the bytecode initialized. Task 6 puts -`init_aggregation_bytecode()` in the public entry points; until then, codec tests must only -use raw signatures (as written above). - -**Step 4: Run tests** - -Run: `cargo test -p lean_multisig_api --lib codec` -Expected: PASS, 5 tests. - -**Step 5: Commit** - -```bash -git commit -am "feat(lean_sig): length-based entry dispatch and pubkey pairing" -``` - ---- - -## Task 5: The `SecretKey` handle - -**Files:** -- Create: `crates/lean_multisig_api/src/key.rs` -- Modify: `crates/lean_multisig_api/src/lib.rs` (add `mod key; pub use key::SecretKey;`) - -**Step 1: Write the failing tests** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sign_then_verify_round_trips() { - let sk = SecretKey::from_seed([1u8; 32], 100, 16).unwrap(); - let sig = sk.sign(&[9u8; 32], 100).unwrap(); - assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); - assert_eq!(sk.public_key().len(), xmss::PUB_KEY_SSZ_LEN); - } - - #[test] - fn from_seed_is_deterministic() { - let a = SecretKey::from_seed([2u8; 32], 100, 16).unwrap(); - let b = SecretKey::from_seed([2u8; 32], 100, 16).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, 16).unwrap(); - let before = sk.sign(&[4u8; 32], 105).unwrap(); - let restored = SecretKey::from_bytes(&sk.to_bytes()).unwrap(); - assert_eq!(restored.public_key(), sk.public_key()); - assert_eq!(restored.sign(&[4u8; 32], 105).unwrap(), before); - } - - #[test] - fn signing_outside_the_slot_range_fails() { - let sk = SecretKey::from_seed([5u8; 32], 100, 16).unwrap(); - assert_eq!(sk.slots(), 100..=115); - assert!(sk.sign(&[0u8; 32], 116).is_err()); - assert!(sk.sign(&[0u8; 32], 99).is_err()); - } - - #[test] - fn malformed_bytes_are_rejected() { - assert!(matches!( - SecretKey::from_bytes(&[0u8; 3]), - Err(Error::MalformedSecretKey) - )); - } -} -``` - -**Step 2: Run to verify failure** - -Run: `cargo test -p lean_multisig_api --lib key` -Expected: FAIL, `cannot find type SecretKey`. - -**Step 3: Implement** - -```rust -use crate::Error; -use ssz::Encode; -use xmss::{XmssSecretKey, xmss_key_gen, xmss_key_gen_from_seed, xmss_sign}; - -/// An XMSS secret key, active for a fixed slot range. -/// -/// This is a handle rather than a byte slice on purpose: the key holds a bottom-subtree cache -/// that `sign` warms and reuses. Serializing drops that cache, so a bytes-in/bytes-out `sign` -/// would rebuild a subtree on every call. -/// -/// WARNING: XMSS is stateful. Never sign two different messages at the same slot. Signing is -/// derandomized, so repeating the same (slot, message) is harmless and returns identical bytes. -#[derive(Debug)] -pub struct SecretKey(XmssSecretKey); - -impl SecretKey { - /// Generates a key active for `num_active_slots` slots starting at `activation_slot`. - pub fn generate(activation_slot: u64, num_active_slots: u64) -> Result { - 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. - pub fn from_seed(seed: [u8; 32], activation_slot: u64, num_active_slots: u64) - -> Result - { - let (_, sk) = xmss_key_gen_from_seed(seed, activation_slot, num_active_slots)?; - Ok(Self(sk)) - } - - pub fn from_bytes(bytes: &[u8]) -> Result { - postcard::from_bytes::(bytes) - .map(Self) - .map_err(|_| Error::MalformedSecretKey) - } - - pub fn to_bytes(&self) -> Vec { - postcard::to_allocvec(&self.0).expect("postcard serialization failed") - } - - /// SSZ-encoded public key, `PUB_KEY_SSZ_LEN` bytes. - pub fn public_key(&self) -> Vec { - self.0.public_key().as_ssz_bytes() - } - - pub fn slots(&self) -> std::ops::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 choice the library cannot make for you. - pub fn prepare(&self, slot: u32) -> Result<(), Error> { - self.0.prepare(slot).map_err(Into::into) - } - - /// SSZ-encoded signature, `SIGNATURE_SSZ_LEN` bytes, ready for `aggregate`. - pub fn sign(&self, message: &[u8; 32], slot: u32) -> Result, Error> { - Ok(xmss_sign(&self.0, slot, message)?.as_ssz_bytes()) - } -} -``` - -Add `rand.workspace = true` to `[dependencies]` (it is currently only a dev-dependency). - -**Step 4: Run tests** - -Run: `cargo test -p lean_multisig_api --lib key` -Expected: PASS, 5 tests. - -**Step 5: Commit** - -```bash -git commit -am "feat(lean_sig): SecretKey handle with warm signing cache" -``` - ---- - -## Task 6: `aggregate` and `verify` - -**Files:** -- Modify: `crates/lean_multisig_api/src/lib.rs` - -No unit tests here — every path needs a real prover. Task 7 covers it with integration tests. - -**Step 1: Implement the public functions** - -```rust -use rec_aggregation::{ - MAX_XMSS_AGGREGATED, SingleMessageAggregateSignature, aggregate_single_message_signatures, - init_aggregation_bytecode, verify_single_message_aggregate, -}; -use ssz::Encode; -use std::collections::BTreeSet; - -/// Pays the one-time bytecode compile up front. Optional: every entry point does this lazily. -pub fn warm_up() { - init_aggregation_bytecode(); -} - -/// Aggregates raw XMSS signatures and previously produced aggregates into a single proof, -/// all sharing one `(message, slot)`. -/// -/// `public_keys` covers **raw signatures only** — aggregates carry their own signer sets — so -/// the k-th raw entry of `proof_or_sig` pairs with `public_keys[k]`. The two vectors are -/// therefore not index-aligned when aggregates are present. -/// -/// The recursion tree and every `log_inv_rate` are chosen internally. -/// -/// Runs entirely sequentially: only one proving job may run per process, so wall-clock is the -/// sum of every node's proving time. Expect this to be slow for large inputs. -pub fn aggregate( - proof_or_sig: Vec>, - public_keys: Vec>, - message: [u8; 32], - slot: u32, -) -> Result, Error> { - init_aggregation_bytecode(); - let (raw, children) = codec::classify(proof_or_sig, public_keys)?; - - // Reject over-capacity before proving anything: failing after minutes of work is cruel. - let mut signers: BTreeSet<_> = raw.iter().map(|(pk, _)| pk.clone()).collect(); - for child in &children { - signers.extend(child.info.pubkeys.iter().cloned()); - } - if signers.len() > MAX_XMSS_AGGREGATED { - return Err(Error::TooManySigners { got: signers.len(), max: MAX_XMSS_AGGREGATED }); - } - - let tree = plan::plan(raw.len(), children.len()); - Ok(execute(&tree, &raw, &children, message, slot)?.to_bytes()) -} - -fn execute( - node: &plan::Plan, - raw: &[(xmss::XmssPublicKey, xmss::XmssSignature)], - children: &[SingleMessageAggregateSignature], - message: [u8; 32], - slot: u32, -) -> Result { - match node { - plan::Plan::Passthrough(i) => Ok(children[*i].clone()), - plan::Plan::Node { raw: range, children: kids, log_inv_rate } => { - let proved: Vec<_> = kids - .iter() - .map(|k| execute(k, raw, children, message, slot)) - .collect::>()?; - let mine = raw[range.clone()].to_vec(); - Ok(aggregate_single_message_signatures(&proved, mine, message, slot, *log_inv_rate)?) - } - } -} - -/// Verifies an aggregate and returns the signer set it actually proves, as SSZ-encoded -/// public keys. -/// -/// The signer set is the success value rather than an input on purpose: an aggregate over the -/// wrong validator set is still a valid proof, so a `bool` would let callers forget to check -/// who signed. Order is the library's canonical sorted order, not the order you aggregated in -/// — compare as a set. -#[must_use = "an aggregate proves nothing until you check who signed it"] -pub fn verify(aggregate: &[u8], message: &[u8; 32], slot: u32) -> Result>, Error> { - init_aggregation_bytecode(); - let sig = SingleMessageAggregateSignature::from_bytes(aggregate) - .ok_or(Error::MalformedEntry { index: 0 })?; - if &sig.info.core.message != message || sig.info.core.slot != slot { - return Err(Error::SignerSetMismatch); - } - verify_single_message_aggregate(&sig)?; - Ok(sig.info.pubkeys.iter().map(Encode::as_ssz_bytes).collect()) -} - -/// [`verify`], checking the proved signer set against one you already know. -pub fn verify_with_signers( - aggregate: &[u8], - expected: &[Vec], - message: &[u8; 32], - slot: u32, -) -> Result<(), Error> { - let proved = verify(aggregate, message, slot)?; - let proved: BTreeSet<_> = proved.into_iter().collect(); - let expected: BTreeSet<_> = expected.iter().cloned().collect(); - if proved == expected { Ok(()) } else { Err(Error::SignerSetMismatch) } -} -``` - -Note the message/slot mismatch currently reuses `SignerSetMismatch`. Add a distinct -`Error::MessageMismatch` variant instead — a wrong message is not a wrong signer set. - -**Step 2: Verify it builds** - -Run: `cargo build -p lean_multisig_api && cargo clippy -p lean_multisig_api --all-targets` -Expected: no warnings (the workspace denies a lot; fix what it flags). - -**Step 3: Commit** - -```bash -git commit -am "feat(lean_sig): aggregate and verify entry points" -``` - ---- - -## Task 7: Integration tests - -**Files:** -- Create: `crates/lean_multisig_api/tests/round_trip.rs` - -These invoke the real prover and are slow. Keep counts tiny. - -**Step 1: Write the tests** - -```rust -use lean_multisig_api::{SecretKey, aggregate, verify, verify_with_signers}; -use std::collections::BTreeSet; - -const MSG: [u8; 32] = [42u8; 32]; -const SLOT: u32 = 100; - -fn signers(n: u8) -> Vec { - (0..n).map(|i| SecretKey::from_seed([i; 32], 100, 16).unwrap()).collect() -} - -#[test] -fn aggregate_then_verify_returns_the_signer_set() { - let keys = signers(2); - let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); - let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); - - let agg = aggregate(sigs, pks.clone(), MSG, SLOT).unwrap(); - - // Aggregation sorts and dedups, so compare as sets. - let proved: BTreeSet<_> = verify(&agg, &MSG, SLOT).unwrap().into_iter().collect(); - assert_eq!(proved, pks.iter().cloned().collect::>()); - - verify_with_signers(&agg, &pks, &MSG, SLOT).unwrap(); -} - -#[test] -fn verify_rejects_the_wrong_message() { - let keys = signers(2); - let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); - let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); - let agg = aggregate(sigs, pks, MSG, SLOT).unwrap(); - - assert!(verify(&agg, &[0u8; 32], SLOT).is_err()); - assert!(verify(&agg, &MSG, SLOT + 1).is_err()); -} - -#[test] -fn verify_rejects_a_tampered_proof() { - let keys = signers(2); - let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); - let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); - let mut agg = aggregate(sigs, pks, MSG, SLOT).unwrap(); - - let last = agg.len() - 1; - agg[last] ^= 0xff; - assert!(verify(&agg, &MSG, SLOT).is_err()); -} - -#[test] -fn verify_rejects_a_different_expected_signer_set() { - let keys = signers(2); - let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); - let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); - let agg = aggregate(sigs, pks.clone(), MSG, SLOT).unwrap(); - - let outsider = SecretKey::from_seed([99u8; 32], 100, 16).unwrap().public_key(); - assert!(verify_with_signers(&agg, &[pks[0].clone(), outsider], &MSG, SLOT).is_err()); -} - -#[test] -fn works_without_an_explicit_warm_up() { - // Lazy init must make the bytecode OnceLock invisible; this must not panic. - let keys = signers(1); - let sigs = vec![keys[0].sign(&MSG, SLOT).unwrap()]; - let pks = vec![keys[0].public_key()]; - let agg = aggregate(sigs, pks, MSG, SLOT).unwrap(); - verify(&agg, &MSG, SLOT).unwrap(); -} - -#[test] -#[ignore = "slow: builds a multi-level recursion tree"] -fn multi_level_tree_round_trips() { - // Feed a prior aggregate back in alongside fresh raw signatures. - let keys = signers(3); - let first: Vec<_> = keys[..2].iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); - let first_pks: Vec<_> = keys[..2].iter().map(SecretKey::public_key).collect(); - let inner = aggregate(first, first_pks, MSG, SLOT).unwrap(); - - let outer = aggregate( - vec![inner, keys[2].sign(&MSG, SLOT).unwrap()], - vec![keys[2].public_key()], // raw signatures only — the aggregate carries its own - MSG, - SLOT, - ).unwrap(); - - let proved = verify(&outer, &MSG, SLOT).unwrap(); - assert_eq!(proved.len(), 3); -} -``` - -### Required: the assertions Task 4 could not make - -`codec::classify` has two coverage holes that are impossible to close at unit level, because -building a parseable aggregate needs both the bytecode `OnceLock` populated and a real -`ExecutionProof`. Both must be closed here, and `multi_level_tree_round_trips` is the natural -home since it already feeds an aggregate back in: - -1. **No test has ever observed `classify` return successfully with a non-raw entry present** — - the `aggregates` vector has never been seen non-empty. Assert that folding a real aggregate - together with fresh raw signatures both succeeds *and* yields the union of signers, not just - that it returns `Ok`. Note pairing-by-raw-order is structural in the `zip`, so this is - confirming an untested path rather than guarding a fragile one. - -2. **`rejects_a_correctly_sized_but_corrupt_signature` cannot discriminate what its name says.** - A hypothetical fall-through to the aggregate parser produces the identical error at unit - level, since `from_bytes` returns `None` with no bytecode initialized. Fall-through is - prevented by construction (the `if`/`else` in `classify`), but with the bytecode genuinely - initialized here, a corrupt 1208-byte blob can be shown to give `MalformedSignature` rather - than `MalformedEntry` — which does discriminate. - -Since this test is `#[ignore]`d, run it manually at least once and record that you did. - -### Correction (Task 8): the multi-level tree is neither ignored nor impractical - -**The paragraphs above are wrong about cost, and were wrong when written.** They assume a real -multi-level tree is unaffordable and gate `multi_level_tree_round_trips` behind `#[ignore]`. -Tree depth does not come from `LEAF_TARGET`; it comes from **fan-in** (`MAX_FAN_IN = -MAX_RECURSIONS = 16`). Reaching depth 3 through raw signatures alone would need `1500 * 17` of -them, which is indeed not a test at any budget — but 17 *supplied children* fold into an -internal node over 16 plus a leftover, which is a three-level tree for 19 small proving jobs in -**~8s release**. - -As built, `a_multi_level_tree_round_trips` takes 17 single-signer children, is **not** -`#[ignore]`d, and runs in CI with everything else. It is the only test that exercises the -planner's `while pool.len() > MAX_FAN_IN` fold loop, and with it the only `Passthrough` under a -*non-root* node — so gating it would have left the planner's most interesting branch unproved. -It is still the most expensive default test in the file and the first place to look if that -binary's runtime becomes a problem. - -The two tests that *are* `#[ignore]`d are the `LEAF_TARGET` boundary pair added later -(`a_leaf_target_sized_batch_proves`, `a_batch_one_past_leaf_target_splits_and_proves`), and CI -runs those too, in a scoped `Ignored slow tests` step. - -The coverage holes 1 and 2 above are genuine and were closed — but in -`folding_an_aggregate_with_fresh_signatures_unions_the_signers` and -`a_corrupt_signature_sized_blob_is_a_malformed_signature_not_a_malformed_entry` respectively, -both of which run by default. - -**Step 2: Run** - -Run: `cargo test -p lean_multisig_api --test round_trip` -Expected: 5 pass, 1 ignored. Expect minutes, not seconds. -*As built:* 12 pass, 2 ignored, ~11s in release (~317s in debug). The "minutes" estimate was -for debug; use `--release`. - -If a small signer count trips a prover edge case, raise the count rather than shrinking the -test, and record the working count in the design doc's open questions. - -**Step 3: Run the ignored one once manually** - -Run: `cargo test -p lean_multisig_api --test round_trip -- --ignored` -Expected: PASS. *As built:* this runs the two `LEAF_TARGET` boundary tests, not the multi-level -one, and CI runs it too — see Task 8. The mixed raw+aggregate input path is covered by a -default test. - -**Step 4: Commit** - -```bash -git commit -am "test(lean_sig): round-trip and negative integration tests" -``` - ---- - -## Task 8: Rename, CI, and final verification - -Task 8 also renamed the crate from `lean_sig` to `lean_multisig_api` and added a scoped CI step -for the two `#[ignore]`d `LEAF_TARGET` boundary tests. See `.github/workflows/rust.yml`'s -`Ignored slow tests` step; note `--verbose` has to go *before* the `--`, since libtest rejects -it (`error: Unrecognized option: 'verbose'`) and would fail the step before running anything. - -**Step 1: Full workspace** - -Run: `cargo test --workspace --lib --no-fail-fast` -Expected: 0 failures across all targets. -*As built:* run without `--lib` and in release — `cargo test --release --workspace ---no-fail-fast` — since `--lib` skips every integration binary, which is where this crate's -proving tests live. Result: 130 passed, 0 failed, 18 ignored. - -**Step 2: Lints and formatting** - -Run: `cargo clippy --workspace --all-targets && cargo fmt --all -- --check` -Expected: clean. -*As built:* `-Dwarnings` added to match CI, plus a pedantic/nursery pass over this crate alone. - -**Step 3: Docs** - -Run: `cargo doc -p lean_multisig_api --no-deps` -Expected: no warnings (`rustdoc.all = "warn"` is set workspace-wide). - -**Step 4: Two decisions carried over from Task 6** - -*Move the wire-format fixture upstream.* `tests/unprovable_child.rs` hand-encodes -`SingleMessageAggregateSignature`'s postcard layout, exploiting the fact that a tuple of the -right leaves is byte-identical to a struct whose fields are `pub(crate)` and unconstructable -from outside. `the_fixture_really_does_get_past_the_envelope` keeps it from silently going -vacuous, which is what makes the technique safe — but the knowledge lives in the wrong crate. -A `rec_aggregation` `test-utils` feature exposing a constructor for a structurally valid, -unprovable aggregate would put it where a field reorder is a compile error instead of a -downstream fixture that parses by luck. `xmss` already gates `signers_cache` this way, so the -pattern exists. Decide whether to do it here or file it as follow-up. - -**DECIDED: defer, filed as follow-up** in the design doc's "Follow-up" section. Three reasons. -(1) It changes `rec_aggregation`'s public surface — a new feature and a new exported -constructor — which is outside the scope of a facade whose whole premise is depending on -`rec_aggregation` rather than reshaping it. (2) The stated risk is bounded today: -`the_fixture_really_does_get_past_the_envelope` asserts that the blob parses far enough to -reach `MessageMismatch`, so a field reorder upstream turns the two real tests from -"passing for the right reason" into a *failing* guard test, not a silently vacuous suite. The -failure would be confusing rather than invisible, which is a much smaller problem than the one -the move is meant to solve. (3) It is not cheap. A useful upstream constructor has to keep the -"structurally valid but unprovable" property, which means it also has to know -`cumulated_n_vars()` and `check_single_message_pubkeys`'s requirements — the same knowledge, -relocated, plus a feature flag and its CI configuration. Worth doing when `rec_aggregation` is -next opened for its own reasons; not worth opening it for. - -*Decide whether `lazy_init_verify_with_signers` earns its ~24s.* All three lazy-init binaries -are separate files purely so each gets its own process — a shared one would let one test's -init satisfy another's assertion. But they are not equal value: the `aggregate` and `verify` -ones pin ordering claims no other test can observe, while this one pins that a four-line -function calls `verify` first, which is visible by reading it. With Task 7's costs now on the -table, decide once whether to keep, `#[ignore]`, or drop it. - -**DECIDED: keep, unchanged.** The ~24s was a debug figure and the premise it rested on is -gone. Measured in release, the binary costs **2.83s** — and the other two lazy-init binaries -cost 2.83s each as well, so this is not the expensive one; all three are dominated by the same -one-time bytecode compile. A cost-based argument for singling it out no longer exists. - -The value argument stands as written — it is the weakest of the three — but "weakest of three" -is not "worthless". `verify_with_signers` calling `verify` first is visible by reading it -*today*; the test is what keeps it true after someone adds a length or set-size pre-check -above that call, which is exactly the plausible edit that would move a public entry point back -in front of the `OnceLock`. At 2.83s for a claim about a public entry point fed by gossip, and -with the alternative being an asymmetry a reader would have to be told about, keeping it is -the cheaper option in every sense that was actually measured. - -**Step 5: Update the design doc** - -Resolve the open questions in `docs/plans/2026-08-14-lean-sig-facade-design.md`: record the -measured `LEAF_TARGET`, the final crate name, and the `generate`-without-RNG decision (now -resolved — `generate` takes no RNG; `from_seed` covers deterministic testing). - -Also record what is knowingly untested, so nobody later mistakes absence for coverage: -the **accepting** side of the 32768 ceiling (exactly 32768 signers passes the check and goes -on to prove 22 leaves — untestable at any tier), `MAX_XMSS_DUPLICATES` (reachable only after -proving everything below the root, since an exact pre-check means simulating the tree), and -whether the bottom-subtree cache actually saves work (a timing property, with no benchmark -harness here to assert it without inventing one). - -**Done.** The design doc's "Open questions" is now a "Resolved questions" section, followed by -"Knowingly untested", an operational note on the runner's stdout diagnostic, and "Follow-up". -It also records one item this step did not anticipate: the **largest leaf that proves** is -still unmeasured, so `LEAF_TARGET = 1500` is known-good rather than known-optimal. - -**Step 6: Commit** - -```bash -git commit -am "docs: resolve lean_multisig_api design open questions" -``` - ---- - -## Tuning questions deferred from Task 3 to Task 6 - -The planner (`plan.rs`) is deliberately conservative. Three shape decisions were left -unmeasured rather than guessed at; all three are measurement work, not redesign. - -**Wall-clock is the sum over nodes, not a critical path.** `crates/backend/zk-alloc/src/lib.rs:99` -asserts *"only one proving job runs at a time"*. So any reasoning of the form "the widest node -dominates" is wrong here — minimizing total node count is what matters. Greedy `chunks(16)` already -does that (`ceil(L/16)` is minimal). The genuine open question is whether per-node trace **padding** -makes a 16+1 split cost more than a balanced 9+8, which only measurement settles. - -> **Task 8 measurement, bearing directly on this.** 1500 raw signatures is one node at -> `RATE_ROOT` and takes **8.16s**. 1501 is two leaves at `RATE_LEAF` under a root at -> `RATE_ROOT` — three proving jobs, one more signature — and takes **6.74s**. The 3-node plan -> is *cheaper*. Minimizing node count is therefore the wrong primary objective: the **rate** -> the planner assigns dominates the node count it creates, because only the root pays -> `RATE_ROOT`. This points against the intuition that motivated the paragraph above. Anyone -> picking this up should measure rate assignment first and node count second. (Both figures are -> from `round_trip.rs`'s two `#[ignore]`d boundary tests, which CI now runs.) - -**Mixed raw+child nodes are unused but supported.** The planner never emits a node holding both -raw signatures and child proofs: `raw` is non-empty if and only if `children` is empty. But -`aggregate_single_message_signatures(children, raw_xmss, ...)` accepts both, and `src/main.rs:111-117` -— the same topology `LEAF_TARGET` is derived from — mixes at raw counts of 10 and 25. - -The cost of not mixing falls on what is likely the most common call: for `n_raw <= LEAF_TARGET` -with 1..=15 supplied children ("add my batch of signatures to an existing aggregate"), the current -plan is **two** proving jobs — a leaf, then a root merging it with the passthroughs — where one node -holding both would be **one**. At minutes per job that is roughly 2x on the incremental path. - -Before implementing it, measure whether `LEAF_TARGET` raw plus a full fan-in of children fits the -trace bound. `main.rs` only ever mixes at small raw counts, which is consistent with a conservative -rule that would itself need measuring. Getting this wrong means a failed proof after minutes of work. - -**The degenerate leaf.** `step_by(LEAF_TARGET)` means `plan(LEAF_TARGET + 1, 0)` produces leaves of -1500 and **1** — a full proving job for a single signature. Same greedy-vs-balanced question as -above, with a more extreme worst case. - ---- - -## Notes for the implementer - -- **Do not** add multi-message aggregation. It was explicitly ruled out of scope. -- Task 6's entry point must call `plan(raw.len(), children.len())` itself and never accept an - externally-constructed `Plan`. The plan's `Range`s and `Passthrough` indices point into - caller-owned slices with nothing type-level tying them together, so constructing a plan away - from its slices is the one way to misuse this module. -- **Do not** expose `log_inv_rate`, topology, or any `rec_aggregation` type in a public - signature. The entire point is that callers have no knobs. -- `LEAF_TARGET = 1500` is inherited from a tuned benchmark topology, not derived. If a leaf - ever exceeds the table-height limit, lower it and say so in the design doc. -- Prefer widening a test's signer count over deleting a test if the prover misbehaves at small - sizes. A recently fixed bug (`cea9fe7`) lived exactly in that small-instance regime. From 0568584db4ecfd4b5b88bf5ec097d4df71a7ea87 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 20:26:46 +0100 Subject: [PATCH 05/12] refactor(lean_multisig_api): hide signature representations --- Cargo.lock | 1 + Cargo.toml | 1 + crates/lean_multisig_api/Cargo.toml | 4 +- crates/lean_multisig_api/src/codec.rs | 250 -------- crates/lean_multisig_api/src/error.rs | 92 +-- crates/lean_multisig_api/src/key.rs | 100 +++- crates/lean_multisig_api/src/lib.rs | 496 +++++----------- crates/lean_multisig_api/src/signature.rs | 157 +++++ .../tests/lazy_init_aggregate.rs | 19 +- .../tests/lazy_init_verify.rs | 22 - .../tests/lazy_init_verify_with_signers.rs | 16 - crates/lean_multisig_api/tests/round_trip.rs | 556 ++++-------------- crates/lean_multisig_api/tests/simple_api.rs | 27 + .../tests/unprovable_child.rs | 90 --- 14 files changed, 547 insertions(+), 1284 deletions(-) delete mode 100644 crates/lean_multisig_api/src/codec.rs create mode 100644 crates/lean_multisig_api/src/signature.rs delete mode 100644 crates/lean_multisig_api/tests/lazy_init_verify.rs delete mode 100644 crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs create mode 100644 crates/lean_multisig_api/tests/simple_api.rs delete mode 100644 crates/lean_multisig_api/tests/unprovable_child.rs diff --git a/Cargo.lock b/Cargo.lock index 36ea2cbf..d5e85c36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1515,6 +1515,7 @@ dependencies = [ "postcard", "rand 0.10.1", "rec_aggregation", + "sha2", "xmss", ] 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/crates/lean_multisig_api/Cargo.toml b/crates/lean_multisig_api/Cargo.toml index fccb0222..8819fe76 100644 --- a/crates/lean_multisig_api/Cargo.toml +++ b/crates/lean_multisig_api/Cargo.toml @@ -16,10 +16,8 @@ backend.workspace = true ssz.workspace = true postcard.workspace = true rand.workspace = true +sha2.workspace = true -# Integration tests also see `[dependencies]`, so only feature additions belong here. -# `tests/unprovable_child.rs` hand-builds an aggregate envelope from `lean_vm`'s field types, -# which `[dependencies]` already provides. [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 diff --git a/crates/lean_multisig_api/src/codec.rs b/crates/lean_multisig_api/src/codec.rs deleted file mode 100644 index 3e89e014..00000000 --- a/crates/lean_multisig_api/src/codec.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Length dispatch and pubkey pairing. -//! -//! `proof_or_sig` deliberately mixes raw XMSS signatures with previously produced aggregates, -//! so a caller can fold an existing aggregate together with fresh signatures. This module -//! splits that vector back apart. - -use crate::Error; -use rec_aggregation::SingleMessageAggregateSignature; -use ssz::Decode; -use xmss::{SIGNATURE_SSZ_LEN, XmssPublicKey, XmssSignature}; - -/// A raw signature paired with the public key that produced it. -/// -/// Shared with `lib.rs` rather than restated there: the two only ever typechecked against each -/// other because the tuples happened to be written identically. -pub(crate) type Raw = (XmssPublicKey, XmssSignature); - -/// A raw XMSS signature is exactly `SIGNATURE_SSZ_LEN` bytes; anything else is parsed as an -/// aggregate. Counted once and dispatched on once, so the two cannot drift apart. -/// -/// Drift here would be silent and severe: a predicate that counts more entries than the loop -/// classifies as signatures leaves `signatures` shorter than `pubkeys`, and the `zip` below -/// then truncates and pairs every later signature with the wrong key — a valid proof of the -/// wrong signer set, with no decode error to show for it. -const fn is_raw_signature(entry: &[u8]) -> bool { - entry.len() == SIGNATURE_SSZ_LEN -} - -/// Splits the mixed input vector into raw signatures (paired with their pubkeys) and -/// previously produced aggregates. -/// -/// Entries are classified by length: exactly `SIGNATURE_SSZ_LEN` means a raw signature, -/// anything else is parsed as a postcard aggregate. A correctly sized blob that fails SSZ -/// decode is `MalformedSignature`, never a fallback to the aggregate parser — silent -/// reclassification would surface as a baffling failure much later. -/// -/// Aggregates carry their own signer sets, so `public_keys` covers raw signatures only: -/// the k-th raw entry pairs with `public_keys[k]`. The two vectors are therefore *not* -/// index-aligned whenever an aggregate is present. -/// -/// An empty `proof_or_sig` is `Error::Empty`. The check lives here rather than in the caller -/// because this module owns the input vector, and the planner downstream documents that it -/// expects the empty case to have been rejected already. -/// -/// The index-carrying errors point into *different* vectors, each named by its variant: -/// `MalformedEntry { index }` and `MalformedSignature { index }` index `proof_or_sig`, -/// `MalformedPublicKey { index }` indexes `public_keys`. Reporting a pubkey fault against a -/// `proof_or_sig` position would point the caller at a blob it cannot fix. The two entry -/// faults are kept apart because their remedies differ: a signature-sized blob that fails to -/// decode is damaged data, not data of the wrong kind. -/// -/// Every public key is decoded before any entry is, so when both vectors hold a bad blob the -/// public key is reported whatever the two positions are. That is a stable rule rather than -/// one that shifts with how the two vectors interleave, and it is pinned by test — changing -/// it is therefore a deliberate act rather than a side effect. -/// -/// Both arguments are taken by value and consumed as they are decoded, so the caller's byte -/// buffers (up to tens of megabytes at the signer ceiling) are freed here rather than living -/// on through all the proving that follows. -/// -/// This cannot panic. In particular `SingleMessageAggregateSignature::from_bytes` returns -/// `None` rather than panicking when the aggregation bytecode is uninitialized, which would -/// surface here as `MalformedEntry`; every public entry point calls -/// `init_aggregation_bytecode()` first so that cannot happen. -pub(crate) fn classify( - proof_or_sig: Vec>, - public_keys: Vec>, -) -> Result<(Vec, Vec), Error> { - if proof_or_sig.is_empty() { - return Err(Error::Empty); - } - - let expected = proof_or_sig.iter().filter(|e| is_raw_signature(e.as_slice())).count(); - if expected != public_keys.len() { - return Err(Error::PubkeyCountMismatch { - expected, - got: public_keys.len(), - }); - } - - // `from_ssz_bytes` enforces the fixed length itself, so a short or long blob and one - // holding non-canonical field elements both land on the same variant. - let pubkeys = public_keys - .into_iter() - .enumerate() - .map(|(index, bytes)| XmssPublicKey::from_ssz_bytes(&bytes).map_err(|_| Error::MalformedPublicKey { index })) - .collect::, _>>()?; - - let mut signatures = Vec::with_capacity(expected); - let mut aggregates = Vec::new(); - - for (index, entry) in proof_or_sig.into_iter().enumerate() { - if is_raw_signature(&entry) { - signatures.push(XmssSignature::from_ssz_bytes(&entry).map_err(|_| Error::MalformedSignature { index })?); - } else { - aggregates - .push(SingleMessageAggregateSignature::from_bytes(&entry).ok_or(Error::MalformedEntry { index })?); - } - } - - // `zip` would silently truncate on a length mismatch; the count check above is what makes - // it exact, and it counted `is_raw_signature` — the same function this loop dispatches on. - debug_assert_eq!(pubkeys.len(), signatures.len()); - Ok((pubkeys.into_iter().zip(signatures).collect(), aggregates)) -} - -#[cfg(test)] -mod tests { - use super::*; - use ssz::Encode; - use xmss::{xmss_key_gen_from_seed, xmss_sign}; - - fn sample(seed: u8) -> (Vec, Vec) { - let (pk, sk) = xmss_key_gen_from_seed([seed; 32], 100, 16).unwrap(); - let sig = xmss_sign(&sk, 100, &[7u8; 32]).unwrap(); - (pk.as_ssz_bytes(), sig.as_ssz_bytes()) - } - - #[test] - fn classifies_raw_signatures_by_length() { - let (pk, sig) = sample(1); - assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); - let (raw, aggs) = classify(vec![sig], vec![pk]).unwrap(); - assert_eq!(raw.len(), 1); - assert!(aggs.is_empty()); - } - - #[test] - fn rejects_a_correctly_sized_but_corrupt_signature() { - // A 1208-byte blob that fails SSZ decode is an error rather than a success. This - // cannot show that it was not first tried as an aggregate: that path also returns - // `None` with no bytecode initialized, so a fall-through classifier would produce an - // error here too. Fall-through is ruled out by the if/else in `classify`, not by this. - let (pk, mut sig) = sample(2); - sig[0] = 0xff; - sig[1] = 0xff; - sig[2] = 0xff; - sig[3] = 0xff; // non-canonical field element - assert!(matches!( - classify(vec![sig], vec![pk]), - Err(Error::MalformedSignature { index: 0 }) - )); - } - - #[test] - fn pubkey_count_must_match_raw_count() { - let (pk, sig) = sample(3); - let err = classify(vec![sig.clone(), sig], vec![pk]).unwrap_err(); - assert!(matches!(err, Error::PubkeyCountMismatch { expected: 2, got: 1 })); - } - - #[test] - fn rejects_a_wrong_length_pubkey() { - let (_, sig) = sample(4); - assert!(matches!( - classify(vec![sig], vec![vec![0u8; 8]]), - Err(Error::MalformedPublicKey { index: 0 }) - )); - } - - #[test] - fn rejects_a_right_length_but_non_canonical_pubkey() { - // The other half of what `from_ssz_bytes` covers, and the reason no explicit length - // pre-check is needed here: 0xffffffff exceeds the KoalaBear modulus, so a correctly - // sized blob is still rejected. If canonicality checking is ever lost upstream, this - // is what catches it. - let (_, sig) = sample(9); - assert!(matches!( - classify(vec![sig], vec![vec![0xffu8; xmss::PUB_KEY_SSZ_LEN]]), - Err(Error::MalformedPublicKey { index: 0 }) - )); - } - - #[test] - fn reports_a_malformed_pubkey_ahead_of_a_malformed_signature() { - // Documented ordering: all pubkeys decode before any entry, so the pubkey wins even - // though the corrupt entry sits at a lower position in its own vector. - let (pk, mut sig) = sample(10); - sig[0] = 0xff; - sig[1] = 0xff; - sig[2] = 0xff; - sig[3] = 0xff; - assert!(matches!( - classify(vec![sig.clone(), sig], vec![pk, vec![0xffu8; xmss::PUB_KEY_SSZ_LEN]]), - Err(Error::MalformedPublicKey { index: 1 }) - )); - } - - #[test] - fn pairs_each_pubkey_with_its_own_signature() { - // Counts alone would pass a reversed or off-by-one pairing, which verifies as a proof - // of the wrong signer set rather than as a decode failure. - let (pk_a, sig_a) = sample(5); - let (pk_b, sig_b) = sample(6); - assert_ne!(pk_a, pk_b); - let (raw, _) = classify(vec![sig_a.clone(), sig_b.clone()], vec![pk_a.clone(), pk_b.clone()]).unwrap(); - let expected: Vec = [(pk_a, sig_a), (pk_b, sig_b)] - .into_iter() - .map(|(pk, sig)| { - ( - XmssPublicKey::from_ssz_bytes(&pk).unwrap(), - XmssSignature::from_ssz_bytes(&sig).unwrap(), - ) - }) - .collect(); - assert_eq!(raw, expected); - } - - #[test] - fn too_many_pubkeys_is_rejected() { - // The misuse the module doc predicts: a caller assuming the two vectors ARE - // index-aligned supplies one pubkey per entry, aggregates included. - let (pk_a, sig) = sample(11); - let (pk_b, _) = sample(12); - assert!(matches!( - classify(vec![sig, vec![0u8; 9]], vec![pk_a, pk_b]), - Err(Error::PubkeyCountMismatch { expected: 1, got: 2 }) - )); - } - - #[test] - fn a_non_signature_entry_does_not_consume_a_pubkey() { - // The asymmetry this module exists for: an aggregate carries its own signer set, so - // only raw entries count towards `public_keys.len()`. One raw entry here, so one - // pubkey is expected however many non-raw entries sit beside it. - let (_, sig) = sample(7); - assert!(matches!( - classify(vec![vec![0u8; 9], sig], vec![]), - Err(Error::PubkeyCountMismatch { expected: 1, got: 0 }) - )); - } - - #[test] - fn an_unparseable_non_signature_entry_reports_its_index_in_proof_or_sig() { - // Coverage of the aggregate branch stops here: this only shows that a blob which is no - // aggregate is rejected against its own `proof_or_sig` position (1, not 0, which is - // where it lands among the aggregates). Parsing a *real* aggregate needs a real prover - // and `init_aggregation_bytecode`; that is the integration tests' job. - let (pk, sig) = sample(8); - assert!(matches!( - classify(vec![sig, vec![0u8; 9]], vec![pk]), - Err(Error::MalformedEntry { index: 1 }) - )); - } - - #[test] - fn empty_input_is_rejected() { - assert!(matches!(classify(vec![], vec![]), Err(Error::Empty))); - } -} diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs index 288a484d..876d58a3 100644 --- a/crates/lean_multisig_api/src/error.rs +++ b/crates/lean_multisig_api/src/error.rs @@ -1,50 +1,29 @@ use std::fmt::{Display, Formatter}; -/// Every way a `lean_multisig_api` call can fail. +/// Every way a `lean_multisig_api` operation can fail. #[non_exhaustive] #[derive(Debug)] pub enum Error { KeyGen(xmss::XmssKeyGenError), Sign(xmss::XmssSignatureError), - Aggregation(rec_aggregation::AggregationError), - Proof(backend::ProofError), - /// A `proof_or_sig` entry was not `SIGNATURE_SSZ_LEN` bytes and did not parse as an - /// aggregate. The index is into `proof_or_sig`. - MalformedEntry { - index: usize, - }, - /// A `proof_or_sig` entry was `SIGNATURE_SSZ_LEN` bytes — so it is a signature by the only - /// classification rule there is — but failed to decode: damaged bytes, or non-canonical - /// field elements. The index is into `proof_or_sig`. - MalformedSignature { - index: usize, - }, - /// The bytes handed to `verify` are not a well-formed aggregate. Distinct from - /// [`Self::MalformedEntry`], which names a position in a `proof_or_sig` vector — `verify` - /// takes one blob and has no vector to index into. - MalformedAggregate, - /// A public key blob was not `PUB_KEY_SSZ_LEN` bytes, or held non-canonical field elements. - MalformedPublicKey { + /// 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, }, - /// Secret key bytes could not be deserialized. + Aggregation(rec_aggregation::AggregationError), + Proof(backend::ProofError), + /// A serialized [`crate::Signature`] envelope was malformed or unsupported. + MalformedSignature, + /// Secret-key bytes failed their format or integrity checks. MalformedSecretKey, - /// `public_keys.len()` must equal the number of raw signatures in `proof_or_sig`. - PubkeyCountMismatch { - expected: usize, - got: usize, - }, - /// The deduplicated signer union exceeds `MAX_XMSS_AGGREGATED`. TooManySigners { got: usize, max: usize, }, - /// `proof_or_sig` was empty. Empty, - /// An aggregate proves a different (message, slot) than the one supplied: from `verify`, - /// the aggregate under test; from `aggregate`, one of the supplied child aggregates. MessageMismatch, - /// The proved signer set differs from the expected one. SignerSetMismatch, } @@ -62,7 +41,10 @@ impl From for Error { impl From for Error { fn from(err: rec_aggregation::AggregationError) -> Self { - Self::Aggregation(err) + match err { + rec_aggregation::AggregationError::InvalidChildProof(err) => Self::Proof(err), + err => Self::Aggregation(err), + } } } @@ -75,34 +57,16 @@ impl From for Error { impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - // These carry their cause in `source()`; adding it here too would print it twice - // under any chain-aware reporter. Self::KeyGen(_) => write!(f, "Key generation failed"), - // Not "Signing failed": `SecretKey::prepare` maps through this variant too, and it - // signs nothing. The wording has to fit every entry point that can raise it. 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::MalformedEntry { index } => { - write!(f, "Entry {index} is neither a signature nor an aggregate") - } - Self::MalformedSignature { index } => { - write!(f, "Entry {index} is signature-sized but could not be decoded") - } - Self::MalformedAggregate => write!(f, "The supplied bytes are not a well-formed aggregate"), - Self::MalformedPublicKey { index } => write!(f, "Public key {index} is malformed"), - Self::MalformedSecretKey => write!(f, "Secret key bytes could not be deserialized"), - Self::PubkeyCountMismatch { expected, got } => { - write!(f, "Expected {expected} public keys, got {got}") - } + Self::MalformedSignature => write!(f, "The supplied bytes are not a well-formed signature"), + Self::MalformedSecretKey => write!(f, "Secret key bytes failed validation"), Self::TooManySigners { got, max } => write!(f, "Too many signers: {got} (max {max})"), - Self::Empty => write!(f, "Nothing to aggregate: no signatures or aggregates were supplied"), - Self::MessageMismatch => { - write!( - f, - "The aggregate proves a different (message, slot) than the one supplied" - ) - } + 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"), } } @@ -111,19 +75,13 @@ impl Display for Error { impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::KeyGen(e) => Some(e), - Self::Sign(e) => Some(e), - Self::Aggregation(e) => Some(e), - Self::Proof(e) => Some(e), - // Spelled out rather than `_`: `#[non_exhaustive]` does not apply inside the - // defining crate, so this match is compile-checked. A new wrapping variant then - // fails to compile here instead of silently truncating the chain. - Self::MalformedEntry { .. } - | Self::MalformedSignature { .. } - | Self::MalformedAggregate - | Self::MalformedPublicKey { .. } + 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::MalformedSecretKey - | Self::PubkeyCountMismatch { .. } | Self::TooManySigners { .. } | Self::Empty | Self::MessageMismatch diff --git a/crates/lean_multisig_api/src/key.rs b/crates/lean_multisig_api/src/key.rs index 2165f5c4..bcd1e77f 100644 --- a/crates/lean_multisig_api/src/key.rs +++ b/crates/lean_multisig_api/src/key.rs @@ -1,13 +1,19 @@ //! The `SecretKey` handle. //! -//! Every other value crossing this crate's boundary is a `Vec`. This one is not, and that -//! asymmetry is the whole point of the module: see the type's own documentation. +//! Unlike the inert persistence bytes returned by `to_bytes`, this handle retains the signing +//! cache that makes repeated use practical. -use crate::Error; +use crate::{Claim, Error, Signature}; +use sha2::{Digest, Sha256}; use ssz::Encode; 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 @@ -124,10 +130,22 @@ impl SecretKey { /// /// [`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. Trailing bytes are rejected rather than ignored so that a - /// key has exactly one encoding. + /// 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 { - let (key, rest) = postcard::take_from_bytes::(bytes).map_err(|_| Error::MalformedSecretKey)?; + 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 { @@ -144,6 +162,10 @@ impl SecretKey { /// 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 @@ -152,11 +174,18 @@ impl SecretKey { /// the identical `expect` in `rec_aggregation`'s aggregate codecs. #[must_use] pub fn to_bytes(&self) -> Vec { - postcard::to_allocvec(&self.0).expect("XmssSecretKey serialization is infallible") + 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 to - /// hand to `aggregate` alongside a signature. + /// 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) -> Vec { self.0.public_key().as_ssz_bytes() @@ -181,26 +210,27 @@ impl SecretKey { self.0.prepare(slot).map_err(Into::into) } - /// Signs a 32-byte message at `slot`, returning `xmss::SIGNATURE_SSZ_LEN` SSZ bytes ready - /// for `aggregate`. + /// 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 `slot` is outside [`slots`](Self::slots), or if no valid WOTS encoding - /// was found within the attempt budget. - pub fn sign(&self, message: &[u8; 32], slot: u32) -> Result, Error> { - Ok(xmss_sign(&self.0, slot, message)?.as_ssz_bytes()) + /// [`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, XmssSignature}; + use xmss::XmssPublicKey; #[test] fn sign_then_verify_round_trips() { @@ -211,12 +241,14 @@ mod tests { // 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 sig = sk.sign(&[9u8; 32], 100).unwrap(); - assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN); + 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 signature = XmssSignature::from_ssz_bytes(&sig).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. @@ -236,10 +268,11 @@ mod tests { // 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 before = sk.sign(&[4u8; 32], 105).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(&[4u8; 32], 105).unwrap(), before); + 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 @@ -255,20 +288,21 @@ mod tests { // 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 first = sk.sign(&message, 105).unwrap(); - let second = sk.sign(&message, 105).unwrap(); + 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(&[14u8; 32], 105).unwrap(), first); + 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(&[0u8; 32], 116).is_err()); - assert!(sk.sign(&[0u8; 32], 99).is_err()); + assert!(sk.sign(&Claim::new([0u8; 32], 116)).is_err()); + assert!(sk.sign(&Claim::new([0u8; 32], 99)).is_err()); } #[test] @@ -312,6 +346,15 @@ mod tests { )); } + #[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 @@ -355,9 +398,8 @@ mod tests { #[test] fn trailing_bytes_are_rejected() { - // postcard's `from_bytes` stops at the end of the value and ignores whatever follows, - // which would give one key many encodings. `SingleMessageAggregateSignature::from_bytes` - // guards against this with `take_from_bytes`; so does this. + // 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); diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index 7c51f52a..63ff400d 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -1,160 +1,89 @@ -//! An opinionated facade over `xmss` and `rec_aggregation`. +//! A small, opinionated facade over XMSS and recursive aggregation. //! -//! Every tuning parameter is chosen internally. Callers needing control over `log_inv_rate` -//! or recursion topology should use `rec_aggregation` directly. -//! -//! # Trusted claims cannot enter through this facade -//! -//! `SingleMessageCore::bytecode_claim` carries a `value` that verification *trusts*, and -//! `rec_aggregation` warns that a claim taken from an untrusted source must be recomputed -//! before use. Every aggregate crossing this boundary is a byte slice, and deserializing one -//! always runs `rebuild_bytecode_claim`, which recomputes that value from the point alone. -//! There is no way to hand this crate a pre-built aggregate struct, so the unsound path is -//! unreachable here by construction rather than by discipline. +//! [`Signature`] hides whether a contribution is a raw XMSS signature or an aggregate. The +//! recursion topology, proof parameters, bytecode initialization, public-key pairing, and proof +//! representation are all internal choices. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -mod codec; mod error; mod key; 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::Encode; use std::borrow::Cow; use std::collections::BTreeSet; -use xmss::XmssPublicKey; +use xmss::{XmssPublicKey, XmssSignature, xmss_verify}; -use crate::codec::Raw; pub use error::Error; pub use key::SecretKey; +pub use signature::{Claim, Signature}; -/// Whether `sig` proves the `(message, slot)` asked for. -/// -/// One rule, one spelling: `aggregate` applies it to every supplied child and `verify` to the -/// aggregate handed in, and both raise [`Error::MessageMismatch`] from it. Also the only place -/// reaching two levels into `rec_aggregation`'s struct, so a layout change lands here alone. -fn proves(sig: &SingleMessageAggregateSignature, message: &[u8; 32], slot: u32) -> bool { - &sig.info.core.message == message && sig.info.core.slot == slot +type Raw = (XmssPublicKey, XmssSignature); + +fn proves(signature: &SingleMessageAggregateSignature, claim: &Claim) -> bool { + signature.info.core.message == *claim.message() && signature.info.core.slot == claim.slot() } -/// Pays the one-time aggregation-bytecode compile up front. -/// -/// Entirely optional: [`aggregate`], [`verify`] and [`verify_with_signers`] all do this -/// themselves, and it is idempotent, so this only moves *when* the cost lands. A long-running -/// service calls it at startup rather than paying it inside its first real request. +/// Pays the one-time aggregation-bytecode compilation cost at startup. /// -/// It warms the bytecode compile and nothing else. The worker pool and the DFT twiddle table -/// are also built lazily on first use, and this does not touch either — an embedder that wants -/// those paid at startup too calls `lean_multisig::setup_prover_without_arena` (or -/// `setup_prover`, which additionally engages the arena; see [`aggregate`]'s `# Cost`). -/// -/// # Panics -/// -/// If the bytecode fails to compile. The program source is embedded in the binary, so that is -/// a bug in this workspace rather than anything a caller can provoke. +/// Calling this is optional. Aggregation and aggregate verification initialize the bytecode +/// lazily themselves. pub fn warm_up() { init_aggregation_bytecode(); } -/// Aggregates raw XMSS signatures and previously produced aggregates into a single proof, all -/// sharing one `(message, slot)`. -/// -/// `public_keys` covers **raw signatures only** — aggregates carry their own signer sets — so -/// the k-th raw entry of `proof_or_sig` pairs with `public_keys[k]`. The two vectors are -/// therefore *not* index-aligned whenever an aggregate is present. Entries are told apart by -/// length: exactly `xmss::SIGNATURE_SSZ_LEN` bytes is a raw signature, anything else is parsed -/// as an aggregate. -/// -/// The recursion tree, its fan-in, and every `log_inv_rate` are chosen internally. The result -/// is the wire proof, ready for [`verify`] or for feeding back into another `aggregate` call. -/// -/// Repeated signers are dropped: the signer set of the result is the sorted, deduplicated union -/// of the raw pubkeys and every supplied aggregate's pubkeys. Supplying one signer twice is -/// therefore harmless, but wasteful — the same key in two *different* supplied aggregates still -/// costs a duplicate slot at the node that merges them. +/// Combines raw and previously aggregated signatures proving one [`Claim`]. /// -/// # Cost -/// -/// Proving is sequential within one call: wall-clock is the **sum** of every node's proving -/// time, with no parallelism across nodes. Seconds per node, at the leaf boundary as much as at -/// small signer counts — measured un-warmed at 19 small nodes in ~8s release and one full -/// 1500-signature leaf in about the same, so an embedder that has called -/// `lean_multisig::setup_prover_without_arena` may see different numbers. No progress reporting, -/// no way to resume. -/// -/// Concurrency across calls is a property of the *process*, not of this crate. Two `aggregate` -/// calls on different threads are safe only while nothing has engaged `zk_alloc`'s arena. An -/// application calling `lean_multisig::setup_prover` engages it, after which `rec_aggregation` -/// asserts that proving phases neither nest nor overlap and the losing call **panics**. The same -/// code in a `lean_multisig_api`-only harness runs fine because the arena is never engaged there — that -/// is an artefact of the harness, not a guarantee. Serialize `aggregate` calls unconditionally. -/// -/// # Errors -/// -/// - [`Error::Empty`] if `proof_or_sig` is empty. -/// - [`Error::PubkeyCountMismatch`] if `public_keys.len()` is not the number of raw entries. -/// - [`Error::MalformedSignature`], [`Error::MalformedEntry`], [`Error::MalformedPublicKey`] -/// for a blob that does not decode; the index names the vector its variant documents. -/// - [`Error::MessageMismatch`] if a supplied aggregate proves a different `(message, slot)`. -/// - [`Error::TooManySigners`] if the deduplicated union exceeds `MAX_XMSS_AGGREGATED` (32768). -/// Recursion does not raise that ceiling — it is re-checked at every node including the root, -/// so the tree exists to get past the ~1500 signatures one node can prove, not past 32768. -/// - [`Error::Proof`] if a supplied aggregate's proof does not verify. Every child is checked, -/// including the lone-aggregate case that is passed straight through: a successful return -/// always means the bytes handed back are a valid aggregate. -/// - [`Error::Aggregation`] or [`Error::Proof`] if a proving job fails. A raw signature that does -/// not verify under the public key it was paired with — the shape a misordered `public_keys` -/// produces, since the counts still match and both blobs still decode — surfaces here as a bare -/// constraint-system mismatch carrying no index. Check the ordering first. -/// -/// Everything except the last two is decided before any proving starts, so a malformed or -/// over-capacity request fails in milliseconds rather than after the whole tree has been proved. -/// -/// The two faults a *supplied aggregate* can raise — [`Error::MessageMismatch`] and -/// [`Error::Proof`] — carry no index, so a caller passing several aggregates learns that one of -/// them is bad but not which, and has to bisect. Naming one cheaply would mean indexing the -/// filtered aggregate list rather than `proof_or_sig`, which is a third index space -/// contradicting the rule above; pointing at `proof_or_sig` needs `classify` to carry original -/// positions. -/// -/// # Panics -/// -/// If another proving job is already running in this process *and* something has engaged -/// `zk_alloc`'s arena — see the concurrency paragraph under [Cost](#cost) for why that condition -/// is not automatic, and why it is no reason to leave calls unserialized. Also if the aggregation -/// bytecode fails to compile — see [`warm_up`]. -pub fn aggregate( - proof_or_sig: Vec>, - public_keys: Vec>, - message: [u8; 32], - slot: u32, -) -> Result, Error> { - // Before `classify`, which parses aggregates: `from_bytes` silently returns `None` with the - // bytecode uninitialized, so a valid aggregate would come back as `MalformedEntry`. +/// Every input is self-contained: a raw signature already owns its public key, while an aggregate +/// already owns 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); + } init_aggregation_bytecode(); - let (mut raw, children) = codec::classify(proof_or_sig, public_keys)?; - // A supplied aggregate over some other (message, slot) is caught here rather than by - // `aggregate_single_message_signatures`, which only sees it at a node that consumes it. - // For a lone aggregate the plan is a `Passthrough` and no node ever consumes it, so - // without this check that call would return an aggregate over the wrong message as a - // *success*. Other shapes reach upstream's own check eventually — immediately if every - // sibling is a passthrough, but only after proving them if any sibling is a node this call - // has to prove first. Checking the flat vector here covers every child wherever the - // planner later puts it. - if !children.iter().all(|c| proves(c, &message, slot)) { - return Err(Error::MessageMismatch); + 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); - // Reject over-capacity before proving anything: failing after the whole tree is cruel. - // Held by reference — cloning 32768 public keys to count them would be its own small waste. - let mut signers: BTreeSet<&XmssPublicKey> = raw.iter().map(|(pk, _)| pk).collect(); - signers.extend(children.iter().flat_map(|c| c.info.pubkeys.iter())); + 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 { @@ -162,160 +91,79 @@ pub fn aggregate( max: MAX_XMSS_AGGREGATED, }); } - - // The plan is built here, from these exact lengths, and never accepted from outside: its - // ranges and passthrough indices are bare `usize`s into the two slices below, with nothing - // at type level tying them together. - let tree = plan::plan(raw.len(), children.len()); - - // A lone supplied aggregate is planned as a `Passthrough` and consumed by nothing, so this - // is the one shape where no node verifies the child's proof — `classify` only decodes the - // envelope. Every other shape gets it free from `aggregate_single_message_signatures`, - // which verifies each child before folding it in. Without this, `aggregate` returns `Ok` - // for a peer's structurally intact but unprovable aggregate and the caller re-gossips it. - // - // At the root rather than in `execute`'s `Passthrough` arm: the planner puts *every* - // supplied child into the pool as a passthrough, so that arm would fire for all of them and - // each would then be verified a second time by the node that consumes it — 32 verifications - // for 16 supplied aggregates. Only the lone case is unconsumed, and the tree says which - // case this is before any of it runs. - if let plan::Plan::Passthrough(i) = tree { - verify_single_message_aggregate(&children[i])?; - } - Ok(execute(&tree, &raw, &children, message, slot)?.to_bytes()) + Ok(()) } -/// Drops repeat signers, keeping the earliest signature offered for each public key. -/// -/// Every node deduplicates its own share anyway, so the *signer set* of the result is the same -/// either way. What this adds is that duplicates landing in two different leaves — which survive -/// the per-node dedup and reappear as `dup_pub_keys` at the node merging them — cost neither a -/// wasted leaf slot nor a second ceiling (`MAX_XMSS_DUPLICATES`) to blow through at the very top -/// of the tree, with everything below it already proved. -/// -/// It is not *entirely* result-preserving: where one key is offered twice with different -/// signature bytes and the two would have landed in different leaves, upstream would have proved -/// both and this proves only the first. That is a laxening rather than a soundness hole — the -/// surviving signature is still proved, and the signer set is unchanged. -/// -/// The sort is stable and the dedup drops the later of each equal pair, so "earliest" means -/// earliest in the caller's `proof_or_sig` order. This is exactly what upstream does per node. fn dedup_signers(raw: &mut Vec) { raw.sort_by(|(a, _), (b, _)| a.cmp(b)); raw.dedup_by(|(a, _), (b, _)| a == b); } -/// Proves one node of the plan, depth first, recursing into its children first. -/// -/// Returns `Cow` so that a plan which is nothing but a `Passthrough` — a lone supplied -/// aggregate, re-encoded — does not clone a whole `ExecutionProof` on the way out. A -/// passthrough *under* a node still has to be cloned, because -/// `aggregate_single_message_signatures` wants a contiguous slice of owned children. -/// -/// Recursion depth is not a stack concern. `dedup_signers` and the ceiling check together cap -/// `raw.len()` at 32768 before planning, so raw contributes at most 22 leaves; the pool is -/// otherwise supplied children, and the planner folds at a fan-in of 16, so reaching depth `d` -/// needs 16^(d-1) of them. Each is a decoded `ExecutionProof`, so a pathological input runs out -/// of addressable memory several levels before it runs out of stack. -/// -/// Indexing `raw` and `children` cannot panic: `plan` was called with exactly these two -/// lengths, and it covers every index of both exactly once (pinned by its own tests). fn execute<'a>( node: &plan::Plan, raw: &[Raw], children: &'a [SingleMessageAggregateSignature], - message: [u8; 32], - slot: u32, + claim: Claim, ) -> Result, Error> { match node { - // Proof checking is not done here. A passthrough under a node is verified by that node, - // and the one passthrough with no node above it — a lone supplied aggregate — is - // verified by `aggregate` before this is ever called. - plan::Plan::Passthrough(i) => Ok(Cow::Borrowed(&children[*i])), + plan::Plan::Passthrough(index) => Ok(Cow::Borrowed(&children[*index])), plan::Plan::Node { raw: range, - children: kids, + children: child_plans, log_inv_rate, } => { - let proved: Vec = kids + let proved = child_plans .iter() - .map(|kid| execute(kid, raw, children, message, slot).map(Cow::into_owned)) - .collect::>()?; - Ok(Cow::Owned(aggregate_single_message_signatures( + .map(|child| execute(child, raw, children, claim).map(Cow::into_owned)) + .collect::, _>>()?; + aggregate_single_message_signatures( &proved, raw[range.clone()].to_vec(), - message, - slot, + *claim.message(), + claim.slot(), *log_inv_rate, - )?)) + ) + .map(Cow::Owned) + .map_err(Into::into) } } } -/// Verifies an aggregate and returns the signer set it actually proves, as SSZ-encoded public -/// keys. -/// -/// The signer set is the success value rather than an input on purpose: an aggregate over the -/// wrong validator set is still a perfectly valid proof, so a `bool` return would invite -/// `if verify(..)` while the caller forgets to check *who* signed. Returning the set instead -/// means a caller who ignores who signed has to discard a value to do it, rather than simply -/// not asking. It is not a guarantee — `verify(..)?;` still compiles, because after `?` the -/// type is a plain `Vec>` and nothing on it is `#[must_use]` — which is why -/// [`verify_with_signers`] exists for the common case where the expected set is already known. -/// -/// Keys come back in the library's canonical sorted order, not the order they were aggregated -/// in, and without duplicates. Compare as a set. +/// Verifies a signature and returns the canonical, deduplicated signer set it proves. /// -/// # Errors -/// -/// - [`Error::MalformedAggregate`] if the bytes are not a well-formed aggregate, including -/// trailing bytes after a complete one. -/// - [`Error::MessageMismatch`] if the aggregate proves a different `(message, slot)`. This is -/// not implied by the proof check below, which validates the aggregate against its *own* -/// message and slot; it is what binds the proof to the pair you asked about. -/// - [`Error::Proof`] if the proof does not verify. -/// -/// # Panics -/// -/// If the aggregation bytecode fails to compile — see [`warm_up`], which is the only way this -/// can happen. Verification runs no proving job of its own, so unlike [`aggregate`] it never -/// takes the process-wide arena phase. -#[must_use = "an aggregate proves nothing until you check who signed it"] -pub fn verify(aggregate: &[u8], message: &[u8; 32], slot: u32) -> Result>, Error> { - // Before parsing: `from_bytes` returns `None` rather than panicking with the bytecode - // uninitialized, which would report a perfectly good aggregate as malformed. - init_aggregation_bytecode(); - let sig = SingleMessageAggregateSignature::from_bytes(aggregate).ok_or(Error::MalformedAggregate)?; - if !proves(&sig, message, slot) { +/// 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); } - verify_single_message_aggregate(&sig)?; - Ok(sig.info.pubkeys.iter().map(Encode::as_ssz_bytes).collect()) + 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![public_key.as_ssz_bytes()]) + } + Kind::Aggregate(signature) => { + init_aggregation_bytecode(); + if !proves(signature, claim) { + return Err(Error::MessageMismatch); + } + verify_single_message_aggregate(signature)?; + Ok(signature.info.pubkeys.iter().map(Encode::as_ssz_bytes).collect()) + } + } } -/// [`verify`], checking the proved signer set against one already known. -/// -/// Both sides are compared as sets, so the order of `expected` is irrelevant and repeats in it -/// are ignored — `[a, a]` matches a proof of `{a}`. -/// -/// # Errors +/// Verifies a signature against its claim and exact expected signer set. /// -/// As [`verify`], plus [`Error::SignerSetMismatch`] if the proved set is not exactly -/// `expected`. A signer missing from `expected` fails just as loudly as an unexpected one. -/// -/// Entries of `expected` are compared as opaque bytes and never decoded, so one that is not a -/// `xmss::PUB_KEY_SSZ_LEN` SSZ public key is not reported as malformed input — it simply -/// matches nothing, and surfaces as [`Error::SignerSetMismatch`] like any other wrong set. -pub fn verify_with_signers(aggregate: &[u8], expected: &[Vec], message: &[u8; 32], slot: u32) -> Result<(), Error> { - // Inherits the bytecode initialization from `verify`, which is the first thing this calls - // and which initializes before it parses anything. - let proved = verify(aggregate, message, slot)?; - // Only `expected` needs collecting: `verify` returns a set that `check_single_message_pubkeys` - // already enforced to be strictly sorted, so it holds no repeats and a length match plus - // containment is exact. Building a second tree of up to 32768 nodes only to compare it - // against the first is work this path runs per gossiped aggregate. +/// Ordering and duplicate entries in `expected` are ignored; both sides are compared as sets. +pub fn verify(signature: &Signature, expected: &[Vec], claim: &Claim) -> Result<(), Error> { + let proved = verified_signers(signature, claim)?; let expected: BTreeSet<&[u8]> = expected.iter().map(Vec::as_slice).collect(); - if proved.len() == expected.len() && proved.iter().all(|p| expected.contains(p.as_slice())) { + if proved.len() == expected.len() && proved.iter().all(|key| expected.contains(key.as_slice())) { Ok(()) } else { Err(Error::SignerSetMismatch) @@ -325,138 +173,70 @@ pub fn verify_with_signers(aggregate: &[u8], expected: &[Vec], message: &[u8 #[cfg(test)] mod tests { use super::*; + use lean_vm::{EF, F}; use ssz::Decode; use xmss::XmssSignature; - const MSG: [u8; 32] = [42u8; 32]; - const SLOT: u32 = 100; - - /// A distinct, well-formed, entirely fictitious public key. - /// - /// `classify` checks SSZ length and field-element canonicality, never that a key belongs to - /// anybody, and the ceiling counts distinct keys — so a real keygen (milliseconds each, and - /// 32769 of them here) buys nothing these tests need. A small index in the leading field - /// element is canonical for every index these tests use. - fn synthetic_pubkey_bytes(index: u32) -> Vec { - let mut bytes = vec![0u8; xmss::PUB_KEY_SSZ_LEN]; - bytes[..4].copy_from_slice(&index.to_le_bytes()); - bytes - } - - fn synthetic_pubkey(index: u32) -> XmssPublicKey { - XmssPublicKey::from_ssz_bytes(&synthetic_pubkey_bytes(index)).unwrap() - } - - /// A decodable signature that is not a valid one. Only its identity matters here. - fn synthetic_signature(tag: u8) -> XmssSignature { - let mut bytes = vec![0u8; xmss::SIGNATURE_SSZ_LEN]; - bytes[0] = tag; - XmssSignature::from_ssz_bytes(&bytes).unwrap() - } + const CLAIM: Claim = Claim::new([42u8; 32], 100); #[test] - fn aggregate_rejects_empty_input() { - // Returns before the planner, so no prover is involved. - assert!(matches!(aggregate(vec![], vec![], MSG, SLOT), Err(Error::Empty))); - } + fn aggregate_rejects_a_wrong_raw_public_key_before_proving() { + 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); - #[test] - fn aggregate_rejects_a_pubkey_count_mismatch() { - // A zero-filled blob of signature length classifies as raw on length alone, and the - // count check fires before anything is decoded — so this needs no real signature. - let entry = vec![0u8; xmss::SIGNATURE_SSZ_LEN]; assert!(matches!( - aggregate(vec![entry], vec![], MSG, SLOT), - Err(Error::PubkeyCountMismatch { expected: 1, got: 0 }) + aggregate(vec![signature], &CLAIM), + Err(Error::InvalidSignature { index: 0, .. }) )); } #[test] - fn aggregate_rejects_more_signers_than_the_ceiling() { - // Trap #2: the ceiling recursion does *not* raise. `aggregate_single_message_signatures` - // re-checks it at every node including the root, so a tree buys capacity past the ~1500 - // signatures one node can prove, and nothing past 32768 signers. This is the check that - // turns "fails after the whole tree is proved" into "fails in milliseconds", so it needs - // pinned by something. Costs about 3s on top of the shared bytecode compile. - // - // Only the rejecting side of the boundary is testable: exactly 32768 signers gets *past* - // this check and straight into proving 22 leaves, so no test at any tier can assert the - // accepting side cheaply. - let n = MAX_XMSS_AGGREGATED + 1; - let entries = vec![vec![0u8; xmss::SIGNATURE_SSZ_LEN]; n]; - let pubkeys: Vec> = (0..u32::try_from(n).unwrap()).map(synthetic_pubkey_bytes).collect(); + 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(entries, pubkeys, MSG, SLOT), - Err(Error::TooManySigners { got, max }) if got == n && max == MAX_XMSS_AGGREGATED + aggregate(vec![invalid.clone(), invalid], &CLAIM), + Err(Error::Proof(_)) )); } #[test] - fn dedup_keeps_the_earliest_signature_offered_for_a_key() { - // The property `dedup_signers` documents, and previously only claimed in a comment: a - // stable sort plus `dedup_by` (which drops the *later* of each equal pair) means the - // survivor is the earliest in the caller's order, matching what upstream does per node. - // Reachable only through `aggregate`, and so only at proving cost, until it was extracted. - let repeated = synthetic_pubkey(1); - let other = synthetic_pubkey(2); - let (first, second) = (synthetic_signature(1), synthetic_signature(2)); - assert_ne!(first, second, "the two signatures must be distinguishable"); - - let mut raw = vec![ - (other.clone(), second.clone()), - (repeated.clone(), first.clone()), - (repeated.clone(), second.clone()), - ]; - dedup_signers(&mut raw); - - assert_eq!(raw.len(), 2, "the repeated key must collapse to one entry"); - let kept = raw - .iter() - .find(|(pk, _)| *pk == repeated) - .expect("the key must survive"); - assert_eq!(kept.1, first, "the earliest signature offered must be the survivor"); - // The other key is untouched, so dedup is not merely truncating. - let kept_other = raw.iter().find(|(pk, _)| *pk == other).expect("the key must survive"); - assert_eq!(kept_other.1, second); - } - - #[test] - fn dedup_leaves_distinct_signers_alone() { - let mut raw: Vec = (0..8).map(|i| (synthetic_pubkey(i), synthetic_signature(1))).collect(); - dedup_signers(&mut raw); - assert_eq!(raw.len(), 8); - } + 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::>(); - #[test] - fn verify_rejects_garbage() { - // Not signature-length, not a postcard aggregate: rejected before any proof work. assert!(matches!( - verify(&[0xffu8; 64], &MSG, SLOT), - Err(Error::MalformedAggregate) + check_signer_limit(&raw, &[]), + Err(Error::TooManySigners { got, max }) if got == n && max == MAX_XMSS_AGGREGATED )); } - #[test] - fn verify_rejects_empty_bytes() { - assert!(matches!(verify(&[], &MSG, SLOT), Err(Error::MalformedAggregate))); - } - - #[test] - fn warm_up_is_idempotent() { - // The bytecode lives in a `OnceLock`; a second init must be a no-op, not a panic. + fn unprovable_aggregate() -> Signature { warm_up(); - warm_up(); - } - - #[test] - fn every_entry_point_survives_without_an_explicit_warm_up() { - // This pins only that none of the three entry points panics on a caller that never - // called `warm_up`. It cannot pin that they *initialize* the bytecode: unit tests share - // a process, so by the time this runs another test has very likely filled the - // `OnceLock` already. That half is pinned by `tests/lazy_init_*.rs`, one file per entry - // point so that each gets a process where it is the first thing to run. - assert!(aggregate(vec![], vec![], MSG, SLOT).is_err()); - assert!(verify(&[0xffu8; 64], &MSG, SLOT).is_err()); - assert!(verify_with_signers(&[0xffu8; 64], &[], &MSG, SLOT).is_err()); + 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 = vec![XmssPublicKey::from_ssz_bytes(&public_key).unwrap()]; + let payload = postcard::to_allocvec(&( + (*CLAIM.message(), CLAIM.slot(), point), + public_keys, + (Vec::::new(), Vec::::new()), + )) + .unwrap(); + let mut envelope = b"LMSI\x01\x01".to_vec(); + envelope.extend(payload); + Signature::from_bytes(&envelope).unwrap() } } diff --git a/crates/lean_multisig_api/src/signature.rs b/crates/lean_multisig_api/src/signature.rs new file mode 100644 index 00000000..731ccc4f --- /dev/null +++ b/crates/lean_multisig_api/src/signature.rs @@ -0,0 +1,157 @@ +use crate::Error; +use rec_aggregation::{SingleMessageAggregateSignature, init_aggregation_bytecode}; +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 + 32 + 4 + xmss::PUB_KEY_SSZ_LEN + xmss::SIGNATURE_SSZ_LEN; + +/// The statement signed by every input to one aggregation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, 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. +#[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 this facade signature into a tagged, self-describing envelope. + #[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 { + claim, + public_key, + signature, + } => { + out.reserve(RAW_LEN - out.len()); + out.push(RAW); + out.extend_from_slice(claim.message()); + out.extend_from_slice(&claim.slot().to_le_bytes()); + out.extend_from_slice(&public_key.as_ssz_bytes()); + out.extend_from_slice(&signature.as_ssz_bytes()); + } + Kind::Aggregate(signature) => { + out.push(AGGREGATE); + out.extend_from_slice(&signature.to_bytes()); + } + } + out + } + + /// Restores a signature produced by [`Self::to_bytes`]. + /// + /// This checks framing and canonical encodings only. Use [`crate::verify`] or + /// [`crate::aggregate`] to establish cryptographic validity. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_LEN || &bytes[..MAGIC.len()] != MAGIC || bytes[MAGIC.len()] != VERSION { + return Err(Error::MalformedSignature); + } + match bytes[MAGIC.len() + 1] { + RAW if bytes.len() == RAW_LEN => { + let mut offset = HEADER_LEN; + let message = bytes[offset..offset + 32] + .try_into() + .map_err(|_| Error::MalformedSignature)?; + offset += 32; + let slot = u32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .map_err(|_| Error::MalformedSignature)?, + ); + offset += 4; + let public_key = XmssPublicKey::from_ssz_bytes(&bytes[offset..offset + xmss::PUB_KEY_SSZ_LEN]) + .map_err(|_| Error::MalformedSignature)?; + offset += xmss::PUB_KEY_SSZ_LEN; + let signature = + XmssSignature::from_ssz_bytes(&bytes[offset..]).map_err(|_| Error::MalformedSignature)?; + Ok(Self::raw(Claim::new(message, slot), public_key, signature)) + } + AGGREGATE => { + init_aggregation_bytecode(); + SingleMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]) + .map(Self::aggregate) + .ok_or(Error::MalformedSignature) + } + _ => Err(Error::MalformedSignature), + } + } +} diff --git a/crates/lean_multisig_api/tests/lazy_init_aggregate.rs b/crates/lean_multisig_api/tests/lazy_init_aggregate.rs index be52e2e2..8a65afb2 100644 --- a/crates/lean_multisig_api/tests/lazy_init_aggregate.rs +++ b/crates/lean_multisig_api/tests/lazy_init_aggregate.rs @@ -1,20 +1,11 @@ -//! `aggregate` must initialize the aggregation bytecode itself, before `codec::classify` runs. -//! -//! In its own file so that it owns its process; see `lazy_init_verify.rs` for why that is the -//! only arrangement that can observe a `OnceLock`. -//! -//! The ordering matters here as much as the call: `classify` parses supplied aggregates, and -//! with the lock empty every one of them decodes as `None` and is reported as -//! `Error::MalformedEntry`. Initializing after `classify` would therefore look perfectly correct -//! to every test that supplies no aggregate. +use lean_multisig_api::{Claim, SecretKey, aggregate}; #[test] fn aggregate_initializes_the_bytecode() { - // Empty input, so this returns `Error::Empty` from inside `classify` — before the planner - // and before any proving. That early return is exactly what makes this discriminating: an - // `init` placed after `classify` would never run here. - let _ = lean_multisig_api::aggregate(vec![], vec![], [0u8; 32], 0); + let claim = Claim::new([0u8; 32], 0); + let key = SecretKey::from_seed([1u8; 32], 0..=15).unwrap(); + aggregate(vec![key.sign(&claim).unwrap()], &claim).unwrap(); - // Panics if the lock is still empty. + // Panics if aggregation did not initialize the process-wide bytecode. let _ = rec_aggregation::get_aggregation_bytecode(); } diff --git a/crates/lean_multisig_api/tests/lazy_init_verify.rs b/crates/lean_multisig_api/tests/lazy_init_verify.rs deleted file mode 100644 index d16fb9e0..00000000 --- a/crates/lean_multisig_api/tests/lazy_init_verify.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! `verify` must initialize the aggregation bytecode itself. -//! -//! This file exists to get its own process. The bytecode lives in a `OnceLock`, so only the -//! *first* call in a process can observe whether an entry point initializes it; any test sharing -//! a process with another entry point — or with `warm_up` — finds the lock already filled and -//! proves nothing. One file per entry point is the only way to keep each one first, which is why -//! these are three near-identical files rather than one with three tests. -//! -//! Getting this wrong is invisible until it is expensive: with the lock empty, -//! `SingleMessageAggregateSignature::from_bytes` silently returns `None`, so a perfectly good -//! aggregate is reported as malformed by the first `verify` in a fresh process and by no other. - -#[test] -fn verify_initializes_the_bytecode() { - // Garbage in, so this returns `Err` long before any proof work. The return value is not the - // point; what happens to the `OnceLock` on the way is. - let _ = lean_multisig_api::verify(&[0xffu8; 64], &[0u8; 32], 0); - - // The assertion. `get_aggregation_bytecode` panics when the lock is empty, so this fails - // loudly if `verify` ever stops initializing, or starts doing it after it parses. - let _ = rec_aggregation::get_aggregation_bytecode(); -} diff --git a/crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs b/crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs deleted file mode 100644 index 296a3489..00000000 --- a/crates/lean_multisig_api/tests/lazy_init_verify_with_signers.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! `verify_with_signers` must initialize the aggregation bytecode too. -//! -//! In its own file so that it owns its process; see `lazy_init_verify.rs` for why. -//! -//! It has no `init` call of its own — it inherits one by delegating to `verify` before it -//! touches anything else. That is a claim about a call this function makes first, not a -//! property of its own body, so it is worth pinning separately: reordering -//! `verify_with_signers` to do any parsing of its own before delegating would break it silently. - -#[test] -fn verify_with_signers_initializes_the_bytecode() { - let _ = lean_multisig_api::verify_with_signers(&[0xffu8; 64], &[], &[0u8; 32], 0); - - // Panics if the lock is still empty. - let _ = rec_aggregation::get_aggregation_bytecode(); -} diff --git a/crates/lean_multisig_api/tests/round_trip.rs b/crates/lean_multisig_api/tests/round_trip.rs index 861a1d3e..8945b708 100644 --- a/crates/lean_multisig_api/tests/round_trip.rs +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -1,527 +1,213 @@ -//! The only tests that run a real aggregation. +//! End-to-end tests for the opaque signature boundary. //! -//! Everything else in this crate's suite stops at an early return, a pure function, or a -//! hand-built fixture; these produce genuine proofs and feed them back through the public API. -//! That makes them the sole home of several claims the cheaper tiers cannot even phrase — the -//! mixed raw+aggregate input path, the passthrough of a *valid* aggregate, and the discrimination -//! between `MalformedSignature` and `MalformedEntry` — each marked below. -//! -//! # Cost -//! -//! Proving is the entire runtime, so this file spends it deliberately: one 2-signer aggregate is -//! built once and shared by every test that only needs *some* valid aggregate. Two signers is as -//! structurally meaningful as two hundred for everything asserted here, and proportionally -//! cheaper. Seven of the twelve default tests run no proving job at all — four never call -//! `aggregate`, and three more are rejected before the planner or planned as a passthrough — so -//! the binary's ten-odd seconds belongs to the other five, most of it to -//! `a_multi_level_tree_round_trips`. -//! -//! Release, in practice: measured at 10-12s with `--release` against 317s without, on the same -//! machine. Both pass, so an unoptimized run is a patience problem rather than a broken one. CI -//! runs `cargo test --release --all` (`.github/workflows/rust.yml`), so release is the figure CI -//! pays and debug is what a local `cargo test` costs. -//! -//! # Why the two boundary tests are `#[ignore]`d -//! -//! `a_leaf_target_sized_batch_proves` and `a_batch_one_past_leaf_target_splits_and_proves` prove -//! real 1500- and 1501-signature batches, to check that `plan::LEAF_TARGET` is a leaf size the -//! prover accepts rather than a number inherited from a benchmark topology. They are the only -//! tests here whose *size* is the point rather than an incidental cost. -//! -//! Not gated because they are expensive in CI, which they are not. ~12s of proving in a job that -//! already spends minutes on a release build, and the 10,000-signer cache they load is already -//! paid for: `tests/test_multisignatures.rs` calls `get_benchmark_signatures` from tests that are -//! *not* ignored, so every CI run generates or loads it before this file is reached. The marginal -//! cost is the proving alone. -//! -//! **CI runs them.** `.github/workflows/rust.yml` has an `Ignored slow tests` step immediately -//! after `Test`, in the same job so it inherits the matrix condition and `SIGNERS_CACHE_DIR`. It -//! names this binary explicitly rather than passing `--include-ignored`, which would also run six -//! unrelated ignored tests elsewhere in the workspace, several of them benchmarks. So `#[ignore]` -//! here means "not in a local `cargo test`", not "unverified". -//! -//! They stay gated to keep *local* iteration bearable, where the debug suite already costs 317s -//! without them. Run them by hand after touching the planner: +//! 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 //! ``` -//! -//! No test here calls [`lean_multisig_api::warm_up`]: the lazy initialization inside each entry point is -//! what the whole file leans on, and `tests/lazy_init_*.rs` is where that is pinned per entry -//! point in a process each owns. -use lean_multisig_api::{Error, SecretKey, aggregate, verify, verify_with_signers}; +use lean_multisig_api::{Claim, Error, SecretKey, Signature, aggregate, verified_signers, verify}; +use ssz::Encode; use std::collections::BTreeSet; -use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::sync::{Mutex, OnceLock}; -const MSG: [u8; 32] = [42u8; 32]; -const SLOT: u32 = 100; - -/// Serializes proving across the test binary's threads. -/// -/// Not currently load-bearing: `zk_alloc`'s phase assertion — the one that panics when two -/// proving jobs overlap — is a no-op until `enable_arena`, which only `lean-multisig`'s -/// `setup_prover` calls, and nothing in `lean_multisig_api`'s dependency path does. So concurrent -/// `aggregate` calls would today run rather than panic. They would still be two provers fighting -/// over the machine, and the day `lean_multisig_api` (or anything under it) engages the arena the failure -/// mode is a panic in whichever test loses the race. Following `tests/test_multisignatures.rs`'s -/// `ARENA_TEST_LOCK` precedent costs nothing and removes the question. +const CLAIM: Claim = Claim::new([42u8; 32], 100); static PROVE_LOCK: Mutex<()> = Mutex::new(()); +static BASE: OnceLock = OnceLock::new(); -/// The shared 2-signer aggregate over `(MSG, SLOT)`, proved at most once per process. -/// -/// Tests are independent in what they *assert* — this is a fixture, not a channel between them — -/// but sharing it means one proving job instead of six. -static BASE: OnceLock> = OnceLock::new(); - -/// `n` distinct keys, seeded `0..n`, each active over `SLOT`. -/// -/// One-off keys elsewhere in this file are seeded from 200 up, deliberately disjoint from these. -/// XMSS is stateful: signing two *different* messages at one slot with one key leaks that slot's -/// WOTS key, and this file signs a second message in -/// `a_lone_aggregate_over_another_message_or_slot_is_rejected`. Nothing here would fail if the -/// seeds collided — the proofs would still verify — which is exactly why the separation has to be -/// deliberate rather than noticed later by a reader copying the pattern. fn signers(n: u8) -> Vec { (0..n) - .map(|i| SecretKey::from_seed([i; 32], 100..=115).unwrap()) + .map(|seed| SecretKey::from_seed([seed; 32], 100..=115).unwrap()) .collect() } -/// A key belonging to no `signers` set. See that function for why the ranges are kept apart. fn lone_key(seed: u8) -> SecretKey { - assert!(seed >= 200, "one-off seeds live at 200 and up"); + assert!(seed >= 200); SecretKey::from_seed([seed; 32], 100..=115).unwrap() } -/// Runs one aggregation with the prover to itself. -/// -/// The lock is taken and released entirely inside this function. Nothing else acquires it, so no -/// caller can be holding it when it blocks on `BASE`'s initialization — which is exactly the -/// cycle that would deadlock, since `base` proves while holding the `OnceLock`. -fn prove(entries: Vec>, pubkeys: Vec>, message: [u8; 32], slot: u32) -> Result, Error> { - let _guard: MutexGuard<'_, ()> = PROVE_LOCK.lock().unwrap(); - aggregate(entries, pubkeys, message, slot) -} - -/// The two public keys `base` proves, in the caller's order (aggregation sorts them; compare as -/// sets). -fn base_pubkeys() -> Vec> { - signers(2).iter().map(SecretKey::public_key).collect() +fn prove(signatures: Vec, claim: &Claim) -> Result { + let _guard = PROVE_LOCK.lock().unwrap(); + aggregate(signatures, claim) } -/// [`base_pubkeys`] as a set, which is how every comparison against a proved signer set wants -/// it — `proved_set` is the other half of the same pairing. -fn base_set() -> BTreeSet> { - base_pubkeys().into_iter().collect() -} - -/// A real aggregate over `(MSG, SLOT)` signed by `base_pubkeys()`. -fn base() -> &'static [u8] { +fn base() -> &'static Signature { BASE.get_or_init(|| { - let keys = signers(2); - let sigs: Vec<_> = keys.iter().map(|k| k.sign(&MSG, SLOT).unwrap()).collect(); - let pks: Vec<_> = keys.iter().map(SecretKey::public_key).collect(); - prove(sigs, pks, MSG, SLOT).expect("aggregating two honest signatures must succeed") + let signatures = signers(2).iter().map(|key| key.sign(&CLAIM).unwrap()).collect(); + prove(signatures, &CLAIM).unwrap() }) } -/// The proved signer set as a set, since `verify` returns the library's canonical sorted order -/// rather than the order anything was aggregated in. -fn proved_set(aggregate_bytes: &[u8]) -> BTreeSet> { - verify(aggregate_bytes, &MSG, SLOT).unwrap().into_iter().collect() +fn base_public_keys() -> Vec> { + signers(2).iter().map(SecretKey::public_key).collect() } -#[test] -fn aggregate_then_verify_returns_the_signer_set() { - // The end-to-end claim the whole crate exists to make: keys sign, `aggregate` proves, and - // `verify` reports exactly who signed. Everything below is a variation on this failing. - let pks = base_pubkeys(); - let agg = base(); +fn signer_set(signature: &Signature, claim: &Claim) -> BTreeSet> { + verified_signers(signature, claim).unwrap().into_iter().collect() +} - assert_eq!(proved_set(agg), pks.iter().cloned().collect::>()); +#[test] +fn aggregate_round_trips_through_the_public_wire_format() { + let aggregate = Signature::from_bytes(&base().to_bytes()).unwrap(); + let expected = base_public_keys(); - // The same claim through the API that checks the set for you. - verify_with_signers(agg, &pks, &MSG, SLOT).unwrap(); + verify(&aggregate, &expected, &CLAIM).unwrap(); + assert_eq!(signer_set(&aggregate, &CLAIM), expected.into_iter().collect()); } #[test] -fn verify_rejects_the_wrong_message_and_slot() { - // The proof is valid; it just does not prove what is being asked about. This is the check - // that binds an aggregate to a `(message, slot)` — `verify_single_message_aggregate` on its - // own validates the aggregate against its *own* pair and would happily pass. - let agg = base(); - assert!(matches!(verify(agg, &[0u8; 32], SLOT), Err(Error::MessageMismatch))); - assert!(matches!(verify(agg, &MSG, SLOT + 1), Err(Error::MessageMismatch))); +fn verification_binds_the_claim_and_signer_set() { + let wrong_claim = Claim::new([7u8; 32], CLAIM.slot()); assert!(matches!( - verify_with_signers(agg, &base_pubkeys(), &[0u8; 32], SLOT), + verify(base(), &base_public_keys(), &wrong_claim), Err(Error::MessageMismatch) )); -} - -#[test] -fn verify_rejects_a_tampered_proof() { - // A real proof with one byte changed, in the two classes that turn out to exist. Which class - // a mutation falls into is decided by postcard, not by the prover: field elements are LEB128 - // varints, so most edits move a continuation bit and desynchronize every value after it. - // - // Measured by hand over 64 evenly spaced offsets — that probe is not committed; the sweep at - // the end of this test is a different, smaller one of 15 offsets and 30 mutations. `^= 0xff` - // gives `MalformedAggregate` at 63 of the 64 and never reaches the proof check (the odd one - // out is byte 0, the message's first byte, which gives `MessageMismatch`). `^= 0x01` gives - // `Error::Proof` at all 63 and `MalformedAggregate` at none. Neither mask is ever accepted at - // any offset. - // - // Even spacing tops out at `len * 63/64`, so the probe never lands on the last byte — which - // is precisely the byte the class-1 assertion below flips. That assertion is its own evidence; - // the sweep above it is not. - let agg = base(); - - // Class 1: framing broken, so the blob stops being an aggregate before any proof work. - let mut framing = agg.to_vec(); - *framing.last_mut().unwrap() ^= 0xff; - let result = verify(&framing, &MSG, SLOT); - assert!( - matches!(result, Err(Error::MalformedAggregate)), - "a wholesale byte flip breaks postcard framing, got {result:?}" - ); - - // Class 2: the interesting one. Flipping the *low* bit preserves every varint's length and - // leaves the field element canonical, so the aggregate parses and the proof itself is what - // rejects it. This is the only place a genuine `Error::Proof` comes out of a real proof — - // `tests/unprovable_child.rs` reaches that variant with a hand-built empty transcript, which - // says nothing about what a prover actually emits. - let mut tampered = agg.to_vec(); - tampered[agg.len() / 2] ^= 0x01; - let result = verify(&tampered, &MSG, SLOT); - assert!( - matches!(result, Err(Error::Proof(_))), - "a framing-preserving edit must reach the proof check, got {result:?}" - ); - // The claim both classes serve: no single-byte change anywhere is *accepted*. Verify-only, so - // this sweep is milliseconds. If the encoding ever drifts and class 2 stops reaching the proof - // check, this still holds and the assertion above is what fails — which is the right place to - // find out. - for k in 1..16 { - let offset = agg.len() * k / 16; - for mask in [0x01u8, 0xff] { - let mut bytes = agg.to_vec(); - bytes[offset] ^= mask; - let result = verify(&bytes, &MSG, SLOT); - assert!( - result.is_err(), - "flipping {mask:#04x} at byte {offset} verified: {result:?}" - ); - } - } -} - -#[test] -fn verify_rejects_a_different_expected_signer_set() { - // `verify_with_signers` exists so a caller cannot forget to check *who* signed; a set that - // is merely plausible must fail as loudly as garbage. - let agg = base(); - let pks = base_pubkeys(); let outsider = lone_key(200).public_key(); - - // One real signer swapped for someone who never signed. - assert!(matches!( - verify_with_signers(agg, &[pks[0].clone(), outsider.clone()], &MSG, SLOT), - Err(Error::SignerSetMismatch) - )); - // A subset fails too: a signer missing from `expected` is as wrong as an extra one. assert!(matches!( - verify_with_signers(agg, &[pks[0].clone()], &MSG, SLOT), + verify(base(), &[base_public_keys()[0].clone(), outsider], &CLAIM), Err(Error::SignerSetMismatch) )); - // And a superset, which is the shape a caller checking "did my validators sign?" gets wrong. - let superset = [pks[0].clone(), pks[1].clone(), outsider]; - assert!(matches!( - verify_with_signers(agg, &superset, &MSG, SLOT), - Err(Error::SignerSetMismatch) - )); - // Repeats in `expected` are ignored, as documented — this is the *passing* side. - verify_with_signers(agg, &[pks[0].clone(), pks[1].clone(), pks[0].clone()], &MSG, SLOT).unwrap(); } #[test] -fn folding_an_aggregate_with_fresh_signatures_unions_the_signers() { - // The mixed-input path, and the only test anywhere that sees `codec::classify` return - // successfully with its `aggregates` vector non-empty. Unit tests cannot reach it: parsing a - // real aggregate needs the bytecode `OnceLock` filled and a genuine `ExecutionProof`. - // - // Also the shape where the two input vectors are deliberately *not* index-aligned: - // `proof_or_sig` holds an aggregate and one raw signature, and `public_keys` holds exactly - // one key — the raw one's. A caller who assumed alignment would pass two keys and get - // `PubkeyCountMismatch`. - // - // The assertion is the *union*, not merely `Ok`: pairing the third signature with the wrong - // key, or dropping the child's signers, both produce a perfectly valid proof of the wrong - // signer set, which no `is_ok` check would notice. +fn folding_an_aggregate_with_a_fresh_signature_hides_the_representation_split() { let fresh = lone_key(201); - let outer = prove( - vec![base().to_vec(), fresh.sign(&MSG, SLOT).unwrap()], - vec![fresh.public_key()], - MSG, - SLOT, - ) - .unwrap(); + let combined = prove(vec![base().clone(), fresh.sign(&CLAIM).unwrap()], &CLAIM).unwrap(); - let mut expected = base_set(); + let mut expected: BTreeSet> = base_public_keys().into_iter().collect(); expected.insert(fresh.public_key()); - assert_eq!(expected.len(), 3, "the fresh signer must be a genuinely new key"); - assert_eq!(proved_set(&outer), expected); - verify_with_signers(&outer, &expected.iter().cloned().collect::>(), &MSG, SLOT).unwrap(); + assert_eq!(signer_set(&combined, &CLAIM), expected); } #[test] -fn duplicate_raw_signatures_collapse_to_one_signer() { - // `dedup_signers` is pinned as a pure function in the unit suite, which is where its - // "earliest signature wins" tie-break belongs. What that cannot show is that the deduplicated - // batch is still something the prover accepts, and that the *proved* set is the deduplicated - // one rather than a set with a repeat in it — a repeat that upstream would later charge - // against `MAX_XMSS_DUPLICATES`. +fn duplicate_signers_collapse_to_one() { let keys = signers(2); - let (a, b) = (&keys[0], &keys[1]); - // The same key offered twice, beside a second key that must survive untouched. Signing the - // same (message, slot) twice is derandomized and byte-identical, so this is the innocent - // shape a caller hits by merging two overlapping gossip batches. - let entries = vec![ - a.sign(&MSG, SLOT).unwrap(), - a.sign(&MSG, SLOT).unwrap(), - b.sign(&MSG, SLOT).unwrap(), - ]; - let pks = vec![a.public_key(), a.public_key(), b.public_key()]; + let combined = prove( + vec![ + keys[0].sign(&CLAIM).unwrap(), + keys[0].sign(&CLAIM).unwrap(), + keys[1].sign(&CLAIM).unwrap(), + ], + &CLAIM, + ) + .unwrap(); - let agg = prove(entries, pks, MSG, SLOT).unwrap(); - assert_eq!( - proved_set(&agg), - base_set(), - "a repeated signer must prove exactly once, and must not take the other one down with it" - ); + assert_eq!(signer_set(&combined, &CLAIM), base_public_keys().into_iter().collect()); } #[test] -fn a_signer_present_in_both_a_child_and_a_raw_batch_appears_once() { - // The overlap `aggregate`'s rustdoc documents: the result's signer set is the *union* of the - // raw pubkeys and every child's. `dedup_signers` cannot help here — it only sees the raw - // vector — so this is upstream's per-node deduplication being relied on across the boundary - // between a supplied aggregate and fresh signatures. - // - // Distinct from the excluded `MAX_XMSS_DUPLICATES` ceiling: this asserts that one overlapping - // signer is *correct*, not how many the node tolerates before refusing. +fn a_signer_shared_by_a_child_and_fresh_input_appears_once() { let keys = signers(2); - let fresh = lone_key(204); - let entries = vec![ - base().to_vec(), - keys[0].sign(&MSG, SLOT).unwrap(), // already inside `base` - fresh.sign(&MSG, SLOT).unwrap(), // genuinely new - ]; - let pks = vec![keys[0].public_key(), fresh.public_key()]; - - let agg = prove(entries, pks, MSG, SLOT).unwrap(); + 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 = base_set(); + let mut expected: BTreeSet> = base_public_keys().into_iter().collect(); expected.insert(fresh.public_key()); - assert_eq!(expected.len(), 3, "two from the child plus one new one"); - assert_eq!( - proved_set(&agg), - expected, - "the overlapping signer must appear once, and the new one must appear at all" - ); + assert_eq!(signer_set(&combined, &CLAIM), expected); } #[test] -fn a_corrupt_signature_sized_blob_is_a_malformed_signature_not_a_malformed_entry() { - // `codec`'s unit test of the same shape cannot discriminate what its name says: with no - // bytecode initialized, a fall-through to the aggregate parser also returns `None`, so a - // classifier that tried both would produce an error there too — just a different variant - // nobody could distinguish from the right one. - // - // Here `aggregate` initializes the bytecode before `classify` runs, so the aggregate parser - // is genuinely live: a fall-through would report `MalformedEntry`, and this asserts it does - // not. The two variants have different remedies — damaged data versus data of the wrong kind - // — which is why they are kept apart at all. - let key = lone_key(202); - let mut sig = key.sign(&MSG, SLOT).unwrap(); - assert_eq!(sig.len(), xmss::SIGNATURE_SSZ_LEN, "the classifier dispatches on this"); - sig[..4].copy_from_slice(&[0xff; 4]); // non-canonical field element +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(); - // No proving: this fails inside `classify`, before the planner. - let result = prove(vec![sig], vec![key.public_key()], MSG, SLOT); - assert!( - matches!(result, Err(Error::MalformedSignature { index: 0 })), - "a 1208-byte blob must never fall through to the aggregate parser, got {result:?}" - ); + assert!(matches!( + prove(vec![other, key.sign(&CLAIM).unwrap()], &CLAIM), + Err(Error::MessageMismatch) + )); } #[test] -fn a_signature_paired_with_the_wrong_key_fails_in_the_prover() { - // The mistake this API's shape invites: the right *number* of pubkeys in the wrong order. - // Nothing before proving can catch it — the counts match and both blobs decode — so the - // constraint system is what rejects it, as `Error::Aggregation(Prover(Runner(..)))`. - // - // Pinned because the alternative to an error here is a *panic*: this is a public entry point - // fed by gossip, and a constraint failure on attacker-supplied bytes must stay a `Result`. - // Nothing else in the suite reaches the runner's failure path, so a change to how it reports - // unsatisfied constraints could turn this into an abort with every other test still green. - // - // The diagnostic is a bare constraint mismatch carrying no index and no hint that pubkey - // ordering is the thing to check — see `aggregate`'s `# Errors`, which now says so. - let a = lone_key(205); - let b = lone_key(206); - let result = prove(vec![a.sign(&MSG, SLOT).unwrap()], vec![b.public_key()], MSG, SLOT); - assert!(matches!(result, Err(Error::Aggregation(_))), "got {result:?}"); +fn malformed_and_tampered_envelopes_are_rejected() { + assert!(matches!( + Signature::from_bytes(b"not a signature"), + Err(Error::MalformedSignature) + )); + + let mut bytes = base().to_bytes(); + *bytes.last_mut().unwrap() ^= 0xff; + match Signature::from_bytes(&bytes) { + Err(Error::MalformedSignature) => {} + Ok(signature) => assert!(verified_signers(&signature, &CLAIM).is_err()), + Err(other) => panic!("unexpected error: {other:?}"), + } } #[test] -fn a_lone_valid_aggregate_is_passed_through_unchanged() { - // `plan(0, 1)` is `Passthrough`, the one shape where `aggregate` proves nothing and hands - // back what it was given. Cheap, and therefore the shape most likely to quietly return the - // wrong thing: it is the only path where no node re-derives the signer set, and the only one - // where no node verifies the child's proof either (`aggregate` does that itself at the root - // — `tests/unprovable_child.rs` is the negative side of that check; this is the positive one, - // which is what shows the check does not reject *valid* aggregates too). - let out = prove(vec![base().to_vec()], vec![], MSG, SLOT).unwrap(); +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; - assert_eq!(proved_set(&out), base_set(), "a passthrough must not change who signed"); - // Decode/re-encode is the identity, so the passthrough is a passthrough in bytes and not - // merely in meaning. `rebuild_bytecode_claim` recomputes the claim's value on the way in and - // `to_bytes` never writes it, so the round trip has nothing to drift on. - assert_eq!( - out, - base(), - "re-encoding a lone aggregate must reproduce it byte for byte" - ); + let signature = Signature::from_bytes(&bytes).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() { - // The planner's fold loop and `execute`'s nested recursion, on real proofs. Every other test - // here plans a single node or a root over two children, so the `while pool.len() > MAX_FAN_IN` - // branch runs nowhere else — and with it the only `Passthrough` under a *non-root* node, - // which is what makes `execute` recurse into a child that is itself a folded internal node. - // (`Passthrough` under the root is not unique to this test: - // `folding_an_aggregate_with_fresh_signatures_unions_the_signers` plans `Node { children: - // [leaf, Passthrough(0)] }` and so also takes the `Cow::into_owned` clone.) - // - // Depth comes from the fan-in, not from `LEAF_TARGET`: 1501 raw signatures would split into - // two leaves, but 17 supplied children fold into an internal node over 16 plus a leftover, - // which is a three-level tree. Reaching it through raw signatures alone would need 1500 * 17 - // of them, which is not a test at any budget. - // - // The plan for this crate expected it to be `#[ignore]`d as unaffordably slow. Measured, it - // is 19 proving jobs in ~8s — the whole of the rest of this file is ~4s — so it runs in CI - // like everything else. It is still by far the most expensive test here, and the first place - // to look if this binary's runtime ever becomes a problem. let keys = signers(17); - let children: Vec> = keys + let children = keys .iter() - .map(|k| prove(vec![k.sign(&MSG, SLOT).unwrap()], vec![k.public_key()], MSG, SLOT).unwrap()) + .map(|key| prove(vec![key.sign(&CLAIM).unwrap()], &CLAIM).unwrap()) .collect(); + let root = prove(children, &CLAIM).unwrap(); - let root = prove(children, vec![], MSG, SLOT).unwrap(); - - let expected: BTreeSet> = keys.iter().map(SecretKey::public_key).collect(); - assert_eq!(expected.len(), 17, "every child must contribute a distinct signer"); - assert_eq!(proved_set(&root), expected); + let expected = keys.iter().map(SecretKey::public_key).collect::>(); + assert_eq!(signer_set(&root, &CLAIM), expected); } -#[test] -fn a_lone_aggregate_over_another_message_or_slot_is_rejected() { - // The check that has to live in `aggregate` itself. Upstream compares a child's `(message, - // slot)` only at the node that *consumes* it, and a lone aggregate is consumed by nothing — - // so without this, `aggregate` returns an aggregate over MSG as a success for a caller who - // asked for a different message entirely, and the caller then gossips it as proof of the - // wrong thing. - let other = [7u8; 32]; - assert_ne!(other, MSG); - let result = prove(vec![base().to_vec()], vec![], other, SLOT); - assert!(matches!(result, Err(Error::MessageMismatch)), "got {result:?}"); - - let result = prove(vec![base().to_vec()], vec![], MSG, SLOT + 1); - assert!(matches!(result, Err(Error::MessageMismatch)), "got {result:?}"); - - // The same fault with a sibling present, which is the shape upstream would eventually catch - // on its own — but only after proving the sibling's leaf. Fails here in milliseconds instead. - let fresh = lone_key(203); - let result = prove( - vec![base().to_vec(), fresh.sign(&other, SLOT).unwrap()], - vec![fresh.public_key()], - other, - SLOT, - ); - assert!(matches!(result, Err(Error::MessageMismatch)), "got {result:?}"); -} - -/// Mirror of `plan::LEAF_TARGET`, which is `pub(crate)` in a private module and so invisible -/// here. Nothing enforces that these agree — if the constant moves, the tests below quietly stop -/// testing the boundary they are named for, which is the cost of not exposing it. const LEAF_TARGET: usize = 1500; -/// The first `n` pre-generated benchmark signatures, as `(entries, pubkeys)` in `aggregate`'s -/// argument shapes. -/// -/// Real keygen for 1501 signers would dwarf the proving these tests exist to measure; -/// `xmss::signers_cache` generates 10,000 once and caches them on disk (`target/signers-cache`, -/// or `$SIGNERS_CACHE_DIR`), which is the same cache `tests/test_multisignatures.rs` uses. They -/// are signed over `message_for_benchmark()` at `BENCHMARK_SLOT`, not this file's `MSG`/`SLOT`. -fn benchmark_batch(n: usize) -> (Vec>, Vec>) { - use ssz::Encode; - let signatures = xmss::signers_cache::get_benchmark_signatures(); - assert!(signatures.len() >= n, "the cache holds {} signatures", signatures.len()); - signatures[..n] +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(|(pk, sig)| (sig.as_ssz_bytes(), pk.as_ssz_bytes())) - .unzip() + .map(|(public_key, signature)| { + let public_key_bytes = public_key.as_ssz_bytes(); + public_keys.push(public_key_bytes.clone()); + let mut bytes = b"LMSI\x01\x00".to_vec(); + bytes.extend_from_slice(claim.message()); + bytes.extend_from_slice(&claim.slot().to_le_bytes()); + bytes.extend(public_key_bytes); + bytes.extend(signature.as_ssz_bytes()); + Signature::from_bytes(&bytes).unwrap() + }) + .collect(); + (signatures, public_keys, claim) } #[test] #[ignore = "slow: proves a full 1500-signature leaf"] fn a_leaf_target_sized_batch_proves() { - // The crate's biggest untested assumption. `plan::LEAF_TARGET` is 1500 because `src/main.rs`'s - // tuned topology proves leaves of 508..1550 — it was inherited, not computed against the 2^22 - // table height. If a 1500-signature node does not actually prove, every aggregation past that - // many signers fails at runtime, and nothing in the default suite would notice: its largest - // single node holds three signatures. - // - // `plan(LEAF_TARGET, 0)` is one node at `RATE_ROOT`, which is also the slowest rate the - // planner uses — so this is the worst case for the boundary, not a favourable reading of it. - // - // Measured: it proves, in ~8s release including the cache load. So 1500 is a real leaf size - // and not merely an inherited one. That is a statement about this one shape at this one rate; - // the largest leaf that proves is still unmeasured, and the constant sits below 1550 for - // reasons `plan.rs` records rather than reasons anything checks. - let message = xmss::signers_cache::message_for_benchmark(); - let slot = xmss::signers_cache::BENCHMARK_SLOT; - let (entries, pubkeys) = benchmark_batch(LEAF_TARGET); - - let agg = prove(entries, pubkeys, message, slot).expect("a LEAF_TARGET-sized leaf must prove"); - assert_eq!( - verify(&agg, &message, slot).unwrap().len(), - LEAF_TARGET, - "every signer must survive to the wire proof" - ); + 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() { - // One signature past the boundary, which is where `plan` stops returning a single node and - // starts returning `[leaf(0..1500), leaf(1500..1501)]` under a root — three proving jobs, and - // the degenerate one-signature leaf the planner's own test records as a shape it tolerates. - // Neither the split nor that leaf has ever been proved for real. - // - // Measured at ~7s against the single 1500-node's ~8s — one more signature, three proving jobs - // instead of one, and *less* wall-clock, because the two leaves run at `RATE_LEAF` and only - // the root pays `RATE_ROOT`. Worth knowing before optimizing node counts: the rate the - // planner assigns dominates how many nodes it creates. - let message = xmss::signers_cache::message_for_benchmark(); - let slot = xmss::signers_cache::BENCHMARK_SLOT; - let (entries, pubkeys) = benchmark_batch(LEAF_TARGET + 1); - - let agg = prove(entries, pubkeys, message, slot).expect("a split batch must prove"); - assert_eq!(verify(&agg, &message, slot).unwrap().len(), LEAF_TARGET + 1); + 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..70f85e9f --- /dev/null +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -0,0 +1,27 @@ +use lean_multisig_api::{Claim, SecretKey, Signature, aggregate, verified_signers, verify}; +use std::collections::BTreeSet; + +#[test] +fn signatures_and_aggregates_share_one_opaque_api() { + 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_signature = Signature::from_bytes(&alice_signature.to_bytes()).unwrap(); + let aggregate = aggregate(vec![alice_signature, bob_signature], &claim).unwrap(); + let aggregate = Signature::from_bytes(&aggregate.to_bytes()).unwrap(); + + let expected = vec![alice.public_key(), bob.public_key()]; + verify(&aggregate, &expected, &claim).unwrap(); + + assert_eq!( + verified_signers(&aggregate, &claim) + .unwrap() + .into_iter() + .collect::>(), + expected.into_iter().collect() + ); +} diff --git a/crates/lean_multisig_api/tests/unprovable_child.rs b/crates/lean_multisig_api/tests/unprovable_child.rs deleted file mode 100644 index 31fb0b8a..00000000 --- a/crates/lean_multisig_api/tests/unprovable_child.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! A supplied aggregate whose envelope decodes but whose proof is worthless must be rejected. -//! -//! `codec::classify` only decodes the envelope — postcard, then `rebuild_bytecode_claim`, then -//! the pubkey well-formedness check. Nothing in it looks at the proof. Every plan shape that -//! *consumes* a child gets the proof checked for free, because -//! `aggregate_single_message_signatures` verifies each child before folding it in — but a lone -//! supplied aggregate is planned as a `Passthrough`, which is consumed by nothing. That one path -//! has to check the proof itself, and these tests are what say so. -//! -//! The blob below is the cheapest thing that gets past the envelope: a real bytecode-claim point -//! of the right length, one well-formed public key, and an empty proof. Building it by hand -//! avoids the minutes of proving a genuine aggregate would cost, and an empty transcript fails -//! verification for the most unambiguous reason available (`ExceededTranscript`). - -use lean_vm::{EF, F}; -use ssz::Decode; -use xmss::XmssPublicKey; - -const MSG: [u8; 32] = [0u8; 32]; -const SLOT: u32 = 0; - -/// The postcard encoding of a `SingleMessageAggregateSignature`, field by field. -/// -/// The type cannot be constructed directly from outside `rec_aggregation` — `Proof`'s fields are -/// `pub(crate)` — so this writes the wire format instead. Postcard encodes structs and tuples as -/// their fields back to back with no framing, so a tuple of the right leaves in the right order -/// is byte-identical to the real thing: -/// -/// `SingleMessageAggregateSignature { info: { core: (message, slot, point), pubkeys }, proof }`, -/// where `ExecutionProof`'s only serialized field is `Proof { transcript, merkle_paths }`. -fn unprovable_aggregate() -> Vec { - // The point must match the bytecode's variable count or `rebuild_bytecode_claim` rejects it - // and the blob never gets past parsing — which would make these tests pass for the wrong - // reason. Its *value* is recomputed on deserialize, so zeros are fine. - let n_vars = rec_aggregation::get_aggregation_bytecode().cumulated_n_vars(); - let point: Vec = vec![EF::default(); n_vars]; - - // One public key, non-empty and trivially sorted, as `check_single_message_pubkeys` demands. - let mut pk_bytes = vec![0u8; xmss::PUB_KEY_SSZ_LEN]; - pk_bytes[0] = 1; - let pubkeys: Vec = vec![XmssPublicKey::from_ssz_bytes(&pk_bytes).unwrap()]; - - postcard::to_allocvec(&((MSG, SLOT, point), pubkeys, (Vec::::new(), Vec::::new()))) - .expect("the fixture serializes infallibly") -} - -#[test] -fn aggregate_rejects_an_unprovable_lone_aggregate() { - lean_multisig_api::warm_up(); - let blob = unprovable_aggregate(); - - // No raw signatures and one child, so the planner returns `Passthrough(0)` — the shape where - // no node ever consumes the child. Without the check in that arm this returns `Ok`, and the - // caller goes on to re-gossip a blob that fails everywhere downstream. - let result = lean_multisig_api::aggregate(vec![blob], vec![], MSG, SLOT); - assert!( - matches!(result, Err(lean_multisig_api::Error::Proof(_))), - "a passthrough must verify its child's proof, got {result:?}" - ); -} - -#[test] -fn verify_rejects_an_unprovable_aggregate() { - lean_multisig_api::warm_up(); - let blob = unprovable_aggregate(); - - // Distinct from the malformed-bytes tests in the unit suite: those stop at parsing, so they - // never reach `verify_single_message_aggregate`. This one is well-formed all the way to the - // proof, so `Error::Proof` rather than `Error::MalformedAggregate` is the whole assertion. - let result = lean_multisig_api::verify(&blob, &MSG, SLOT); - assert!( - matches!(result, Err(lean_multisig_api::Error::Proof(_))), - "expected the proof check to reject this, got {result:?}" - ); -} - -#[test] -fn the_fixture_really_does_get_past_the_envelope() { - // If the wire format ever drifts, the two tests above would still pass — on - // `MalformedAggregate`, having proved nothing about proof checking. This is what tells the - // difference: a wrong (message, slot) has to be reported as a mismatch, which is only - // reachable once the blob has parsed. - lean_multisig_api::warm_up(); - let blob = unprovable_aggregate(); - let result = lean_multisig_api::verify(&blob, &[9u8; 32], SLOT); - assert!( - matches!(result, Err(lean_multisig_api::Error::MessageMismatch)), - "the fixture must parse, or the tests above prove nothing, got {result:?}" - ); -} From ba279110f6993dc4e31c7f8db71e3b902c8001a4 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 20:51:37 +0100 Subject: [PATCH 06/12] refactor(lean_multisig_api): type public key bytes --- crates/lean_multisig_api/src/key.rs | 7 +++-- crates/lean_multisig_api/src/lib.rs | 27 +++++++++++++++----- crates/lean_multisig_api/src/plan.rs | 22 +++++++++++----- crates/lean_multisig_api/tests/round_trip.rs | 14 +++++----- crates/lean_multisig_api/tests/simple_api.rs | 5 ++-- 5 files changed, 49 insertions(+), 26 deletions(-) diff --git a/crates/lean_multisig_api/src/key.rs b/crates/lean_multisig_api/src/key.rs index bcd1e77f..ff1c285f 100644 --- a/crates/lean_multisig_api/src/key.rs +++ b/crates/lean_multisig_api/src/key.rs @@ -3,9 +3,8 @@ //! Unlike the inert persistence bytes returned by `to_bytes`, this handle retains the signing //! cache that makes repeated use practical. -use crate::{Claim, Error, Signature}; +use crate::{Claim, Error, PublicKey, Signature, encode_public_key}; use sha2::{Digest, Sha256}; -use ssz::Encode; use std::ops::RangeInclusive; use xmss::{XmssKeyGenError, XmssSecretKey, xmss_key_gen, xmss_key_gen_from_seed, xmss_sign}; @@ -187,8 +186,8 @@ impl SecretKey { /// 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) -> Vec { - self.0.public_key().as_ssz_bytes() + pub fn public_key(&self) -> PublicKey { + encode_public_key(&self.0.public_key()) } /// The inclusive range of slots this key can sign for. diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index 63ff400d..9cd511a3 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -24,8 +24,23 @@ pub use error::Error; pub use key::SecretKey; 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]; + +const _: () = assert!(xmss::PUB_KEY_SSZ_LEN == size_of::()); + type Raw = (XmssPublicKey, XmssSignature); +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") +} + fn proves(signature: &SingleMessageAggregateSignature, claim: &Claim) -> bool { signature.info.core.message == *claim.message() && signature.info.core.slot == claim.slot() } @@ -134,7 +149,7 @@ fn execute<'a>( /// 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> { +pub fn verified_signers(signature: &Signature, claim: &Claim) -> Result, Error> { if signature.claim() != *claim { return Err(Error::MessageMismatch); } @@ -144,7 +159,7 @@ pub fn verified_signers(signature: &Signature, claim: &Claim) -> Result { xmss_verify(public_key, claim.slot(), claim.message(), signature) .map_err(|source| Error::InvalidSignature { index: 0, source })?; - Ok(vec![public_key.as_ssz_bytes()]) + Ok(vec![encode_public_key(public_key)]) } Kind::Aggregate(signature) => { init_aggregation_bytecode(); @@ -152,7 +167,7 @@ pub fn verified_signers(signature: &Signature, claim: &Claim) -> Result Result], claim: &Claim) -> Result<(), Error> { +pub fn verify(signature: &Signature, expected: &[PublicKey], claim: &Claim) -> Result<(), Error> { let proved = verified_signers(signature, claim)?; - let expected: BTreeSet<&[u8]> = expected.iter().map(Vec::as_slice).collect(); - if proved.len() == expected.len() && proved.iter().all(|key| expected.contains(key.as_slice())) { + let expected: BTreeSet<&PublicKey> = expected.iter().collect(); + if proved.len() == expected.len() && proved.iter().all(|key| expected.contains(key)) { Ok(()) } else { Err(Error::SignerSetMismatch) diff --git a/crates/lean_multisig_api/src/plan.rs b/crates/lean_multisig_api/src/plan.rs index fd353d46..20f3cc85 100644 --- a/crates/lean_multisig_api/src/plan.rs +++ b/crates/lean_multisig_api/src/plan.rs @@ -41,15 +41,23 @@ pub(crate) const RATE_INTERNAL: usize = 2; /// Smallest proof. Only the root goes on the wire. pub(crate) const RATE_ROOT: usize = 4; -// The three rates above are literals restating the band `lean_prover::default_whir_config` -// accepts, and it `assert!`s rather than erroring — so a narrowed band upstream would abort -// every aggregation at proving time with no compile-time or test-time signal. Same guard the -// two constants above get, for the same reason: this crate does not own the value. +// 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_ROOT <= lean_vm::MAX_WHIR_LOG_INV_RATE - && RATE_LEAF <= RATE_INTERNAL - && RATE_INTERNAL <= RATE_ROOT + && 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. diff --git a/crates/lean_multisig_api/tests/round_trip.rs b/crates/lean_multisig_api/tests/round_trip.rs index 8945b708..8df201ed 100644 --- a/crates/lean_multisig_api/tests/round_trip.rs +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -7,7 +7,7 @@ //! cargo test --release -p lean_multisig_api --test round_trip -- --ignored //! ``` -use lean_multisig_api::{Claim, Error, SecretKey, Signature, aggregate, verified_signers, verify}; +use lean_multisig_api::{Claim, Error, PublicKey, SecretKey, Signature, aggregate, verified_signers, verify}; use ssz::Encode; use std::collections::BTreeSet; use std::sync::{Mutex, OnceLock}; @@ -39,11 +39,11 @@ fn base() -> &'static Signature { }) } -fn base_public_keys() -> Vec> { +fn base_public_keys() -> Vec { signers(2).iter().map(SecretKey::public_key).collect() } -fn signer_set(signature: &Signature, claim: &Claim) -> BTreeSet> { +fn signer_set(signature: &Signature, claim: &Claim) -> BTreeSet { verified_signers(signature, claim).unwrap().into_iter().collect() } @@ -76,7 +76,7 @@ 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(); + let mut expected: BTreeSet = base_public_keys().into_iter().collect(); expected.insert(fresh.public_key()); assert_eq!(signer_set(&combined, &CLAIM), expected); } @@ -111,7 +111,7 @@ fn a_signer_shared_by_a_child_and_fresh_input_appears_once() { ) .unwrap(); - let mut expected: BTreeSet> = base_public_keys().into_iter().collect(); + let mut expected: BTreeSet = base_public_keys().into_iter().collect(); expected.insert(fresh.public_key()); assert_eq!(signer_set(&combined, &CLAIM), expected); } @@ -172,7 +172,7 @@ fn a_multi_level_tree_round_trips() { const LEAF_TARGET: usize = 1500; -fn cached_batch(n: usize) -> (Vec, Vec>, Claim) { +fn cached_batch(n: usize) -> (Vec, Vec, Claim) { let cached = xmss::signers_cache::get_benchmark_signatures(); assert!(cached.len() >= n); let claim = Claim::new( @@ -184,7 +184,7 @@ fn cached_batch(n: usize) -> (Vec, Vec>, Claim) { .iter() .map(|(public_key, signature)| { let public_key_bytes = public_key.as_ssz_bytes(); - public_keys.push(public_key_bytes.clone()); + public_keys.push(public_key_bytes.as_slice().try_into().unwrap()); let mut bytes = b"LMSI\x01\x00".to_vec(); bytes.extend_from_slice(claim.message()); bytes.extend_from_slice(&claim.slot().to_le_bytes()); diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs index 70f85e9f..a3ab3aba 100644 --- a/crates/lean_multisig_api/tests/simple_api.rs +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -1,4 +1,4 @@ -use lean_multisig_api::{Claim, SecretKey, Signature, aggregate, verified_signers, verify}; +use lean_multisig_api::{Claim, PublicKey, SecretKey, Signature, aggregate, verified_signers, verify}; use std::collections::BTreeSet; #[test] @@ -14,7 +14,8 @@ fn signatures_and_aggregates_share_one_opaque_api() { let aggregate = aggregate(vec![alice_signature, bob_signature], &claim).unwrap(); let aggregate = Signature::from_bytes(&aggregate.to_bytes()).unwrap(); - let expected = vec![alice.public_key(), bob.public_key()]; + let _: [u8; 32] = alice.public_key(); + let expected: Vec = vec![alice.public_key(), bob.public_key()]; verify(&aggregate, &expected, &claim).unwrap(); assert_eq!( From e33f66ef10dd59bba7e99d028cd16ebf037b08da Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 22:28:18 +0100 Subject: [PATCH 07/12] feat(lean_multisig_api): add multi-claim signatures --- crates/lean_multisig_api/src/error.rs | 17 +- crates/lean_multisig_api/src/lib.rs | 12 +- .../lean_multisig_api/src/multi_signature.rs | 159 ++++++++++++++++++ crates/lean_multisig_api/src/signature.rs | 2 +- crates/lean_multisig_api/tests/multi_claim.rs | 156 +++++++++++++++++ crates/lean_multisig_api/tests/round_trip.rs | 2 +- 6 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 crates/lean_multisig_api/src/multi_signature.rs create mode 100644 crates/lean_multisig_api/tests/multi_claim.rs diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs index 876d58a3..c9711046 100644 --- a/crates/lean_multisig_api/src/error.rs +++ b/crates/lean_multisig_api/src/error.rs @@ -16,15 +16,22 @@ pub enum Error { Proof(backend::ProofError), /// A serialized [`crate::Signature`] envelope was malformed or unsupported. MalformedSignature, + /// A serialized [`crate::MultiClaimSignature`] envelope was malformed or unsupported. + MalformedMultiClaimSignature, /// 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 { @@ -63,11 +70,16 @@ impl Display for Error { 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::MalformedMultiClaimSignature => { + write!(f, "The supplied bytes are not a well-formed multi-claim signature") + } 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"), } } } @@ -81,11 +93,14 @@ impl std::error::Error for Error { Self::Aggregation(err) => Some(err), Self::Proof(err) => Some(err), Self::MalformedSignature + | Self::MalformedMultiClaimSignature | Self::MalformedSecretKey | Self::TooManySigners { .. } + | Self::TooManyClaims { .. } | Self::Empty | Self::MessageMismatch - | Self::SignerSetMismatch => None, + | Self::SignerSetMismatch + | Self::ClaimSetMismatch => None, } } } diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index 9cd511a3..93d585b3 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -1,12 +1,14 @@ //! A small, opinionated facade over XMSS and recursive aggregation. //! -//! [`Signature`] hides whether a contribution is a raw XMSS signature or an aggregate. The -//! recursion topology, proof parameters, bytecode initialization, public-key pairing, and proof -//! representation are all internal choices. +//! [`Signature`] hides whether one-claim contribution is a raw XMSS signature or an aggregate. +//! [`MultiClaimSignature`] groups any mixture of those contributions by claim and binds the +//! resulting groups in one self-contained proof. The recursion topology, proof parameters, +//! bytecode initialization, public-key pairing, and proof representations are internal choices. #![cfg_attr(not(test), warn(unused_crate_dependencies))] mod error; mod key; +mod multi_signature; mod plan; mod signature; @@ -22,6 +24,7 @@ use xmss::{XmssPublicKey, XmssSignature, xmss_verify}; pub use error::Error; pub use key::SecretKey; +pub use multi_signature::{ClaimSigners, MultiClaimSignature, merge_claims, verified_claims, verify_claims}; pub use signature::{Claim, Signature}; /// A canonically encoded, 32-byte XMSS public key. @@ -30,6 +33,9 @@ pub use signature::{Claim, Signature}; /// callers' storage or serialization types. pub type PublicKey = [u8; 32]; +/// Maximum number of distinct claim components in one [`MultiClaimSignature`]. +pub const MAX_CLAIMS: usize = rec_aggregation::MAX_RECURSIONS; + const _: () = assert!(xmss::PUB_KEY_SSZ_LEN == size_of::()); type Raw = (XmssPublicKey, XmssSignature); diff --git a/crates/lean_multisig_api/src/multi_signature.rs b/crates/lean_multisig_api/src/multi_signature.rs new file mode 100644 index 00000000..62864807 --- /dev/null +++ b/crates/lean_multisig_api/src/multi_signature.rs @@ -0,0 +1,159 @@ +use crate::signature::Kind; +use crate::{Claim, Error, PublicKey, Signature, aggregate, encode_public_key}; +use rec_aggregation::{ + MultiMessageAggregateSignature, init_aggregation_bytecode, 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. + pub signers: Vec, +} + +/// A self-contained 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. +#[derive(Clone)] +pub struct MultiClaimSignature(pub(crate) MultiMessageAggregateSignature); + +impl Debug for MultiClaimSignature { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MultiClaimSignature") + .field("claims", &self.0.info.len()) + .finish_non_exhaustive() + } +} + +impl MultiClaimSignature { + /// Serializes the proof, claims, and signer sets into one versioned envelope. + #[must_use] + pub fn to_bytes(&self) -> Vec { + let payload = self.0.to_bytes(); + let mut bytes = Vec::with_capacity(HEADER_LEN + payload.len()); + bytes.extend_from_slice(MAGIC); + bytes.push(VERSION); + bytes.extend(payload); + bytes + } + + /// Restores a self-contained multi-claim signature produced by [`Self::to_bytes`]. + /// + /// This checks framing, canonical encodings, and unique claims only. Use + /// [`verify_claims`] or [`verified_claims`] to establish cryptographic validity. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() <= HEADER_LEN || &bytes[..MAGIC.len()] != MAGIC || bytes[MAGIC.len()] != VERSION { + return Err(Error::MalformedMultiClaimSignature); + } + init_aggregation_bytecode(); + let signature = MultiMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]) + .ok_or(Error::MalformedMultiClaimSignature)?; + let claims = signature + .info + .iter() + .map(|info| Claim::new(info.core.message, info.core.slot)) + .collect::>(); + if signature.info.is_empty() || signature.info.len() > crate::MAX_CLAIMS || claims.len() != signature.info.len() + { + return Err(Error::MalformedMultiClaimSignature); + } + Ok(Self(signature)) + } +} + +/// Groups signatures by claim and proves all groups in one self-contained bundle. +/// +/// 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(MultiClaimSignature) + .map_err(Into::into) +} + +/// Verifies a multi-claim proof and returns its canonical claim-to-signer mapping. +/// +/// 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(signature: &MultiClaimSignature) -> Result, Error> { + init_aggregation_bytecode(); + verify_multi_message_aggregate(&signature.0)?; + let mut groups = signature + .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(signature: &MultiClaimSignature, expected: &[ClaimSigners]) -> Result<(), Error> { + let proved = verified_claims(signature)?; + 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/signature.rs b/crates/lean_multisig_api/src/signature.rs index 731ccc4f..9cc59280 100644 --- a/crates/lean_multisig_api/src/signature.rs +++ b/crates/lean_multisig_api/src/signature.rs @@ -12,7 +12,7 @@ const HEADER_LEN: usize = MAGIC.len() + 2; const RAW_LEN: usize = HEADER_LEN + 32 + 4 + xmss::PUB_KEY_SSZ_LEN + xmss::SIGNATURE_SSZ_LEN; /// The statement signed by every input to one aggregation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Claim { message: [u8; 32], slot: u32, 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..9e686e11 --- /dev/null +++ b/crates/lean_multisig_api/tests/multi_claim.rs @@ -0,0 +1,156 @@ +use lean_multisig_api::{ + Claim, ClaimSigners, Error, MAX_CLAIMS, MultiClaimSignature, SecretKey, aggregate, merge_claims, verified_claims, + verify_claims, +}; +use std::sync::{Mutex, OnceLock}; + +const ATTESTATION: Claim = Claim::new([0xa1; 32], 100); +const PROPOSAL: Claim = Claim::new([0xb2; 32], 101); +static PROVE_LOCK: Mutex<()> = Mutex::new(()); + +struct Fixture { + signature: MultiClaimSignature, + expected: Vec, +} + +fn fixture() -> &'static Fixture { + static FIXTURE: OnceLock = OnceLock::new(); + FIXTURE.get_or_init(|| { + let _guard = PROVE_LOCK.lock().unwrap(); + 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 signature = 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 { signature, expected } + }) +} + +#[test] +fn mixed_signatures_are_grouped_by_claim_and_verified_as_one_bundle() { + let Fixture { signature, expected } = fixture(); + + verify_claims(signature, expected).unwrap(); + let proved = verified_claims(signature).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 self_contained_bundle_round_trips_without_external_claim_context() { + let Fixture { signature, expected } = fixture(); + + let restored = MultiClaimSignature::from_bytes(&signature.to_bytes()).unwrap(); + + verify_claims(&restored, expected).unwrap(); +} + +#[test] +fn authorization_rejects_a_wrong_claim_signer_mapping() { + let Fixture { signature, expected } = fixture(); + let mut wrong = expected.clone(); + wrong[0].signers.pop(); + + assert!(matches!(verify_claims(signature, &wrong), Err(Error::ClaimSetMismatch))); +} + +#[test] +fn authorization_is_order_independent_but_rejects_repeated_claim_groups() { + let Fixture { signature, 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(signature, &reordered).unwrap(); + + let mut repeated = expected.clone(); + repeated.push(expected[0].clone()); + assert!(matches!( + verify_claims(signature, &repeated), + Err(Error::ClaimSetMismatch) + )); +} + +#[test] +fn malformed_multi_claim_envelopes_are_rejected() { + assert!(matches!( + MultiClaimSignature::from_bytes(b"not a multi-claim signature"), + Err(Error::MalformedMultiClaimSignature) + )); + assert!(matches!( + MultiClaimSignature::from_bytes(b"LMCM\x01"), + Err(Error::MalformedMultiClaimSignature) + )); +} + +#[test] +fn decoding_is_structural_and_verification_rejects_a_tampered_bundle() { + let mut bytes = fixture().signature.to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + + match MultiClaimSignature::from_bytes(&bytes) { + Err(Error::MalformedMultiClaimSignature) => {} + Ok(signature) => assert!(verified_claims(&signature).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() { + let _guard = PROVE_LOCK.lock().unwrap(); + let claim = Claim::new([0xc3; 32], 200); + let proposer = SecretKey::from_seed([4; 32], 200..=215).unwrap(); + let signature = merge_claims(vec![proposer.sign(&claim).unwrap()]).unwrap(); + + verify_claims( + &signature, + &[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 index 8df201ed..2924c1ae 100644 --- a/crates/lean_multisig_api/tests/round_trip.rs +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -66,7 +66,7 @@ fn verification_binds_the_claim_and_signer_set() { let outsider = lone_key(200).public_key(); assert!(matches!( - verify(base(), &[base_public_keys()[0].clone(), outsider], &CLAIM), + verify(base(), &[base_public_keys()[0], outsider], &CLAIM), Err(Error::SignerSetMismatch) )); } From 02dcdb1e8ad0cd0cef31de5e7d4ff144cffe4522 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 22:45:50 +0100 Subject: [PATCH 08/12] add example for multiclaimSignature --- crates/lean_multisig_api/tests/simple_api.rs | 75 +++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs index a3ab3aba..cefae210 100644 --- a/crates/lean_multisig_api/tests/simple_api.rs +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -1,8 +1,15 @@ -use lean_multisig_api::{Claim, PublicKey, SecretKey, Signature, aggregate, verified_signers, verify}; +use lean_multisig_api::{ + Claim, ClaimSigners, MultiClaimSignature, PublicKey, SecretKey, Signature, aggregate, merge_claims, + verified_signers, verify, verify_claims, +}; use std::collections::BTreeSet; +use std::sync::Mutex; + +static PROVE_LOCK: Mutex<()> = Mutex::new(()); #[test] fn signatures_and_aggregates_share_one_opaque_api() { + let _guard = PROVE_LOCK.lock().unwrap(); 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(); @@ -26,3 +33,69 @@ fn signatures_and_aggregates_share_one_opaque_api() { expected.into_iter().collect() ); } + +#[test] +fn multiple_claims_share_one_self_contained_signature() { + let _guard = PROVE_LOCK.lock().unwrap(); + 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 signature = merge_claims(vec![ + alice.sign(&attestation).unwrap(), + bob.sign(&attestation).unwrap(), + proposer.sign(&proposal).unwrap(), + ]) + .unwrap(); + let signature = MultiClaimSignature::from_bytes(&signature.to_bytes()).unwrap(); + + verify_claims( + &signature, + &[ + ClaimSigners { + claim: attestation, + signers: vec![alice.public_key(), bob.public_key()], + }, + ClaimSigners { + claim: proposal, + signers: vec![proposer.public_key()], + }, + ], + ) + .unwrap(); +} + +#[test] +fn a_single_claim_aggregate_can_be_merged_with_another_claim() { + let _guard = PROVE_LOCK.lock().unwrap(); + 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 signature = merge_claims(vec![attestation_signature, proposer.sign(&proposal).unwrap()]).unwrap(); + let signature = MultiClaimSignature::from_bytes(&signature.to_bytes()).unwrap(); + + verify_claims( + &signature, + &[ + ClaimSigners { + claim: attestation, + signers: vec![alice.public_key(), bob.public_key()], + }, + ClaimSigners { + claim: proposal, + signers: vec![proposer.public_key()], + }, + ], + ) + .unwrap(); +} From 7a6964cfe757ab26502d674808f339d1695ede6c Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 22:49:29 +0100 Subject: [PATCH 09/12] remove locks (zk-alloc is not enabled) --- crates/lean_multisig_api/tests/simple_api.rs | 35 ++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs index cefae210..3f246f11 100644 --- a/crates/lean_multisig_api/tests/simple_api.rs +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -3,13 +3,10 @@ use lean_multisig_api::{ verified_signers, verify, verify_claims, }; use std::collections::BTreeSet; -use std::sync::Mutex; - -static PROVE_LOCK: Mutex<()> = Mutex::new(()); +use std::sync::Barrier; #[test] fn signatures_and_aggregates_share_one_opaque_api() { - let _guard = PROVE_LOCK.lock().unwrap(); 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(); @@ -36,7 +33,6 @@ fn signatures_and_aggregates_share_one_opaque_api() { #[test] fn multiple_claims_share_one_self_contained_signature() { - let _guard = PROVE_LOCK.lock().unwrap(); let attestation = Claim::new([0xa1; 32], 100); let proposal = Claim::new([0xb2; 32], 101); let alice = SecretKey::from_seed([1; 32], 100..=115).unwrap(); @@ -69,7 +65,6 @@ fn multiple_claims_share_one_self_contained_signature() { #[test] fn a_single_claim_aggregate_can_be_merged_with_another_claim() { - let _guard = PROVE_LOCK.lock().unwrap(); let attestation = Claim::new([0xc1; 32], 100); let proposal = Claim::new([0xd2; 32], 101); let alice = SecretKey::from_seed([4; 32], 100..=115).unwrap(); @@ -99,3 +94,31 @@ fn a_single_claim_aggregate_can_be_merged_with_another_claim() { ) .unwrap(); } + +#[test] +fn concurrent_proving_without_the_arena_does_not_panic() { + 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(); + } + }); +} From b0ee64253fec5dc3b3911a0490b042e84f97c8b0 Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 23:16:50 +0100 Subject: [PATCH 10/12] rename multi-claim signature to proof --- crates/lean_multisig_api/src/error.rs | 10 ++-- crates/lean_multisig_api/src/lib.rs | 8 +-- ...ulti_signature.rs => multi_claim_proof.rs} | 39 +++++++------ crates/lean_multisig_api/tests/multi_claim.rs | 56 +++++++++---------- crates/lean_multisig_api/tests/simple_api.rs | 18 +++--- 5 files changed, 62 insertions(+), 69 deletions(-) rename crates/lean_multisig_api/src/{multi_signature.rs => multi_claim_proof.rs} (81%) diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs index c9711046..5da481e6 100644 --- a/crates/lean_multisig_api/src/error.rs +++ b/crates/lean_multisig_api/src/error.rs @@ -16,8 +16,8 @@ pub enum Error { Proof(backend::ProofError), /// A serialized [`crate::Signature`] envelope was malformed or unsupported. MalformedSignature, - /// A serialized [`crate::MultiClaimSignature`] envelope was malformed or unsupported. - MalformedMultiClaimSignature, + /// A serialized [`crate::MultiClaimProof`] envelope was malformed or unsupported. + MalformedMultiClaimProof, /// Secret-key bytes failed their format or integrity checks. MalformedSecretKey, TooManySigners { @@ -70,8 +70,8 @@ impl Display for Error { 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::MalformedMultiClaimSignature => { - write!(f, "The supplied bytes are not a well-formed multi-claim signature") + Self::MalformedMultiClaimProof => { + write!(f, "The supplied bytes are not a well-formed multi-claim proof") } Self::MalformedSecretKey => write!(f, "Secret key bytes failed validation"), Self::TooManySigners { got, max } => write!(f, "Too many signers: {got} (max {max})"), @@ -93,7 +93,7 @@ impl std::error::Error for Error { Self::Aggregation(err) => Some(err), Self::Proof(err) => Some(err), Self::MalformedSignature - | Self::MalformedMultiClaimSignature + | Self::MalformedMultiClaimProof | Self::MalformedSecretKey | Self::TooManySigners { .. } | Self::TooManyClaims { .. } diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index 93d585b3..7b5e6e78 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -1,14 +1,14 @@ //! A small, opinionated facade over XMSS and recursive aggregation. //! //! [`Signature`] hides whether one-claim contribution is a raw XMSS signature or an aggregate. -//! [`MultiClaimSignature`] groups any mixture of those contributions by claim and binds the +//! [`MultiClaimProof`] groups any mixture of those contributions by claim and binds the //! resulting groups in one self-contained proof. The recursion topology, proof parameters, //! bytecode initialization, public-key pairing, and proof representations are internal choices. #![cfg_attr(not(test), warn(unused_crate_dependencies))] mod error; mod key; -mod multi_signature; +mod multi_claim_proof; mod plan; mod signature; @@ -24,7 +24,7 @@ use xmss::{XmssPublicKey, XmssSignature, xmss_verify}; pub use error::Error; pub use key::SecretKey; -pub use multi_signature::{ClaimSigners, MultiClaimSignature, merge_claims, verified_claims, verify_claims}; +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. @@ -33,7 +33,7 @@ pub use signature::{Claim, Signature}; /// callers' storage or serialization types. pub type PublicKey = [u8; 32]; -/// Maximum number of distinct claim components in one [`MultiClaimSignature`]. +/// 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::()); diff --git a/crates/lean_multisig_api/src/multi_signature.rs b/crates/lean_multisig_api/src/multi_claim_proof.rs similarity index 81% rename from crates/lean_multisig_api/src/multi_signature.rs rename to crates/lean_multisig_api/src/multi_claim_proof.rs index 62864807..0d7e20d8 100644 --- a/crates/lean_multisig_api/src/multi_signature.rs +++ b/crates/lean_multisig_api/src/multi_claim_proof.rs @@ -25,17 +25,17 @@ pub struct ClaimSigners { /// Build this from any mixture of raw and aggregated [`Signature`] values with /// [`merge_claims`]. Inputs sharing a claim are grouped automatically. #[derive(Clone)] -pub struct MultiClaimSignature(pub(crate) MultiMessageAggregateSignature); +pub struct MultiClaimProof(pub(crate) MultiMessageAggregateSignature); -impl Debug for MultiClaimSignature { +impl Debug for MultiClaimProof { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MultiClaimSignature") + f.debug_struct("MultiClaimProof") .field("claims", &self.0.info.len()) .finish_non_exhaustive() } } -impl MultiClaimSignature { +impl MultiClaimProof { /// Serializes the proof, claims, and signer sets into one versioned envelope. #[must_use] pub fn to_bytes(&self) -> Vec { @@ -47,27 +47,26 @@ impl MultiClaimSignature { bytes } - /// Restores a self-contained multi-claim signature produced by [`Self::to_bytes`]. + /// Restores a self-contained multi-claim proof produced by [`Self::to_bytes`]. /// /// This checks framing, canonical encodings, and unique claims only. Use /// [`verify_claims`] or [`verified_claims`] to establish cryptographic validity. pub fn from_bytes(bytes: &[u8]) -> Result { if bytes.len() <= HEADER_LEN || &bytes[..MAGIC.len()] != MAGIC || bytes[MAGIC.len()] != VERSION { - return Err(Error::MalformedMultiClaimSignature); + return Err(Error::MalformedMultiClaimProof); } init_aggregation_bytecode(); - let signature = MultiMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]) - .ok_or(Error::MalformedMultiClaimSignature)?; - let claims = signature + let proof = + MultiMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]).ok_or(Error::MalformedMultiClaimProof)?; + let claims = proof .info .iter() .map(|info| Claim::new(info.core.message, info.core.slot)) .collect::>(); - if signature.info.is_empty() || signature.info.len() > crate::MAX_CLAIMS || claims.len() != signature.info.len() - { - return Err(Error::MalformedMultiClaimSignature); + if proof.info.is_empty() || proof.info.len() > crate::MAX_CLAIMS || claims.len() != proof.info.len() { + return Err(Error::MalformedMultiClaimProof); } - Ok(Self(signature)) + Ok(Self(proof)) } } @@ -75,7 +74,7 @@ impl MultiClaimSignature { /// /// 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 { +pub fn merge_claims(signatures: Vec) -> Result { if signatures.is_empty() { return Err(Error::Empty); } @@ -103,7 +102,7 @@ pub fn merge_claims(signatures: Vec) -> Result, Error>>()?; merge_single_message_aggregates(single_claims, crate::plan::RATE_ROOT) - .map(MultiClaimSignature) + .map(MultiClaimProof) .map_err(Into::into) } @@ -112,10 +111,10 @@ pub fn merge_claims(signatures: Vec) -> Result Result, Error> { +pub fn verified_claims(proof: &MultiClaimProof) -> Result, Error> { init_aggregation_bytecode(); - verify_multi_message_aggregate(&signature.0)?; - let mut groups = signature + verify_multi_message_aggregate(&proof.0)?; + let mut groups = proof .0 .info .iter() @@ -143,8 +142,8 @@ fn canonical_groups(groups: &[ClaimSigners]) -> Option Result<(), Error> { - let proved = verified_claims(signature)?; +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); }; diff --git a/crates/lean_multisig_api/tests/multi_claim.rs b/crates/lean_multisig_api/tests/multi_claim.rs index 9e686e11..1c56e134 100644 --- a/crates/lean_multisig_api/tests/multi_claim.rs +++ b/crates/lean_multisig_api/tests/multi_claim.rs @@ -1,27 +1,25 @@ use lean_multisig_api::{ - Claim, ClaimSigners, Error, MAX_CLAIMS, MultiClaimSignature, SecretKey, aggregate, merge_claims, verified_claims, + Claim, ClaimSigners, Error, MAX_CLAIMS, MultiClaimProof, SecretKey, aggregate, merge_claims, verified_claims, verify_claims, }; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; const ATTESTATION: Claim = Claim::new([0xa1; 32], 100); const PROPOSAL: Claim = Claim::new([0xb2; 32], 101); -static PROVE_LOCK: Mutex<()> = Mutex::new(()); struct Fixture { - signature: MultiClaimSignature, + proof: MultiClaimProof, expected: Vec, } fn fixture() -> &'static Fixture { static FIXTURE: OnceLock = OnceLock::new(); FIXTURE.get_or_init(|| { - let _guard = PROVE_LOCK.lock().unwrap(); 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 signature = merge_claims(vec![ + let proof = merge_claims(vec![ proposer.sign(&PROPOSAL).unwrap(), bob.sign(&ATTESTATION).unwrap(), attestation_child, @@ -37,16 +35,16 @@ fn fixture() -> &'static Fixture { signers: vec![proposer.public_key()], }, ]; - Fixture { signature, expected } + Fixture { proof, expected } }) } #[test] fn mixed_signatures_are_grouped_by_claim_and_verified_as_one_bundle() { - let Fixture { signature, expected } = fixture(); + let Fixture { proof, expected } = fixture(); - verify_claims(signature, expected).unwrap(); - let proved = verified_claims(signature).unwrap(); + verify_claims(proof, expected).unwrap(); + let proved = verified_claims(proof).unwrap(); assert_eq!(proved.len(), 2); assert!( proved @@ -62,60 +60,57 @@ fn mixed_signatures_are_grouped_by_claim_and_verified_as_one_bundle() { #[test] fn self_contained_bundle_round_trips_without_external_claim_context() { - let Fixture { signature, expected } = fixture(); + let Fixture { proof, expected } = fixture(); - let restored = MultiClaimSignature::from_bytes(&signature.to_bytes()).unwrap(); + let restored = MultiClaimProof::from_bytes(&proof.to_bytes()).unwrap(); verify_claims(&restored, expected).unwrap(); } #[test] fn authorization_rejects_a_wrong_claim_signer_mapping() { - let Fixture { signature, expected } = fixture(); + let Fixture { proof, expected } = fixture(); let mut wrong = expected.clone(); wrong[0].signers.pop(); - assert!(matches!(verify_claims(signature, &wrong), Err(Error::ClaimSetMismatch))); + assert!(matches!(verify_claims(proof, &wrong), Err(Error::ClaimSetMismatch))); } #[test] fn authorization_is_order_independent_but_rejects_repeated_claim_groups() { - let Fixture { signature, expected } = fixture(); + 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(signature, &reordered).unwrap(); + verify_claims(proof, &reordered).unwrap(); let mut repeated = expected.clone(); repeated.push(expected[0].clone()); - assert!(matches!( - verify_claims(signature, &repeated), - Err(Error::ClaimSetMismatch) - )); + assert!(matches!(verify_claims(proof, &repeated), Err(Error::ClaimSetMismatch))); } #[test] fn malformed_multi_claim_envelopes_are_rejected() { assert!(matches!( - MultiClaimSignature::from_bytes(b"not a multi-claim signature"), - Err(Error::MalformedMultiClaimSignature) + MultiClaimProof::from_bytes(b"not a multi-claim proof"), + Err(Error::MalformedMultiClaimProof) )); assert!(matches!( - MultiClaimSignature::from_bytes(b"LMCM\x01"), - Err(Error::MalformedMultiClaimSignature) + MultiClaimProof::from_bytes(b"LMCM\x01"), + Err(Error::MalformedMultiClaimProof) )); } #[test] fn decoding_is_structural_and_verification_rejects_a_tampered_bundle() { - let mut bytes = fixture().signature.to_bytes(); + let mut bytes = fixture().proof.to_bytes(); *bytes.last_mut().unwrap() ^= 1; - match MultiClaimSignature::from_bytes(&bytes) { - Err(Error::MalformedMultiClaimSignature) => {} - Ok(signature) => assert!(verified_claims(&signature).is_err()), + match MultiClaimProof::from_bytes(&bytes) { + Err(Error::MalformedMultiClaimProof) => {} + Ok(proof) => assert!(verified_claims(&proof).is_err()), Err(other) => panic!("unexpected error: {other:?}"), } } @@ -127,13 +122,12 @@ fn merging_no_signatures_is_rejected_before_proving() { #[test] fn a_proposer_only_bundle_can_contain_one_claim() { - let _guard = PROVE_LOCK.lock().unwrap(); let claim = Claim::new([0xc3; 32], 200); let proposer = SecretKey::from_seed([4; 32], 200..=215).unwrap(); - let signature = merge_claims(vec![proposer.sign(&claim).unwrap()]).unwrap(); + let proof = merge_claims(vec![proposer.sign(&claim).unwrap()]).unwrap(); verify_claims( - &signature, + &proof, &[ClaimSigners { claim, signers: vec![proposer.public_key()], diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs index 3f246f11..e282cd31 100644 --- a/crates/lean_multisig_api/tests/simple_api.rs +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -1,6 +1,6 @@ use lean_multisig_api::{ - Claim, ClaimSigners, MultiClaimSignature, PublicKey, SecretKey, Signature, aggregate, merge_claims, - verified_signers, verify, verify_claims, + Claim, ClaimSigners, MultiClaimProof, PublicKey, SecretKey, Signature, aggregate, merge_claims, verified_signers, + verify, verify_claims, }; use std::collections::BTreeSet; use std::sync::Barrier; @@ -32,23 +32,23 @@ fn signatures_and_aggregates_share_one_opaque_api() { } #[test] -fn multiple_claims_share_one_self_contained_signature() { +fn multiple_claims_share_one_self_contained_proof() { 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 signature = merge_claims(vec![ + let proof = merge_claims(vec![ alice.sign(&attestation).unwrap(), bob.sign(&attestation).unwrap(), proposer.sign(&proposal).unwrap(), ]) .unwrap(); - let signature = MultiClaimSignature::from_bytes(&signature.to_bytes()).unwrap(); + let proof = MultiClaimProof::from_bytes(&proof.to_bytes()).unwrap(); verify_claims( - &signature, + &proof, &[ ClaimSigners { claim: attestation, @@ -76,11 +76,11 @@ fn a_single_claim_aggregate_can_be_merged_with_another_claim() { &attestation, ) .unwrap(); - let signature = merge_claims(vec![attestation_signature, proposer.sign(&proposal).unwrap()]).unwrap(); - let signature = MultiClaimSignature::from_bytes(&signature.to_bytes()).unwrap(); + let proof = merge_claims(vec![attestation_signature, proposer.sign(&proposal).unwrap()]).unwrap(); + let proof = MultiClaimProof::from_bytes(&proof.to_bytes()).unwrap(); verify_claims( - &signature, + &proof, &[ ClaimSigners { claim: attestation, From 317d9beb66387f0ec52bf16f102b6366efdbf34f Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Fri, 14 Aug 2026 23:27:20 +0100 Subject: [PATCH 11/12] require explicit aggregation setup --- crates/lean_multisig_api/src/error.rs | 4 +++ crates/lean_multisig_api/src/lib.rs | 29 ++++++++++++++----- .../src/multi_claim_proof.rs | 15 ++++++---- crates/lean_multisig_api/src/signature.rs | 9 ++++-- .../lean_multisig_api/tests/explicit_setup.rs | 22 ++++++++++++++ .../tests/lazy_init_aggregate.rs | 11 ------- crates/lean_multisig_api/tests/multi_claim.rs | 6 ++-- crates/lean_multisig_api/tests/round_trip.rs | 7 ++--- crates/lean_multisig_api/tests/simple_api.rs | 8 +++-- 9 files changed, 76 insertions(+), 35 deletions(-) create mode 100644 crates/lean_multisig_api/tests/explicit_setup.rs delete mode 100644 crates/lean_multisig_api/tests/lazy_init_aggregate.rs diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs index 5da481e6..f720675d 100644 --- a/crates/lean_multisig_api/src/error.rs +++ b/crates/lean_multisig_api/src/error.rs @@ -4,6 +4,8 @@ use std::fmt::{Display, Formatter}; #[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`], @@ -64,6 +66,7 @@ impl From for Error { 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"), @@ -87,6 +90,7 @@ impl Display for Error { 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), diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index 7b5e6e78..ea7d8e1b 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -3,7 +3,8 @@ //! [`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 self-contained proof. The recursion topology, proof parameters, -//! bytecode initialization, public-key pairing, and proof representations are internal choices. +//! 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; @@ -20,6 +21,7 @@ use signature::Kind; use ssz::Encode; use std::borrow::Cow; use std::collections::BTreeSet; +use std::sync::OnceLock; use xmss::{XmssPublicKey, XmssSignature, xmss_verify}; pub use error::Error; @@ -39,6 +41,7 @@ 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 @@ -51,16 +54,23 @@ fn proves(signature: &SingleMessageAggregateSignature, claim: &Claim) -> bool { signature.info.core.message == *claim.message() && signature.info.core.slot == claim.slot() } -/// Pays the one-time aggregation-bytecode compilation cost at startup. +/// Initializes the process-wide resources used by recursive proofs. /// -/// Calling this is optional. Aggregation and aggregate verification initialize the bytecode -/// lazily themselves. -pub fn warm_up() { +/// 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 input is self-contained: a raw signature already owns its public key, while an aggregate /// already owns 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. @@ -68,7 +78,7 @@ pub fn aggregate(signatures: Vec, claim: &Claim) -> Result( /// 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"] @@ -168,7 +180,7 @@ pub fn verified_signers(signature: &Signature, claim: &Claim) -> Result { - init_aggregation_bytecode(); + require_setup()?; if !proves(signature, claim) { return Err(Error::MessageMismatch); } @@ -202,6 +214,7 @@ mod tests { #[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 { @@ -245,7 +258,7 @@ mod tests { } fn unprovable_aggregate() -> Signature { - warm_up(); + 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; diff --git a/crates/lean_multisig_api/src/multi_claim_proof.rs b/crates/lean_multisig_api/src/multi_claim_proof.rs index 0d7e20d8..12c5e38e 100644 --- a/crates/lean_multisig_api/src/multi_claim_proof.rs +++ b/crates/lean_multisig_api/src/multi_claim_proof.rs @@ -1,8 +1,7 @@ use crate::signature::Kind; -use crate::{Claim, Error, PublicKey, Signature, aggregate, encode_public_key}; +use crate::{Claim, Error, PublicKey, Signature, aggregate, encode_public_key, require_setup}; use rec_aggregation::{ - MultiMessageAggregateSignature, init_aggregation_bytecode, merge_single_message_aggregates, - verify_multi_message_aggregate, + MultiMessageAggregateSignature, merge_single_message_aggregates, verify_multi_message_aggregate, }; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{Debug, Formatter}; @@ -49,13 +48,15 @@ impl MultiClaimProof { /// Restores a self-contained multi-claim proof produced by [`Self::to_bytes`]. /// + /// Call [`crate::setup`] before using this function. + /// /// This checks framing, canonical encodings, and unique claims only. Use /// [`verify_claims`] or [`verified_claims`] to establish cryptographic validity. pub fn from_bytes(bytes: &[u8]) -> Result { if bytes.len() <= HEADER_LEN || &bytes[..MAGIC.len()] != MAGIC || bytes[MAGIC.len()] != VERSION { return Err(Error::MalformedMultiClaimProof); } - init_aggregation_bytecode(); + require_setup()?; let proof = MultiMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]).ok_or(Error::MalformedMultiClaimProof)?; let claims = proof @@ -72,6 +73,8 @@ impl MultiClaimProof { /// Groups signatures by claim and proves all groups in one self-contained 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 { @@ -108,11 +111,13 @@ pub fn merge_claims(signatures: Vec) -> Result Result, Error> { - init_aggregation_bytecode(); + require_setup()?; verify_multi_message_aggregate(&proof.0)?; let mut groups = proof .0 diff --git a/crates/lean_multisig_api/src/signature.rs b/crates/lean_multisig_api/src/signature.rs index 9cc59280..b45b9931 100644 --- a/crates/lean_multisig_api/src/signature.rs +++ b/crates/lean_multisig_api/src/signature.rs @@ -1,5 +1,5 @@ -use crate::Error; -use rec_aggregation::{SingleMessageAggregateSignature, init_aggregation_bytecode}; +use crate::{Error, require_setup}; +use rec_aggregation::SingleMessageAggregateSignature; use ssz::{Decode, Encode}; use std::fmt::{Debug, Formatter}; use xmss::{XmssPublicKey, XmssSignature}; @@ -119,6 +119,9 @@ impl Signature { /// Restores a signature produced by [`Self::to_bytes`]. /// + /// Call [`crate::setup`] first when decoding an aggregate. Raw signatures do not require + /// setup. + /// /// This checks framing and canonical encodings only. Use [`crate::verify`] or /// [`crate::aggregate`] to establish cryptographic validity. pub fn from_bytes(bytes: &[u8]) -> Result { @@ -146,7 +149,7 @@ impl Signature { Ok(Self::raw(Claim::new(message, slot), public_key, signature)) } AGGREGATE => { - init_aggregation_bytecode(); + require_setup()?; SingleMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]) .map(Self::aggregate) .ok_or(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..d5b22870 --- /dev/null +++ b/crates/lean_multisig_api/tests/explicit_setup.rs @@ -0,0 +1,22 @@ +use lean_multisig_api::{Claim, 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"), + Err(Error::NotInitialized) + )); + assert!(matches!( + MultiClaimProof::from_bytes(b"LMCM\x01proof"), + Err(Error::NotInitialized) + )); + setup(); + aggregate(vec![key.sign(&claim).unwrap()], &claim).unwrap(); +} diff --git a/crates/lean_multisig_api/tests/lazy_init_aggregate.rs b/crates/lean_multisig_api/tests/lazy_init_aggregate.rs deleted file mode 100644 index 8a65afb2..00000000 --- a/crates/lean_multisig_api/tests/lazy_init_aggregate.rs +++ /dev/null @@ -1,11 +0,0 @@ -use lean_multisig_api::{Claim, SecretKey, aggregate}; - -#[test] -fn aggregate_initializes_the_bytecode() { - let claim = Claim::new([0u8; 32], 0); - let key = SecretKey::from_seed([1u8; 32], 0..=15).unwrap(); - aggregate(vec![key.sign(&claim).unwrap()], &claim).unwrap(); - - // Panics if aggregation did not initialize the process-wide bytecode. - let _ = rec_aggregation::get_aggregation_bytecode(); -} diff --git a/crates/lean_multisig_api/tests/multi_claim.rs b/crates/lean_multisig_api/tests/multi_claim.rs index 1c56e134..c8a87dce 100644 --- a/crates/lean_multisig_api/tests/multi_claim.rs +++ b/crates/lean_multisig_api/tests/multi_claim.rs @@ -1,6 +1,6 @@ use lean_multisig_api::{ - Claim, ClaimSigners, Error, MAX_CLAIMS, MultiClaimProof, SecretKey, aggregate, merge_claims, verified_claims, - verify_claims, + Claim, ClaimSigners, Error, MAX_CLAIMS, MultiClaimProof, SecretKey, aggregate, merge_claims, setup, + verified_claims, verify_claims, }; use std::sync::OnceLock; @@ -15,6 +15,7 @@ struct Fixture { 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(); @@ -122,6 +123,7 @@ fn merging_no_signatures_is_rejected_before_proving() { #[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(); diff --git a/crates/lean_multisig_api/tests/round_trip.rs b/crates/lean_multisig_api/tests/round_trip.rs index 2924c1ae..d9785753 100644 --- a/crates/lean_multisig_api/tests/round_trip.rs +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -7,13 +7,12 @@ //! cargo test --release -p lean_multisig_api --test round_trip -- --ignored //! ``` -use lean_multisig_api::{Claim, Error, PublicKey, SecretKey, Signature, aggregate, verified_signers, verify}; +use lean_multisig_api::{Claim, Error, PublicKey, SecretKey, Signature, aggregate, setup, verified_signers, verify}; use ssz::Encode; use std::collections::BTreeSet; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; const CLAIM: Claim = Claim::new([42u8; 32], 100); -static PROVE_LOCK: Mutex<()> = Mutex::new(()); static BASE: OnceLock = OnceLock::new(); fn signers(n: u8) -> Vec { @@ -28,7 +27,7 @@ fn lone_key(seed: u8) -> SecretKey { } fn prove(signatures: Vec, claim: &Claim) -> Result { - let _guard = PROVE_LOCK.lock().unwrap(); + setup(); aggregate(signatures, claim) } diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs index e282cd31..5ffb6d00 100644 --- a/crates/lean_multisig_api/tests/simple_api.rs +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -1,12 +1,13 @@ use lean_multisig_api::{ - Claim, ClaimSigners, MultiClaimProof, PublicKey, SecretKey, Signature, aggregate, merge_claims, verified_signers, - verify, verify_claims, + 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(); @@ -33,6 +34,7 @@ fn signatures_and_aggregates_share_one_opaque_api() { #[test] fn multiple_claims_share_one_self_contained_proof() { + 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(); @@ -65,6 +67,7 @@ fn multiple_claims_share_one_self_contained_proof() { #[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(); @@ -97,6 +100,7 @@ fn a_single_claim_aggregate_can_be_merged_with_another_claim() { #[test] fn concurrent_proving_without_the_arena_does_not_panic() { + setup(); const THREADS: usize = 2; let barrier = Barrier::new(THREADS); From aed646200cf5ae3199c25c61f2bfe094582678ae Mon Sep 17 00:00:00 2001 From: Kevaundray Wedderburn Date: Sat, 15 Aug 2026 00:21:54 +0100 Subject: [PATCH 12/12] use externally resolved proof context --- crates/lean_multisig_api/src/error.rs | 4 + crates/lean_multisig_api/src/lib.rs | 39 ++++++---- .../src/multi_claim_proof.rs | 66 +++++++++++----- crates/lean_multisig_api/src/signature.rs | 77 ++++++++++--------- .../lean_multisig_api/tests/explicit_setup.rs | 12 ++- crates/lean_multisig_api/tests/multi_claim.rs | 32 ++++++-- crates/lean_multisig_api/tests/round_trip.rs | 49 ++++++++++-- crates/lean_multisig_api/tests/simple_api.rs | 65 ++++++++-------- .../src/multi_message_aggregation.rs | 48 +++++++++++- .../src/single_message_aggregation.rs | 32 ++++++++ tests/test_multisignatures.rs | 32 +++++++- 11 files changed, 333 insertions(+), 123 deletions(-) diff --git a/crates/lean_multisig_api/src/error.rs b/crates/lean_multisig_api/src/error.rs index f720675d..13918ed6 100644 --- a/crates/lean_multisig_api/src/error.rs +++ b/crates/lean_multisig_api/src/error.rs @@ -20,6 +20,8 @@ pub enum Error { 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 { @@ -76,6 +78,7 @@ impl Display for Error { 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})"), @@ -98,6 +101,7 @@ impl std::error::Error for Error { Self::Proof(err) => Some(err), Self::MalformedSignature | Self::MalformedMultiClaimProof + | Self::MalformedPublicKey | Self::MalformedSecretKey | Self::TooManySigners { .. } | Self::TooManyClaims { .. } diff --git a/crates/lean_multisig_api/src/lib.rs b/crates/lean_multisig_api/src/lib.rs index ea7d8e1b..5f726617 100644 --- a/crates/lean_multisig_api/src/lib.rs +++ b/crates/lean_multisig_api/src/lib.rs @@ -1,10 +1,11 @@ //! 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 self-contained proof. The recursion topology, proof parameters, -//! public-key pairing, and proof representations are internal choices. Call [`setup`] once before -//! using operations involving recursive proofs. +//! [`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; @@ -18,7 +19,7 @@ use rec_aggregation::{ init_aggregation_bytecode, verify_single_message_aggregate, }; use signature::Kind; -use ssz::Encode; +use ssz::{Decode, Encode}; use std::borrow::Cow; use std::collections::BTreeSet; use std::sync::OnceLock; @@ -50,6 +51,16 @@ pub(crate) fn encode_public_key(public_key: &XmssPublicKey) -> PublicKey { .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() } @@ -71,9 +82,9 @@ pub(crate) fn require_setup() -> Result<(), Error> { /// /// Call [`setup`] before using this function. /// -/// Every input is self-contained: a raw signature already owns its public key, while an aggregate -/// already owns 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. +/// 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); @@ -262,15 +273,11 @@ mod tests { 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 = vec![XmssPublicKey::from_ssz_bytes(&public_key).unwrap()]; - let payload = postcard::to_allocvec(&( - (*CLAIM.message(), CLAIM.slot(), point), - public_keys, - (Vec::::new(), Vec::::new()), - )) - .unwrap(); + 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); - Signature::from_bytes(&envelope).unwrap() + 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 index 12c5e38e..2ee466bf 100644 --- a/crates/lean_multisig_api/src/multi_claim_proof.rs +++ b/crates/lean_multisig_api/src/multi_claim_proof.rs @@ -1,5 +1,5 @@ use crate::signature::Kind; -use crate::{Claim, Error, PublicKey, Signature, aggregate, encode_public_key, require_setup}; +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, }; @@ -15,14 +15,16 @@ const HEADER_LEN: usize = MAGIC.len() + 1; pub struct ClaimSigners { /// The message and slot this group signed. pub claim: Claim, - /// The exact public-key set authorized for this claim. + /// The exact public-key set authorized for this claim. Resolve validator bitlists to public + /// keys before constructing this value. pub signers: Vec, } -/// A self-contained proof binding one or more distinct claims to their signer sets. +/// 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. +/// [`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); @@ -35,10 +37,13 @@ impl Debug for MultiClaimProof { } impl MultiClaimProof { - /// Serializes the proof, claims, and signer sets into one versioned envelope. + /// 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(); + 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); @@ -46,32 +51,53 @@ impl MultiClaimProof { bytes } - /// Restores a self-contained multi-claim proof produced by [`Self::to_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. /// - /// This checks framing, canonical encodings, and unique claims only. Use - /// [`verify_claims`] or [`verified_claims`] to establish cryptographic validity. - pub fn from_bytes(bytes: &[u8]) -> Result { + /// 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 proof = - MultiMessageAggregateSignature::from_bytes(&bytes[HEADER_LEN..]).ok_or(Error::MalformedMultiClaimProof)?; - let claims = proof - .info - .iter() - .map(|info| Claim::new(info.core.message, info.core.slot)) - .collect::>(); - if proof.info.is_empty() || proof.info.len() > crate::MAX_CLAIMS || claims.len() != proof.info.len() { - return Err(Error::MalformedMultiClaimProof); + 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 self-contained bundle. +/// Groups signatures by claim and proves all groups in one bundle. /// /// Call [`crate::setup`] before using this function. /// diff --git a/crates/lean_multisig_api/src/signature.rs b/crates/lean_multisig_api/src/signature.rs index b45b9931..b8174039 100644 --- a/crates/lean_multisig_api/src/signature.rs +++ b/crates/lean_multisig_api/src/signature.rs @@ -1,4 +1,4 @@ -use crate::{Error, require_setup}; +use crate::{Error, PublicKey, decode_public_keys, require_setup}; use rec_aggregation::SingleMessageAggregateSignature; use ssz::{Decode, Encode}; use std::fmt::{Debug, Formatter}; @@ -9,7 +9,7 @@ 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 + 32 + 4 + xmss::PUB_KEY_SSZ_LEN + xmss::SIGNATURE_SSZ_LEN; +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)] @@ -40,7 +40,8 @@ impl Claim { /// 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. +/// contain. Serialized values rely on the claim and signer set carried by the outer protocol +/// container. #[derive(Clone)] pub struct Signature(pub(crate) Kind); @@ -90,69 +91,73 @@ impl Signature { } } - /// Serializes this facade signature into a tagged, self-describing envelope. + /// 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 { - claim, - public_key, - signature, - } => { + Kind::Raw { signature, .. } => { out.reserve(RAW_LEN - out.len()); out.push(RAW); - out.extend_from_slice(claim.message()); - out.extend_from_slice(&claim.slot().to_le_bytes()); - out.extend_from_slice(&public_key.as_ssz_bytes()); out.extend_from_slice(&signature.as_ssz_bytes()); } Kind::Aggregate(signature) => { out.push(AGGREGATE); - out.extend_from_slice(&signature.to_bytes()); + out.extend_from_slice(&signature.to_bytes_without_context()); } } out } - /// Restores a signature produced by [`Self::to_bytes`]. + /// 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. /// - /// This checks framing and canonical encodings only. Use [`crate::verify`] or - /// [`crate::aggregate`] to establish cryptographic validity. - pub fn from_bytes(bytes: &[u8]) -> Result { + /// 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 mut offset = HEADER_LEN; - let message = bytes[offset..offset + 32] - .try_into() - .map_err(|_| Error::MalformedSignature)?; - offset += 32; - let slot = u32::from_le_bytes( - bytes[offset..offset + 4] - .try_into() - .map_err(|_| Error::MalformedSignature)?, - ); - offset += 4; - let public_key = XmssPublicKey::from_ssz_bytes(&bytes[offset..offset + xmss::PUB_KEY_SSZ_LEN]) - .map_err(|_| Error::MalformedSignature)?; - offset += xmss::PUB_KEY_SSZ_LEN; + let [public_key] = public_keys.as_slice() else { + return Err(Error::SignerSetMismatch); + }; let signature = - XmssSignature::from_ssz_bytes(&bytes[offset..]).map_err(|_| Error::MalformedSignature)?; - Ok(Self::raw(Claim::new(message, slot), public_key, 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(&bytes[HEADER_LEN..]) - .map(Self::aggregate) - .ok_or(Error::MalformedSignature) + 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 index d5b22870..b44a3cb8 100644 --- a/crates/lean_multisig_api/tests/explicit_setup.rs +++ b/crates/lean_multisig_api/tests/explicit_setup.rs @@ -1,4 +1,4 @@ -use lean_multisig_api::{Claim, Error, MultiClaimProof, SecretKey, Signature, aggregate, setup}; +use lean_multisig_api::{Claim, ClaimSigners, Error, MultiClaimProof, SecretKey, Signature, aggregate, setup}; #[test] fn proof_operations_require_explicit_setup_without_initializing_themselves() { @@ -10,11 +10,17 @@ fn proof_operations_require_explicit_setup_without_initializing_themselves() { Err(Error::NotInitialized) )); assert!(matches!( - Signature::from_bytes(b"LMSI\x01\x01proof"), + Signature::from_bytes(b"LMSI\x01\x01proof", &claim, &[key.public_key()]), Err(Error::NotInitialized) )); assert!(matches!( - MultiClaimProof::from_bytes(b"LMCM\x01proof"), + MultiClaimProof::from_bytes( + b"LMCM\x01proof", + &[ClaimSigners { + claim, + signers: vec![key.public_key()], + }], + ), Err(Error::NotInitialized) )); setup(); diff --git a/crates/lean_multisig_api/tests/multi_claim.rs b/crates/lean_multisig_api/tests/multi_claim.rs index c8a87dce..c447c3bf 100644 --- a/crates/lean_multisig_api/tests/multi_claim.rs +++ b/crates/lean_multisig_api/tests/multi_claim.rs @@ -60,14 +60,30 @@ fn mixed_signatures_are_grouped_by_claim_and_verified_as_one_bundle() { } #[test] -fn self_contained_bundle_round_trips_without_external_claim_context() { +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()).unwrap(); + 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(); @@ -95,11 +111,17 @@ fn authorization_is_order_independent_but_rejects_repeated_claim_groups() { #[test] fn malformed_multi_claim_envelopes_are_rejected() { assert!(matches!( - MultiClaimProof::from_bytes(b"not a multi-claim proof"), + 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(b"LMCM\x01"), + MultiClaimProof::from_bytes(&unsupported_version, &fixture().expected), Err(Error::MalformedMultiClaimProof) )); } @@ -109,7 +131,7 @@ 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) { + match MultiClaimProof::from_bytes(&bytes, &fixture().expected) { Err(Error::MalformedMultiClaimProof) => {} Ok(proof) => assert!(verified_claims(&proof).is_err()), Err(other) => panic!("unexpected error: {other:?}"), diff --git a/crates/lean_multisig_api/tests/round_trip.rs b/crates/lean_multisig_api/tests/round_trip.rs index d9785753..b8736537 100644 --- a/crates/lean_multisig_api/tests/round_trip.rs +++ b/crates/lean_multisig_api/tests/round_trip.rs @@ -48,8 +48,8 @@ fn signer_set(signature: &Signature, claim: &Claim) -> BTreeSet { #[test] fn aggregate_round_trips_through_the_public_wire_format() { - let aggregate = Signature::from_bytes(&base().to_bytes()).unwrap(); 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()); @@ -70,6 +70,33 @@ fn verification_binds_the_claim_and_signer_set() { )); } +#[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); @@ -130,13 +157,20 @@ fn mismatched_inputs_are_rejected_before_a_new_proof() { #[test] fn malformed_and_tampered_envelopes_are_rejected() { assert!(matches!( - Signature::from_bytes(b"not a signature"), + 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) { + 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:?}"), @@ -149,7 +183,8 @@ fn decoding_is_structural_and_verification_rejects_a_tampered_raw_signature() { let mut bytes = key.sign(&CLAIM).unwrap().to_bytes(); *bytes.last_mut().unwrap() ^= 1; - let signature = Signature::from_bytes(&bytes).expect("the tagged envelope is still structurally valid"); + 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, .. }) @@ -184,12 +219,10 @@ fn cached_batch(n: usize) -> (Vec, Vec, Claim) { .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_from_slice(claim.message()); - bytes.extend_from_slice(&claim.slot().to_le_bytes()); - bytes.extend(public_key_bytes); bytes.extend(signature.as_ssz_bytes()); - Signature::from_bytes(&bytes).unwrap() + Signature::from_bytes(&bytes, &claim, &[public_key]).unwrap() }) .collect(); (signatures, public_keys, claim) diff --git a/crates/lean_multisig_api/tests/simple_api.rs b/crates/lean_multisig_api/tests/simple_api.rs index 5ffb6d00..b427848f 100644 --- a/crates/lean_multisig_api/tests/simple_api.rs +++ b/crates/lean_multisig_api/tests/simple_api.rs @@ -15,12 +15,15 @@ fn signatures_and_aggregates_share_one_opaque_api() { let alice_signature = alice.sign(&claim).unwrap(); let bob_signature = bob.sign(&claim).unwrap(); - let alice_signature = Signature::from_bytes(&alice_signature.to_bytes()).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 aggregate = Signature::from_bytes(&aggregate.to_bytes()).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!( @@ -33,7 +36,7 @@ fn signatures_and_aggregates_share_one_opaque_api() { } #[test] -fn multiple_claims_share_one_self_contained_proof() { +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); @@ -47,22 +50,21 @@ fn multiple_claims_share_one_self_contained_proof() { proposer.sign(&proposal).unwrap(), ]) .unwrap(); - let proof = MultiClaimProof::from_bytes(&proof.to_bytes()).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, - &[ - ClaimSigners { - claim: attestation, - signers: vec![alice.public_key(), bob.public_key()], - }, - ClaimSigners { - claim: proposal, - signers: vec![proposer.public_key()], - }, - ], - ) - .unwrap(); + verify_claims(&proof, &groups).unwrap(); } #[test] @@ -80,22 +82,19 @@ fn a_single_claim_aggregate_can_be_merged_with_another_claim() { ) .unwrap(); let proof = merge_claims(vec![attestation_signature, proposer.sign(&proposal).unwrap()]).unwrap(); - let proof = MultiClaimProof::from_bytes(&proof.to_bytes()).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, - &[ - ClaimSigners { - claim: attestation, - signers: vec![alice.public_key(), bob.public_key()], - }, - ClaimSigners { - claim: proposal, - signers: vec![proposer.public_key()], - }, - ], - ) - .unwrap(); + verify_claims(&proof, &groups).unwrap(); } #[test] 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());