Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: SignatureScheme>(
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<T: Serialize + DeserializeOwned>(ori: &T) {
use bincode::serde::{decode_from_slice, encode_to_vec};
let config = bincode::config::standard();
Expand Down
77 changes: 76 additions & 1 deletion src/signature/generalized_xmss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1123,6 +1125,10 @@ mod tests {
test_signature_scheme_correctness::<Sig>(19, 0, Sig::LIFETIME as usize);
test_signature_scheme_correctness::<Sig>(0, 0, Sig::LIFETIME as usize);
test_signature_scheme_correctness::<Sig>(11, 0, Sig::LIFETIME as usize);

// An honest signature must be rejected under a wrong message, epoch, or key.
test_signature_scheme_robustness::<Sig>(0, 0, Sig::LIFETIME as usize);
test_signature_scheme_robustness::<Sig>(11, 0, Sig::LIFETIME as usize);
}

#[test]
Expand Down Expand Up @@ -1210,6 +1216,10 @@ mod tests {
test_signature_scheme_correctness::<Sig>(19, 0, Sig::LIFETIME as usize);
test_signature_scheme_correctness::<Sig>(0, 0, Sig::LIFETIME as usize);
test_signature_scheme_correctness::<Sig>(11, 0, Sig::LIFETIME as usize);

// An honest signature must be rejected under a wrong message, epoch, or key.
test_signature_scheme_robustness::<Sig>(0, 0, Sig::LIFETIME as usize);
test_signature_scheme_robustness::<Sig>(11, 0, Sig::LIFETIME as usize);
}

#[test]
Expand Down Expand Up @@ -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<MH, EXPECTED_SUM>;
const LOG_LIFETIME: usize = 6;
type Sig = GeneralizedXMSSSignatureScheme<PRF, IE, TH, LOG_LIFETIME>;

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::<u8>()),
other_message in prop::array::uniform32(any::<u8>()),
) {
// 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<MH, EXPECTED_SUM>;
const LOG_LIFETIME: usize = 6;
type Sig = GeneralizedXMSSSignatureScheme<PRF, IE, TH, LOG_LIFETIME>;

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(
Expand Down