Skip to main content

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}