Skip to main content

silentops/verify/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! Dudect-style constant-time verification helpers.
5//!
6//! This module provides a small, dependency-free toolkit to detect
7//! timing side-channels statistically. It implements the methodology
8//! described by Reparaz, Balasch, and Verbauwhede in
9//! *"dude, is my code constant time?"* (2017):
10//!
11//! 1. Build two **input classes**: "fixed" (worst-case) and "random".
12//! 2. Interleave measurements of both classes randomly.
13//! 3. Feed each measurement into a running **Welch's t-test**.
14//! 4. **Verdict**: if `|t| > 4.5` after enough samples, there is
15//!    strong evidence of a timing side channel (`p < 10⁻⁵`).
16//!
17//! The helpers are crate-agnostic: callers from `quantica` or
18//! `arcana` (or any downstream user) build their own
19//! measurement loops on top of [`TTest`], [`Xorshift64`], and
20//! [`measure_ns`], then call [`report`] to print the verdict.
21//!
22//! See `silentops/examples/ct_verify_pqc.rs` for a complete usage
23//! example covering ML-KEM and ML-DSA timing tests.
24
25use std::println;
26use std::time::Instant;
27
28/// Default decision threshold: `|t| < 4.5` ⇒ no detectable timing leak.
29pub const T_THRESHOLD: f64 = 4.5;
30
31/// Welch's t-test on two streaming sample populations.
32///
33/// Uses Welford's incremental algorithm for the running mean and
34/// variance, so it does not need to store individual samples.
35#[derive(Default)]
36pub struct TTest {
37    n0: f64,
38    n1: f64,
39    mean0: f64,
40    mean1: f64,
41    /// Sum of squared deviations from the mean (class 0).
42    m2_0: f64,
43    /// Sum of squared deviations from the mean (class 1).
44    m2_1: f64,
45}
46
47impl TTest {
48    /// Create an empty t-test.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Add a new measurement. `class` must be 0 or 1.
54    pub fn push(&mut self, value: f64, class: u8) {
55        if class == 0 {
56            self.n0 += 1.0;
57            let delta = value - self.mean0;
58            self.mean0 += delta / self.n0;
59            let delta2 = value - self.mean0;
60            self.m2_0 += delta * delta2;
61        } else {
62            self.n1 += 1.0;
63            let delta = value - self.mean1;
64            self.mean1 += delta / self.n1;
65            let delta2 = value - self.mean1;
66            self.m2_1 += delta * delta2;
67        }
68    }
69
70    /// Compute Welch's t-statistic. Returns 0.0 if not enough samples.
71    pub fn t_value(&self) -> f64 {
72        if self.n0 < 2.0 || self.n1 < 2.0 {
73            return 0.0;
74        }
75        let var0 = self.m2_0 / (self.n0 - 1.0);
76        let var1 = self.m2_1 / (self.n1 - 1.0);
77        let se = (var0 / self.n0 + var1 / self.n1).sqrt();
78        if se == 0.0 {
79            return 0.0;
80        }
81        (self.mean0 - self.mean1) / se
82    }
83}
84
85/// Tiny xorshift64 PRNG used to interleave classes during measurement.
86///
87/// **Not cryptographic** — only used to schedule which class is
88/// measured next and to fill random buffers in the test fixtures.
89pub struct Xorshift64 {
90    state: u64,
91}
92
93impl Xorshift64 {
94    /// Create a PRNG seeded with `seed` (must be non-zero).
95    pub fn new(seed: u64) -> Self {
96        Self { state: seed }
97    }
98
99    /// Return the next 64-bit pseudo-random word.
100    pub fn next(&mut self) -> u64 {
101        let mut x = self.state;
102        x ^= x << 13;
103        x ^= x >> 7;
104        x ^= x << 17;
105        self.state = x;
106        x
107    }
108
109    /// Return a pseudo-random boolean (used as the class selector).
110    pub fn next_bool(&mut self) -> bool {
111        self.next() & 1 == 1
112    }
113
114    /// Fill `buf` with pseudo-random bytes.
115    pub fn fill_bytes(&mut self, buf: &mut [u8]) {
116        for chunk in buf.chunks_mut(8) {
117            let val = self.next();
118            for (i, b) in chunk.iter_mut().enumerate() {
119                *b = (val >> (i * 8)) as u8;
120            }
121        }
122    }
123}
124
125/// Measure a closure's execution time in nanoseconds.
126///
127/// Marked `#[inline(never)]` to keep the measurement boundary stable
128/// across optimization levels.
129#[inline(never)]
130pub fn measure_ns<F: FnMut()>(mut f: F) -> f64 {
131    let start = Instant::now();
132    f();
133    start.elapsed().as_nanos() as f64
134}
135
136/// Print a single test result with PASS/FAIL verdict against
137/// [`T_THRESHOLD`].
138pub fn report(name: &str, t: &TTest) {
139    let t_val = t.t_value();
140    let verdict = if t_val.abs() < T_THRESHOLD { "PASS" } else { "FAIL" };
141    println!("  {:<45} t={:>8.2}  [{}]", name, t_val, verdict);
142}