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}