Skip to main content

fraiseql_auth/
constant_time.rs

1//! Constant-time comparison utilities to prevent timing-based side-channel attacks.
2//!
3//! Timing attacks exploit measurable differences in how long comparisons take
4//! depending on where they diverge, allowing an attacker to iteratively discover
5//! secret values (e.g., HMAC tokens, API keys). All comparisons of secret material
6//! must use the functions in this module instead of `==`.
7
8use subtle::ConstantTimeEq;
9
10/// Constant-time comparison utilities for security tokens
11/// Uses subtle crate to ensure comparisons take the same time regardless of where differences occur
12pub struct ConstantTimeOps;
13
14impl ConstantTimeOps {
15    /// Compare two byte slices in constant time
16    ///
17    /// Returns true if equal, false otherwise.
18    /// Time is independent of where the difference occurs, preventing timing attacks.
19    ///
20    /// # Arguments
21    /// * `expected` - The expected (correct/known) value
22    /// * `actual` - The actual (untrusted) value from the user/attacker
23    ///
24    /// # Examples
25    /// ```rust
26    /// use fraiseql_auth::constant_time::ConstantTimeOps;
27    /// let stored_token = b"secret_token_value";
28    /// let user_token = b"user_provided_token";
29    /// assert!(!ConstantTimeOps::compare(stored_token, user_token));
30    /// ```
31    pub fn compare(expected: &[u8], actual: &[u8]) -> bool {
32        expected.ct_eq(actual).into()
33    }
34
35    /// Compare two strings in constant time
36    ///
37    /// Converts strings to bytes and performs constant-time comparison.
38    /// Useful for comparing JWT tokens, session tokens, or other string-based secrets.
39    ///
40    /// # Arguments
41    /// * `expected` - The expected (correct/known) string value
42    /// * `actual` - The actual (untrusted) string value from the user/attacker
43    pub fn compare_str(expected: &str, actual: &str) -> bool {
44        Self::compare(expected.as_bytes(), actual.as_bytes())
45    }
46
47    /// Compare two slices with different lengths in constant time
48    ///
49    /// If lengths differ, still compares as much as possible to avoid leaking
50    /// length information through timing.
51    ///
52    /// # SECURITY WARNING
53    /// This function is vulnerable to timing attacks that measure comparison duration.
54    /// For JWT tokens or other security-sensitive values, use `compare_padded()` instead
55    /// which always compares at a fixed length to prevent length disclosure.
56    pub fn compare_len_safe(expected: &[u8], actual: &[u8]) -> bool {
57        // If lengths differ, still compare constant-time
58        // First compare what we can, then check length
59        let min_len = expected.len().min(actual.len());
60        let prefix_equal = expected[..min_len].ct_eq(&actual[..min_len]);
61        let length_equal = u8::from(expected.len() == actual.len());
62
63        (prefix_equal.unwrap_u8() & length_equal) != 0
64    }
65
66    /// Compare two byte slices at a fixed/padded length for timing attack prevention
67    ///
68    /// Always compares at `fixed_len` bytes, padding with zeros if necessary.
69    /// This prevents timing attacks that measure comparison duration to determine length.
70    ///
71    /// # Arguments
72    /// * `expected` - The expected (correct/known) value
73    /// * `actual` - The actual (untrusted) value from the user/attacker
74    /// * `fixed_len` - The fixed length to use for comparison (e.g., 512 for JWT tokens)
75    ///
76    /// # SECURITY
77    /// Prevents length-based timing attacks. Time is independent of actual input lengths.
78    ///
79    /// # Example
80    /// ```rust
81    /// use fraiseql_auth::constant_time::ConstantTimeOps;
82    /// let stored_jwt = "eyJhbGc...";
83    /// let user_jwt = "eyJhbGc...";
84    /// // Always compares at 512 bytes, padding with zeros if needed
85    /// let result = ConstantTimeOps::compare_padded(
86    ///     stored_jwt.as_bytes(),
87    ///     user_jwt.as_bytes(),
88    ///     512
89    /// );
90    /// ```
91    pub fn compare_padded(expected: &[u8], actual: &[u8], fixed_len: usize) -> bool {
92        // SECURITY: Pad both inputs to fixed_len before comparison.
93        // Using Vec avoids the previous 1024-byte silent cap that produced incorrect
94        // results for tokens longer than 1024 bytes.
95        let mut expected_padded = vec![0u8; fixed_len];
96        let mut actual_padded = vec![0u8; fixed_len];
97
98        let copy_expected = expected.len().min(fixed_len);
99        expected_padded[..copy_expected].copy_from_slice(&expected[..copy_expected]);
100
101        let copy_actual = actual.len().min(fixed_len);
102        actual_padded[..copy_actual].copy_from_slice(&actual[..copy_actual]);
103
104        // Constant-time comparison at fixed length
105        expected_padded.ct_eq(&actual_padded).into()
106    }
107
108    /// Compare JWT tokens in constant time with fixed-length padding
109    ///
110    /// JWT tokens are typically 300-800 bytes. Using 512-byte fixed-length comparison
111    /// prevents attackers from determining token length through timing analysis.
112    pub fn compare_jwt_constant(expected: &str, actual: &str) -> bool {
113        // Use 512-byte fixed length for JWT comparison (typical JWT size)
114        Self::compare_padded(expected.as_bytes(), actual.as_bytes(), 512)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    #[allow(clippy::wildcard_imports)]
121    // Reason: test module — wildcard keeps test boilerplate minimal
122    use super::*;
123
124    #[test]
125    fn test_compare_equal_bytes() {
126        let token1 = b"equal_token_value";
127        let token2 = b"equal_token_value";
128        assert!(ConstantTimeOps::compare(token1, token2));
129    }
130
131    #[test]
132    fn test_compare_different_bytes() {
133        let token1 = b"expected_token";
134        let token2 = b"actual_token_x";
135        assert!(!ConstantTimeOps::compare(token1, token2));
136    }
137
138    #[test]
139    fn test_compare_equal_strings() {
140        let token1 = "equal_token_value";
141        let token2 = "equal_token_value";
142        assert!(ConstantTimeOps::compare_str(token1, token2));
143    }
144
145    #[test]
146    fn test_compare_different_strings() {
147        let token1 = "expected_token";
148        let token2 = "actual_token_x";
149        assert!(!ConstantTimeOps::compare_str(token1, token2));
150    }
151
152    #[test]
153    fn test_compare_empty() {
154        let token1 = b"";
155        let token2 = b"";
156        assert!(ConstantTimeOps::compare(token1, token2));
157    }
158
159    #[test]
160    fn test_compare_different_lengths() {
161        let token1 = b"short";
162        let token2 = b"much_longer_token";
163        assert!(!ConstantTimeOps::compare(token1, token2));
164    }
165
166    #[test]
167    fn test_compare_len_safe() {
168        let expected = b"abcdefghij";
169        let actual = b"abcdefghij";
170        assert!(ConstantTimeOps::compare_len_safe(expected, actual));
171
172        let different = b"abcdefghix";
173        assert!(!ConstantTimeOps::compare_len_safe(expected, different));
174
175        let shorter = b"abcdefgh";
176        assert!(!ConstantTimeOps::compare_len_safe(expected, shorter));
177    }
178
179    #[test]
180    fn test_null_bytes_comparison() {
181        let token1 = b"token\x00with\x00nulls";
182        let token2 = b"token\x00with\x00nulls";
183        assert!(ConstantTimeOps::compare(token1, token2));
184
185        let different = b"token\x00with\x00other";
186        assert!(!ConstantTimeOps::compare(token1, different));
187    }
188
189    #[test]
190    fn test_all_byte_values() {
191        let mut token1 = vec![0u8; 256];
192        let mut token2 = vec![0u8; 256];
193        for i in 0..256 {
194            #[allow(clippy::cast_possible_truncation)]
195            // Reason: loop bound is 256, so i is always 0..=255
196            let byte = i as u8;
197            token1[i] = byte;
198            token2[i] = byte;
199        }
200
201        assert!(ConstantTimeOps::compare(&token1, &token2));
202
203        token2[127] = token2[127].wrapping_add(1);
204        assert!(!ConstantTimeOps::compare(&token1, &token2));
205    }
206
207    #[test]
208    fn test_very_long_tokens() {
209        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
210        // Reason: i % 256 is always 0..=255 for non-negative i32, both casts safe
211        let token1: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
212        let token2 = token1.clone();
213        assert!(ConstantTimeOps::compare(&token1, &token2));
214
215        let mut token3 = token1.clone();
216        token3[5_000] = token3[5_000].wrapping_add(1);
217        assert!(!ConstantTimeOps::compare(&token1, &token3));
218    }
219
220    #[test]
221    fn test_compare_padded_equal_length() {
222        let token1 = b"same_token_value";
223        let token2 = b"same_token_value";
224        assert!(ConstantTimeOps::compare_padded(token1, token2, 512));
225    }
226
227    #[test]
228    fn test_compare_padded_different_length_shorter_actual() {
229        let expected = b"this_is_expected_token_value";
230        let actual = b"short";
231        // Should still reject because content differs when padded to fixed length
232        assert!(!ConstantTimeOps::compare_padded(expected, actual, 512));
233    }
234
235    #[test]
236    fn test_compare_padded_different_length_longer_actual() {
237        let expected = b"expected";
238        let actual = b"this_is_a_much_longer_actual_token_that_exceeds_expected";
239        // Should still reject because content differs
240        assert!(!ConstantTimeOps::compare_padded(expected, actual, 512));
241    }
242
243    #[test]
244    fn test_compare_padded_timing_consistency() {
245        // SECURITY TEST: Ensure padding prevents timing leaks on token length
246        let short_token = b"short";
247        let long_token = b"this_is_a_much_longer_token_value_with_more_content";
248
249        // Both should perform comparison at fixed 512-byte length
250        // If timing attack vulnerability existed, these would take different times
251        let _ = ConstantTimeOps::compare_padded(short_token, short_token, 512);
252        let _ = ConstantTimeOps::compare_padded(long_token, long_token, 512);
253
254        // Should both return true since they're comparing to themselves
255        assert!(ConstantTimeOps::compare_padded(short_token, short_token, 512));
256        assert!(ConstantTimeOps::compare_padded(long_token, long_token, 512));
257    }
258
259    #[test]
260    fn test_compare_jwt_constant() {
261        let jwt1 = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.signature123";
262        let jwt2 = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.signature123";
263        assert!(ConstantTimeOps::compare_jwt_constant(jwt1, jwt2));
264    }
265
266    #[test]
267    fn test_compare_jwt_constant_different() {
268        let jwt1 = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.signature123";
269        let jwt2 = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.signature999";
270        assert!(!ConstantTimeOps::compare_jwt_constant(jwt1, jwt2));
271    }
272
273    #[test]
274    fn test_compare_jwt_constant_prevents_length_attack() {
275        // SECURITY: Verify that short JWT is rejected even against long JWT
276        let short_invalid_jwt = "short";
277        let long_valid_jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.sig123";
278
279        // Should reject because they're different
280        assert!(!ConstantTimeOps::compare_jwt_constant(short_invalid_jwt, long_valid_jwt));
281
282        // Both comparisons should take similar time despite length difference
283        // (constant-time due to padding to 512 bytes)
284        assert!(!ConstantTimeOps::compare_jwt_constant(short_invalid_jwt, long_valid_jwt));
285    }
286
287    #[test]
288    fn test_compare_padded_zero_length() {
289        // Edge case: comparing empty tokens at fixed length
290        let token1 = b"";
291        let token2 = b"";
292        assert!(ConstantTimeOps::compare_padded(token1, token2, 512));
293    }
294
295    #[test]
296    fn test_compare_padded_exact_fixed_length() {
297        // Tokens exactly matching fixed length
298        let token = b"a".repeat(512);
299        assert!(ConstantTimeOps::compare_padded(&token, &token, 512));
300
301        let mut different = token.clone();
302        different[256] = different[256].wrapping_add(1);
303        assert!(!ConstantTimeOps::compare_padded(&token, &different, 512));
304    }
305
306    #[test]
307    fn test_compare_padded_large_fixed_len() {
308        // fixed_len larger than input: both padded with zeros, equal content → equal
309        let token1 = b"test";
310        let token2 = b"test";
311        assert!(ConstantTimeOps::compare_padded(token1, token2, 2048));
312
313        // Tokens that differ only beyond fixed_len are treated as equal (truncated)
314        let long_a: Vec<u8> = b"prefix".iter().chain(b"AAAA".iter()).copied().collect();
315        let long_b: Vec<u8> = b"prefix".iter().chain(b"BBBB".iter()).copied().collect();
316        // fixed_len = 6 → only "prefix" compared → equal
317        assert!(ConstantTimeOps::compare_padded(&long_a, &long_b, 6));
318        // fixed_len = 10 → full content compared → different
319        assert!(!ConstantTimeOps::compare_padded(&long_a, &long_b, 10));
320    }
321
322    #[test]
323    fn test_timing_attack_prevention_early_difference() {
324        // First byte different - timing attack would be fast on this
325        let token1 = b"XXXXXXX_correct_token";
326        let token2 = b"YYYYYYY_correct_token";
327        let result = ConstantTimeOps::compare(token1, token2);
328        assert!(!result);
329        // Should take same time as other comparisons due to constant-time implementation
330    }
331
332    #[test]
333    fn test_timing_attack_prevention_late_difference() {
334        // Last byte different - timing attack would be slow on this
335        let token1 = b"correct_token_XXXXXXX";
336        let token2 = b"correct_token_YYYYYYY";
337        let result = ConstantTimeOps::compare(token1, token2);
338        assert!(!result);
339        // Should take same time as early_difference due to constant-time implementation
340    }
341
342    #[test]
343    fn test_jwt_constant_padding() {
344        // Test that padded JWT comparison handles typical JWT sizes
345        let short_jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.abc";
346        let padded_jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.abc";
347        assert!(ConstantTimeOps::compare_jwt_constant(short_jwt, padded_jwt));
348    }
349
350    #[test]
351    fn test_jwt_constant_different_lengths() {
352        // Padded comparison prevents length-based timing attacks
353        let jwt1 = "short";
354        let jwt2 = "very_long_jwt_token_with_lots_of_data_making_it_much_longer";
355        let result = ConstantTimeOps::compare_jwt_constant(jwt1, jwt2);
356        assert!(!result);
357        // Comparison time is independent of length difference
358    }
359}