mcp_compressor_core/proxy/auth.rs
1//! Bearer-token authentication for the generic tool proxy.
2//!
3//! # Design
4//!
5//! `SessionToken` wraps a 64-character hex string (32 random bytes). A fresh
6//! token is minted at proxy startup and embedded into every generated client
7//! artifact. Stale artifacts from previous sessions simply fail to
8//! authenticate.
9//!
10//! Token verification uses a constant-time comparison (byte-wise XOR fold) to
11//! prevent timing side-channels — matching the Python `hmac.compare_digest`
12//! behaviour.
13//!
14//! # Wire format
15//!
16//! Clients must send `Authorization: Bearer <token>` in every `POST /exec`
17//! request. The `verify` method accepts the raw header value (everything
18//! after the colon-space).
19
20use rand::{rngs::OsRng, RngCore};
21
22/// A session-scoped bearer token.
23///
24/// ## Lifetime
25///
26/// One `SessionToken` is created per proxy-server invocation. It is printed
27/// to stderr (informational) and embedded at generation time into every client
28/// artifact produced for that session.
29#[derive(Debug, Clone)]
30pub struct SessionToken(String);
31
32impl SessionToken {
33 /// Generate a new cryptographically random 64-character hex token.
34 ///
35 /// Internally draws 32 random bytes from the OS RNG and hex-encodes them,
36 /// yielding a string that is unique with overwhelming probability.
37 pub fn generate() -> Self {
38 let mut bytes = [0_u8; 32];
39 OsRng.fill_bytes(&mut bytes);
40
41 let mut token = String::with_capacity(64);
42 for byte in bytes {
43 token.push(hex_digit(byte >> 4));
44 token.push(hex_digit(byte & 0x0f));
45 }
46
47 Self(token)
48 }
49
50 /// Return the raw hex token string (without the `Bearer` prefix).
51 pub fn value(&self) -> &str {
52 &self.0
53 }
54
55 /// Verify an `Authorization` header value in constant time.
56 ///
57 /// The `header` argument is the **full** header value, e.g.
58 /// `"Bearer a3f7..."`. Returns `true` only when the header matches
59 /// `"Bearer <self.value()>"` exactly (case-sensitive on the prefix).
60 ///
61 /// Uses XOR-fold over bytes to avoid early-exit timing leaks.
62 pub fn verify(&self, header: &str) -> bool {
63 let expected = format!("Bearer {}", self.0);
64 constant_time_eq(header.as_bytes(), expected.as_bytes())
65 }
66}
67
68fn hex_digit(nibble: u8) -> char {
69 match nibble {
70 0..=9 => (b'0' + nibble) as char,
71 10..=15 => (b'a' + (nibble - 10)) as char,
72 _ => unreachable!("nibble must be in 0..=15"),
73 }
74}
75
76fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
77 let mut diff = left.len() ^ right.len();
78 let max_len = left.len().max(right.len());
79
80 for index in 0..max_len {
81 let left_byte = left.get(index).copied().unwrap_or(0);
82 let right_byte = right.get(index).copied().unwrap_or(0);
83 diff |= usize::from(left_byte ^ right_byte);
84 }
85
86 diff == 0
87}
88
89// ---------------------------------------------------------------------------
90// Tests
91// ---------------------------------------------------------------------------
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 // ------------------------------------------------------------------
98 // Token generation
99 // ------------------------------------------------------------------
100
101 /// A generated token is non-empty.
102 #[test]
103 fn generate_non_empty() {
104 let token = SessionToken::generate();
105 assert!(!token.value().is_empty());
106 }
107
108 /// The generated token is exactly 64 characters (32 bytes, hex-encoded).
109 #[test]
110 fn generate_length_64() {
111 let token = SessionToken::generate();
112 assert_eq!(token.value().len(), 64, "expected 64-char hex string, got {:?}", token.value());
113 }
114
115 /// The generated token contains only valid lowercase hex characters.
116 #[test]
117 fn generate_is_lowercase_hex() {
118 let token = SessionToken::generate();
119 assert!(
120 token.value().chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')),
121 "token is not lowercase hex: {:?}",
122 token.value(),
123 );
124 }
125
126 /// Two independently generated tokens are different (uniqueness).
127 ///
128 /// The probability of a collision for 256-bit tokens is negligible; a
129 /// failure here would strongly indicate a broken RNG.
130 #[test]
131 fn generate_produces_unique_tokens() {
132 let t1 = SessionToken::generate();
133 let t2 = SessionToken::generate();
134 assert_ne!(t1.value(), t2.value(), "two tokens must not be identical");
135 }
136
137 /// value() returns the raw hex string without any prefix.
138 #[test]
139 fn value_returns_raw_hex() {
140 let token = SessionToken::generate();
141 let val = token.value();
142 // No "Bearer" prefix
143 assert!(!val.starts_with("Bearer "));
144 // No whitespace
145 assert!(!val.contains(' '));
146 }
147
148 // ------------------------------------------------------------------
149 // verify — successful authentication
150 // ------------------------------------------------------------------
151
152 /// A correct "Bearer <token>" header is accepted.
153 #[test]
154 fn verify_correct_bearer_header() {
155 let token = SessionToken::generate();
156 let header = format!("Bearer {}", token.value());
157 assert!(token.verify(&header), "correct header must be accepted");
158 }
159
160 // ------------------------------------------------------------------
161 // verify — rejection cases
162 // ------------------------------------------------------------------
163
164 /// An empty string is rejected.
165 #[test]
166 fn verify_empty_string_rejected() {
167 let token = SessionToken::generate();
168 assert!(!token.verify(""), "empty header must be rejected");
169 }
170
171 /// A random wrong token is rejected.
172 #[test]
173 fn verify_wrong_token_rejected() {
174 let token = SessionToken::generate();
175 assert!(!token.verify("Bearer wrongtoken0000000000000000000000000000000000000000000000000000"));
176 }
177
178 /// The raw token without the "Bearer " prefix is rejected.
179 #[test]
180 fn verify_raw_token_without_prefix_rejected() {
181 let token = SessionToken::generate();
182 assert!(!token.verify(token.value()), "bare token without 'Bearer ' must be rejected");
183 }
184
185 /// A header with a lowercase "bearer" prefix is rejected (case-sensitive).
186 #[test]
187 fn verify_lowercase_bearer_rejected() {
188 let token = SessionToken::generate();
189 let header = format!("bearer {}", token.value());
190 assert!(!token.verify(&header), "lowercase 'bearer' prefix must be rejected");
191 }
192
193 /// "Bearer" with no token value after it is rejected.
194 #[test]
195 fn verify_bearer_with_no_token_rejected() {
196 let token = SessionToken::generate();
197 assert!(!token.verify("Bearer"), "bare 'Bearer' with no value must be rejected");
198 }
199
200 /// "Bearer " (trailing space, no token) is rejected.
201 #[test]
202 fn verify_bearer_trailing_space_rejected() {
203 let token = SessionToken::generate();
204 assert!(!token.verify("Bearer "), "'Bearer ' with empty token must be rejected");
205 }
206
207 /// A token from a *different* session is rejected.
208 #[test]
209 fn verify_different_session_token_rejected() {
210 let token1 = SessionToken::generate();
211 let token2 = SessionToken::generate();
212 let header = format!("Bearer {}", token2.value());
213 assert!(!token1.verify(&header), "different session token must be rejected");
214 }
215
216 /// A token with one character flipped is rejected.
217 ///
218 /// This exercises the constant-time path where lengths match but content
219 /// differs.
220 #[test]
221 fn verify_single_bit_flip_rejected() {
222 let token = SessionToken::generate();
223 // Flip the first hex digit: replace with the next character or wrap.
224 let mut bad = format!("Bearer {}", token.value());
225 // The 7th character (index 7) is the start of the token hex string.
226 let bytes = unsafe { bad.as_bytes_mut() };
227 bytes[7] ^= 1; // mutate one byte of the token
228 let bad = String::from_utf8(bytes.to_vec()).unwrap_or_default();
229 assert!(!token.verify(&bad), "one-bit-flipped token must be rejected");
230 }
231
232 // ------------------------------------------------------------------
233 // verify — constant-time property (structural)
234 // ------------------------------------------------------------------
235
236 /// verify always examines all bytes, even when the prefix is wrong.
237 ///
238 /// This is a structural test: we confirm verify returns false for a string
239 /// that has the right length but wrong prefix, and also for one that has
240 /// a completely wrong length. The actual constant-time guarantee is
241 /// enforced by implementation (XOR fold), not easily measurable in unit
242 /// tests, but is documented here as a specification requirement.
243 #[test]
244 fn verify_does_not_short_circuit_on_wrong_prefix() {
245 let token = SessionToken::generate();
246 // Build a header of the correct *total* length but with "Hearer " as prefix
247 let fake_header = format!("Hearer {}", token.value());
248 // Length is same as the correct "Bearer <token>" — must still be rejected
249 assert_eq!(fake_header.len(), format!("Bearer {}", token.value()).len());
250 assert!(!token.verify(&fake_header));
251 }
252}