diff --git a/tinycloud-threshold/README.md b/tinycloud-threshold/README.md index 53879070..292bc51a 100644 --- a/tinycloud-threshold/README.md +++ b/tinycloud-threshold/README.md @@ -19,6 +19,8 @@ The crate is a workspace member with `publish = false`. No other workspace crate The §3.6 request-digest amendment is explicit in SPEC.md. It makes an identity or transport-key transplant a context mismatch before share equations are evaluated. +`combine` accepts at most `2*n` raw share entries, where `n` is the validated current member count. Duplicates count toward this limit. An oversized input returns `CombineError::TooManyShares`, naming no member, before request/share binding checks or algebra. Within the limit, the existing binding-first checks and member deduplication remain unchanged. + ## Checks Use the workspace's pinned Rust 1.97.1 toolchain: diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 4416ed2d..5f38e99f 100644 --- a/tinycloud-threshold/SPEC.md +++ b/tinycloud-threshold/SPEC.md @@ -115,6 +115,7 @@ unwrap(k: &DerivedKey, expected_id, (U, V, W)): - **Issue** (member i, `KeyShare` with context X): require the request's context fields equal X exactly, else `Error::ContextMismatch` (no share released). r_i ← Zr* (§3.2 sampling). Share = (i, context digest, request digest, c1 = r_i·g1, c2 = r_i·g2, c3 = s_i·H1(id) + r_i·tpk). The request digest is the approved S0.1 amendment recorded below. - **Response signing preimage** = TH(`…DECRYPT-SHARE`, request digest ‖ certificate digest ‖ u16be(i) ‖ encode(share)). The node signs it with its node key. - **Verify** against the `PublicKeySet` of context X: first the context check (share's and request's context = X; share's request digest = request digest; member i in X); a mismatch is `ContextMismatch`, never `InvalidShare`. Then e(c1, g2) = e(g1, c2) and e(c3, g2) = e(H1(id), pk_i)·e(tpk, c2), each checked as one multi-pairing; failure is `ShareError::Invalid { member }`. These equations follow the published aggregatable vetBLS scheme. +- **Combine input bound:** take n from the validated current `PublicKeySet` and count all raw share entries, including duplicates. Accept at most `2*n` entries. Above that bound, return `CombineError::TooManyShares`, naming no member, before request/share binding checks, list iteration, allocation proportional to the supplied entries or algebra. For accepted-length inputs, request mismatch remains `CombineError::ContextMismatch`; all share bindings, including surplus entries, are checked before algebra. The following context-first combine rule applies within this bound. - **Combine:** context check on every share first (mixed contexts → `CombineError::MixedContexts`; this check is unconditional, independently of the final pairing result). Each share verified individually; duplicates by member counted once; needs ≥ t; uses the t lowest indices. C_j = Σ λ_i^S·c_{j,i}. - **Decrypt key:** k = C3 − tsk·C1. Accept only if k ≠ identity and e(k, g2) = e(H1(id), mpk). Returns `DerivedKey { identity, key context digest, k }`. This final check checks the key against id and mpk; context and authorization checks run separately first. diff --git a/tinycloud-threshold/src/checkpoint.rs b/tinycloud-threshold/src/checkpoint.rs index 1c0d7306..1d68e390 100644 --- a/tinycloud-threshold/src/checkpoint.rs +++ b/tinycloud-threshold/src/checkpoint.rs @@ -122,6 +122,97 @@ fn signing_preimage_rejects_changed_record_nonce() { ); } +#[test] +fn p3_combine_accepts_exact_raw_limit() { + let (public, _, tsk, req, issued) = setup(11); + let repeated: [EncryptedKeyShare; 6] = + std::array::from_fn(|i| issued[i % issued.len()].clone()); + let aggregate = combine(&public, &req, &repeated).unwrap(); + let key = decrypt_derived_key(&aggregate, &tsk, &public, &req).unwrap(); + let data = DataKey::new([0x42; 32]); + let ciphertext = wrap_key( + public.master_public_key(), + req.identity(), + &data, + &mut ChaCha20Rng::from_seed([12; 32]), + ) + .unwrap(); + assert_eq!( + unwrap_key(&key, req.identity(), &ciphertext) + .unwrap() + .as_bytes(), + data.as_bytes() + ); +} + +#[test] +fn p3_combine_counts_duplicate_members_once_at_limit() { + let (public, _, _, req, issued) = setup(11); + let repeated: [EncryptedKeyShare; 6] = std::array::from_fn(|_| issued[0].clone()); + assert!(matches!( + combine(&public, &req, &repeated), + Err(CombineError::NotEnoughShares) + )); +} + +#[test] +fn p3_combine_rejects_above_raw_limit() { + let (public, _, _, req, issued) = setup(11); + for share in &issued { + assert_eq!(verify_share(&public, &req, share), Ok(())); + } + let too_many: [EncryptedKeyShare; 7] = + std::array::from_fn(|i| issued[i % issued.len()].clone()); + let result = combine(&public, &req, &too_many); + let maximum = 2 * usize::from(public.context().threshold().n()); + assert!( + result.is_err(), + "combine accepted {} raw entries above 2*n={maximum}", + too_many.len() + ); + assert_eq!(result.err(), Some(CombineError::TooManyShares)); +} + +#[test] +fn p3_combine_limit_precedes_context_and_share_checks() { + let (public, _, _, req, issued) = setup(11); + let mut too_many: [EncryptedKeyShare; 7] = + std::array::from_fn(|i| issued[i % issued.len()].clone()); + let mut other_request = req.clone(); + other_request.context_digest[0] ^= 1; + assert_eq!( + combine(&public, &other_request, &too_many).unwrap_err(), + CombineError::TooManyShares + ); + too_many[6].context_digest[0] ^= 1; + assert_eq!( + combine(&public, &req, &too_many).unwrap_err(), + CombineError::TooManyShares + ); + too_many[6].context_digest[0] ^= 1; + too_many[6].c3 = G1::identity(); + assert_eq!( + combine(&public, &req, &too_many).unwrap_err(), + CombineError::TooManyShares + ); +} + +#[test] +fn p3_verify_share_rejects_canonical_identity_c3_mutation() { + let (public, _, _, req, issued) = setup(11); + assert_eq!(verify_share(&public, &req, &issued[0]), Ok(())); + assert!(!issued[0].c3.is_identity()); + let mut changed = issued[0].clone(); + changed.c3 = G1::identity(); + let changed = EncryptedKeyShare::decode(&changed.encode()).unwrap(); + assert_eq!( + verify_share(&public, &req, &changed), + Err(ShareError::Invalid { + member: issued[0].index() + }) + ); +} + #[test] fn checkpoint_2of3() { let (public, shares, tsk, req, issued) = setup(11); diff --git a/tinycloud-threshold/src/derive.rs b/tinycloud-threshold/src/derive.rs index e0738846..d414c797 100644 --- a/tinycloud-threshold/src/derive.rs +++ b/tinycloud-threshold/src/derive.rs @@ -348,11 +348,17 @@ pub struct EncryptedDerivedKey { } /// Verifies every input, counts each member once, then uses the lowest t indices. +/// Refuses more than `2 * n` entries, including duplicates, before context checks +/// or algebra, where `n` is the validated current member count. pub fn combine( public: &PublicKeySet, req: &DecryptRequest, shares: &[EncryptedKeyShare], ) -> Result { + let maximum = usize::from(public.context.threshold().n()).saturating_mul(2); + if shares.len() > maximum { + return Err(CombineError::TooManyShares); + } if !req.matches(&public.context) { return Err(CombineError::ContextMismatch); } diff --git a/tinycloud-threshold/src/error.rs b/tinycloud-threshold/src/error.rs index 0c567eb2..3deea2a8 100644 --- a/tinycloud-threshold/src/error.rs +++ b/tinycloud-threshold/src/error.rs @@ -66,6 +66,9 @@ pub enum ShareError { /// Combining never interprets mixed-context inputs as member faults. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum CombineError { + /// Raw entries exceed twice the current member count; no member is blamed. + #[error("too many supplied share entries")] + TooManyShares, #[error("request context mismatch")] ContextMismatch, #[error("mixed share contexts")]