Skip to main content

aethel_core/
sampling.rs

1//! # Enclave Constant-Time Rejection Sampling Module
2//!
3//! This module provides two complete implementations:
4//!
5//! 1. **Enclave Constant-Time Rejection Sampling** (`#![no_std]`, written to be bare-metal
6//!    enclave-compatible, though nothing in this repo runs it inside an actual hardware
7//!    enclave/TEE, and it has had no independent audit; see the README's audit
8//!    status): constant-time rejection sampling enforcing a 16-iteration padded
9//!    execution loop, constant-time norm checking, bitwise CMOV selection, and volatile
10//!    memory zeroization via compiler barriers.
11//!
12//! 2. **M-LWE Centered Binomial Distribution Sampler (CBD η=2)**: Constant-time
13//!    Centered Binomial Distribution polynomial sampler for Module-LWE ring
14//!    `R_q = Z_q[X]/(X^256 + 1)`, including infinity norm checking for rejection
15//!    sampling.
16//!
17//! ## Key Structures
18//!
19//! - [`Polynomial`](crate::sampling::Polynomial) — Cache-line-aligned ring element (64-byte aligned)
20//! - [`VectorK`](crate::sampling::VectorK) — k-dimensional module vector
21//! - [`PlpProof`](crate::sampling::PlpProof) — Output proof structure with response vector and iteration counter
22//! - [`RejectionError`](crate::sampling::RejectionError) — Error type for exhausted iteration ceiling
23//! - [`PolyRq`](crate::sampling::PolyRq) — Ring polynomial for the CBD sampler; `Copy`, so zeroization
24//!   is on-demand via `Zeroize::zeroize()`, not automatic on drop
25//!
26//! ## Key Functions
27//!
28//! - [`enclave_explicit_zeroize`](crate::sampling::enclave_explicit_zeroize) — Volatile memory zeroization with compiler barrier
29//! - [`ct_check_norm_bound`](crate::sampling::ct_check_norm_bound) — Constant-time infinity norm bound check
30//! - [`ct_cond_copy`](crate::sampling::ct_cond_copy) — Constant-time conditional byte copy (CMOV equivalent)
31//! - [`enclave_plp_prove_fixed_time`](crate::sampling::enclave_plp_prove_fixed_time) — Fixed 16-iteration padded proof generation
32//! - [`poly_cbd_eta2`](crate::sampling::poly_cbd_eta2) — CBD η=2 polynomial sampler
33//! - [`poly_check_infinity_norm`](crate::sampling::poly_check_infinity_norm) — Constant-time infinity norm check
34//!
35//! ## Parameters
36//!
37//! - RING_N=256, MODULE_K=4, PARAM_Q=8_380_417
38//! - PARAM_GAMMA1=131_072, PARAM_BETA=78
39//! - FIXED_ITERATION_CEILING=16
40
41// Constant-time code: its index loops are left exactly as written, since an
42// iterator rewrite can change the generated code this module relies on.
43#![allow(clippy::needless_range_loop)]
44
45// ── Enclave Constant-Time Rejection Sampling (aethel-enclave-plp) ────────────
46
47use core::sync::atomic::{compiler_fence, Ordering};
48use sha3::{Shake256, digest::{Update, ExtendableOutput, XofReader}};
49
50/// Ring degree `N`.
51pub const RING_N: usize = 256;
52/// Module rank `k`.
53pub const MODULE_K: usize = 4;
54/// Prime modulus `q`.
55pub const PARAM_Q: i32 = 8_380_417;
56/// Masking bound `γ₁`.
57pub const PARAM_GAMMA1: i32 = 131_072;
58/// Rejection margin `β`.
59pub const PARAM_BETA: i32 = 78;
60/// `γ₁ − β`: a response is released only if every coefficient is below this.
61pub const REJECTION_BOUND: i32 = PARAM_GAMMA1 - PARAM_BETA;
62/// Iterations the fixed-time prover always runs, whichever one is accepted.
63pub const FIXED_ITERATION_CEILING: usize = 16;
64
65/// Cache-line-aligned polynomial in R_q.
66#[derive(Copy, Clone)]
67#[repr(align(64))]
68pub struct Polynomial {
69    /// Coefficients, lowest degree first.
70    pub coeffs: [i32; RING_N],
71}
72
73impl Polynomial {
74    /// The zero polynomial.
75    pub const fn zero() -> Self {
76        Self { coeffs: [0i32; RING_N] }
77    }
78}
79
80/// k-dimensional module vector.
81#[derive(Copy, Clone)]
82#[repr(align(64))]
83pub struct VectorK {
84    /// The `k` polynomials.
85    pub vec: [Polynomial; MODULE_K],
86}
87
88impl VectorK {
89    /// The zero vector.
90    pub const fn zero() -> Self {
91        Self { vec: [Polynomial { coeffs: [0i32; RING_N] }; MODULE_K] }
92    }
93}
94
95/// Output proof structure.
96#[derive(Copy, Clone)]
97#[repr(C)]
98pub struct PlpProof {
99    /// The response vector `z`.
100    pub z: VectorK,
101    /// The iteration whose response was accepted.
102    pub iteration_counter: u32,
103}
104
105impl PlpProof {
106    /// An all-zero proof, used as the output buffer before any iteration runs.
107    pub const fn zero() -> Self {
108        Self {
109            z: VectorK { vec: [Polynomial { coeffs: [0; RING_N] }; MODULE_K] },
110            iteration_counter: 0,
111        }
112    }
113}
114
115// `Polynomial`/`VectorK`/`PlpProof` are `Copy`, so none of them can implement
116// `Drop` — meaning no `ZeroizeOnDrop`. They already have a correct, audited,
117// constant-time volatile-write zeroization path in `enclave_explicit_zeroize`
118// (used by `enclave_plp_prove_fixed_time` below); these impls give that same
119// path a standard `zeroize::Zeroize` interface, matching the rest of the
120// crate (P3-08), without introducing new `unsafe` code or changing behavior.
121
122impl zeroize::Zeroize for Polynomial {
123    fn zeroize(&mut self) {
124        enclave_explicit_zeroize(self);
125    }
126}
127
128impl zeroize::Zeroize for VectorK {
129    fn zeroize(&mut self) {
130        enclave_explicit_zeroize(self);
131    }
132}
133
134impl zeroize::Zeroize for PlpProof {
135    fn zeroize(&mut self) {
136        enclave_explicit_zeroize(self);
137    }
138}
139
140/// Volatile memory zeroization with compiler barrier.
141pub fn enclave_explicit_zeroize<T: Sized>(data: &mut T) {
142    let ptr = data as *mut T as *mut u8;
143    let len = core::mem::size_of::<T>();
144    // SAFETY: `ptr` is derived from `&mut T`, so it is non-null, aligned, and valid for
145    // `len = size_of::<T>()` bytes for the duration of this loop; `0..len` never advances
146    // past that allocation. Byte-wise writes need no alignment beyond `u8`. `write_volatile`
147    // (rather than a plain store) and the `compiler_fence` below prevent the compiler from
148    // eliding or reordering this zeroization around the secret's last use.
149    unsafe {
150        for i in 0..len {
151            core::ptr::write_volatile(ptr.add(i), 0u8);
152        }
153    }
154    compiler_fence(Ordering::SeqCst);
155}
156
157#[inline(always)]
158fn ct_abs(coeff: i32) -> i32 {
159    let mask = coeff >> 31;
160    (coeff + mask) ^ mask
161}
162
163#[inline(always)]
164fn ct_is_out_of_bounds(coeff: i32, bound: i32) -> u32 {
165    let abs_coeff = ct_abs(coeff);
166    let diff = (bound - 1) - abs_coeff;
167    ((diff >> 31) & 1) as u32 * 0xFFFFFFFF
168}
169
170/// Constant-time infinity norm bound check.
171///
172/// Returns 0 if all coefficients satisfy |coeff| < bound,
173/// or 0xFFFFFFFF if any coefficient violates the bound.
174pub fn ct_check_norm_bound(z: &VectorK, bound: i32) -> u32 {
175    let mut bad_coeff_mask: u32 = 0;
176    for k in 0..MODULE_K {
177        for n in 0..RING_N {
178            let coeff = z.vec[k].coeffs[n];
179            bad_coeff_mask |= ct_is_out_of_bounds(coeff, bound);
180        }
181    }
182    bad_coeff_mask
183}
184
185/// Constant-time conditional byte copy (CMOV equivalent).
186///
187/// If mask == 0xFFFFFFFF: `dst[i] = src[i]`
188/// If mask == 0x00000000: `dst[i]` unchanged
189#[inline(always)]
190pub fn ct_cond_copy(dst: &mut [u8], src: &[u8], mask: u32) {
191    let mask_u8 = mask as u8;
192    for i in 0..dst.len() {
193        dst[i] ^= mask_u8 & (src[i] ^ dst[i]);
194    }
195}
196
197/// Fixed 16-iteration padded proof generation.
198///
199/// Executes exactly FIXED_ITERATION_CEILING iterations regardless of when
200/// a valid candidate is found. Uses constant-time CMOV selection to capture
201/// the first valid proof without timing leaks.
202pub fn enclave_plp_prove_fixed_time(
203    proof_out: &mut PlpProof,
204    s: &VectorK,
205    tau: &[u8; 32],
206) -> Result<(), RejectionError> {
207    let mut proof_captured_mask: u32 = 0;
208    let mut candidate_z = VectorK::zero();
209    let mut candidate_proof = PlpProof::zero();
210    let mut dummy_buffer = PlpProof::zero();
211
212    for iter in 0..FIXED_ITERATION_CEILING {
213        generate_candidate_z(&mut candidate_z, s, tau, iter);
214        candidate_proof.z = candidate_z;
215        candidate_proof.iteration_counter = iter as u32;
216        let reject_mask = ct_check_norm_bound(&candidate_z, REJECTION_BOUND);
217        let capture_mask = (!reject_mask) & (!proof_captured_mask);
218        // SAFETY: `candidate_proof` is a live local of type `PlpProof`, so the pointer is
219        // non-null, aligned, and valid for exactly `size_of::<PlpProof>()` bytes — `PlpProof`
220        // is `#[repr(C)]` with no padding-sensitive invariants, so reinterpreting it as a
221        // byte slice is sound. The slice only borrows for this statement and is read-only.
222        let cand_bytes = unsafe {
223            core::slice::from_raw_parts(
224                &candidate_proof as *const PlpProof as *const u8,
225                core::mem::size_of::<PlpProof>(),
226            )
227        };
228        // SAFETY: `proof_out` is the `&mut PlpProof` this function was called with, valid
229        // and uniquely borrowed for `size_of::<PlpProof>()` bytes; it aliases neither
230        // `candidate_proof` nor `dummy_buffer` (distinct objects), which is what lets the
231        // read of `cand_bytes` above and the writes below coexist safely.
232        let target_bytes = unsafe {
233            core::slice::from_raw_parts_mut(
234                proof_out as *mut PlpProof as *mut u8,
235                core::mem::size_of::<PlpProof>(),
236            )
237        };
238        // SAFETY: `dummy_buffer` is a live local of type `PlpProof`, distinct from
239        // `candidate_proof` and `*proof_out`; same size/alignment/no-alias reasoning as
240        // `target_bytes` above. Its only purpose is to receive the discarded ct_cond_copy
241        // branch so both branches take equal time.
242        let dummy_bytes = unsafe {
243            core::slice::from_raw_parts_mut(
244                &mut dummy_buffer as *mut PlpProof as *mut u8,
245                core::mem::size_of::<PlpProof>(),
246            )
247        };
248        ct_cond_copy(target_bytes, cand_bytes, capture_mask);
249        ct_cond_copy(dummy_bytes, cand_bytes, !capture_mask);
250        proof_captured_mask |= capture_mask;
251    }
252
253    enclave_explicit_zeroize(&mut candidate_z);
254    enclave_explicit_zeroize(&mut candidate_proof);
255    enclave_explicit_zeroize(&mut dummy_buffer);
256
257    if proof_captured_mask != 0 {
258        Ok(())
259    } else {
260        enclave_explicit_zeroize(proof_out);
261        Err(RejectionError::AllIterationsRejected)
262    }
263}
264
265/// Error type for exhausted iteration ceiling.
266#[derive(Debug, Copy, Clone, PartialEq, Eq)]
267pub enum RejectionError {
268    /// Every iteration was rejected, so no response is released.
269    AllIterationsRejected,
270}
271
272// ── Fiat-Shamir sigma protocol step ──────────────────────────────────────────
273
274/// Sample a masking vector y from uniform [-γ₁, γ₁]^k using SHAKE-256.
275///
276/// y ← SHAKE-256("AETHEL_MASK_V1" ∥ seed ∥ nonce)
277fn sample_masking_vector(seed: &[u8; 32], nonce: u8) -> VectorK {
278    let mut hasher = Shake256::default();
279    hasher.update(b"AETHEL_MASK_V1");
280    hasher.update(seed);
281    hasher.update(&[nonce]);
282    let mut xof = hasher.finalize_xof();
283
284    let mut y = VectorK::zero();
285    let range = 2 * PARAM_GAMMA1 as u32 + 1; // 262145
286
287    for k in 0..MODULE_K {
288        let mut coeff_idx = 0usize;
289        while coeff_idx < RING_N {
290            let mut buf = [0u8; 3];
291            xof.read(&mut buf);
292            let val = (buf[0] as u32)
293                | ((buf[1] as u32) << 8)
294                | ((buf[2] as u32 & 0x7F) << 16);
295            if val < range {
296                // Center: val in [0, 2*γ₁] → coeff in [-γ₁, γ₁]
297                let centered = val as i32 - PARAM_GAMMA1;
298                y.vec[k].coeffs[coeff_idx] = centered;
299                coeff_idx += 1;
300            }
301        }
302    }
303    y
304}
305
306/// Derive the context matrix A_τ row for a given (tau, row_idx) using SHAKE-256.
307///
308/// A_τ[row] ← SHAKE-256("AETHEL_PLP_CTX_V1" ∥ tau ∥ row_idx)
309fn derive_context_row(tau: &[u8; 32], row_idx: usize) -> [Polynomial; MODULE_K] {
310    let mut row = [Polynomial::zero(); MODULE_K];
311    for col in 0..MODULE_K {
312        let mut hasher = Shake256::default();
313        hasher.update(b"AETHEL_PLP_CTX_V1");
314        hasher.update(tau);
315        hasher.update(&[row_idx as u8, col as u8]);
316        let mut xof = hasher.finalize_xof();
317
318        let mut coeff_idx = 0usize;
319        while coeff_idx < RING_N {
320            let mut buf = [0u8; 3];
321            xof.read(&mut buf);
322            let val = (buf[0] as u32)
323                | ((buf[1] as u32) << 8)
324                | ((buf[2] as u32 & 0x7F) << 16);
325            if (val as i32) < PARAM_Q {
326                // Store as centered representative
327                let centered = if val > (PARAM_Q as u32 / 2) {
328                    val as i32 - PARAM_Q
329                } else {
330                    val as i32
331                };
332                row[col].coeffs[coeff_idx] = centered;
333                coeff_idx += 1;
334            }
335        }
336    }
337    row
338}
339
340/// Polynomial multiplication in R_q = Z_q[X]/(X^N + 1), schoolbook O(N²).
341/// Operates on centered i32 coefficients.
342fn poly_mul_centered(a: &Polynomial, b: &Polynomial) -> Polynomial {
343    let mut tmp = [0i64; 2 * RING_N];
344    for i in 0..RING_N {
345        for j in 0..RING_N {
346            tmp[i + j] += (a.coeffs[i] as i64) * (b.coeffs[j] as i64);
347        }
348    }
349    let mut res = Polynomial::zero();
350    let q = PARAM_Q as i64;
351    for i in 0..RING_N {
352        let v = (tmp[i] - tmp[i + RING_N]).rem_euclid(q);
353        // Center
354        res.coeffs[i] = if v > q / 2 { (v - q) as i32 } else { v as i32 };
355    }
356    res
357}
358
359/// Polynomial addition mod q (centered).
360fn poly_add_centered(a: &Polynomial, b: &Polynomial) -> Polynomial {
361    let mut res = Polynomial::zero();
362    let q = PARAM_Q as i64;
363    for i in 0..RING_N {
364        let v = ((a.coeffs[i] as i64) + (b.coeffs[i] as i64)).rem_euclid(q);
365        res.coeffs[i] = if v > q / 2 { (v - q) as i32 } else { v as i32 };
366    }
367    res
368}
369
370/// Hash-to-challenge: sparse ternary polynomial with exactly 60 ±1 coefficients.
371///
372/// c = HashToPoly(SHAKE-256("AETHEL_SAAP_CHALLENGE_V1" ∥ w ∥ tau))
373fn hash_to_challenge_sampling(w: &VectorK, tau: &[u8; 32]) -> Polynomial {
374    let mut hasher = Shake256::default();
375    hasher.update(b"AETHEL_SAAP_CHALLENGE_V1");
376    for k in 0..MODULE_K {
377        for n in 0..RING_N {
378            hasher.update(&w.vec[k].coeffs[n].to_le_bytes());
379        }
380    }
381    hasher.update(tau);
382    let mut xof = hasher.finalize_xof();
383
384    let mut c_poly = Polynomial::zero();
385
386    let mut signs = [0u8; 8];
387    xof.read(&mut signs);
388    let mut sign_bit = 0usize;
389
390    let mut count = 0usize;
391    let mut used = [false; RING_N];
392    let mut pos_buf = [0u8; 1];
393
394    while count < 60 {
395        xof.read(&mut pos_buf);
396        let pos = pos_buf[0] as usize;
397        if pos >= RING_N {
398            continue;
399        }
400        if used[pos] {
401            continue;
402        }
403        used[pos] = true;
404        let sign_byte = signs[sign_bit / 8];
405        let sign: i32 = if (sign_byte >> (sign_bit % 8)) & 1 == 0 { 1 } else { -1 };
406        sign_bit += 1;
407        if sign_bit >= 64 {
408            xof.read(&mut signs);
409            sign_bit = 0;
410        }
411        c_poly.coeffs[pos] = sign;
412        count += 1;
413    }
414    c_poly
415}
416
417/// Generate a candidate response vector z for iteration `iter`.
418///
419/// Full Fiat-Shamir sigma protocol step:
420/// 1. Sample masking vector y ← S_{γ₁}^k
421/// 2. Compute commitment w = A_τ · y
422/// 3. Compute challenge c = HashToPoly(w, τ)
423/// 4. Compute z = y + c · s
424/// 5. Rejection check: ||z||∞ ≤ γ₁ - β
425fn generate_candidate_z(z: &mut VectorK, s: &VectorK, tau: &[u8; 32], iter: usize) {
426    // 1. Sample masking vector y
427    let y = sample_masking_vector(tau, iter as u8);
428
429    // 2. Compute commitment w = A_τ · y (k×k matrix × k vector)
430    let mut w = VectorK::zero();
431    for i in 0..MODULE_K {
432        let row = derive_context_row(tau, i);
433        for j in 0..MODULE_K {
434            let prod = poly_mul_centered(&row[j], &y.vec[j]);
435            w.vec[i] = poly_add_centered(&w.vec[i], &prod);
436        }
437    }
438
439    // 3. Compute challenge c = HashToPoly(w, τ)
440    let c = hash_to_challenge_sampling(&w, tau);
441
442    // 4. Compute z = y + c · s
443    for k in 0..MODULE_K {
444        let cs_k = poly_mul_centered(&c, &s.vec[k]);
445        z.vec[k] = poly_add_centered(&y.vec[k], &cs_k);
446    }
447}
448
449// ── M-LWE Centered Binomial Distribution Sampler (CBD η=2) ───────────────────
450
451/// Ring degree for the CBD sampler.
452pub const N_DEGREE: usize = 256;
453
454/// Prime modulus for the CBD sampler.
455pub const Q_MODULUS: i32 = 8_380_417;
456
457/// Ring polynomial `R_q = Z_q[X]/(X^N + 1)`.
458///
459/// P3-08 correction: despite this type's original doc claim, it cannot be
460/// "zeroize-on-drop" — it derives `Copy`, and `Copy` and `Drop` are mutually
461/// exclusive in Rust. Callers must call `zeroize()` (via `zeroize::Zeroize`) explicitly.
462/// Note also: nothing in this crate currently constructs a `PolyRq` outside
463/// its own tests (`poly_cbd_eta2` has no caller elsewhere in the crate), so
464/// this zeroization has no live call site to exercise yet — implemented and
465/// documented now so it's correct and available the moment something uses it.
466#[derive(Copy, Clone)]
467pub struct PolyRq {
468    /// Coefficients, lowest degree first.
469    pub coeffs: [i32; N_DEGREE],
470}
471
472impl PolyRq {
473    /// Create a zero polynomial.
474    pub const fn zero() -> Self {
475        Self { coeffs: [0i32; N_DEGREE] }
476    }
477}
478
479impl zeroize::Zeroize for PolyRq {
480    /// Zeroize all coefficients using volatile writes.
481    fn zeroize(&mut self) {
482        for i in 0..N_DEGREE {
483            // SAFETY: `&mut self.coeffs[i]` is a valid, uniquely-borrowed, aligned `i32`
484            // reference for the duration of this call; `write_volatile` and the
485            // `compiler_fence` below prevent the compiler from eliding this zeroization as
486            // a dead store now that `self.coeffs` has no further reads in this scope.
487            unsafe {
488                core::ptr::write_volatile(&mut self.coeffs[i], 0i32);
489            }
490        }
491        compiler_fence(Ordering::SeqCst);
492    }
493}
494
495/// Sample a polynomial from the Centered Binomial Distribution with η=2.
496///
497/// For each coefficient position, samples 4 bits (a₀, a₁, b₀, b₁) from
498/// the provided byte stream and computes:
499///
500/// ```text
501/// coeff = (a₀ + a₁) - (b₀ + b₁) ∈ {-2, -1, 0, 1, 2}
502/// ```
503pub fn poly_cbd_eta2(seed_bytes: &[u8]) -> PolyRq {
504    let mut poly = PolyRq::zero();
505    for i in 0..N_DEGREE {
506        let byte = seed_bytes[i % seed_bytes.len()];
507        let a0 = (byte & 0x01) as i32;
508        let a1 = ((byte >> 1) & 0x01) as i32;
509        let b0 = ((byte >> 2) & 0x01) as i32;
510        let b1 = ((byte >> 3) & 0x01) as i32;
511        let coeff = (a0 + a1) - (b0 + b1);
512        // Store as centered representative mod Q
513        poly.coeffs[i] = (coeff + Q_MODULUS) % Q_MODULUS;
514    }
515    poly
516}
517
518/// Check the infinity norm of a polynomial against a bound.
519///
520/// Returns `true` if all coefficients satisfy `|coeff| < bound` (centered),
521/// `false` if any coefficient violates the bound.
522///
523/// This check is performed in constant time: no early exit on violation.
524pub fn poly_check_infinity_norm(poly: &PolyRq, bound: i32) -> bool {
525    let mut all_ok: u32 = 0xFFFF_FFFF;
526    for i in 0..N_DEGREE {
527        let coeff = poly.coeffs[i];
528        let centered = if coeff > Q_MODULUS / 2 {
529            coeff - Q_MODULUS
530        } else {
531            coeff
532        };
533        let sign_mask = centered >> 31;
534        let abs_coeff = (centered + sign_mask) ^ sign_mask;
535        let diff = (bound - 1) - abs_coeff;
536        let violation_mask = (diff >> 31) as u32;
537        all_ok &= !violation_mask;
538    }
539    all_ok != 0
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_ct_check_norm_bound_accept() {
548        let mut z = VectorK::zero();
549        // Fill with values well within bound
550        for k in 0..MODULE_K {
551            for n in 0..RING_N {
552                z.vec[k].coeffs[n] = 1000;
553            }
554        }
555        let result = ct_check_norm_bound(&z, REJECTION_BOUND);
556        assert_eq!(result, 0, "should accept: all coefficients within bound");
557    }
558
559    #[test]
560    fn test_ct_check_norm_bound_reject() {
561        let mut z = VectorK::zero();
562        z.vec[0].coeffs[0] = REJECTION_BOUND + 1; // out of bounds
563        let result = ct_check_norm_bound(&z, REJECTION_BOUND);
564        assert_ne!(result, 0, "should reject: coefficient out of bound");
565    }
566
567    #[test]
568    fn test_poly_cbd_eta2_range() {
569        let seed = [0x5Au8; 256];
570        let poly = poly_cbd_eta2(&seed);
571        for &c in poly.coeffs.iter() {
572            // Centered representative should be in {-2,-1,0,1,2} mod Q
573            let centered = if c > Q_MODULUS / 2 { c - Q_MODULUS } else { c };
574            assert!((-2..=2).contains(&centered), "CBD coefficient out of range: {}", centered);
575        }
576    }
577
578    #[test]
579    fn test_enclave_plp_prove_fixed_time() {
580        let mut s = VectorK::zero();
581        for k in 0..MODULE_K {
582            for n in 0..RING_N {
583                s.vec[k].coeffs[n] = (n % 5) as i32 - 2;
584            }
585        }
586        let tau = [0x5Au8; 32];
587        let mut proof_out = PlpProof::zero();
588        let result = enclave_plp_prove_fixed_time(&mut proof_out, &s, &tau);
589        assert!(result.is_ok(), "proof generation should succeed: {:?}", result);
590    }
591
592    #[test]
593    fn test_masking_vector_range() {
594        let seed = [0x42u8; 32];
595        let y = sample_masking_vector(&seed, 0);
596        for k in 0..MODULE_K {
597            for n in 0..RING_N {
598                let c = y.vec[k].coeffs[n];
599                assert!(
600                    (-PARAM_GAMMA1..=PARAM_GAMMA1).contains(&c),
601                    "masking coefficient {} out of range [-γ₁, γ₁]", c
602                );
603            }
604        }
605    }
606}