diff --git a/src/inc_encoding/target_sum.rs b/src/inc_encoding/target_sum.rs index 7360719..50b1930 100644 --- a/src/inc_encoding/target_sum.rs +++ b/src/inc_encoding/target_sum.rs @@ -61,8 +61,32 @@ impl IncomparableEncoding randomness: &Self::Randomness, epoch: u32, ) -> Result, Self::Error> { + // Compile-time parameter validation for Target Sum Encoding + // + // This encoding implements Construction 6 (IE for Target Sum Winternitz) + // from DKKW25. It maps a message to a codeword x ∈ C ⊆ Z_w^v, where: + // + // C = { (x_1, ..., x_v) ∈ {0, ..., w-1}^v | Σ x_i = T } + // + // The code C enforces the *incomparability* property (Definition 13): + // no two distinct codewords x, x' satisfy x_i ≥ x'_i for all i. + // This is critical for the security of the XMSS signature scheme. + // + // DKKW25: https://eprint.iacr.org/2025/055 + // HHKTW26: https://eprint.iacr.org/2026/016 const { - // base and dimension must not be too large + // Representation constraints + // + // In the Generalized XMSS construction (DKKW25), + // each chain position and chain index is encoded as a single byte + // in the tweak function: + // + // tweak(ep, i, k) = (0x00 || ep || i || k) + // 8b ⌈log L⌉ ⌈log v⌉ w bits + // + // - Since chain_index `i` is stored as u8, we need v ≤ 256. + // - Since pos_in_chain `k` is stored as u8, we need w ≤ 256. + // - Codeword entries (chunks) are also stored as u8 in signatures. assert!( MH::BASE <= 1 << 8, "Target Sum Encoding: Base must be at most 2^8" @@ -71,6 +95,36 @@ impl IncomparableEncoding MH::DIMENSION <= 1 << 8, "Target Sum Encoding: Dimension must be at most 2^8" ); + + // Encoding well-formedness + // + // Definition 13 (DKKW25): an incomparable encoding maps messages + // to codewords in {0, ..., w-1}^v. For the incomparability + // property to be meaningful, we need w ≥ 2 (otherwise every + // codeword is the zero vector, and distinct codewords cannot + // exist). + assert!( + MH::BASE >= 2, + "Target Sum Encoding: Base must be at least 2" + ); + + // Target sum range + // + // Construction 6 (DKKW25) defines the code: + // + // C = { x ∈ {0,...,w-1}^v | Σ x_i = T } + // + // For C to be non-empty, T must be achievable: each x_i can + // contribute at most w-1 to the sum, so T ≤ v*(w-1). The lower + // bound T ≥ 0 is guaranteed by the usize type. + // + // Choosing T close to v*(w-1)/2 (the expected sum of a uniform + // hash) maximizes |C| and minimizes the signing retry rate + // (Lemma 7, DKKW25). + assert!( + TARGET_SUM <= MH::DIMENSION * (MH::BASE - 1), + "Target Sum Encoding: TARGET_SUM must be at most DIMENSION * (BASE - 1)" + ); } // apply the message hash first to get chunks diff --git a/src/signature/generalized_xmss.rs b/src/signature/generalized_xmss.rs index 6524470..69f1756 100644 --- a/src/signature/generalized_xmss.rs +++ b/src/signature/generalized_xmss.rs @@ -653,37 +653,77 @@ where const LIFETIME: u64 = 1 << LOG_LIFETIME; + #[allow(clippy::too_many_lines)] fn key_gen( rng: &mut R, activation_epoch: usize, num_active_epochs: usize, ) -> (Self::PublicKey, Self::SecretKey) { const { - // assert BASE and DIMENSION are small enough to make sure that we can fit - // pos_in_chain and chain_index in u8. + // Encoding well-formedness + // + // Definition 13 (DKKW25): the incomparable encoding maps + // messages to codewords x ∈ C ⊆ {0, ..., w-1}^v. For the + // incomparability property to hold, we need: + // - w >= 2: a single-element alphabet makes all codewords + // identical, so incomparability is vacuous. + // - v >= 1: codewords must have at least one coordinate. + assert!( + IE::BASE >= 2, + "Generalized XMSS: Encoding base (w) must be at least 2" + ); + assert!( + IE::DIMENSION >= 1, + "Generalized XMSS: Encoding dimension (v) must be at least 1" + ); + + // Representation constraints + // + // The chain tweak function (DKKW25) encodes: + // + // tweak(ep, i, k) = (0x00 || ep || i || k) + // 8 bits ceil(log L) ceil(log v) w bits + // + // chain_index `i` and pos_in_chain `k` are stored as u8, and + // chunk values in signatures are also u8. Therefore: + // - BASE (= w) <= 256 (chunk fits in u8) + // - DIMENSION (= v) <= 256 (chain_index fits in u8) assert!( IE::BASE <= 1 << 8, - "Generalized XMSS: Encoding base too large, must be at most 2^8" + "Generalized XMSS: Encoding base (w) must fit in u8 (<= 256)" ); assert!( IE::DIMENSION <= 1 << 8, - "Generalized XMSS: Encoding dimension too large, must be at most 2^8" + "Generalized XMSS: Encoding dimension (v) must fit in u8 (<= 256)" ); - // LOG_LIFETIME needs to be even, so that we can use the top-bottom tree approach + // Merkle tree structure + // + // The key lifetime is L = 2^LOG_LIFETIME epochs. The Merkle tree + // has depth h = LOG_LIFETIME, with L leaves (one per epoch). + // + // The top-bottom optimization splits the tree at depth h/2, + // creating one top tree of depth h/2 and sqrt(L) bottom trees of + // depth h/2. This requires h to be even. assert!( LOG_LIFETIME.is_multiple_of(2), - "Generalized XMSS: LOG_LIFETIME must be multiple of two" + "Generalized XMSS: LOG_LIFETIME must be even (top-bottom tree split)" ); - // sign() and verify() take epoch as u32, so LOG_LIFETIME > 32 would create - // epochs unreachable by the signing/verification API. - const { - assert!( - LOG_LIFETIME <= 32, - "Generalized XMSS: LOG_LIFETIME must be at most 32 (epoch type is u32)" - ); - } + // The smallest valid even LOG_LIFETIME is 2, giving L = 4 epochs, + // a top tree of depth 1, and 2 bottom trees of depth 1. + // LOG_LIFETIME = 0 would mean L = 1 (no internal Merkle nodes). + assert!( + LOG_LIFETIME >= 2, + "Generalized XMSS: LOG_LIFETIME must be at least 2" + ); + + // The sign() and verify() APIs take the epoch as u32, so + // LOG_LIFETIME > 32 would create epochs that cannot be addressed. + assert!( + LOG_LIFETIME <= 32, + "Generalized XMSS: LOG_LIFETIME must be at most 32 (epoch is u32)" + ); } // Overflow-safe validation of the requested activation interval. diff --git a/src/symmetric/message_hash/aborting.rs b/src/symmetric/message_hash/aborting.rs index 90818ff..54519c9 100644 --- a/src/symmetric/message_hash/aborting.rs +++ b/src/symmetric/message_hash/aborting.rs @@ -77,50 +77,108 @@ where randomness: &Self::Randomness, message: &[u8; MESSAGE_LENGTH], ) -> Result, HypercubeHashError> { + // Compile-time parameter validation for AbortingHypercubeMessageHash + // + // This hash implements H^hc_{w,v,z,Q} from §6.1 of HHKTW26. It uses + // rejection sampling to uniformly map Poseidon field elements into + // the hypercube Z_w^v, avoiding big-integer arithmetic entirely: + // + // 1. Compute (A_1, ..., A_ℓ) := Poseidon(R || P || T || M) + // 2. For each A_i: reject if A_i ≥ Q·w^z (ensures uniformity) + // 3. Decompose d_i = ⌊A_i / Q⌋ into z base-w digits + // 4. Collect the first v digits as the output + // + // The field prime decomposes as p = Q·w^z + α (α ≥ 0). + // Rejection happens with per-element probability α/p, and the + // overall abort probability is θ = 1 - ((Q·w^z)/p)^ℓ (Lemma 8). + // + // By Theorem 4 of HHKTW26, this construction is indifferentiable + // from a θ-aborting random oracle when Poseidon is modeled as a + // standard random oracle. + // + // DKKW25: https://eprint.iacr.org/2025/055 + // HHKTW26: https://eprint.iacr.org/2026/016 const { - // Check that Poseidon of width 24 is enough + // Poseidon capacity constraints + // + // We use Poseidon in compression mode with a width-24 permutation. + // All inputs must fit in one call, and the output is extracted + // from the same state. assert!( PARAMETER_LEN + RAND_LEN_FE + TWEAK_LEN_FE + MSG_LEN_FE <= 24, - "Poseidon of width 24 is not enough" + "Poseidon of width 24 is not enough for the input" ); - assert!(HASH_LEN_FE <= 24, "Poseidon of width 24 is not enough"); - - // Check that we have enough hash output field elements assert!( - HASH_LEN_FE >= DIMENSION.div_ceil(Z), - "Not enough hash output field elements for the requested dimension" + HASH_LEN_FE <= 24, + "Poseidon of width 24 is not enough for the output" ); + + // Poseidon compression mode can only produce as many output + // field elements as there are input elements. assert!( PARAMETER_LEN + RAND_LEN_FE + TWEAK_LEN_FE + MSG_LEN_FE >= HASH_LEN_FE, "Input shorter than requested output" ); - // Base check + // Hypercube decomposition parameters + // + // Each good field element A_i < Q·w^z is decomposed into z + // base-w digits, so we need ℓ = ⌈v/z⌉ field elements to get + // at least v digits. HASH_LEN_FE must supply enough elements. assert!( - BASE <= 1 << 8, - "Aborting Hypercube Message Hash: Base must be at most 2^8" + DIMENSION >= 1, + "AbortingHypercubeMessageHash: DIMENSION (v) must be at least 1" + ); + assert!( + Z >= 1, + "AbortingHypercubeMessageHash: Z (digits per field element) must be at least 1" + ); + assert!( + HASH_LEN_FE >= DIMENSION.div_ceil(Z), + "Not enough hash output field elements: need ceil(v/z)" ); - // Check that Q * w^z fits within the field + // Q is the quotient in the decomposition A_i = Q·d_i + c_i, + // where c_i ∈ {0, ..., Q-1} is discarded and d_i ∈ {0, ..., w^z-1} + // carries the uniform digits. Q must be positive for a valid range. + assert!(Q >= 1, "AbortingHypercubeMessageHash: Q must be at least 1"); + + // The rejection threshold Q·w^z must not exceed the field order p, + // since field elements A_i live in {0, ..., p-1}. The remainder + // α = p - Q·w^z determines the per-element abort probability α/p. + // + // Example (KoalaBear): p = 2^31 - 2^24 + 1 = 127·8^8 + 1 + // ⟹ Q=127, w=8, z=8, α=1, abort prob ≈ 4.7e-10 per element. assert!( Q as u64 * (BASE as u64).pow(Z as u32) <= F::ORDER_U64, - "Q * w^z exceeds field order" + "Q * w^z exceeds field order p" ); - // floor(log2(ORDER)) - let bits_per_fe = F::ORDER_U64.ilog2() as usize; + // Representation constraints + // + // Same as the Poseidon message hash: chunks and chain indices + // are stored as u8 in signatures and tweak encodings. + assert!( + BASE >= 2, + "AbortingHypercubeMessageHash: BASE (w) must be at least 2 (Definition 13, DKKW25)" + ); + assert!( + BASE <= 1 << 8, + "AbortingHypercubeMessageHash: BASE (w) must fit in u8" + ); - // Check that we have enough bits to encode message + // Injective encoding of inputs + // + // Same requirements as the standard Poseidon message hash: + // message and epoch must be losslessly encodable as field elements. + let bits_per_fe = F::ORDER_U64.ilog2() as usize; assert!( bits_per_fe * MSG_LEN_FE >= 8 * MESSAGE_LENGTH, - "Aborting Hypercube Message Hash: not enough field elements to encode the message" + "AbortingHypercubeMessageHash: not enough field elements to encode the message" ); - - // Check that we have enough bits to encode tweak - // Epoch is a u32, and we have one domain separator byte assert!( bits_per_fe * TWEAK_LEN_FE >= 40, - "Aborting Hypercube Message Hash: not enough field elements to encode the epoch tweak" + "AbortingHypercubeMessageHash: not enough field elements to encode the epoch tweak" ); } diff --git a/src/symmetric/message_hash/poseidon.rs b/src/symmetric/message_hash/poseidon.rs index 5605cbf..c86791d 100644 --- a/src/symmetric/message_hash/poseidon.rs +++ b/src/symmetric/message_hash/poseidon.rs @@ -186,15 +186,47 @@ where randomness: &Self::Randomness, message: &[u8; MESSAGE_LENGTH], ) -> Result, Infallible> { + // Compile-time parameter validation for Poseidon Message Hash + // + // This hash implements Th_msg from DKKW25. It takes: + // + // Th_msg(P, T, M, R) = Decode_{p,η',w}( + // PoseidonCompress_{p, t_msg, η'}(R || P || EncT(T) || EncM(M)) + // ) + // + // The output is a vector of DIMENSION chunks, each in {0, ..., BASE-1}. + // This is used inside the incomparable encoding to map (message, seed) + // to a hypercube vertex x ∈ Z_w^v before the target sum filter. + // + // DKKW25: https://eprint.iacr.org/2025/055 const { - // Check that Poseidon of width 24 is enough + // Poseidon capacity constraints + // + // We use Poseidon in compression mode with a width-24 permutation. + // The entire input (R || P || T || M) must fit in one call, and + // the output η' field elements are extracted from the same state. assert!( PARAMETER_LEN + TWEAK_LEN_FE + RAND_LEN_FE + MSG_LEN_FE <= 24, - "Poseidon of width 24 is not enough" + "Poseidon of width 24 is not enough for the input" + ); + assert!( + HASH_LEN_FE <= 24, + "Poseidon of width 24 is not enough for the output" ); - assert!(HASH_LEN_FE <= 24, "Poseidon of width 24 is not enough"); - // Base and dimension check + // Non-trivial output + // + // The hash must produce at least one field element to be useful. + assert!( + HASH_LEN_FE >= 1, + "Poseidon Message Hash: HASH_LEN_FE must be at least 1" + ); + + // Representation constraints + // + // The Generalized XMSS construction (DKKW25) stores chunk values + // and chain indices as u8 in the signature and tweak encodings. + // BASE (= w) and DIMENSION (= v) must each fit in one byte. assert!( BASE <= 1 << 8, "Poseidon Message Hash: Base must be at most 2^8" @@ -204,30 +236,53 @@ where "Poseidon Message Hash: Dimension must be at most 2^8" ); - // how many bits can be represented by one field element: floor(log2(ORDER)) + // Encoding well-formedness + // + // Definition 13 (DKKW25): the incomparable encoding maps into + // {0, ..., w-1}^v with w ≥ 2. A single-element alphabet makes + // the code trivial and incomparability vacuous. + assert!(BASE >= 2, "Poseidon Message Hash: BASE must be at least 2"); + + // Injective encoding of inputs + // + // The message (32 bytes = 256 bits) is encoded into MSG_LEN_FE + // field elements via base-p decomposition. For this to be + // injective, the output space must be at least as large: + // + // p^MSG_LEN_FE ≥ 2^(8 * MESSAGE_LENGTH) + // ⟹ ⌊log2(p)⌋ * MSG_LEN_FE ≥ 8 * MESSAGE_LENGTH let bits_per_fe = F::ORDER_U64.ilog2() as usize; - - // Check that we have enough bits to encode message assert!( bits_per_fe * MSG_LEN_FE >= 8 * MESSAGE_LENGTH, - "Poseidon Message Hash: Parameter mismatch: not enough field elements to encode the message" + "Poseidon Message Hash: not enough field elements to encode the message" ); - // Check that we have enough bits to encode tweak - // Epoch is a u32, and we have one domain separator byte + // The epoch tweak (DKKW25) packs a u32 epoch and an + // 8-bit domain separator into one value, requiring 40 bits total: + // + // tweakm(ep) = (0x02 || ep) ← 8 + 32 = 40 bits + // sep epoch + // + // The field element representation needs enough capacity: + // ⌊log2(p)⌋ * TWEAK_LEN_FE ≥ 40 assert!( bits_per_fe * TWEAK_LEN_FE >= 40, - "Poseidon Message Hash: Parameter mismatch: not enough field elements to encode the epoch tweak" + "Poseidon Message Hash: not enough field elements to encode the epoch tweak" ); - // Check that decoding from field elements to chunks can be done - // injectively, i.e., we have enough chunks - // chunk_size = ceil(log2(BASE)) - assert!(BASE > 1, "Poseidon Message Hash: BASE must be > 1"); + // Injective decoding to chunks + // + // The hash output (η' field elements) is decoded via Decode_{p,η',w} + // (DKKW25): interpret the η' elements as one big integer + // and express it in base w to get DIMENSION chunks. + // + // For injectivity, the big integer must have enough room: + // p^η' ≤ w^DIMENSION + // ⟹ ⌊log2(p)⌋ * HASH_LEN_FE ≤ DIMENSION * ⌈log2(w)⌉ let chunk_size = (usize::BITS - (BASE - 1).leading_zeros()) as usize; assert!( bits_per_fe * HASH_LEN_FE <= DIMENSION * chunk_size, - "Poseidon Message Hash: Parameter mismatch: not enough bits to decode the hash" + "Poseidon Message Hash: not enough chunks to decode the hash output" ); } diff --git a/src/symmetric/tweak_hash/poseidon.rs b/src/symmetric/tweak_hash/poseidon.rs index 286d09e..cb35819 100644 --- a/src/symmetric/tweak_hash/poseidon.rs +++ b/src/symmetric/tweak_hash/poseidon.rs @@ -300,29 +300,54 @@ impl< tweak: &Self::Tweak, message: &[Self::Domain], ) -> Self::Domain { + // Compile-time parameter validation for PoseidonTweakHash + // + // DKKW25: https://eprint.iacr.org/2025/055 const { + // The hash output space H = F_p^η and public parameter space + // P = F_p^l_p must both be non-empty. + assert!( + HASH_LEN >= 1, + "Poseidon Tweak Hash: HASH_LEN (η) must be at least 1" + ); + assert!( + PARAMETER_LEN >= 1, + "Poseidon Tweak Hash: PARAMETER_LEN (l_p) must be at least 1" + ); + + // Leaf hashing uses a sponge construction with rate = 24 - CAPACITY. + // The capacity must leave room for at least one rate element. assert!( CAPACITY < 24, - "Poseidon Tweak Chain Hash: Capacity must be less than 24" + "Poseidon Tweak Hash: Capacity must be less than 24" ); + + // Chain hashing: Th(P, T, M) for M ∈ H uses compression mode + // with width 16. Input = [P || T || M]. assert!( PARAMETER_LEN + TWEAK_LEN + HASH_LEN <= 16, "Poseidon Tweak Chain Hash: Input lengths too large for Poseidon instance" ); + + // Tree hashing: Th(P, T, M) for M ∈ H² uses compression mode + // with width 24. Input = [P || T || left || right]. assert!( PARAMETER_LEN + TWEAK_LEN + 2 * HASH_LEN <= 24, "Poseidon Tweak Tree Hash: Input lengths too large for Poseidon instance" ); - // floor(log2(ORDER)) + // Leaf hashing: the sponge capacity value V_c is derived from + // 4 domain parameters (each 32 bits = 128 bits total) via a + // width-24 Poseidon compression. let bits_per_fe = F::ORDER_U64.ilog2() as usize; assert!( bits_per_fe * 24 >= DOMAIN_PARAMETERS_LENGTH * 32, "Poseidon Tweak Leaf Hash: not enough field elements to hash the domain separator" ); - // tree tweak: 32 (pos_in_level) + 8 (level) = 40 bits - // chain tweak: 32 (epoch) + 8 (chain_index) + 8 (pos_in_chain) + 8 (separator) = 56 bits + // Tweak encoding: the tree tweak packs 32 (pos_in_level) + 8 + // (level) = 40 bits; the chain tweak packs 32 (epoch) + 8 + // (chain_index) + 8 (pos_in_chain) + 8 (separator) = 56 bits. let tweak_fe_bits = bits_per_fe * TWEAK_LEN; assert!( tweak_fe_bits >= 40,