#![allow(dead_code)]
#![allow(clippy::needless_range_loop)]
use core::sync::atomic::{compiler_fence, Ordering};
use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
use zeroize::Zeroize;
use crate::identity_error::IdentityError;
pub const RING_N: usize = 256;
pub const MODULE_K: usize = 4;
pub const MAX_ATTRIBUTES: usize = 8;
pub const PARAM_Q: i32 = 8_380_417;
pub const PARAM_GAMMA1: i32 = 131_072;
pub const PARAM_BETA: i32 = 78;
pub const REJECTION_BOUND: i32 = PARAM_GAMMA1 - PARAM_BETA;
#[derive(Copy, Clone, Zeroize)]
#[repr(align(64))]
pub struct Polynomial {
pub coeffs: [i32; RING_N],
}
impl Polynomial {
pub const fn zero() -> Self {
Self { coeffs: [0; RING_N] }
}
}
#[derive(Copy, Clone, Zeroize)]
#[repr(align(64))]
pub struct VectorK {
pub vec: [Polynomial; MODULE_K],
}
impl VectorK {
pub const fn zero() -> Self {
Self { vec: [Polynomial::zero(); MODULE_K] }
}
}
pub type AttributeMask = u64;
#[derive(Copy, Clone, Debug)]
pub struct AttributePayload {
pub values: [u64; MAX_ATTRIBUTES],
}
impl AttributePayload {
pub const fn zero() -> Self {
Self { values: [0u64; MAX_ATTRIBUTES] }
}
}
#[derive(Copy, Clone)]
#[repr(C)]
pub struct SaapProof {
pub context_tag: [u8; 32],
pub disclosure_mask: AttributeMask,
pub attributes: AttributePayload,
pub challenge: Polynomial,
pub z: VectorK,
pub commitment_hash: [u8; 32],
pub commitment_w: VectorK,
}
impl SaapProof {
pub const fn zero() -> Self {
Self {
context_tag: [0u8; 32],
disclosure_mask: 0,
attributes: AttributePayload::zero(),
challenge: Polynomial::zero(),
z: VectorK::zero(),
commitment_hash: [0u8; 32],
commitment_w: VectorK::zero(),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum SaapValidationError {
NormBoundViolation,
ChallengeMismatch,
InvalidAttributeCommitment,
}
pub fn poly_mul_negacyclic(a: &Polynomial, b: &Polynomial) -> Polynomial {
let pc = crate::plp::poly_mul_ntt(&to_plp_poly(a), &to_plp_poly(b));
let mut result = Polynomial::zero();
for i in 0..RING_N {
result.coeffs[i] = mod_q(pc.coeffs[i] as i64);
}
result
}
fn to_plp_poly(p: &Polynomial) -> crate::plp::Poly {
let mut out = crate::plp::Poly::zero();
for i in 0..RING_N {
let c = p.coeffs[i];
out.coeffs[i] = if c < 0 { (c + PARAM_Q) as u32 } else { c as u32 };
}
out
}
pub fn poly_mul_schoolbook(a: &Polynomial, b: &Polynomial) -> Polynomial {
let mut result = Polynomial::zero();
for i in 0..RING_N {
for j in 0..RING_N {
let idx = i + j;
let product = (a.coeffs[i] as i64) * (b.coeffs[j] as i64);
if idx < RING_N {
let acc = (result.coeffs[idx] as i64) + product;
result.coeffs[idx] = mod_q(acc);
} else {
let wrap_idx = idx - RING_N;
let acc = (result.coeffs[wrap_idx] as i64) - product;
result.coeffs[wrap_idx] = mod_q(acc);
}
}
}
result
}
pub fn poly_add(a: &Polynomial, b: &Polynomial) -> Polynomial {
let mut result = Polynomial::zero();
for i in 0..RING_N {
let acc = (a.coeffs[i] as i64) + (b.coeffs[i] as i64);
result.coeffs[i] = mod_q(acc);
}
result
}
pub fn poly_sub(a: &Polynomial, b: &Polynomial) -> Polynomial {
let mut result = Polynomial::zero();
for i in 0..RING_N {
let acc = (a.coeffs[i] as i64) - (b.coeffs[i] as i64);
result.coeffs[i] = mod_q(acc);
}
result
}
#[inline(always)]
pub fn mod_q(x: i64) -> i32 {
let q = PARAM_Q as i64;
let r = x % q;
let r = if r < 0 { r + q } else { r };
let r = if r > q / 2 { r - q } else { r };
r as i32
}
pub fn verify_response_norm(z: &VectorK) -> u32 {
let mut bad_mask: u32 = 0;
for k in 0..MODULE_K {
for n in 0..RING_N {
let coeff = z.vec[k].coeffs[n];
let sign_mask = coeff >> 31;
let abs_coeff = (coeff + sign_mask) ^ sign_mask;
let diff = (REJECTION_BOUND - 1) - abs_coeff;
bad_mask |= ((diff >> 31) & 1) as u32 * 0xFFFF_FFFF;
}
}
bad_mask
}
pub fn hash_to_challenge_from_xof(xof: &mut impl XofReader) -> Polynomial {
let mut c_poly = Polynomial::zero();
let mut signs = [0u8; 8];
xof.read(&mut signs);
let mut sign_bit = 0usize;
let mut count = 0usize;
let mut used = [false; RING_N];
let mut pos_buf = [0u8; 1];
while count < crate::plp::CHALLENGE_WEIGHT {
xof.read(&mut pos_buf);
let pos = pos_buf[0] as usize;
if pos >= RING_N {
continue;
}
if used[pos] {
continue;
}
used[pos] = true;
let sign_byte = signs[sign_bit / 8];
let sign: i32 = if (sign_byte >> (sign_bit % 8)) & 1 == 0 { 1 } else { -1 };
sign_bit += 1;
if sign_bit >= 64 {
xof.read(&mut signs);
sign_bit = 0;
}
c_poly.coeffs[pos] = sign;
count += 1;
}
c_poly
}
pub fn recompute_challenge(
commitment_w: &VectorK,
tau: &[u8],
disclosure_mask: AttributeMask,
attributes: &AttributePayload,
) -> Polynomial {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_SAAP_CHALLENGE_V1");
for poly in commitment_w.vec.iter() {
for coeff in poly.coeffs.iter() {
hasher.update(&coeff.to_le_bytes());
}
}
hasher.update(tau);
hasher.update(&disclosure_mask.to_le_bytes());
for i in 0..MAX_ATTRIBUTES {
if (disclosure_mask >> i) & 1 == 1 {
hasher.update(&(i as u8).to_le_bytes());
hasher.update(&attributes.values[i].to_le_bytes());
}
}
let mut xof = hasher.finalize_xof();
hash_to_challenge_from_xof(&mut xof)
}
fn derive_saap_matrix(tau: &[u8]) -> [VectorK; MODULE_K] {
let mut matrix = [VectorK::zero(); MODULE_K];
for i in 0..MODULE_K {
for j in 0..MODULE_K {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_SAAP_MATRIX_V1");
hasher.update(tau);
hasher.update(&[i as u8, j as u8]);
let mut xof = hasher.finalize_xof();
let mut coeff_idx = 0usize;
while coeff_idx < RING_N {
let mut buf = [0u8; 3];
xof.read(&mut buf);
let val = (buf[0] as u32)
| ((buf[1] as u32) << 8)
| ((buf[2] as u32 & 0x7F) << 16);
if (val as i32) < PARAM_Q {
let centered = if val > (PARAM_Q as u32 / 2) {
val as i32 - PARAM_Q
} else {
val as i32
};
matrix[i].vec[j].coeffs[coeff_idx] = centered;
coeff_idx += 1;
}
}
}
}
matrix
}
fn sample_saap_masking_vector(rho: &[u8], tau: &[u8; 32], nonce: u8) -> VectorK {
let mut r = VectorK::zero();
let range = 2 * PARAM_GAMMA1 as u32 + 1;
for k in 0..MODULE_K {
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_SAAP_MASK_V3");
hasher.update(rho);
hasher.update(tau);
hasher.update(&[nonce, k as u8]);
let mut xof = hasher.finalize_xof();
let mut coeff_idx = 0usize;
while coeff_idx < RING_N {
let mut buf = [0u8; 3];
xof.read(&mut buf);
let val = (buf[0] as u32)
| ((buf[1] as u32) << 8)
| ((buf[2] as u32 & 0x7F) << 16);
if val < range {
let centered = val as i32 - PARAM_GAMMA1;
r.vec[k].coeffs[coeff_idx] = centered;
coeff_idx += 1;
}
}
}
r
}
pub fn saap_prove(
credential: &[u8],
disclosure_mask: AttributeMask,
tau: &[u8],
secret_key: &VectorK,
rho: &[u8],
) -> Result<SaapProof, IdentityError> {
let mut attributes = AttributePayload::zero();
for i in 0..MAX_ATTRIBUTES {
let offset = i * 8;
if offset + 8 <= credential.len() {
let mut bytes = [0u8; 8];
bytes.copy_from_slice(&credential[offset..offset + 8]);
attributes.values[i] = u64::from_le_bytes(bytes);
}
}
let matrix_a = derive_saap_matrix(tau);
let mut context_tag = [0u8; 32];
let len = tau.len().min(32);
context_tag[..len].copy_from_slice(&tau[..len]);
for iter in 0u8..16 {
let mut r = sample_saap_masking_vector(rho, &context_tag, iter);
let mut w = VectorK::zero();
for i in 0..MODULE_K {
for j in 0..MODULE_K {
let prod = poly_mul_negacyclic(&matrix_a[i].vec[j], &r.vec[j]);
w.vec[i] = poly_add(&w.vec[i], &prod);
}
}
let mut c = recompute_challenge(&w, &context_tag, disclosure_mask, &attributes);
let mut z = VectorK::zero();
for k in 0..MODULE_K {
let mut cs_k = poly_mul_negacyclic(&c, &secret_key.vec[k]);
z.vec[k] = poly_add(&r.vec[k], &cs_k);
cs_k.zeroize();
}
r.zeroize();
if verify_response_norm(&z) == 0 {
let mut commitment_hash = [0u8; 32];
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_SAAP_COMMIT_V1");
for poly in w.vec.iter() {
for coeff in poly.coeffs.iter() {
hasher.update(&coeff.to_le_bytes());
}
}
let mut xof = hasher.finalize_xof();
xof.read(&mut commitment_hash);
return Ok(SaapProof {
context_tag,
disclosure_mask,
attributes,
challenge: c,
z,
commitment_hash,
commitment_w: w,
});
}
w.zeroize();
c.zeroize();
z.zeroize();
}
Err(IdentityError::RejectionSamplingFailed)
}
#[doc(hidden)]
#[deprecated(
since = "0.1.1",
note = "superseded by the credential module, which anchors on the PLP projection b_tau. This value was never safe to publish and is now unnecessary."
)]
pub fn saap_public_key(tau: &[u8], secret_key: &VectorK) -> VectorK {
let matrix_a = derive_saap_matrix(tau);
let mut t = VectorK::zero();
for i in 0..MODULE_K {
for j in 0..MODULE_K {
let prod = poly_mul_negacyclic(&matrix_a[i].vec[j], &secret_key.vec[j]);
t.vec[i] = poly_add(&t.vec[i], &prod);
}
}
t
}
pub fn verify_saap_proof_against(
proof: &SaapProof,
tau: &[u8],
public_key: &VectorK,
) -> Result<(), SaapValidationError> {
if verify_response_norm(&proof.z) != 0 {
return Err(SaapValidationError::NormBoundViolation);
}
let mut context_tag = [0u8; 32];
let len = tau.len().min(32);
context_tag[..len].copy_from_slice(&tau[..len]);
let recomputed_c =
recompute_challenge(&proof.commitment_w, &context_tag, proof.disclosure_mask, &proof.attributes);
let mut mismatch = 0u32;
for i in 0..RING_N {
mismatch |= (recomputed_c.coeffs[i] ^ proof.challenge.coeffs[i]) as u32;
}
let mut tag_mismatch = 0u8;
for i in 0..32 {
tag_mismatch |= proof.context_tag[i] ^ context_tag[i];
}
if mismatch != 0 || tag_mismatch != 0 {
return Err(SaapValidationError::ChallengeMismatch);
}
let matrix_a = derive_saap_matrix(tau);
let mut equation_mismatch = 0u32;
for i in 0..MODULE_K {
let mut az_i = Polynomial::zero();
for j in 0..MODULE_K {
let prod = poly_mul_negacyclic(&matrix_a[i].vec[j], &proof.z.vec[j]);
az_i = poly_add(&az_i, &prod);
}
let ct = poly_mul_negacyclic(&proof.challenge, &public_key.vec[i]);
let w_prime = poly_sub(&az_i, &ct);
for n in 0..RING_N {
equation_mismatch |= (w_prime.coeffs[n] ^ proof.commitment_w.vec[i].coeffs[n]) as u32;
}
}
if equation_mismatch != 0 {
return Err(SaapValidationError::InvalidAttributeCommitment);
}
compiler_fence(Ordering::SeqCst);
Ok(())
}
#[deprecated(
since = "0.1.1",
note = "unsound: ignores its public parameters and checks the proof only against \
itself, so it accepts proofs forged with no secret key. Use \
verify_saap_proof_against(proof, tau, public_key). See P3-10 / 0X3-78."
)]
pub fn verify_saap_proof(
proof: &SaapProof,
_matrix_a: &[VectorK; MODULE_K],
_public_attribute_commitments: &[Polynomial; MAX_ATTRIBUTES],
) -> Result<(), SaapValidationError> {
let norm_failure = verify_response_norm(&proof.z);
if norm_failure != 0 {
return Err(SaapValidationError::NormBoundViolation);
}
let recomputed_c = recompute_challenge(
&proof.commitment_w,
&proof.context_tag,
proof.disclosure_mask,
&proof.attributes,
);
let mut mismatch = 0u32;
for i in 0..RING_N {
let a = recomputed_c.coeffs[i];
let b = proof.challenge.coeffs[i];
mismatch |= (a ^ b) as u32;
}
if mismatch != 0 {
return Err(SaapValidationError::ChallengeMismatch);
}
let mut expected_hash = [0u8; 32];
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_SAAP_COMMIT_V1");
for poly in proof.commitment_w.vec.iter() {
for coeff in poly.coeffs.iter() {
hasher.update(&coeff.to_le_bytes());
}
}
let mut xof = hasher.finalize_xof();
xof.read(&mut expected_hash);
let mut hash_mismatch = 0u8;
for i in 0..32 {
hash_mismatch |= proof.commitment_hash[i] ^ expected_hash[i];
}
if hash_mismatch != 0 {
return Err(SaapValidationError::InvalidAttributeCommitment);
}
compiler_fence(Ordering::SeqCst);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_secret_key() -> VectorK {
let mut sk = VectorK::zero();
for k in 0..MODULE_K {
for n in 0..RING_N {
sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
}
}
sk
}
#[test]
fn test_mod_q_range() {
let q = PARAM_Q as i64;
for x in [-q - 1, -q, -1, 0, 1, q - 1, q, q + 1] {
let r = mod_q(x);
assert!((-(PARAM_Q / 2)..=PARAM_Q / 2).contains(&r),
"mod_q({}) = {} out of range", x, r);
}
}
#[test]
fn test_recompute_challenge_deterministic() {
let w = VectorK::zero();
let tau = b"test_tau";
let mask = 0b00001111u64;
let c1 = recompute_challenge(&w, tau, mask, &AttributePayload::zero());
let c2 = recompute_challenge(&w, tau, mask, &AttributePayload::zero());
assert_eq!(c1.coeffs, c2.coeffs, "challenge should be deterministic");
}
#[test]
fn test_recompute_challenge_sparse() {
let w = VectorK::zero();
let c = recompute_challenge(&w, b"tau", 0u64, &AttributePayload::zero());
let nonzero = c.coeffs.iter().filter(|&&x| x != 0).count();
assert_eq!(
nonzero, crate::plp::CHALLENGE_WEIGHT,
"challenge should have exactly CHALLENGE_WEIGHT non-zero coefficients"
);
}
#[test]
fn test_saap_prove_verify() {
let sk = make_test_secret_key();
let credential = [0u8; 64]; let disclosure_mask = 0b00001111u64;
let tau = b"test_context_tau_saap";
let proof = saap_prove(&credential, disclosure_mask, tau, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
assert_eq!(verify_response_norm(&proof.z), 0, "response norm should be within bound");
let recomputed = recompute_challenge(&proof.commitment_w, &proof.context_tag, disclosure_mask, &proof.attributes);
let mut mismatch = 0u32;
for i in 0..RING_N {
mismatch |= (recomputed.coeffs[i] ^ proof.challenge.coeffs[i]) as u32;
}
assert_eq!(mismatch, 0, "challenge should be consistent");
}
}
#[cfg(test)]
#[allow(deprecated)]
mod soundness_tests {
use super::*;
fn test_sk() -> VectorK {
let mut sk = VectorK::zero();
for k in 0..MODULE_K {
for n in 0..RING_N {
sk.vec[k].coeffs[n] = ((k * 31 + n * 7) % 5) as i32 - 2;
}
}
sk
}
use sha3::{digest::{ExtendableOutput, Update, XofReader}, Shake256};
const RHO_A: [u8; 32] = [0x11u8; 32];
const RHO_B: [u8; 32] = [0x22u8; 32];
fn forge(context_tag: [u8; 32], disclosure_mask: u64) -> SaapProof {
let mut proof = SaapProof::zero();
proof.context_tag = context_tag;
proof.disclosure_mask = disclosure_mask;
proof.attributes = AttributePayload::zero();
proof.commitment_w = VectorK::zero();
proof.z = VectorK::zero();
proof.challenge = recompute_challenge(
&proof.commitment_w,
&proof.context_tag,
proof.disclosure_mask,
&proof.attributes,
);
let mut hasher = Shake256::default();
hasher.update(b"AETHEL_SAAP_COMMIT_V1");
for poly in proof.commitment_w.vec.iter() {
for coeff in poly.coeffs.iter() {
hasher.update(&coeff.to_le_bytes());
}
}
let mut xof = hasher.finalize_xof();
xof.read(&mut proof.commitment_hash);
proof
}
#[test]
#[allow(deprecated)]
fn deprecated_verifier_accepts_forgeries_known_defect() {
let matrix_a = [VectorK::zero(); MODULE_K];
let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
let proof = forge([7u8; 32], 0b0000_0011);
assert!(
verify_saap_proof(&proof, &matrix_a, &attr_commits).is_ok(),
"the deprecated verifier now rejects the forgery — if it was fixed, delete \
this characterisation test along with the function"
);
}
#[test]
fn plp_rejects_the_analogous_forgery() {
use crate::plp::{MasterIdentity, Poly, Verifier, ZkIdentityProof};
let identity = MasterIdentity::from_seed(&[0x11u8; 32]);
let projection = identity.project_at_context(b"context-under-attack", &[0xa5u8; 32]);
let commitment_w = [Poly::zero(); crate::plp::MODULE_K];
let challenge_c = crate::plp::hash_to_challenge(
&commitment_w,
&projection.public_b,
&projection.tau,
&projection.salt,
);
let proof = ZkIdentityProof {
commitment_w,
challenge_c,
response_z: [Poly::zero(); crate::plp::MODULE_K],
};
assert!(
!Verifier::verify(&projection, &proof),
"PLP accepted the same style of forgery SAAP accepts — in that case the \
forgery helper is what is wrong, not the SAAP verifier, and the SAAP \
finding must be re-derived before it is reported."
);
}
const TAU: &[u8] = b"context-alpha";
const CREDENTIAL: &[u8] = b"attribute-block-0123456789abcdef";
fn test_secret_key() -> VectorK {
let mut sk = VectorK::zero();
for k in 0..MODULE_K {
for n in 0..RING_N {
sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
}
}
sk
}
#[test]
fn honest_proof_satisfies_the_verification_equation() {
let sk = test_secret_key();
let proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
let public_key = saap_public_key(TAU, &sk);
assert_eq!(
verify_saap_proof_against(&proof, TAU, &public_key),
Ok(()),
"an honestly generated proof failed the verification equation"
);
}
#[test]
fn corrected_verifier_rejects_the_forgery() {
let sk = test_secret_key();
let public_key = saap_public_key(TAU, &sk);
let forged = forge_for(TAU, 0b0000_0011);
assert!(
verify_saap_proof_against(&forged, TAU, &public_key).is_err(),
"the corrected verifier still accepts a proof forged with no secret key"
);
}
#[test]
fn corrected_verifier_rejects_a_proof_from_another_context() {
let sk = test_secret_key();
let proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
let other_tau: &[u8] = b"context-beta";
let public_key_beta = saap_public_key(other_tau, &sk);
assert!(
verify_saap_proof_against(&proof, other_tau, &public_key_beta).is_err(),
"a proof issued for one context verified under another"
);
}
#[test]
fn corrected_verifier_rejects_a_tampered_response() {
let sk = test_secret_key();
let mut proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
let public_key = saap_public_key(TAU, &sk);
proof.z.vec[0].coeffs[0] = proof.z.vec[0].coeffs[0].wrapping_add(1);
assert!(
verify_saap_proof_against(&proof, TAU, &public_key).is_err(),
"a tampered response vector still verified"
);
}
#[test]
fn disclosed_attributes_are_bound_into_the_challenge() {
let sk = test_secret_key();
let mut proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
let public_key = saap_public_key(TAU, &sk);
assert_eq!(
verify_saap_proof_against(&proof, TAU, &public_key),
Ok(()),
"test setup: the honest proof should verify before tampering"
);
let original = proof.attributes.values[0];
proof.attributes.values[0] = original.wrapping_add(0xDEAD_BEEF);
assert!(
verify_saap_proof_against(&proof, TAU, &public_key).is_err(),
"a disclosed attribute value was rewritten after proof generation and the \
proof still verified. SAAP-SPEC.md §7 step 3 binds m_pub into the \
challenge; recompute_challenge does not. The verifier therefore attests \
to attribute values the prover never committed to, which is the property \
SAAP exists to provide."
);
}
fn forge_for(tau: &[u8], disclosure_mask: u64) -> SaapProof {
let mut context_tag = [0u8; 32];
let len = tau.len().min(32);
context_tag[..len].copy_from_slice(&tau[..len]);
forge(context_tag, disclosure_mask)
}
#[test]
#[allow(deprecated)]
fn deprecated_verifier_is_self_certifying_known_defect() {
let matrix_a = [VectorK::zero(); MODULE_K];
let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
let proof = forge([0xAAu8; 32], 0b0000_0001);
assert!(
verify_saap_proof(&proof, &matrix_a, &attr_commits).is_ok(),
"the deprecated verifier has no τ parameter, so it cannot answer 'is this \
valid for MY context?' — it accepts whatever context the proof asserts \
about itself. The WIT world declares \
saap-verify(proof, tau) -> result<bool, identity-error>."
);
}
#[test]
fn saap_mask_is_fresh_per_rho_and_reproducible_given_rho() {
let sk = test_sk();
let credential: [u8; 64] = core::array::from_fn(|i| i as u8);
let tau = b"verifier-session-tau-0001";
let p_a1 = saap_prove(&credential, 0b0000_0011, tau, &sk, &RHO_A)
.expect("honest parameters: 16 rejections is negligible");
let p_a2 = saap_prove(&credential, 0b0000_0011, tau, &sk, &RHO_A)
.expect("honest parameters: 16 rejections is negligible");
let p_b = saap_prove(&credential, 0b0000_0011, tau, &sk, &RHO_B)
.expect("honest parameters: 16 rejections is negligible");
let commitments_equal = |x: &SaapProof, y: &SaapProof| {
(0..MODULE_K).all(|i| {
(0..RING_N).all(|n| x.commitment_w.vec[i].coeffs[n] == y.commitment_w.vec[i].coeffs[n])
})
};
assert!(
commitments_equal(&p_a1, &p_a2),
"same rho must reproduce the same commitment"
);
assert!(
!commitments_equal(&p_a1, &p_b),
"different rho must change the commitment — r carries per-call entropy"
);
}
#[test]
fn saap_distinct_rho_gives_distinct_masks_across_disclosures() {
let sk = test_sk();
let credential: [u8; 64] = core::array::from_fn(|i| i as u8);
let tau = b"verifier-session-tau-0001";
let p1 = saap_prove(&credential, 0b0000_0001, tau, &sk, &RHO_A)
.expect("honest parameters: 16 rejections is negligible");
let p2 = saap_prove(&credential, 0b0000_0010, tau, &sk, &RHO_B)
.expect("honest parameters: 16 rejections is negligible");
let mut any_diff = false;
for i in 0..MODULE_K {
for n in 0..RING_N {
if p1.commitment_w.vec[i].coeffs[n] != p2.commitment_w.vec[i].coeffs[n] {
any_diff = true;
}
}
}
assert!(
any_diff,
"commitments must differ across proofs — masks are independent per rho"
);
}
}
#[cfg(test)]
mod no_debug_leak {
use static_assertions::assert_not_impl_any;
assert_not_impl_any!(super::Polynomial: core::fmt::Debug, core::fmt::Display);
assert_not_impl_any!(super::VectorK: core::fmt::Debug, core::fmt::Display);
}
#[cfg(test)]
#[allow(deprecated)]
mod roundtrip_tests {
use super::*;
#[test]
fn test_saap_prove_norm_bound() {
let mut sk = VectorK::zero();
for k in 0..MODULE_K {
for n in 0..RING_N {
sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
}
}
let credential = [0u8; 64];
let disclosure_mask = 0b00001111u64;
let tau = b"test_saap_context";
let proof = saap_prove(&credential, disclosure_mask, tau, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
let norm_result = verify_response_norm(&proof.z);
assert_eq!(norm_result, 0, "SAAP response norm should be within bound");
}
#[test]
fn test_saap_prove_verify_roundtrip() {
let mut sk = VectorK::zero();
for k in 0..MODULE_K {
for n in 0..RING_N {
sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
}
}
let credential = [0u8; 64];
let disclosure_mask = 0b00001111u64;
let tau = b"test_saap_context_verify";
let proof = saap_prove(&credential, disclosure_mask, tau, &sk, &[0x7cu8; 32])
.expect("honest parameters: 16 rejections is negligible");
let matrix_a = [VectorK::zero(); MODULE_K];
let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
let result = verify_saap_proof(&proof, &matrix_a, &attr_commits);
assert!(result.is_ok(), "SAAP verify should accept valid proof: {:?}", result);
}
#[test]
fn test_saap_verify_rejects_invalid_norm() {
let mut proof = SaapProof::zero();
proof.z.vec[0].coeffs[0] = REJECTION_BOUND + 1000;
let matrix_a = [VectorK::zero(); MODULE_K];
let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
let result = verify_saap_proof(&proof, &matrix_a, &attr_commits);
assert_eq!(
result,
Err(SaapValidationError::NormBoundViolation),
"Should reject proof with out-of-bounds norm"
);
}
fn pseudo_random_poly(seed: u64) -> Polynomial {
let mut p = Polynomial::zero();
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
for i in 0..RING_N {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
p.coeffs[i] = mod_q((x % (PARAM_Q as u64 * 2)) as i64 - PARAM_Q as i64);
}
p
}
#[test]
fn ntt_and_schoolbook_agree_over_saap_polynomials() {
for seed in 0..24u64 {
let a = pseudo_random_poly(seed * 2 + 1);
let b = pseudo_random_poly(seed * 2 + 2);
let fast = poly_mul_negacyclic(&a, &b);
let reference = poly_mul_schoolbook(&a, &b);
assert_eq!(
fast.coeffs, reference.coeffs,
"NTT and schoolbook disagree at seed {seed}"
);
}
}
#[test]
fn the_multiplication_agreement_check_can_fail() {
let a = pseudo_random_poly(1);
let b = pseudo_random_poly(2);
let c = pseudo_random_poly(3);
assert_ne!(a.coeffs, b.coeffs, "operand generator is not varying");
assert_ne!(
poly_mul_negacyclic(&a, &b).coeffs,
poly_mul_negacyclic(&a, &c).coeffs,
"different operands produced the same product, so agreement proves nothing"
);
}
#[test]
fn the_centered_to_unsigned_bridge_round_trips() {
for seed in 0..8u64 {
let p = pseudo_random_poly(seed + 100);
let bridged = to_plp_poly(&p);
for i in 0..RING_N {
assert!(
bridged.coeffs[i] < PARAM_Q as u32,
"bridged coefficient outside [0, q)"
);
assert_eq!(
mod_q(bridged.coeffs[i] as i64),
p.coeffs[i],
"round trip changed coefficient {i} at seed {seed}"
);
}
}
}
}
#[cfg(test)]
mod rejection_sampling_tests {
use super::*;
fn test_sk() -> VectorK {
let mut sk = VectorK::zero();
for k in 0..MODULE_K {
for n in 0..RING_N {
sk.vec[k].coeffs[n] = ((n + k) % 5) as i32 - 2;
}
}
sk
}
#[test]
fn every_returned_proof_satisfies_the_norm_bound() {
let sk = test_sk();
let credential = [0x3Cu8; 64];
for tau_idx in 0u8..12 {
for rho_idx in 0u8..4 {
let tau = [b'c', b't', b'x', tau_idx];
let rho = [0x40u8 ^ rho_idx; 32];
let proof = saap_prove(&credential, 0b0000_0011, &tau, &sk, &rho)
.expect("honest parameters: 16 rejections is negligible");
assert_eq!(
verify_response_norm(&proof.z),
0,
"saap_prove returned a response outside the rejection bound \
at tau {tau_idx}, rho {rho_idx}"
);
}
}
}
#[test]
fn the_norm_check_rejects_an_out_of_bound_response() {
let sk = test_sk();
let credential = [0x3Cu8; 64];
let mut proof = saap_prove(&credential, 0b0000_0011, b"ctx", &sk, &[0x41u8; 32])
.expect("honest parameters: 16 rejections is negligible");
assert_eq!(verify_response_norm(&proof.z), 0, "test setup: an honest proof is in bounds");
proof.z.vec[0].coeffs[0] = REJECTION_BOUND;
assert_ne!(
verify_response_norm(&proof.z),
0,
"a response at the rejection bound was accepted, so the sweep above \
is not testing anything"
);
}
#[test]
fn no_fallback_mask_derivation_remains() {
let source = include_str!("saap.rs");
let needle = concat!("sample_saap_masking_vector(rho, &context_tag, ", "0)");
let hits = source
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.filter(|l| l.contains(needle))
.count();
assert_eq!(
hits, 0,
"the fallback mask derivation is back in saap_prove. Nonce 0 is \
iteration 0's nonce and the derivation is pure, so it reproduces \
iteration 0's mask and rebuilds, byte for byte, the response \
iteration 0 already rejected. That is precisely the value rejection \
sampling exists to withhold. See AETHEL-F-01 and 0b24bc0 before \
restoring it."
);
}
#[test]
fn exhaustion_has_an_error_to_report() {
let sk = test_sk();
let proof: Result<SaapProof, IdentityError> =
saap_prove(&[0x3Cu8; 64], 0b0000_0011, b"ctx", &sk, &[0x42u8; 32]);
assert!(proof.is_ok(), "honest parameters must still produce a proof");
assert_eq!(
IdentityError::from(crate::sampling::RejectionError::AllIterationsRejected),
IdentityError::RejectionSamplingFailed
);
}
}