diff --git a/src/signature.rs b/src/signature.rs index 7799ec6..5bf9c65 100644 --- a/src/signature.rs +++ b/src/signature.rs @@ -288,6 +288,92 @@ mod test_templates { test_bincode_round_trip_consistency(&signature); } + /// Generic robustness test for any implementation of the `SignatureScheme` + /// trait. + /// + /// Where `test_signature_scheme_correctness` checks *completeness* (an + /// honestly produced signature is accepted), this checks the complementary + /// *rejection* properties that the security of the scheme relies on: an + /// honest signature must be **rejected** when it is verified against + /// - a different message (message binding), + /// - a different epoch (epoch binding), or + /// - a different public key (key binding). + /// + /// These are exactly the checks an adversary would try to bypass, and none + /// of them were previously exercised by the test suite. + pub fn test_signature_scheme_robustness( + epoch: u32, + activation_epoch: usize, + num_active_epochs: usize, + ) { + // The epoch must be in the activation interval (same precondition as the + // correctness template, including the u64 cast to avoid overflow on the + // full 2^32 lifetime). + assert!( + activation_epoch as u32 <= epoch + && (epoch as u64) < (activation_epoch + num_active_epochs) as u64, + "Did not even try signing, epoch {:?} outside of activation interval {:?},{:?}", + epoch, + activation_epoch, + num_active_epochs + ); + + let mut rng = rand::rng(); + + // Honest key generation and signing. + let (pk, mut sk) = T::key_gen(&mut rng, activation_epoch, num_active_epochs); + let mut iterations = 0; + while !sk.get_prepared_interval().contains(&(epoch as u64)) && iterations < epoch { + sk.advance_preparation(); + iterations += 1; + } + assert!( + sk.get_prepared_interval().contains(&(epoch as u64)), + "Did not even try signing, failed to advance key preparation to desired epoch {:?}.", + epoch + ); + let message: [u8; MESSAGE_LENGTH] = rng.random(); + let signature = T::sign(&sk, epoch, &message).expect("honest signing should succeed"); + + // Sanity: the honest signature must verify. + assert!( + T::verify(&pk, epoch, &message, &signature), + "honest signature was rejected (epoch {epoch:?})" + ); + + // (1) Message binding: a different message must be rejected. + let mut other_message: [u8; MESSAGE_LENGTH] = rng.random(); + while other_message == message { + other_message = rng.random(); + } + assert!( + !T::verify(&pk, epoch, &other_message, &signature), + "verify accepted a signature under a different message (epoch {epoch:?})" + ); + + // (2) Epoch binding: verifying at a different in-lifetime epoch must be + // rejected. Pick any other valid epoch in [0, LIFETIME). + if T::LIFETIME >= 2 { + let other_epoch = if (epoch as u64) + 1 < T::LIFETIME { + epoch + 1 + } else { + epoch - 1 + }; + assert!( + !T::verify(&pk, other_epoch, &message, &signature), + "verify accepted a signature under a different epoch \ + (signed {epoch:?}, verified {other_epoch:?})" + ); + } + + // (3) Key binding: an independent key pair must not verify this signature. + let (other_pk, _other_sk) = T::key_gen(&mut rng, activation_epoch, num_active_epochs); + assert!( + !T::verify(&other_pk, epoch, &message, &signature), + "verify accepted a signature under a different public key (epoch {epoch:?})" + ); + } + fn test_bincode_round_trip_consistency(ori: &T) { use bincode::serde::{decode_from_slice, encode_to_vec}; let config = bincode::config::standard(); diff --git a/src/signature/generalized_xmss.rs b/src/signature/generalized_xmss.rs index acb9e2a..19b80b3 100644 --- a/src/signature/generalized_xmss.rs +++ b/src/signature/generalized_xmss.rs @@ -1080,7 +1080,9 @@ pub mod instantiations_poseidon; mod tests { use crate::{ inc_encoding::target_sum::TargetSumEncoding, - signature::test_templates::test_signature_scheme_correctness, + signature::test_templates::{ + test_signature_scheme_correctness, test_signature_scheme_robustness, + }, symmetric::{ message_hash::{ MessageHash, @@ -1123,6 +1125,10 @@ mod tests { test_signature_scheme_correctness::(19, 0, Sig::LIFETIME as usize); test_signature_scheme_correctness::(0, 0, Sig::LIFETIME as usize); test_signature_scheme_correctness::(11, 0, Sig::LIFETIME as usize); + + // An honest signature must be rejected under a wrong message, epoch, or key. + test_signature_scheme_robustness::(0, 0, Sig::LIFETIME as usize); + test_signature_scheme_robustness::(11, 0, Sig::LIFETIME as usize); } #[test] @@ -1210,6 +1216,10 @@ mod tests { test_signature_scheme_correctness::(19, 0, Sig::LIFETIME as usize); test_signature_scheme_correctness::(0, 0, Sig::LIFETIME as usize); test_signature_scheme_correctness::(11, 0, Sig::LIFETIME as usize); + + // An honest signature must be rejected under a wrong message, epoch, or key. + test_signature_scheme_robustness::(0, 0, Sig::LIFETIME as usize); + test_signature_scheme_robustness::(11, 0, Sig::LIFETIME as usize); } #[test] @@ -1649,6 +1659,71 @@ mod tests { assert!(Sig::verify(&pk, epoch + 1, &message, &sig2)); } + #[test] + fn test_verify_rejects_tampered_signature() { + // Note: do not use these parameters, they are just for testing. + type PRF = ShakePRFtoF<7, 5>; + type TH = PoseidonTweakW1L5; + type MH = PoseidonMessageHashW1; + const EXPECTED_SUM: usize = MH::DIMENSION * (MH::BASE - 1) / 2; + type IE = TargetSumEncoding; + const LOG_LIFETIME: usize = 6; + type Sig = GeneralizedXMSSSignatureScheme; + + let mut rng = rand::rng(); + let (pk, sk) = Sig::key_gen(&mut rng, 0, 1 << LOG_LIFETIME); + // Epoch 0 is always inside the initial prepared interval. + let epoch = 0u32; + let message: [u8; MESSAGE_LENGTH] = rng.random(); + let mut signature = Sig::sign(&sk, epoch, &message).unwrap(); + assert!(Sig::verify(&pk, epoch, &message, &signature)); + + // Tamper with the per-chain hashes by swapping two distinct entries. + // Each entry commits to a different WOTS chain, so the swapped signature + // is well-formed but must fail verification. + let j = signature + .hashes + .iter() + .position(|h| *h != signature.hashes[0]) + .expect("signature should contain at least two distinct chain hashes"); + signature.hashes.swap(0, j); + assert!( + !Sig::verify(&pk, epoch, &message, &signature), + "verify accepted a signature with tampered chain hashes" + ); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(8))] + + /// Message binding under fuzzing: a signature produced for one message + /// must never verify against a different message. + #[test] + fn proptest_verify_rejects_wrong_message( + message in prop::array::uniform32(any::()), + other_message in prop::array::uniform32(any::()), + ) { + // Note: do not use these parameters, they are just for testing. + type PRF = ShakePRFtoF<7, 5>; + type TH = PoseidonTweakW1L5; + type MH = PoseidonMessageHashW1; + const EXPECTED_SUM: usize = MH::DIMENSION * (MH::BASE - 1) / 2; + type IE = TargetSumEncoding; + const LOG_LIFETIME: usize = 6; + type Sig = GeneralizedXMSSSignatureScheme; + + prop_assume!(message != other_message); + + let mut rng = rand::rng(); + let (pk, sk) = Sig::key_gen(&mut rng, 0, 1 << LOG_LIFETIME); + let epoch = 0u32; + let signature = Sig::sign(&sk, epoch, &message).unwrap(); + + prop_assert!(Sig::verify(&pk, epoch, &message, &signature)); + prop_assert!(!Sig::verify(&pk, epoch, &other_message, &signature)); + } + } + proptest! { #[test] fn proptest_expand_activation_time_invariants(