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 #[must_use]
32 pub fn compare(expected: &[u8], actual: &[u8]) -> bool {
33 expected.ct_eq(actual).into()
34 }
35
36 /// Compare two strings in constant time
37 ///
38 /// Converts strings to bytes and performs constant-time comparison.
39 /// Useful for comparing JWT tokens, session tokens, or other string-based secrets.
40 ///
41 /// # Arguments
42 /// * `expected` - The expected (correct/known) string value
43 /// * `actual` - The actual (untrusted) string value from the user/attacker
44 #[must_use]
45 pub fn compare_str(expected: &str, actual: &str) -> bool {
46 Self::compare(expected.as_bytes(), actual.as_bytes())
47 }
48
49 /// Compare two slices with different lengths in constant time
50 ///
51 /// If lengths differ, still compares as much as possible to avoid leaking
52 /// length information through timing.
53 ///
54 /// # SECURITY WARNING
55 /// This function is vulnerable to timing attacks that measure comparison duration.
56 /// For JWT tokens or other security-sensitive values, use `compare_padded()` instead
57 /// which always compares at a fixed length to prevent length disclosure.
58 #[must_use]
59 pub fn compare_len_safe(expected: &[u8], actual: &[u8]) -> bool {
60 // If lengths differ, still compare constant-time
61 // First compare what we can, then check length
62 let min_len = expected.len().min(actual.len());
63 let prefix_equal = expected[..min_len].ct_eq(&actual[..min_len]);
64 let length_equal = u8::from(expected.len() == actual.len());
65
66 (prefix_equal.unwrap_u8() & length_equal) != 0
67 }
68
69 /// Compare two byte slices at a fixed/padded length for timing attack prevention
70 ///
71 /// Always compares at `fixed_len` bytes, padding with zeros if necessary.
72 /// This prevents timing attacks that measure comparison duration to determine length.
73 ///
74 /// # Arguments
75 /// * `expected` - The expected (correct/known) value
76 /// * `actual` - The actual (untrusted) value from the user/attacker
77 /// * `fixed_len` - The fixed length to use for comparison (e.g., 512 for JWT tokens)
78 ///
79 /// # SECURITY
80 /// Prevents length-based timing attacks. Time is independent of actual input lengths.
81 ///
82 /// # Example
83 /// ```rust
84 /// use fraiseql_auth::constant_time::ConstantTimeOps;
85 /// let stored_jwt = "eyJhbGc...";
86 /// let user_jwt = "eyJhbGc...";
87 /// // Always compares at 512 bytes, padding with zeros if needed
88 /// let result = ConstantTimeOps::compare_padded(
89 /// stored_jwt.as_bytes(),
90 /// user_jwt.as_bytes(),
91 /// 512
92 /// );
93 /// ```
94 #[must_use]
95 pub fn compare_padded(expected: &[u8], actual: &[u8], fixed_len: usize) -> bool {
96 // SECURITY: Pad both inputs to fixed_len before comparison.
97 // Using Vec avoids the previous 1024-byte silent cap that produced incorrect
98 // results for tokens longer than 1024 bytes.
99 let mut expected_padded = vec![0u8; fixed_len];
100 let mut actual_padded = vec![0u8; fixed_len];
101
102 let copy_expected = expected.len().min(fixed_len);
103 expected_padded[..copy_expected].copy_from_slice(&expected[..copy_expected]);
104
105 let copy_actual = actual.len().min(fixed_len);
106 actual_padded[..copy_actual].copy_from_slice(&actual[..copy_actual]);
107
108 // Constant-time comparison at fixed length
109 expected_padded.ct_eq(&actual_padded).into()
110 }
111
112 /// Compare JWT tokens in constant time with fixed-length padding
113 ///
114 /// JWT tokens are typically 300-800 bytes. Using 512-byte fixed-length comparison
115 /// prevents attackers from determining token length through timing analysis.
116 #[must_use]
117 pub fn compare_jwt_constant(expected: &str, actual: &str) -> bool {
118 // Use 512-byte fixed length for JWT comparison (typical JWT size)
119 Self::compare_padded(expected.as_bytes(), actual.as_bytes(), 512)
120 }
121}