Skip to main content

lattice_ai/mcp/
auth.rs

1//! Connection authorization: the per-session token + a constant-time
2//! header check.
3//!
4//! The security boundary is the loopback bind (`127.0.0.1`) plus this
5//! token: the server writes a fresh token into the discovery lockfile,
6//! and an attaching agent must echo it in the
7//! `x-claude-code-ide-authorization` handshake header. The token is
8//! compared in constant time so a local attacker can't time-side-channel
9//! it byte by byte.
10
11use crate::mcp::error::{ClaudeCodeError, Result};
12
13/// The handshake header the agent must present, carrying the token read
14/// from the discovery lockfile. Matches the VS Code IDE-integration
15/// contract so the stock `claude` CLI authorizes unchanged.
16pub const AUTH_HEADER: &str = "x-claude-code-ide-authorization";
17
18/// Mint a fresh random auth token: 16 CSPRNG bytes rendered as 32 hex
19/// characters. Loopback bind + this token are the only security boundary,
20/// so the token must be unpredictable.
21pub fn generate_token() -> Result<String> {
22    let mut bytes = [0u8; 16];
23    getrandom::getrandom(&mut bytes).map_err(|e| ClaudeCodeError::Random(e.to_string()))?;
24    let mut hex = String::with_capacity(32);
25    for b in bytes {
26        use std::fmt::Write as _;
27        // Infallible write into a String.
28        let _ = write!(hex, "{b:02x}");
29    }
30    Ok(hex)
31}
32
33/// Whether the provided header value matches the expected token, compared
34/// in constant time. Returns `false` immediately on length mismatch —
35/// tokens are fixed-length, so length is not secret.
36pub fn header_matches(expected: &str, provided: &str) -> bool {
37    constant_time_eq(expected.as_bytes(), provided.as_bytes())
38}
39
40/// Constant-time byte-slice equality. Leaks only the (non-secret) length;
41/// the content comparison takes the same time regardless of where the
42/// first differing byte is.
43fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
44    if a.len() != b.len() {
45        return false;
46    }
47    let mut diff = 0u8;
48    for (x, y) in a.iter().zip(b.iter()) {
49        diff |= x ^ y;
50    }
51    diff == 0
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn generated_token_is_32_hex_chars() {
60        let t = generate_token().expect("token");
61        assert_eq!(t.len(), 32);
62        assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
63    }
64
65    #[test]
66    fn two_tokens_differ() {
67        // Vanishingly unlikely to collide; guards against a constant token.
68        let a = generate_token().expect("a");
69        let b = generate_token().expect("b");
70        assert_ne!(a, b);
71    }
72
73    #[test]
74    fn matching_token_accepts() {
75        let token = "deadbeefcafef00ddeadbeefcafef00d";
76        assert!(header_matches(token, token));
77    }
78
79    #[test]
80    fn wrong_token_rejects() {
81        let token = "deadbeefcafef00ddeadbeefcafef00d";
82        assert!(!header_matches(token, "deadbeefcafef00ddeadbeefcafef00e"));
83    }
84
85    #[test]
86    fn length_mismatch_rejects() {
87        assert!(!header_matches("short", "shorter-value"));
88        assert!(!header_matches("", "x"));
89    }
90
91    #[test]
92    fn empty_provided_rejects_nonempty_expected() {
93        assert!(!header_matches("token", ""));
94    }
95}