aethel_core/saap.rs
1//! # SAAP Selective Attribute Verification Engine
2//!
3//! This module implements the **Selective Attribute Attestation Protocol (SAAP)**
4//! prove/verify engine for Aethel-ID. This code has had no independent security
5//! audit (see the README's audit status), and its pathway is retired (D-13).
6//! It also covers a narrower surface than the full SAAP design: `saap_prove`
7//! and `verify_saap_proof` operate over an already-parsed credential; issuing
8//! one in the first place (BDLOP commitment issuance, an issuer signature) is
9//! not implemented here.
10//!
11//! ## Purpose
12//!
13//! Verifies SAAP proof transcripts generated by credential holders performing
14//! selective attribute disclosure. The verifier checks:
15//!
16//! 1. **Norm Bound**: Response vector `z` satisfies `∥z∥_∞ < (γ₁ - β)`.
17//! 2. **Commitment Reconstruction**: Recomputes `w' = A · z - c · t_attr`.
18//! 3. **Challenge Validation**: Verifies Fiat-Shamir challenge consistency.
19//!
20//! ## Key Structures
21//!
22//! - [`Polynomial`] — Ring element in R_q = Z_q[X]/(X^256 + 1)
23//! - [`VectorK`] — k-dimensional module vector over R_q
24//! - [`SaapProof`] — Complete SAAP proof transcript
25//! - [`SaapValidationError`] — Typed verification failure reasons
26//! - [`AttributePayload`] — Disclosed attribute values
27//!
28//! ## Domain Separators
29//!
30//! - Challenge hash: `"AETHEL_SAAP_CHALLENGE_V1"`
31
32// The single-response prove and verify pathway below is retired (D-13 in
33// docs/DEVIATIONS.md, 0X3-110): nothing outside this file's tests calls it.
34// It is marked rather than deleted because `SaapProof` and
35// `SaapValidationError` are public, so removing it belongs in a breaking
36// release.
37#![allow(dead_code)]
38// Ring arithmetic indexes several coefficient arrays in step, so an index
39// loop is the clear form here (as in `component.rs`).
40#![allow(clippy::needless_range_loop)]
41
42use core::sync::atomic::{compiler_fence, Ordering};
43use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
44use zeroize::Zeroize;
45
46use crate::identity_error::IdentityError;
47
48pub const RING_N: usize = 256;
49pub const MODULE_K: usize = 4;
50pub const MAX_ATTRIBUTES: usize = 8;
51pub const PARAM_Q: i32 = 8_380_417;
52pub const PARAM_GAMMA1: i32 = 131_072;
53pub const PARAM_BETA: i32 = 78;
54pub const REJECTION_BOUND: i32 = PARAM_GAMMA1 - PARAM_BETA;
55
56/// Ring element in R_q = Z_q[X]/(X^256 + 1).
57///
58/// Used both for public proof data (challenge, commitment) and, as a
59/// component of [`VectorK`], for caller-supplied secret key material passed
60/// into [`saap_prove`]. Because this crate's own `saap_prove` signature uses
61/// `VectorK` to carry a raw secret, neither type derives `Debug`/`Display`
62/// (P3-03) — see the `assert_not_impl_any!` checks in `tests/no_debug_leak.rs`.
63#[derive(Copy, Clone, Zeroize)]
64#[repr(align(64))]
65pub struct Polynomial {
66 pub coeffs: [i32; RING_N],
67}
68
69impl Polynomial {
70 pub const fn zero() -> Self {
71 Self { coeffs: [0; RING_N] }
72 }
73}
74
75/// k-dimensional module vector over R_q. See [`Polynomial`] for why this
76/// type has no `Debug`/`Display` impl: it is the type this crate's own API
77/// uses to carry a raw SAAP secret key (`saap_prove`'s `secret_key` parameter).
78#[derive(Copy, Clone, Zeroize)]
79#[repr(align(64))]
80pub struct VectorK {
81 pub vec: [Polynomial; MODULE_K],
82}
83
84impl VectorK {
85 pub const fn zero() -> Self {
86 Self { vec: [Polynomial::zero(); MODULE_K] }
87 }
88}
89
90/// Bitmask of disclosed attributes.
91pub type AttributeMask = u64;
92
93/// Disclosed attribute values.
94///
95/// Keeps its `Debug` derive: these are the attribute values the credential
96/// holder has *chosen to disclose* — deliberately revealed by SAAP's own
97/// selective-disclosure design, never the secret key itself. Safe to
98/// format-print by construction (P3-03).
99#[derive(Copy, Clone, Debug)]
100pub struct AttributePayload {
101 pub values: [u64; MAX_ATTRIBUTES],
102}
103
104impl AttributePayload {
105 pub const fn zero() -> Self {
106 Self { values: [0u64; MAX_ATTRIBUTES] }
107 }
108}
109
110/// Complete SAAP proof transcript.
111#[derive(Copy, Clone)]
112#[repr(C)]
113pub struct SaapProof {
114 /// Context tag τ.
115 pub context_tag: [u8; 32],
116 /// Bitmask of disclosed attributes.
117 pub disclosure_mask: AttributeMask,
118 /// Disclosed attribute values.
119 pub attributes: AttributePayload,
120 /// Fiat-Shamir challenge polynomial c.
121 pub challenge: Polynomial,
122 /// Response vector z = r + c · secret_key.
123 pub z: VectorK,
124 /// BDLOP commitment hash (SHA3-256 of commitment vector).
125 pub commitment_hash: [u8; 32],
126 /// Commitment vector w = A · r.
127 pub commitment_w: VectorK,
128}
129
130impl SaapProof {
131 /// An all-zero proof, used as an output buffer.
132 pub const fn zero() -> Self {
133 Self {
134 context_tag: [0u8; 32],
135 disclosure_mask: 0,
136 attributes: AttributePayload::zero(),
137 challenge: Polynomial::zero(),
138 z: VectorK::zero(),
139 commitment_hash: [0u8; 32],
140 commitment_w: VectorK::zero(),
141 }
142 }
143}
144
145/// Typed verification failure reasons.
146#[derive(Debug, Copy, Clone, PartialEq, Eq)]
147pub enum SaapValidationError {
148 /// A response coefficient is at or above the rejection bound.
149 NormBoundViolation,
150 /// The recomputed Fiat-Shamir challenge differs from the proof's.
151 ChallengeMismatch,
152 /// A disclosed attribute does not match its commitment.
153 InvalidAttributeCommitment,
154}
155
156// ── Polynomial arithmetic ─────────────────────────────────────────────────────
157
158/// Negacyclic polynomial multiplication in R_q = Z_q[X]/(X^N + 1).
159pub fn poly_mul_negacyclic(a: &Polynomial, b: &Polynomial) -> Polynomial {
160 let pc = crate::plp::poly_mul_ntt(&to_plp_poly(a), &to_plp_poly(b));
161
162 let mut result = Polynomial::zero();
163 for i in 0..RING_N {
164 result.coeffs[i] = mod_q(pc.coeffs[i] as i64);
165 }
166 result
167}
168
169/// Convert a centered SAAP polynomial into PLP's unsigned representation.
170///
171/// SAAP carries coefficients centered in `(-q/2, q/2]` (see [`mod_q`]); PLP
172/// carries them in `[0, q)`. The NTT is PLP's, so the bridge lives here rather
173/// than making PLP aware of a second convention.
174fn to_plp_poly(p: &Polynomial) -> crate::plp::Poly {
175 let mut out = crate::plp::Poly::zero();
176 for i in 0..RING_N {
177 let c = p.coeffs[i];
178 out.coeffs[i] = if c < 0 { (c + PARAM_Q) as u32 } else { c as u32 };
179 }
180 out
181}
182
183/// Schoolbook negacyclic multiplication, retained as the reference.
184///
185/// [`poly_mul_negacyclic`] is the fast path and this is what it is checked
186/// against, by `ntt_and_schoolbook_agree_over_saap_polynomials`. Keeping the
187/// O(N^2) version is the only way that check means anything: a fast path with no
188/// independent reference is a fast path nobody can falsify.
189pub fn poly_mul_schoolbook(a: &Polynomial, b: &Polynomial) -> Polynomial {
190 let mut result = Polynomial::zero();
191 for i in 0..RING_N {
192 for j in 0..RING_N {
193 let idx = i + j;
194 let product = (a.coeffs[i] as i64) * (b.coeffs[j] as i64);
195 if idx < RING_N {
196 let acc = (result.coeffs[idx] as i64) + product;
197 result.coeffs[idx] = mod_q(acc);
198 } else {
199 let wrap_idx = idx - RING_N;
200 let acc = (result.coeffs[wrap_idx] as i64) - product;
201 result.coeffs[wrap_idx] = mod_q(acc);
202 }
203 }
204 }
205 result
206}
207
208/// Polynomial addition mod q (centered).
209pub fn poly_add(a: &Polynomial, b: &Polynomial) -> Polynomial {
210 let mut result = Polynomial::zero();
211 for i in 0..RING_N {
212 let acc = (a.coeffs[i] as i64) + (b.coeffs[i] as i64);
213 result.coeffs[i] = mod_q(acc);
214 }
215 result
216}
217
218/// Polynomial subtraction mod q (centered).
219pub fn poly_sub(a: &Polynomial, b: &Polynomial) -> Polynomial {
220 let mut result = Polynomial::zero();
221 for i in 0..RING_N {
222 let acc = (a.coeffs[i] as i64) - (b.coeffs[i] as i64);
223 result.coeffs[i] = mod_q(acc);
224 }
225 result
226}
227
228/// Centered reduction modulo q.
229#[inline(always)]
230pub fn mod_q(x: i64) -> i32 {
231 let q = PARAM_Q as i64;
232 let r = x % q;
233 let r = if r < 0 { r + q } else { r };
234 let r = if r > q / 2 { r - q } else { r };
235 r as i32
236}
237
238// ── Norm verification ─────────────────────────────────────────────────────────
239
240/// Constant-time response norm verification.
241///
242/// Returns 0 if all coefficients satisfy `|coeff| < REJECTION_BOUND`,
243/// or a non-zero mask if any coefficient violates the bound.
244pub fn verify_response_norm(z: &VectorK) -> u32 {
245 let mut bad_mask: u32 = 0;
246 for k in 0..MODULE_K {
247 for n in 0..RING_N {
248 let coeff = z.vec[k].coeffs[n];
249 let sign_mask = coeff >> 31;
250 let abs_coeff = (coeff + sign_mask) ^ sign_mask;
251 let diff = (REJECTION_BOUND - 1) - abs_coeff;
252 bad_mask |= ((diff >> 31) & 1) as u32 * 0xFFFF_FFFF;
253 }
254 }
255 bad_mask
256}
257
258// ── SHAKE-256 Fiat-Shamir challenge ───────────────────────────────────────────
259
260/// Derive a sparse ternary challenge polynomial from an XOF reader.
261///
262/// Produces exactly [`crate::plp::CHALLENGE_WEIGHT`] coefficients in `{±1}`
263/// using rejection sampling. The weight is shared with `plp` deliberately:
264/// `BETA` is derived from it, and two challenge spaces with one bound would
265/// mean the bound is wrong for at least one of them.
266pub fn hash_to_challenge_from_xof(xof: &mut impl XofReader) -> Polynomial {
267 let mut c_poly = Polynomial::zero();
268
269 let mut signs = [0u8; 8];
270 xof.read(&mut signs);
271 let mut sign_bit = 0usize;
272
273 let mut count = 0usize;
274 let mut used = [false; RING_N];
275 let mut pos_buf = [0u8; 1];
276
277 while count < crate::plp::CHALLENGE_WEIGHT {
278 xof.read(&mut pos_buf);
279 let pos = pos_buf[0] as usize;
280 if pos >= RING_N {
281 continue;
282 }
283 if used[pos] {
284 continue;
285 }
286 used[pos] = true;
287 let sign_byte = signs[sign_bit / 8];
288 let sign: i32 = if (sign_byte >> (sign_bit % 8)) & 1 == 0 { 1 } else { -1 };
289 sign_bit += 1;
290 if sign_bit >= 64 {
291 xof.read(&mut signs);
292 sign_bit = 0;
293 }
294 c_poly.coeffs[pos] = sign;
295 count += 1;
296 }
297 c_poly
298}
299
300/// Recompute the SAAP Fiat-Shamir challenge using SHAKE-256.
301///
302/// c = SHAKE-256("AETHEL_SAAP_CHALLENGE_V1" ∥ commitment_w ∥ tau ∥ disclosure_mask)
303/// Derive the Fiat-Shamir challenge.
304///
305/// Binds the disclosed attribute values (`m_pub` in SAAP-SPEC.md §7 step 3)
306/// alongside the commitment, context and mask. Without them in the hash, the
307/// disclosed values ride in the transcript unbound and can be rewritten after
308/// proof generation while the proof still verifies — see
309/// `disclosed_attributes_are_bound_into_the_challenge`.
310///
311/// Only the values selected by `disclosure_mask` are bound. Undisclosed slots
312/// are skipped rather than hashed, so the challenge cannot act as an oracle on
313/// attributes the proof is meant to withhold (§11.5).
314pub fn recompute_challenge(
315 commitment_w: &VectorK,
316 tau: &[u8],
317 disclosure_mask: AttributeMask,
318 attributes: &AttributePayload,
319) -> Polynomial {
320 let mut hasher = Shake256::default();
321 hasher.update(b"AETHEL_SAAP_CHALLENGE_V1");
322 for poly in commitment_w.vec.iter() {
323 for coeff in poly.coeffs.iter() {
324 hasher.update(&coeff.to_le_bytes());
325 }
326 }
327 hasher.update(tau);
328 hasher.update(&disclosure_mask.to_le_bytes());
329 for i in 0..MAX_ATTRIBUTES {
330 if (disclosure_mask >> i) & 1 == 1 {
331 hasher.update(&(i as u8).to_le_bytes());
332 hasher.update(&attributes.values[i].to_le_bytes());
333 }
334 }
335 let mut xof = hasher.finalize_xof();
336 hash_to_challenge_from_xof(&mut xof)
337}
338
339// ── SAAP Prover ───────────────────────────────────────────────────────────────
340
341/// Derive the SAAP context matrix A from tau using SHAKE-256.
342fn derive_saap_matrix(tau: &[u8]) -> [VectorK; MODULE_K] {
343 let mut matrix = [VectorK::zero(); MODULE_K];
344 for i in 0..MODULE_K {
345 for j in 0..MODULE_K {
346 let mut hasher = Shake256::default();
347 hasher.update(b"AETHEL_SAAP_MATRIX_V1");
348 hasher.update(tau);
349 hasher.update(&[i as u8, j as u8]);
350 let mut xof = hasher.finalize_xof();
351
352 let mut coeff_idx = 0usize;
353 while coeff_idx < RING_N {
354 let mut buf = [0u8; 3];
355 xof.read(&mut buf);
356 let val = (buf[0] as u32)
357 | ((buf[1] as u32) << 8)
358 | ((buf[2] as u32 & 0x7F) << 16);
359 if (val as i32) < PARAM_Q {
360 let centered = if val > (PARAM_Q as u32 / 2) {
361 val as i32 - PARAM_Q
362 } else {
363 val as i32
364 };
365 matrix[i].vec[j].coeffs[coeff_idx] = centered;
366 coeff_idx += 1;
367 }
368 }
369 }
370 }
371 matrix
372}
373
374/// Sample a masking vector r from uniform [-γ₁, γ₁]^k using SHAKE-256.
375/// Sample the sigma-protocol masking vector `r ← [-γ₁, γ₁]^k`.
376///
377/// `rho` MUST be fresh, secret entropy, distinct per proof. The mask is the
378/// only thing hiding `sk` in the response `z = r + c·sk`: an earlier version
379/// derived `r` from the public context tag, so a transcript's `w = A·r` could
380/// be replayed to recover `r` and hence `c·sk`. Worse, `r` did not depend on
381/// the disclosure mask, so two proofs of one credential under one τ disclosing
382/// different attribute sets shared `r` and leaked `sk` via `z₁ − z₂ = (c₁−c₂)·sk`.
383/// Both are closed by sampling `r` from fresh `rho` here.
384/// Sample the sigma-protocol masking vector.
385///
386/// `rho` is caller-supplied fresh randomness and is the primary defence. `tau`
387/// is bound as well so that a caller who *reuses* `rho` across two contexts
388/// still cannot be attacked: without it, two proofs sharing `rho` but under
389/// different contexts share `r` while their challenges differ, and
390/// `z1 - z2 = (c1 - c2)*sk` recovers the secret key. That exact attack was
391/// demonstrated against PLP, which had no fresh randomness at all
392/// (P3-15 / 0X3-85); binding tau here makes the same mistake unreachable
393/// through this path regardless of caller behaviour.
394fn sample_saap_masking_vector(rho: &[u8], tau: &[u8; 32], nonce: u8) -> VectorK {
395 let mut r = VectorK::zero();
396 let range = 2 * PARAM_GAMMA1 as u32 + 1;
397
398 for k in 0..MODULE_K {
399 let mut hasher = Shake256::default();
400 hasher.update(b"AETHEL_SAAP_MASK_V3");
401 hasher.update(rho);
402 hasher.update(tau);
403 hasher.update(&[nonce, k as u8]);
404 let mut xof = hasher.finalize_xof();
405
406 let mut coeff_idx = 0usize;
407 while coeff_idx < RING_N {
408 let mut buf = [0u8; 3];
409 xof.read(&mut buf);
410 let val = (buf[0] as u32)
411 | ((buf[1] as u32) << 8)
412 | ((buf[2] as u32 & 0x7F) << 16);
413 if val < range {
414 let centered = val as i32 - PARAM_GAMMA1;
415 r.vec[k].coeffs[coeff_idx] = centered;
416 coeff_idx += 1;
417 }
418 }
419 }
420 r
421}
422
423/// Generate the SAAP proof. **L1-internal**: `secret_key` is caller-supplied
424/// raw key material, taken by reference and never stored or returned — only
425/// the public [`SaapProof`] (challenge/commitment/response, safe to disclose
426/// by construction) leaves this function. This function does not zeroize the
427/// caller's `secret_key` itself (it's borrowed, not owned — wiping the
428/// caller's memory out from under them would be a correctness bug, not a
429/// safety improvement); it does zeroize every local intermediate derived from
430/// it (`r`, `cs_k`), and on a **rejected** iteration also `w`/`c`/`z` before
431/// looping — same reasoning as `plp::Prover::prove_identity`: a rejected
432/// response must never be observable, so it must be wiped, not merely
433/// dropped. `Polynomial`/`VectorK` are `Copy` and cannot implement `Drop`, so
434/// none of this happens automatically.
435///
436/// Implements the BDLOP commitment scheme with Fiat-Shamir ZK proof.
437///
438/// Returns the first response that satisfies the norm bound, or
439/// `Err(RejectionSamplingFailed)` if all 16 iterations are rejected. There is
440/// deliberately **no fallback proof**: see the note above the error return for
441/// why emitting a candidate there was a soundness hazard rather than a
442/// convenience.
443pub fn saap_prove(
444 credential: &[u8],
445 disclosure_mask: AttributeMask,
446 tau: &[u8],
447 secret_key: &VectorK,
448 rho: &[u8],
449) -> Result<SaapProof, IdentityError> {
450 // 1. Parse credential into attribute vector (up to MAX_ATTRIBUTES u64 values)
451 let mut attributes = AttributePayload::zero();
452 for i in 0..MAX_ATTRIBUTES {
453 let offset = i * 8;
454 if offset + 8 <= credential.len() {
455 let mut bytes = [0u8; 8];
456 bytes.copy_from_slice(&credential[offset..offset + 8]);
457 attributes.values[i] = u64::from_le_bytes(bytes);
458 }
459 }
460
461 // 2. Derive context matrix A
462 let matrix_a = derive_saap_matrix(tau);
463
464 // 3. Context tag (first 32 bytes of tau, padded)
465 let mut context_tag = [0u8; 32];
466 let len = tau.len().min(32);
467 context_tag[..len].copy_from_slice(&tau[..len]);
468
469 // 4. Fixed 16-iteration rejection sampling loop
470 for iter in 0u8..16 {
471 // Sample masking vector r ← S_{γ₁}^k
472 let mut r = sample_saap_masking_vector(rho, &context_tag, iter);
473
474 // Compute commitment w = A · r
475 let mut w = VectorK::zero();
476 for i in 0..MODULE_K {
477 for j in 0..MODULE_K {
478 let prod = poly_mul_negacyclic(&matrix_a[i].vec[j], &r.vec[j]);
479 w.vec[i] = poly_add(&w.vec[i], &prod);
480 }
481 }
482
483 // Compute Fiat-Shamir challenge c using padded context_tag (same as verifier)
484 let mut c = recompute_challenge(&w, &context_tag, disclosure_mask, &attributes);
485
486 // Compute response z = r + c · secret_key
487 let mut z = VectorK::zero();
488 for k in 0..MODULE_K {
489 let mut cs_k = poly_mul_negacyclic(&c, &secret_key.vec[k]);
490 z.vec[k] = poly_add(&r.vec[k], &cs_k);
491 cs_k.zeroize();
492 }
493 r.zeroize();
494
495 // Rejection check: ||z||∞ < γ₁ - β
496 if verify_response_norm(&z) == 0 {
497 // Compute commitment hash
498 let mut commitment_hash = [0u8; 32];
499 let mut hasher = Shake256::default();
500 hasher.update(b"AETHEL_SAAP_COMMIT_V1");
501 for poly in w.vec.iter() {
502 for coeff in poly.coeffs.iter() {
503 hasher.update(&coeff.to_le_bytes());
504 }
505 }
506 let mut xof = hasher.finalize_xof();
507 xof.read(&mut commitment_hash);
508
509 return Ok(SaapProof {
510 context_tag,
511 disclosure_mask,
512 attributes,
513 challenge: c,
514 z,
515 commitment_hash,
516 commitment_w: w,
517 });
518 }
519
520 // Rejected: w/c/z must not survive to the next iteration unwiped.
521 w.zeroize();
522 c.zeroize();
523 z.zeroize();
524 }
525
526 // No fallback, and specifically not the one that used to be here.
527 //
528 // This path rebuilt a candidate by re-deriving the mask as
529 // `sample_saap_masking_vector(rho, &context_tag, 0)` — nonce zero, which is
530 // iteration 0's mask. The derivation is a pure function of
531 // `(rho, context_tag, nonce)`, so `w`, `c` and `z` all recomputed to
532 // iteration 0's values and the function returned, verbatim, the response
533 // iteration 0 had already rejected. It did not compute a fresh candidate; it
534 // re-emitted a rejected one, without re-checking the bound.
535 //
536 // That is precisely the value rejection sampling exists to withhold. A
537 // response outside the bound verifies nowhere, so it could only leak, never
538 // authenticate: an out-of-bound `z` is how a sigma protocol gives up its
539 // secret, which is the whole reason the bound is there.
540 //
541 // "Negligible probability" is the wrong frame for how reachable this was.
542 // All 16 iterations rejecting is negligible by chance, but the derivation is
543 // deterministic in `(rho, context_tag)`, so an attacker searches for a
544 // context that lands here rather than waiting for one. `plp::Prover::
545 // prove_identity` (0X3-85) and `credential::prove` already refuse for this
546 // reason; this brings the third implementation of the same primitive in
547 // line with them.
548 Err(IdentityError::RejectionSamplingFailed)
549}
550
551// ── SAAP Verifier ─────────────────────────────────────────────────────────────
552
553/// Compute `t = A_τ · sk` for the **superseded** SAAP construction.
554///
555/// # This is not an anchor any more
556///
557/// The question this function used to raise, whether `t` could be published, is
558/// now moot rather than open. `t = A·sk` carries no error term, so it is an
559/// exact linear image of the secret and recovering `sk` from it is linear
560/// algebra over the ring rather than an M-LWE instance. It was never
561/// distributable.
562///
563/// [`crate::credential`] replaces it. That construction anchors verification on
564/// the PLP projection `b_τ = A_τ·s + e_τ`, which is an M-LWE sample and is
565/// publishable precisely because of the noise this value lacks. Nothing needs a
566/// SAAP-local public key any more.
567///
568/// Retained only so the characterisation tests in `tests/saap_soundness.rs`
569/// stay executable: they pin the defects of the superseded verifier as running
570/// code rather than prose. It disappears when they do. Not part of the shipped
571/// surface, and not reachable through the WIT world.
572#[doc(hidden)]
573#[deprecated(
574 since = "0.1.1",
575 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."
576)]
577pub fn saap_public_key(tau: &[u8], secret_key: &VectorK) -> VectorK {
578 let matrix_a = derive_saap_matrix(tau);
579 let mut t = VectorK::zero();
580 for i in 0..MODULE_K {
581 for j in 0..MODULE_K {
582 let prod = poly_mul_negacyclic(&matrix_a[i].vec[j], &secret_key.vec[j]);
583 t.vec[i] = poly_add(&t.vec[i], &prod);
584 }
585 }
586 t
587}
588
589/// Verify a SAAP proof against a caller-supplied context and public key.
590///
591/// This is the verification the module documentation always described. Unlike
592/// [`verify_saap_proof`], every check is anchored outside the proof:
593///
594/// 1. Norm bound on `z`
595/// 2. Fiat-Shamir consistency, with the challenge recomputed against the
596/// **caller's** τ, and the proof's own `context_tag` required to match it —
597/// so a proof cannot certify its own context
598/// 3. The verification equation `A_τ · z - c · t == w`
599///
600/// Step 3 is an exact equality rather than an approximate one because this
601/// construction adds no error term: `A·z = A·(r + c·sk) = A·r + c·(A·sk) = w +
602/// c·t`. PLP's analogous check is approximate because its `public_b` is an LWE
603/// sample carrying noise.
604pub fn verify_saap_proof_against(
605 proof: &SaapProof,
606 tau: &[u8],
607 public_key: &VectorK,
608) -> Result<(), SaapValidationError> {
609 // 1. Norm bound.
610 if verify_response_norm(&proof.z) != 0 {
611 return Err(SaapValidationError::NormBoundViolation);
612 }
613
614 // 2. Challenge consistency, against the caller's context.
615 let mut context_tag = [0u8; 32];
616 let len = tau.len().min(32);
617 context_tag[..len].copy_from_slice(&tau[..len]);
618
619 let recomputed_c =
620 recompute_challenge(&proof.commitment_w, &context_tag, proof.disclosure_mask, &proof.attributes);
621 let mut mismatch = 0u32;
622 for i in 0..RING_N {
623 mismatch |= (recomputed_c.coeffs[i] ^ proof.challenge.coeffs[i]) as u32;
624 }
625 // The proof's own tag must match the caller's, or a proof issued for one
626 // context could be replayed under another.
627 let mut tag_mismatch = 0u8;
628 for i in 0..32 {
629 tag_mismatch |= proof.context_tag[i] ^ context_tag[i];
630 }
631 if mismatch != 0 || tag_mismatch != 0 {
632 return Err(SaapValidationError::ChallengeMismatch);
633 }
634
635 // 3. Verification equation: A_τ · z - c · t == w.
636 let matrix_a = derive_saap_matrix(tau);
637 let mut equation_mismatch = 0u32;
638 for i in 0..MODULE_K {
639 let mut az_i = Polynomial::zero();
640 for j in 0..MODULE_K {
641 let prod = poly_mul_negacyclic(&matrix_a[i].vec[j], &proof.z.vec[j]);
642 az_i = poly_add(&az_i, &prod);
643 }
644 let ct = poly_mul_negacyclic(&proof.challenge, &public_key.vec[i]);
645 let w_prime = poly_sub(&az_i, &ct);
646 for n in 0..RING_N {
647 equation_mismatch |= (w_prime.coeffs[n] ^ proof.commitment_w.vec[i].coeffs[n]) as u32;
648 }
649 }
650 if equation_mismatch != 0 {
651 return Err(SaapValidationError::InvalidAttributeCommitment);
652 }
653
654 compiler_fence(Ordering::SeqCst);
655 Ok(())
656}
657
658/// Verify a SAAP proof transcript.
659///
660/// Checks:
661/// 1. Norm bound: ||z||∞ < γ₁ - β
662/// 2. Challenge consistency: c' = SHAKE-256("AETHEL_SAAP_CHALLENGE_V1" ∥ w ∥ τ ∥ mask)
663/// 3. Commitment hash consistency
664#[deprecated(
665 since = "0.1.1",
666 note = "unsound: ignores its public parameters and checks the proof only against \
667 itself, so it accepts proofs forged with no secret key. Use \
668 verify_saap_proof_against(proof, tau, public_key). See P3-10 / 0X3-78."
669)]
670pub fn verify_saap_proof(
671 proof: &SaapProof,
672 _matrix_a: &[VectorK; MODULE_K],
673 _public_attribute_commitments: &[Polynomial; MAX_ATTRIBUTES],
674) -> Result<(), SaapValidationError> {
675 // Step 1: Norm Bound Check
676 let norm_failure = verify_response_norm(&proof.z);
677 if norm_failure != 0 {
678 return Err(SaapValidationError::NormBoundViolation);
679 }
680
681 // Step 2: Reconstruct w' = A * z - c * t_attr
682 // For the verifier, we use the stored commitment_w and verify the challenge
683 // matches the recomputed challenge from commitment_w.
684 let recomputed_c = recompute_challenge(
685 &proof.commitment_w,
686 &proof.context_tag,
687 proof.disclosure_mask,
688 &proof.attributes,
689 );
690
691 // Step 3: Challenge Consistency Verification (constant-time)
692 let mut mismatch = 0u32;
693 for i in 0..RING_N {
694 let a = recomputed_c.coeffs[i];
695 let b = proof.challenge.coeffs[i];
696 mismatch |= (a ^ b) as u32;
697 }
698 if mismatch != 0 {
699 return Err(SaapValidationError::ChallengeMismatch);
700 }
701
702 // Step 4: Verify commitment hash
703 let mut expected_hash = [0u8; 32];
704 let mut hasher = Shake256::default();
705 hasher.update(b"AETHEL_SAAP_COMMIT_V1");
706 for poly in proof.commitment_w.vec.iter() {
707 for coeff in poly.coeffs.iter() {
708 hasher.update(&coeff.to_le_bytes());
709 }
710 }
711 let mut xof = hasher.finalize_xof();
712 xof.read(&mut expected_hash);
713
714 let mut hash_mismatch = 0u8;
715 for i in 0..32 {
716 hash_mismatch |= proof.commitment_hash[i] ^ expected_hash[i];
717 }
718 if hash_mismatch != 0 {
719 return Err(SaapValidationError::InvalidAttributeCommitment);
720 }
721
722 compiler_fence(Ordering::SeqCst);
723 Ok(())
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 fn make_test_secret_key() -> VectorK {
731 let mut sk = VectorK::zero();
732 for k in 0..MODULE_K {
733 for n in 0..RING_N {
734 sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
735 }
736 }
737 sk
738 }
739
740 #[test]
741 fn test_mod_q_range() {
742 let q = PARAM_Q as i64;
743 for x in [-q - 1, -q, -1, 0, 1, q - 1, q, q + 1] {
744 let r = mod_q(x);
745 assert!((-(PARAM_Q / 2)..=PARAM_Q / 2).contains(&r),
746 "mod_q({}) = {} out of range", x, r);
747 }
748 }
749
750 #[test]
751 fn test_recompute_challenge_deterministic() {
752 let w = VectorK::zero();
753 let tau = b"test_tau";
754 let mask = 0b00001111u64;
755 let c1 = recompute_challenge(&w, tau, mask, &AttributePayload::zero());
756 let c2 = recompute_challenge(&w, tau, mask, &AttributePayload::zero());
757 assert_eq!(c1.coeffs, c2.coeffs, "challenge should be deterministic");
758 }
759
760 #[test]
761 fn test_recompute_challenge_sparse() {
762 let w = VectorK::zero();
763 let c = recompute_challenge(&w, b"tau", 0u64, &AttributePayload::zero());
764 let nonzero = c.coeffs.iter().filter(|&&x| x != 0).count();
765 assert_eq!(
766 nonzero, crate::plp::CHALLENGE_WEIGHT,
767 "challenge should have exactly CHALLENGE_WEIGHT non-zero coefficients"
768 );
769 }
770
771 #[test]
772 fn test_saap_prove_verify() {
773 let sk = make_test_secret_key();
774 let credential = [0u8; 64]; // 8 attributes of 8 bytes each
775 let disclosure_mask = 0b00001111u64;
776 let tau = b"test_context_tau_saap";
777
778 let proof = saap_prove(&credential, disclosure_mask, tau, &sk, &[0x7cu8; 32])
779 .expect("honest parameters: 16 rejections is negligible");
780
781 // Verify norm bound
782 assert_eq!(verify_response_norm(&proof.z), 0, "response norm should be within bound");
783
784 // Verify challenge consistency — use proof.context_tag (padded) not raw tau
785 let recomputed = recompute_challenge(&proof.commitment_w, &proof.context_tag, disclosure_mask, &proof.attributes);
786 let mut mismatch = 0u32;
787 for i in 0..RING_N {
788 mismatch |= (recomputed.coeffs[i] ^ proof.challenge.coeffs[i]) as u32;
789 }
790 assert_eq!(mismatch, 0, "challenge should be consistent");
791 }
792}
793
794// ── Characterisation and soundness tests ────────────────────────
795//
796// Moved here from tests/saap_soundness.rs and tests/randomness_is_secret.rs
797// when this module became crate-private (P3-10 / 0X3-78). An integration test
798// cannot reach a pub(crate) module, and this module had to stop being public
799// because saap_prove's signature takes a raw u64 disclosure mask, which the
800// WIT world is explicit about never putting on the wire.
801//
802// They are kept, not deleted: they pin the superseded verifier's defects as
803// executable characterisation rather than prose, and they disappear when it
804// does.
805
806#[cfg(test)]
807#[allow(deprecated)]
808mod soundness_tests {
809 use super::*;
810
811 fn test_sk() -> VectorK {
812 let mut sk = VectorK::zero();
813 for k in 0..MODULE_K {
814 for n in 0..RING_N {
815 sk.vec[k].coeffs[n] = ((k * 31 + n * 7) % 5) as i32 - 2;
816 }
817 }
818 sk
819 }
820 use sha3::{digest::{ExtendableOutput, Update, XofReader}, Shake256};
821
822 const RHO_A: [u8; 32] = [0x11u8; 32];
823
824 const RHO_B: [u8; 32] = [0x22u8; 32];
825
826
827 /// Build a proof with no secret key, no credential, and no key material of any
828 /// kind — only public knowledge of the verification procedure.
829 ///
830 /// Strategy: every check in `verify_saap_proof` derives its expected value from
831 /// a field of the proof. So pick the free fields first, then compute the
832 /// dependent ones the same way the verifier will.
833 fn forge(context_tag: [u8; 32], disclosure_mask: u64) -> SaapProof {
834 let mut proof = SaapProof::zero();
835
836 proof.context_tag = context_tag;
837 proof.disclosure_mask = disclosure_mask;
838 proof.attributes = AttributePayload::zero();
839
840 // Free choice: the commitment vector. The verifier never checks it against
841 // A·z, because `matrix_a` is ignored. Zero is the simplest witness that the
842 // choice is unconstrained.
843 proof.commitment_w = VectorK::zero();
844
845 // Free choice: the response vector. Only its norm is checked, and the norm
846 // of zero trivially passes the bound.
847 proof.z = VectorK::zero();
848
849 // Dependent: the challenge is whatever `recompute_challenge` produces from
850 // fields we already control.
851 proof.challenge = recompute_challenge(
852 &proof.commitment_w,
853 &proof.context_tag,
854 proof.disclosure_mask,
855 &proof.attributes,
856 );
857
858 // Dependent: the commitment hash is SHAKE-256 over the commitment vector,
859 // recomputed by the verifier from the same field.
860 let mut hasher = Shake256::default();
861 hasher.update(b"AETHEL_SAAP_COMMIT_V1");
862 for poly in proof.commitment_w.vec.iter() {
863 for coeff in poly.coeffs.iter() {
864 hasher.update(&coeff.to_le_bytes());
865 }
866 }
867 let mut xof = hasher.finalize_xof();
868 xof.read(&mut proof.commitment_hash);
869
870 proof
871 }
872
873 /// Characterises the defect in the deprecated verifier, executably.
874 ///
875 /// This asserts the **known-bad** behaviour deliberately: `verify_saap_proof`
876 /// accepts a proof forged with no secret key. It is written as a passing test so
877 /// the defect is pinned rather than merely described, and so it disappears
878 /// together with the function it characterises. The correctness assertions live
879 /// in `corrected_verifier_*` above.
880 #[test]
881 #[allow(deprecated)]
882 fn deprecated_verifier_accepts_forgeries_known_defect() {
883 let matrix_a = [VectorK::zero(); MODULE_K];
884 let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
885
886 let proof = forge([7u8; 32], 0b0000_0011);
887
888 assert!(
889 verify_saap_proof(&proof, &matrix_a, &attr_commits).is_ok(),
890 "the deprecated verifier now rejects the forgery — if it was fixed, delete \
891 this characterisation test along with the function"
892 );
893 }
894
895 /// Positive control for the forgery methodology above.
896 ///
897 /// PLP's verifier performs the same three-step shape as SAAP's — norm bound,
898 /// Fiat-Shamir challenge consistency, verification equation — but its third
899 /// step actually uses the projection's public parameters:
900 ///
901 /// ```text
902 /// W' = A_τ · z - c · b_τ then ||W' - W||∞ < 2β
903 /// ```
904 ///
905 /// So the analogous forgery must fail there. If this test passes while the
906 /// SAAP forgery is accepted, the difference is in the verifiers, not in the
907 /// way these tests construct a forgery.
908 #[test]
909 fn plp_rejects_the_analogous_forgery() {
910 use crate::plp::{MasterIdentity, Poly, Verifier, ZkIdentityProof};
911
912 // The forger is allowed to know the public projection — that is the point
913 // of a public key. It has no access to the master secret.
914 let identity = MasterIdentity::from_seed(&[0x11u8; 32]);
915 let projection = identity.project_at_context(b"context-under-attack", &[0xa5u8; 32]);
916
917 // Same free choices as the SAAP forgery: zero commitment, zero response,
918 // challenge recomputed the way the verifier will recompute it.
919 let commitment_w = [Poly::zero(); crate::plp::MODULE_K];
920 let challenge_c = crate::plp::hash_to_challenge(
921 &commitment_w,
922 &projection.public_b,
923 &projection.tau,
924 &projection.salt,
925 );
926 let proof = ZkIdentityProof {
927 commitment_w,
928 challenge_c,
929 response_z: [Poly::zero(); crate::plp::MODULE_K],
930 };
931
932 assert!(
933 !Verifier::verify(&projection, &proof),
934 "PLP accepted the same style of forgery SAAP accepts — in that case the \
935 forgery helper is what is wrong, not the SAAP verifier, and the SAAP \
936 finding must be re-derived before it is reported."
937 );
938 }
939
940 // ── The corrected verifier ────────────────────────────────────────────────────
941
942 const TAU: &[u8] = b"context-alpha";
943 const CREDENTIAL: &[u8] = b"attribute-block-0123456789abcdef";
944
945 fn test_secret_key() -> VectorK {
946 let mut sk = VectorK::zero();
947 for k in 0..MODULE_K {
948 for n in 0..RING_N {
949 sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
950 }
951 }
952 sk
953 }
954
955 /// The load-bearing test: an honestly generated proof must satisfy
956 /// `A_τ · z - c · t == w`. If this fails, the verification equation is wrong and
957 /// nothing built on it can be trusted.
958 #[test]
959 fn honest_proof_satisfies_the_verification_equation() {
960 let sk = test_secret_key();
961 let proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
962 .expect("honest parameters: 16 rejections is negligible");
963 let public_key = saap_public_key(TAU, &sk);
964
965 assert_eq!(
966 verify_saap_proof_against(&proof, TAU, &public_key),
967 Ok(()),
968 "an honestly generated proof failed the verification equation"
969 );
970 }
971
972 #[test]
973 fn corrected_verifier_rejects_the_forgery() {
974 let sk = test_secret_key();
975 let public_key = saap_public_key(TAU, &sk);
976 let forged = forge_for(TAU, 0b0000_0011);
977
978 assert!(
979 verify_saap_proof_against(&forged, TAU, &public_key).is_err(),
980 "the corrected verifier still accepts a proof forged with no secret key"
981 );
982 }
983
984 #[test]
985 fn corrected_verifier_rejects_a_proof_from_another_context() {
986 let sk = test_secret_key();
987 let proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
988 .expect("honest parameters: 16 rejections is negligible");
989
990 // Same identity, different context: derive the public key for the context
991 // the verifier actually cares about.
992 let other_tau: &[u8] = b"context-beta";
993 let public_key_beta = saap_public_key(other_tau, &sk);
994
995 assert!(
996 verify_saap_proof_against(&proof, other_tau, &public_key_beta).is_err(),
997 "a proof issued for one context verified under another"
998 );
999 }
1000
1001 #[test]
1002 fn corrected_verifier_rejects_a_tampered_response() {
1003 let sk = test_secret_key();
1004 let mut proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
1005 .expect("honest parameters: 16 rejections is negligible");
1006 let public_key = saap_public_key(TAU, &sk);
1007
1008 proof.z.vec[0].coeffs[0] = proof.z.vec[0].coeffs[0].wrapping_add(1);
1009
1010 assert!(
1011 verify_saap_proof_against(&proof, TAU, &public_key).is_err(),
1012 "a tampered response vector still verified"
1013 );
1014 }
1015
1016 /// SAAP-SPEC.md §7 step 3 requires the Fiat-Shamir challenge to bind the
1017 /// disclosed attribute values:
1018 ///
1019 /// ```text
1020 /// c' = Hash(W_1' ∥ W_2' ∥ b_τ ∥ t_blind ∥ m_pub ∥ τ)
1021 /// ```
1022 ///
1023 /// `recompute_challenge` hashes only `(commitment_w, τ, disclosure_mask)`. The
1024 /// disclosed values ride in the transcript as `proof.attributes` and never enter
1025 /// the challenge, so they can be rewritten after the fact.
1026 ///
1027 /// This is a gap in the corrected verifier too — fixing the verification
1028 /// equation did not fix attribute binding, because the two are independent.
1029 #[test]
1030 fn disclosed_attributes_are_bound_into_the_challenge() {
1031 let sk = test_secret_key();
1032 let mut proof = saap_prove(CREDENTIAL, 0b0000_0011, TAU, &sk, &[0x7cu8; 32])
1033 .expect("honest parameters: 16 rejections is negligible");
1034 let public_key = saap_public_key(TAU, &sk);
1035
1036 // Sanity: the untampered proof verifies.
1037 assert_eq!(
1038 verify_saap_proof_against(&proof, TAU, &public_key),
1039 Ok(()),
1040 "test setup: the honest proof should verify before tampering"
1041 );
1042
1043 // Rewrite a disclosed attribute value. Nothing else is touched.
1044 let original = proof.attributes.values[0];
1045 proof.attributes.values[0] = original.wrapping_add(0xDEAD_BEEF);
1046
1047 assert!(
1048 verify_saap_proof_against(&proof, TAU, &public_key).is_err(),
1049 "a disclosed attribute value was rewritten after proof generation and the \
1050 proof still verified. SAAP-SPEC.md §7 step 3 binds m_pub into the \
1051 challenge; recompute_challenge does not. The verifier therefore attests \
1052 to attribute values the prover never committed to, which is the property \
1053 SAAP exists to provide."
1054 );
1055 }
1056
1057 /// Same construction as `forge`, parameterised by τ so it can be aimed at the
1058 /// corrected verifier.
1059 fn forge_for(tau: &[u8], disclosure_mask: u64) -> SaapProof {
1060 let mut context_tag = [0u8; 32];
1061 let len = tau.len().min(32);
1062 context_tag[..len].copy_from_slice(&tau[..len]);
1063 forge(context_tag, disclosure_mask)
1064 }
1065
1066 /// Characterises the second defect in the deprecated verifier: it takes no
1067 /// caller-supplied τ at all, so a proof certifies its own context. Asserted as
1068 /// known-bad for the same reason as above.
1069 #[test]
1070 #[allow(deprecated)]
1071 fn deprecated_verifier_is_self_certifying_known_defect() {
1072 let matrix_a = [VectorK::zero(); MODULE_K];
1073 let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
1074
1075 let proof = forge([0xAAu8; 32], 0b0000_0001);
1076
1077 assert!(
1078 verify_saap_proof(&proof, &matrix_a, &attr_commits).is_ok(),
1079 "the deprecated verifier has no τ parameter, so it cannot answer 'is this \
1080 valid for MY context?' — it accepts whatever context the proof asserts \
1081 about itself. The WIT world declares \
1082 saap-verify(proof, tau) -> result<bool, identity-error>."
1083 );
1084 }
1085
1086 /// Fresh randomness changes the proof commitment; the same randomness reproduces
1087 /// it. The commitment `w = A·r` moves with `r`, so it carries per-call entropy.
1088 #[test]
1089 fn saap_mask_is_fresh_per_rho_and_reproducible_given_rho() {
1090 let sk = test_sk();
1091 // A fixed array, not a Vec: this module is no_std and `alloc` is not
1092 // imported here. The wasm job builds test targets with
1093 // --no-default-features, so a Vec here fails only there.
1094 let credential: [u8; 64] = core::array::from_fn(|i| i as u8);
1095 let tau = b"verifier-session-tau-0001";
1096
1097 let p_a1 = saap_prove(&credential, 0b0000_0011, tau, &sk, &RHO_A)
1098 .expect("honest parameters: 16 rejections is negligible");
1099 let p_a2 = saap_prove(&credential, 0b0000_0011, tau, &sk, &RHO_A)
1100 .expect("honest parameters: 16 rejections is negligible");
1101 let p_b = saap_prove(&credential, 0b0000_0011, tau, &sk, &RHO_B)
1102 .expect("honest parameters: 16 rejections is negligible");
1103
1104 let commitments_equal = |x: &SaapProof, y: &SaapProof| {
1105 (0..MODULE_K).all(|i| {
1106 (0..RING_N).all(|n| x.commitment_w.vec[i].coeffs[n] == y.commitment_w.vec[i].coeffs[n])
1107 })
1108 };
1109
1110 assert!(
1111 commitments_equal(&p_a1, &p_a2),
1112 "same rho must reproduce the same commitment"
1113 );
1114 assert!(
1115 !commitments_equal(&p_a1, &p_b),
1116 "different rho must change the commitment — r carries per-call entropy"
1117 );
1118 }
1119
1120 /// Proofs of one credential under one τ disclosing different attribute sets,
1121 /// each with its own fresh `rho`, carry independent masks — their commitments
1122 /// differ, so nothing links the two presentations through a shared mask.
1123 #[test]
1124 fn saap_distinct_rho_gives_distinct_masks_across_disclosures() {
1125 let sk = test_sk();
1126 // A fixed array, not a Vec: this module is no_std and `alloc` is not
1127 // imported here. The wasm job builds test targets with
1128 // --no-default-features, so a Vec here fails only there.
1129 let credential: [u8; 64] = core::array::from_fn(|i| i as u8);
1130 let tau = b"verifier-session-tau-0001";
1131
1132 let p1 = saap_prove(&credential, 0b0000_0001, tau, &sk, &RHO_A)
1133 .expect("honest parameters: 16 rejections is negligible");
1134 let p2 = saap_prove(&credential, 0b0000_0010, tau, &sk, &RHO_B)
1135 .expect("honest parameters: 16 rejections is negligible");
1136
1137 let mut any_diff = false;
1138 for i in 0..MODULE_K {
1139 for n in 0..RING_N {
1140 if p1.commitment_w.vec[i].coeffs[n] != p2.commitment_w.vec[i].coeffs[n] {
1141 any_diff = true;
1142 }
1143 }
1144 }
1145 assert!(
1146 any_diff,
1147 "commitments must differ across proofs — masks are independent per rho"
1148 );
1149 }
1150
1151}
1152
1153// ── P3-03: Debug/Display redaction, asserted at compile time ────────────────
1154//
1155// `VectorK` is the type `saap_prove`'s `secret_key` parameter uses to carry a
1156// raw SAAP secret, and `Polynomial` is its component type, so neither may
1157// implement `Debug`/`Display`: a derive on either would let anyone holding a
1158// value format-print the raw secret with a single `{:?}`.
1159//
1160// `assert_not_impl_any!` expands to code that compiles only if the named type
1161// does NOT implement the named traits, so re-adding a naive `#[derive(Debug)]`
1162// breaks the build rather than a test run. These assertions used to live in
1163// `tests/no_debug_leak.rs`; they moved here when the module became crate-private
1164// and an integration test could no longer name these types.
1165#[cfg(test)]
1166mod no_debug_leak {
1167 use static_assertions::assert_not_impl_any;
1168
1169 assert_not_impl_any!(super::Polynomial: core::fmt::Debug, core::fmt::Display);
1170 assert_not_impl_any!(super::VectorK: core::fmt::Debug, core::fmt::Display);
1171}
1172
1173// ── Prove/verify round-trip, moved from tests/plp_tests.rs ──────────────────
1174//
1175// Same reason as the blocks above: this module is crate-private since
1176// P3-10 / 0X3-78, so these can no longer live in an integration test.
1177#[cfg(test)]
1178#[allow(deprecated)]
1179mod roundtrip_tests {
1180 use super::*;
1181
1182
1183
1184 /// Verify that the SAAP prover produces a proof that passes norm check.
1185 #[test]
1186 fn test_saap_prove_norm_bound() {
1187 let mut sk = VectorK::zero();
1188 for k in 0..MODULE_K {
1189 for n in 0..RING_N {
1190 sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
1191 }
1192 }
1193 let credential = [0u8; 64];
1194 let disclosure_mask = 0b00001111u64;
1195 let tau = b"test_saap_context";
1196
1197 let proof = saap_prove(&credential, disclosure_mask, tau, &sk, &[0x7cu8; 32])
1198 .expect("honest parameters: 16 rejections is negligible");
1199
1200 // Norm bound check
1201 let norm_result = verify_response_norm(&proof.z);
1202 assert_eq!(norm_result, 0, "SAAP response norm should be within bound");
1203 }
1204
1205 /// Verify that the SAAP verifier accepts a well-formed proof.
1206 #[test]
1207 fn test_saap_prove_verify_roundtrip() {
1208 let mut sk = VectorK::zero();
1209 for k in 0..MODULE_K {
1210 for n in 0..RING_N {
1211 sk.vec[k].coeffs[n] = (n % 5) as i32 - 2;
1212 }
1213 }
1214 let credential = [0u8; 64];
1215 let disclosure_mask = 0b00001111u64;
1216 let tau = b"test_saap_context_verify";
1217
1218 let proof = saap_prove(&credential, disclosure_mask, tau, &sk, &[0x7cu8; 32])
1219 .expect("honest parameters: 16 rejections is negligible");
1220
1221 // Build dummy matrix and attribute commitments
1222 let matrix_a = [VectorK::zero(); MODULE_K];
1223 let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
1224
1225 let result = verify_saap_proof(&proof, &matrix_a, &attr_commits);
1226 assert!(result.is_ok(), "SAAP verify should accept valid proof: {:?}", result);
1227 }
1228
1229 /// Verify that the SAAP verifier rejects a proof with an out-of-bounds response norm.
1230 #[test]
1231 fn test_saap_verify_rejects_invalid_norm() {
1232 let mut proof = SaapProof::zero();
1233 // Set a coefficient way out of bounds
1234 proof.z.vec[0].coeffs[0] = REJECTION_BOUND + 1000;
1235
1236 let matrix_a = [VectorK::zero(); MODULE_K];
1237 let attr_commits = [Polynomial::zero(); MAX_ATTRIBUTES];
1238
1239 let result = verify_saap_proof(&proof, &matrix_a, &attr_commits);
1240 assert_eq!(
1241 result,
1242 Err(SaapValidationError::NormBoundViolation),
1243 "Should reject proof with out-of-bounds norm"
1244 );
1245 }
1246
1247
1248
1249 // ── NTT fast path equivalence (Q3 Fix 2) ─────────────────────────────────
1250
1251 /// A deterministic pseudo-random polynomial with full-range coefficients.
1252 ///
1253 /// Small or structured inputs would not exercise the modular reduction, and
1254 /// the schoolbook path accumulates differently from the NTT path, so the
1255 /// coefficients must span the whole centered range for the comparison to be
1256 /// worth anything.
1257 fn pseudo_random_poly(seed: u64) -> Polynomial {
1258 let mut p = Polynomial::zero();
1259 let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
1260 for i in 0..RING_N {
1261 x ^= x << 13;
1262 x ^= x >> 7;
1263 x ^= x << 17;
1264 p.coeffs[i] = mod_q((x % (PARAM_Q as u64 * 2)) as i64 - PARAM_Q as i64);
1265 }
1266 p
1267 }
1268
1269 /// The NTT fast path must agree with the schoolbook reference exactly.
1270 ///
1271 /// This is the whole contract of `poly_mul_negacyclic` after Q3 Fix 2. A
1272 /// faster multiply that returns different answers is not an optimisation,
1273 /// it is a silent break in every SAAP proof and verification, all of which
1274 /// route through this one function.
1275 #[test]
1276 fn ntt_and_schoolbook_agree_over_saap_polynomials() {
1277 for seed in 0..24u64 {
1278 let a = pseudo_random_poly(seed * 2 + 1);
1279 let b = pseudo_random_poly(seed * 2 + 2);
1280
1281 let fast = poly_mul_negacyclic(&a, &b);
1282 let reference = poly_mul_schoolbook(&a, &b);
1283
1284 assert_eq!(
1285 fast.coeffs, reference.coeffs,
1286 "NTT and schoolbook disagree at seed {seed}"
1287 );
1288 }
1289 }
1290
1291 /// Positive control for the test above.
1292 ///
1293 /// If `pseudo_random_poly` returned the same polynomial for every seed, or
1294 /// the comparison were vacuous, the equivalence test would pass while
1295 /// proving nothing. Distinct operands must produce distinct products.
1296 #[test]
1297 fn the_multiplication_agreement_check_can_fail() {
1298 let a = pseudo_random_poly(1);
1299 let b = pseudo_random_poly(2);
1300 let c = pseudo_random_poly(3);
1301
1302 assert_ne!(a.coeffs, b.coeffs, "operand generator is not varying");
1303 assert_ne!(
1304 poly_mul_negacyclic(&a, &b).coeffs,
1305 poly_mul_negacyclic(&a, &c).coeffs,
1306 "different operands produced the same product, so agreement proves nothing"
1307 );
1308 }
1309
1310 /// The centered/unsigned bridge must round trip.
1311 ///
1312 /// SAAP centers coefficients in (-q/2, q/2]; PLP uses [0, q). Every SAAP
1313 /// multiplication now crosses that boundary twice, so an error here would
1314 /// corrupt results in a way that looks like a cryptographic failure rather
1315 /// than a representation bug.
1316 #[test]
1317 fn the_centered_to_unsigned_bridge_round_trips() {
1318 for seed in 0..8u64 {
1319 let p = pseudo_random_poly(seed + 100);
1320 let bridged = to_plp_poly(&p);
1321 for i in 0..RING_N {
1322 assert!(
1323 bridged.coeffs[i] < PARAM_Q as u32,
1324 "bridged coefficient outside [0, q)"
1325 );
1326 assert_eq!(
1327 mod_q(bridged.coeffs[i] as i64),
1328 p.coeffs[i],
1329 "round trip changed coefficient {i} at seed {seed}"
1330 );
1331 }
1332 }
1333 }
1334}
1335
1336
1337// ── Rejection sampling has no fallback (AETHEL-F-01, saap half) ──────────────
1338//
1339// The `plp.rs` half of this finding was fixed in 0b24bc0. This module is the
1340// evidence for the same property here.
1341#[cfg(test)]
1342mod rejection_sampling_tests {
1343 use super::*;
1344
1345 fn test_sk() -> VectorK {
1346 let mut sk = VectorK::zero();
1347 for k in 0..MODULE_K {
1348 for n in 0..RING_N {
1349 sk.vec[k].coeffs[n] = ((n + k) % 5) as i32 - 2;
1350 }
1351 }
1352 sk
1353 }
1354
1355 /// Every proof `saap_prove` returns satisfies the norm bound.
1356 ///
1357 /// This is the invariant the fallback broke. It re-derived iteration 0's
1358 /// mask, recomputed iteration 0's `w`, `c` and `z`, and returned that `z`
1359 /// without re-checking the bound — so the one response the function was
1360 /// certain to be out of bounds was the one it emitted on exhaustion.
1361 ///
1362 /// Swept across contexts and randomness rather than asserted once, because
1363 /// a single honest call proves only that the happy path works.
1364 #[test]
1365 fn every_returned_proof_satisfies_the_norm_bound() {
1366 let sk = test_sk();
1367 let credential = [0x3Cu8; 64];
1368
1369 for tau_idx in 0u8..12 {
1370 for rho_idx in 0u8..4 {
1371 let tau = [b'c', b't', b'x', tau_idx];
1372 let rho = [0x40u8 ^ rho_idx; 32];
1373
1374 let proof = saap_prove(&credential, 0b0000_0011, &tau, &sk, &rho)
1375 .expect("honest parameters: 16 rejections is negligible");
1376
1377 assert_eq!(
1378 verify_response_norm(&proof.z),
1379 0,
1380 "saap_prove returned a response outside the rejection bound \
1381 at tau {tau_idx}, rho {rho_idx}"
1382 );
1383 }
1384 }
1385 }
1386
1387 /// Positive control for the sweep above.
1388 ///
1389 /// Without this, the sweep would pass just as happily against a norm check
1390 /// that accepted everything, which would make it evidence of nothing.
1391 #[test]
1392 fn the_norm_check_rejects_an_out_of_bound_response() {
1393 let sk = test_sk();
1394 let credential = [0x3Cu8; 64];
1395 let mut proof = saap_prove(&credential, 0b0000_0011, b"ctx", &sk, &[0x41u8; 32])
1396 .expect("honest parameters: 16 rejections is negligible");
1397
1398 assert_eq!(verify_response_norm(&proof.z), 0, "test setup: an honest proof is in bounds");
1399
1400 proof.z.vec[0].coeffs[0] = REJECTION_BOUND;
1401 assert_ne!(
1402 verify_response_norm(&proof.z),
1403 0,
1404 "a response at the rejection bound was accepted, so the sweep above \
1405 is not testing anything"
1406 );
1407 }
1408
1409 /// Nothing may re-derive the mask after the loop.
1410 ///
1411 /// The old fallback was `sample_saap_masking_vector(rho, &context_tag, 0)`.
1412 /// Nonce zero is iteration 0's nonce, and the derivation is a pure function
1413 /// of `(rho, context_tag, nonce)`, so that call reproduced iteration 0's
1414 /// mask exactly and the proof rebuilt from it was, byte for byte, the
1415 /// response iteration 0 had already rejected.
1416 ///
1417 /// A behavioural test cannot reach this: forcing 16 consecutive rejections
1418 /// at honest parameters is exactly the negligible event the fallback was
1419 /// excused by. So the derivation itself is pinned instead, the same way
1420 /// `plp.rs` pins `no_tau_independent_mask_derivation_remains`.
1421 #[test]
1422 fn no_fallback_mask_derivation_remains() {
1423 let source = include_str!("saap.rs");
1424
1425 // Split across `concat!` so the needle never appears contiguously in
1426 // this file, which would otherwise make the test match itself. Comment
1427 // lines are skipped for the same reason: the doc comment above names
1428 // the old call on purpose.
1429 let needle = concat!("sample_saap_masking_vector(rho, &context_tag, ", "0)");
1430 let hits = source
1431 .lines()
1432 .filter(|l| !l.trim_start().starts_with("//"))
1433 .filter(|l| l.contains(needle))
1434 .count();
1435
1436 assert_eq!(
1437 hits, 0,
1438 "the fallback mask derivation is back in saap_prove. Nonce 0 is \
1439 iteration 0's nonce and the derivation is pure, so it reproduces \
1440 iteration 0's mask and rebuilds, byte for byte, the response \
1441 iteration 0 already rejected. That is precisely the value rejection \
1442 sampling exists to withhold. See AETHEL-F-01 and 0b24bc0 before \
1443 restoring it."
1444 );
1445 }
1446
1447 /// Exhaustion is reported, not papered over.
1448 ///
1449 /// Pins the signature: a caller must be able to tell "no proof exists" from
1450 /// "here is a proof", which the previous `-> SaapProof` return type made
1451 /// impossible.
1452 #[test]
1453 fn exhaustion_has_an_error_to_report() {
1454 let sk = test_sk();
1455 let proof: Result<SaapProof, IdentityError> =
1456 saap_prove(&[0x3Cu8; 64], 0b0000_0011, b"ctx", &sk, &[0x42u8; 32]);
1457 assert!(proof.is_ok(), "honest parameters must still produce a proof");
1458
1459 // The variant the exhausted path returns, matching plp::Prover::
1460 // prove_identity and credential::prove.
1461 assert_eq!(
1462 IdentityError::from(crate::sampling::RejectionError::AllIterationsRejected),
1463 IdentityError::RejectionSamplingFailed
1464 );
1465 }
1466}