Skip to main content

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}