Skip to main content

confium_privacy/
side_channel.rs

1//! Side-channel timing test framework.
2//!
3//! Provides utilities for measuring operation durations and detecting
4//! timing-based information leakage. Used to verify constant-time
5//! guarantees of cryptographic operations.
6
7use std::time::{Duration, Instant};
8
9/// A single timing measurement.
10#[derive(Debug, Clone)]
11pub struct TimingSample {
12    pub label: String,
13    pub duration: Duration,
14}
15
16/// Collect timing samples for an operation.
17pub fn measure<F: FnOnce()>(label: &str, f: F) -> TimingSample {
18    let start = Instant::now();
19    f();
20    let duration = start.elapsed();
21    TimingSample {
22        label: label.into(),
23        duration,
24    }
25}
26
27/// Collect N timing samples for an operation.
28pub fn measure_n<F: Fn()>(label: &str, n: usize, f: F) -> Vec<TimingSample> {
29    (0..n)
30        .map(|_| {
31            let start = Instant::now();
32            f();
33            TimingSample {
34                label: label.into(),
35                duration: start.elapsed(),
36            }
37        })
38        .collect()
39}
40
41/// Statistics over a set of timing samples.
42#[derive(Debug, Clone)]
43pub struct TimingStats {
44    pub count: usize,
45    pub min: Duration,
46    pub max: Duration,
47    pub mean: Duration,
48    pub median: Duration,
49    pub stddev: Duration,
50}
51
52/// Compute statistics over timing samples.
53pub fn stats(samples: &[TimingSample]) -> TimingStats {
54    if samples.is_empty() {
55        return TimingStats {
56            count: 0,
57            min: Duration::ZERO,
58            max: Duration::ZERO,
59            mean: Duration::ZERO,
60            median: Duration::ZERO,
61            stddev: Duration::ZERO,
62        };
63    }
64    let durations: Vec<Duration> = samples.iter().map(|s| s.duration).collect();
65    let min = *durations.iter().min().unwrap();
66    let max = *durations.iter().max().unwrap();
67    let total: Duration = durations.iter().sum();
68    let mean = total / samples.len() as u32;
69
70    let mut sorted = durations.clone();
71    sorted.sort();
72    let median = sorted[sorted.len() / 2];
73
74    let variance: f64 = durations
75        .iter()
76        .map(|d| {
77            let diff = d.as_nanos() as f64 - mean.as_nanos() as f64;
78            diff * diff
79        })
80        .sum::<f64>()
81        / samples.len() as f64;
82    let stddev = Duration::from_nanos(variance.sqrt() as u64);
83
84    TimingStats {
85        count: samples.len(),
86        min,
87        max,
88        mean,
89        median,
90        stddev,
91    }
92}
93
94/// Compare timing distributions between two groups. Returns the
95/// ratio of max/min mean. A ratio near 1.0 suggests constant-time;
96/// a large ratio suggests timing leakage.
97pub fn timing_ratio(group_a: &[TimingSample], group_b: &[TimingSample]) -> f64 {
98    let stats_a = stats(group_a);
99    let stats_b = stats(group_b);
100    let mean_a = stats_a.mean.as_nanos() as f64;
101    let mean_b = stats_b.mean.as_nanos() as f64;
102    if mean_a == 0.0 || mean_b == 0.0 {
103        return 1.0;
104    }
105    let (larger, smaller) = if mean_a > mean_b {
106        (mean_a, mean_b)
107    } else {
108        (mean_b, mean_a)
109    };
110    larger / smaller
111}
112
113/// Check if two groups have consistent timing (ratio < threshold).
114pub fn is_constant_time(
115    group_a: &[TimingSample],
116    group_b: &[TimingSample],
117    threshold: f64,
118) -> bool {
119    timing_ratio(group_a, group_b) < threshold
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::thread;
126
127    #[test]
128    fn measure_returns_duration() {
129        let sample = measure("test", || {
130            thread::sleep(Duration::from_micros(100));
131        });
132        assert!(sample.duration >= Duration::from_micros(90));
133    }
134
135    #[test]
136    fn measure_n_returns_multiple() {
137        let samples = measure_n("test", 5, || {});
138        assert_eq!(samples.len(), 5);
139        assert!(samples.iter().all(|s| s.label == "test"));
140    }
141
142    #[test]
143    fn stats_empty_returns_zero() {
144        let s = stats(&[]);
145        assert_eq!(s.count, 0);
146    }
147
148    #[test]
149    fn stats_computes_min_max() {
150        let samples = vec![
151            TimingSample {
152                label: "x".into(),
153                duration: Duration::from_nanos(100),
154            },
155            TimingSample {
156                label: "x".into(),
157                duration: Duration::from_nanos(300),
158            },
159            TimingSample {
160                label: "x".into(),
161                duration: Duration::from_nanos(200),
162            },
163        ];
164        let s = stats(&samples);
165        assert_eq!(s.min, Duration::from_nanos(100));
166        assert_eq!(s.max, Duration::from_nanos(300));
167    }
168
169    #[test]
170    fn stats_computes_mean() {
171        let samples = vec![
172            TimingSample {
173                label: "x".into(),
174                duration: Duration::from_nanos(100),
175            },
176            TimingSample {
177                label: "x".into(),
178                duration: Duration::from_nanos(200),
179            },
180            TimingSample {
181                label: "x".into(),
182                duration: Duration::from_nanos(300),
183            },
184        ];
185        let s = stats(&samples);
186        assert_eq!(s.mean, Duration::from_nanos(200));
187    }
188
189    #[test]
190    fn stats_computes_median() {
191        let samples = vec![
192            TimingSample {
193                label: "x".into(),
194                duration: Duration::from_nanos(100),
195            },
196            TimingSample {
197                label: "x".into(),
198                duration: Duration::from_nanos(200),
199            },
200            TimingSample {
201                label: "x".into(),
202                duration: Duration::from_nanos(300),
203            },
204            TimingSample {
205                label: "x".into(),
206                duration: Duration::from_nanos(400),
207            },
208            TimingSample {
209                label: "x".into(),
210                duration: Duration::from_nanos(500),
211            },
212        ];
213        let s = stats(&samples);
214        assert_eq!(s.median, Duration::from_nanos(300));
215    }
216
217    #[test]
218    fn timing_ratio_equal_groups() {
219        let a = vec![TimingSample {
220            label: "a".into(),
221            duration: Duration::from_nanos(100),
222        }];
223        let b = vec![TimingSample {
224            label: "b".into(),
225            duration: Duration::from_nanos(100),
226        }];
227        assert!((timing_ratio(&a, &b) - 1.0).abs() < 0.01);
228    }
229
230    #[test]
231    fn timing_ratio_different_groups() {
232        let a = vec![TimingSample {
233            label: "a".into(),
234            duration: Duration::from_nanos(100),
235        }];
236        let b = vec![TimingSample {
237            label: "b".into(),
238            duration: Duration::from_nanos(200),
239        }];
240        assert!((timing_ratio(&a, &b) - 2.0).abs() < 0.01);
241    }
242
243    #[test]
244    fn is_constant_time_passes_for_equal() {
245        let a = vec![TimingSample {
246            label: "a".into(),
247            duration: Duration::from_nanos(100),
248        }];
249        let b = vec![TimingSample {
250            label: "b".into(),
251            duration: Duration::from_nanos(105),
252        }];
253        assert!(is_constant_time(&a, &b, 1.5));
254    }
255
256    #[test]
257    fn is_constant_time_fails_for_different() {
258        let a = vec![TimingSample {
259            label: "a".into(),
260            duration: Duration::from_nanos(100),
261        }];
262        let b = vec![TimingSample {
263            label: "b".into(),
264            duration: Duration::from_nanos(500),
265        }];
266        assert!(!is_constant_time(&a, &b, 1.5));
267    }
268}