Skip to main content

brainos_core/
auth.rs

1//! Shared authentication logic for all protocol adapters.
2//!
3//! This module is the single source of truth for API key validation and
4//! permission checking. Every adapter (HTTP, WebSocket, gRPC, MCP) must
5//! use these functions instead of rolling their own validation.
6
7use subtle::ConstantTimeEq;
8
9use crate::config::ApiKeyConfig;
10
11/// Result of an authentication check.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum AuthResult {
14    /// Key is valid and has the requested permission.
15    Allowed,
16    /// No key was provided.
17    MissingKey,
18    /// Key was provided but does not match any configured key.
19    InvalidKey,
20    /// Key is valid but lacks the requested permission.
21    InsufficientPermission,
22}
23
24impl AuthResult {
25    /// Returns `true` if the request should be allowed.
26    pub fn is_allowed(&self) -> bool {
27        matches!(self, AuthResult::Allowed)
28    }
29
30    /// Returns a human-readable error message, or `None` if allowed.
31    pub fn error_message(&self, permission: &str) -> Option<String> {
32        match self {
33            AuthResult::Allowed => None,
34            AuthResult::MissingKey => Some("Missing API key".to_string()),
35            AuthResult::InvalidKey => Some("Invalid API key".to_string()),
36            AuthResult::InsufficientPermission => {
37                Some(format!("API key does not have '{permission}' permission"))
38            }
39        }
40    }
41}
42
43/// Extract a bearer token from an "Authorization" header value string.
44///
45/// E.g. `extract_bearer_from_value("Bearer abc123")` returns `Some("abc123")`.
46pub fn extract_bearer_from_value(value: &str) -> Option<&str> {
47    value.strip_prefix("Bearer ")
48}
49
50/// Constant-time `find` over an api-key list (Issue 62).
51///
52/// Used by adapters that previously called `api_keys.iter().find(|k| k.key == key)`.
53/// Direct `==` on `String` early-returns on the first differing byte, giving
54/// a per-byte timing oracle once an attacker can issue requests against the
55/// daemon (binding non-localhost, reverse-proxy, MCP over a shared socket).
56/// Iterating *all* entries and using `ConstantTimeEq` for equal-length
57/// entries closes that side-channel — total time depends only on the number
58/// and lengths of configured keys, not on which one matched.
59///
60/// Length mismatch is still an early skip; padding to a uniform max key
61/// length would only obscure the size signal, not eliminate it, and the
62/// length of a self-minted operator key isn't a useful secret in practice.
63pub fn find_key_ct<'a>(api_keys: &'a [ApiKeyConfig], key: &str) -> Option<&'a ApiKeyConfig> {
64    let key_bytes = key.as_bytes();
65    let mut matched: Option<&'a ApiKeyConfig> = None;
66    for entry in api_keys {
67        let stored = entry.key.as_bytes();
68        if stored.len() != key_bytes.len() {
69            continue;
70        }
71        if stored.ct_eq(key_bytes).unwrap_u8() == 1 && matched.is_none() {
72            matched = Some(entry);
73        }
74    }
75    matched
76}
77
78/// Validate an API key against the configured keys with permission checking.
79///
80/// - If `api_keys` is empty, returns `MissingKey` (fail-closed security).
81/// - If `provided_key` is `None`, returns `MissingKey`.
82/// - If the key doesn't match any configured key, returns `InvalidKey`.
83/// - If the key matches but lacks the required permission, returns `InsufficientPermission`.
84/// - Otherwise returns `Allowed`.
85pub fn check_auth(
86    api_keys: &[ApiKeyConfig],
87    provided_key: Option<&str>,
88    permission: &str,
89) -> AuthResult {
90    if api_keys.is_empty() {
91        return AuthResult::MissingKey;
92    }
93
94    let key = match provided_key {
95        Some(k) if !k.is_empty() => k,
96        _ => return AuthResult::MissingKey,
97    };
98
99    match api_keys.iter().find(|k| {
100        let a = k.key.as_bytes();
101        let b = key.as_bytes();
102        // NOTE: length precheck may leak key length via timing side-channel.
103        // This is accepted because: (1) Brain binds to 127.0.0.1 by default,
104        // so remote attackers cannot reach auth endpoints; (2) keys are
105        // minted with ~128 bits of entropy, making length knowledge
106        // inconsequential; (3) the timing delta (~50 ns) is below practical
107        // measurement thresholds even on localhost.
108        // See docs/deferred-followups-0.2.x.md for full analysis.
109        a.len() == b.len() && a.ct_eq(b).into()
110    }) {
111        None => AuthResult::InvalidKey,
112        Some(k) if !k.has_permission(permission) => AuthResult::InsufficientPermission,
113        Some(_) => AuthResult::Allowed,
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn test_keys() -> Vec<ApiKeyConfig> {
122        vec![
123            ApiKeyConfig {
124                key: "rw-key".to_string(),
125                name: "Read-Write".to_string(),
126                permissions: vec!["read".to_string(), "write".to_string()],
127                agent_id: None,
128            },
129            ApiKeyConfig {
130                key: "ro-key".to_string(),
131                name: "Read-Only".to_string(),
132                permissions: vec!["read".to_string()],
133                agent_id: None,
134            },
135        ]
136    }
137
138    #[test]
139    fn test_empty_keys_fails_closed() {
140        assert_eq!(
141            check_auth(&[], Some("anything"), "write"),
142            AuthResult::MissingKey
143        );
144        assert_eq!(check_auth(&[], None, "read"), AuthResult::MissingKey);
145    }
146
147    #[test]
148    fn test_missing_key() {
149        let keys = test_keys();
150        assert_eq!(check_auth(&keys, None, "read"), AuthResult::MissingKey);
151        assert_eq!(check_auth(&keys, Some(""), "read"), AuthResult::MissingKey);
152    }
153
154    #[test]
155    fn test_invalid_key() {
156        let keys = test_keys();
157        assert_eq!(
158            check_auth(&keys, Some("bad-key"), "read"),
159            AuthResult::InvalidKey
160        );
161    }
162
163    #[test]
164    fn test_insufficient_permission() {
165        let keys = test_keys();
166        assert_eq!(
167            check_auth(&keys, Some("ro-key"), "write"),
168            AuthResult::InsufficientPermission
169        );
170    }
171
172    #[test]
173    fn test_valid_read() {
174        let keys = test_keys();
175        assert_eq!(
176            check_auth(&keys, Some("ro-key"), "read"),
177            AuthResult::Allowed
178        );
179        assert_eq!(
180            check_auth(&keys, Some("rw-key"), "read"),
181            AuthResult::Allowed
182        );
183    }
184
185    #[test]
186    fn test_valid_write() {
187        let keys = test_keys();
188        assert_eq!(
189            check_auth(&keys, Some("rw-key"), "write"),
190            AuthResult::Allowed
191        );
192    }
193}