Skip to main content

shell_tunnel/security/
auth.rs

1//! API Key authentication.
2
3use std::collections::HashMap;
4use std::sync::RwLock;
5use std::time::SystemTime;
6
7use axum::{
8    extract::{Request, State},
9    http::{header::AUTHORIZATION, StatusCode},
10    middleware::Next,
11    response::Response,
12};
13
14use super::capability::CapabilitySet;
15
16/// API key configuration.
17#[derive(Debug, Clone)]
18pub struct AuthConfig {
19    /// Whether authentication is enabled.
20    pub enabled: bool,
21    /// Header name for API key (default: "Authorization").
22    pub header_name: String,
23    /// Prefix for the API key (default: "Bearer ").
24    pub prefix: String,
25}
26
27impl Default for AuthConfig {
28    fn default() -> Self {
29        Self {
30            enabled: true,
31            header_name: AUTHORIZATION.to_string(),
32            prefix: "Bearer ".to_string(),
33        }
34    }
35}
36
37impl AuthConfig {
38    /// Create a disabled auth config (for development).
39    pub fn disabled() -> Self {
40        Self {
41            enabled: false,
42            ..Default::default()
43        }
44    }
45
46    /// Create auth config with custom prefix.
47    pub fn with_prefix(prefix: impl Into<String>) -> Self {
48        Self {
49            prefix: prefix.into(),
50            ..Default::default()
51        }
52    }
53}
54
55/// A registered token: the capabilities it grants plus provenance metadata.
56///
57/// The store maps an opaque bearer-token string to one of these records
58/// (spec §9). The token string itself stays the wire credential
59/// (`Authorization: Bearer <token>`); the record is the server-side authority
60/// on what that token may do.
61#[derive(Debug, Clone)]
62pub struct TokenRecord {
63    /// Identifier for the audit trail.
64    ///
65    /// Assigned at registration and unrelated to the token's value, so a trail
66    /// can name the caller without containing the credential. Per-process:
67    /// tokens are not persisted, so neither is this.
68    pub id: String,
69    /// Capabilities this token grants (spec §2 mechanism).
70    pub capabilities: CapabilitySet,
71    /// Human-readable label for provenance (e.g. `"legacy"`, `"operator"`).
72    pub label: String,
73    /// When the token was registered.
74    pub created_at: SystemTime,
75}
76
77impl TokenRecord {
78    /// Create a token record with the given capabilities and label.
79    pub fn new(capabilities: CapabilitySet, label: impl Into<String>) -> Self {
80        Self {
81            id: generate_token_id(),
82            capabilities,
83            label: label.into(),
84            created_at: SystemTime::now(),
85        }
86    }
87
88    /// Create a full-control (wildcard) token — the legacy-key mapping target
89    /// and the `full-control` preset (spec §4, §6).
90    pub fn full_control(label: impl Into<String>) -> Self {
91        Self::new(CapabilitySet::wildcard(), label)
92    }
93}
94
95/// Thread-safe token store, keyed by opaque bearer-token string.
96#[derive(Debug)]
97pub struct ApiKeyStore {
98    tokens: RwLock<HashMap<String, TokenRecord>>,
99    config: AuthConfig,
100}
101
102impl ApiKeyStore {
103    /// Create a new token store.
104    pub fn new(config: AuthConfig) -> Self {
105        Self {
106            tokens: RwLock::new(HashMap::new()),
107            config,
108        }
109    }
110
111    /// Create a store with authentication disabled.
112    pub fn disabled() -> Self {
113        Self::new(AuthConfig::disabled())
114    }
115
116    /// Add a legacy full-control API key.
117    ///
118    /// Backward-compatibility path (spec §4): a bare key with no declared
119    /// capabilities maps to a `full-control` token holding the wildcard, so any
120    /// existing `--api-key` / `--require-auth` consumer is unaffected and can
121    /// never trigger a 403.
122    pub fn add_key(&self, key: impl Into<String>) {
123        self.add_token(key, TokenRecord::full_control("legacy"));
124    }
125
126    /// Register a token string with an explicit capability record.
127    pub fn add_token(&self, key: impl Into<String>, record: TokenRecord) {
128        if let Ok(mut tokens) = self.tokens.write() {
129            tokens.insert(key.into(), record);
130        }
131    }
132
133    /// Register a token with the given capabilities and label.
134    pub fn add_key_with_capabilities(
135        &self,
136        key: impl Into<String>,
137        capabilities: CapabilitySet,
138        label: impl Into<String>,
139    ) {
140        self.add_token(key, TokenRecord::new(capabilities, label));
141    }
142
143    /// Remove a token.
144    pub fn remove_key(&self, key: &str) -> bool {
145        self.tokens
146            .write()
147            .map(|mut tokens| tokens.remove(key).is_some())
148            .unwrap_or(false)
149    }
150
151    /// Check if a token is registered (valid).
152    pub fn is_valid(&self, key: &str) -> bool {
153        self.tokens
154            .read()
155            .map(|tokens| tokens.contains_key(key))
156            .unwrap_or(false)
157    }
158
159    /// Look up the capabilities a token grants, if it is registered.
160    ///
161    /// This is the store surface the scope-aware middleware consumes
162    /// (spec §5 step 2): token → `TokenRecord` → capability check.
163    pub fn capabilities(&self, key: &str) -> Option<CapabilitySet> {
164        self.tokens
165            .read()
166            .ok()
167            .and_then(|tokens| tokens.get(key).map(|record| record.capabilities.clone()))
168    }
169
170    /// Identify a token for the audit trail, without revealing it.
171    pub fn identity(&self, key: &str) -> Option<crate::audit::Identity> {
172        self.tokens.read().ok().and_then(|tokens| {
173            tokens.get(key).map(|record| crate::audit::Identity {
174                token_id: record.id.clone(),
175                label: record.label.clone(),
176            })
177        })
178    }
179
180    /// Get the number of registered tokens.
181    pub fn count(&self) -> usize {
182        self.tokens.read().map(|t| t.len()).unwrap_or(0)
183    }
184
185    /// Check if authentication is enabled.
186    pub fn is_enabled(&self) -> bool {
187        self.config.enabled
188    }
189
190    /// Extract API key from authorization header.
191    pub fn extract_key(&self, header_value: &str) -> Option<String> {
192        if header_value.starts_with(&self.config.prefix) {
193            Some(header_value[self.config.prefix.len()..].to_string())
194        } else {
195            None
196        }
197    }
198}
199
200impl Default for ApiKeyStore {
201    fn default() -> Self {
202        Self::new(AuthConfig::default())
203    }
204}
205
206/// Authentication middleware for axum.
207pub async fn auth_middleware(
208    State(store): State<std::sync::Arc<ApiKeyStore>>,
209    request: Request,
210    next: Next,
211) -> Result<Response, StatusCode> {
212    // Skip auth if disabled
213    if !store.is_enabled() {
214        return Ok(next.run(request).await);
215    }
216
217    // Skip auth for health endpoint
218    if request.uri().path() == "/health" {
219        return Ok(next.run(request).await);
220    }
221
222    // Extract and validate API key
223    let auth_header = request
224        .headers()
225        .get(AUTHORIZATION)
226        .and_then(|v| v.to_str().ok());
227
228    match auth_header {
229        Some(header) => {
230            if let Some(key) = store.extract_key(header) {
231                if store.is_valid(&key) {
232                    return Ok(next.run(request).await);
233                }
234            }
235            Err(StatusCode::UNAUTHORIZED)
236        }
237        None => Err(StatusCode::UNAUTHORIZED),
238    }
239}
240
241/// Short identifier for an audit trail entry.
242///
243/// Random rather than derived from the token: a derived value would let anyone
244/// holding the log test guesses against it.
245fn generate_token_id() -> String {
246    let full = generate_api_key();
247    format!("tok_{}", &full[full.len().saturating_sub(12)..])
248}
249
250/// Generate a random API key.
251pub fn generate_api_key() -> String {
252    // 128 bits of OS entropy, printed in the shape keys have always had
253    // (`st_<16 hex>_<16 hex>`). The shape is all that survives of the old
254    // scheme: its second half was an affine function of its first (a
255    // timestamp), which made every issued key recoverable from a guess at
256    // the clock. An entropy source that fails stops key issuance here,
257    // rather than shipping a credential weaker than it looks.
258    let mut bytes = [0u8; 16];
259    getrandom::fill(&mut bytes).expect("the OS entropy source is unavailable");
260    let (a, b) = bytes.split_at(8);
261    format!(
262        "st_{:016x}_{:016x}",
263        u64::from_be_bytes(a.try_into().expect("split_at(8) yields 8 bytes")),
264        u64::from_be_bytes(b.try_into().expect("split_at(8) yields 8 bytes"))
265    )
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_auth_config_default() {
274        let config = AuthConfig::default();
275        assert!(config.enabled);
276        assert_eq!(config.prefix, "Bearer ");
277    }
278
279    #[test]
280    fn test_auth_config_disabled() {
281        let config = AuthConfig::disabled();
282        assert!(!config.enabled);
283    }
284
285    #[test]
286    fn test_api_key_store_add_remove() {
287        let store = ApiKeyStore::default();
288
289        store.add_key("test-key-123");
290        assert!(store.is_valid("test-key-123"));
291        assert!(!store.is_valid("invalid-key"));
292        assert_eq!(store.count(), 1);
293
294        assert!(store.remove_key("test-key-123"));
295        assert!(!store.is_valid("test-key-123"));
296        assert_eq!(store.count(), 0);
297    }
298
299    #[test]
300    fn test_api_key_store_extract() {
301        let store = ApiKeyStore::default();
302
303        let key = store.extract_key("Bearer my-secret-key");
304        assert_eq!(key, Some("my-secret-key".to_string()));
305
306        let no_key = store.extract_key("Basic credentials");
307        assert!(no_key.is_none());
308    }
309
310    #[test]
311    fn test_api_key_store_disabled() {
312        let store = ApiKeyStore::disabled();
313        assert!(!store.is_enabled());
314    }
315
316    #[test]
317    fn test_generate_api_key() {
318        let key1 = generate_api_key();
319        let key2 = generate_api_key();
320
321        assert!(key1.starts_with("st_"));
322        assert!(key2.starts_with("st_"));
323        assert_ne!(key1, key2);
324    }
325
326    #[test]
327    fn the_secret_half_is_not_a_function_of_the_printed_half() {
328        // The vulnerability this pins down: keys were
329        // `st_<timestamp>_<timestamp * 0x5DEECE66D + 0xB>`, so the "secret"
330        // half was recoverable from the half printed next to it — and the
331        // whole key from a guess at the clock. The halves must be
332        // independent; under a random key this relation holds with
333        // probability 2^-64.
334        let key = generate_api_key();
335        let mut halves = key.trim_start_matches("st_").split('_');
336        let printed = u64::from_str_radix(halves.next().unwrap(), 16).unwrap();
337        let secret = u64::from_str_radix(halves.next().unwrap(), 16).unwrap();
338        assert_ne!(
339            secret,
340            printed.wrapping_mul(0x5DEECE66D).wrapping_add(0xB),
341            "the second half of {key} is derived from the first"
342        );
343    }
344
345    #[test]
346    fn keys_generated_back_to_back_are_all_distinct() {
347        // The macOS CI failure: two calls inside one clock tick produced the
348        // same key, because the clock was the only entropy. A tight loop must
349        // never collide.
350        let keys: std::collections::HashSet<String> =
351            (0..1000).map(|_| generate_api_key()).collect();
352        assert_eq!(keys.len(), 1000);
353    }
354
355    #[test]
356    fn test_api_key_store_multiple_keys() {
357        let store = ApiKeyStore::default();
358
359        store.add_key("key1");
360        store.add_key("key2");
361        store.add_key("key3");
362
363        assert_eq!(store.count(), 3);
364        assert!(store.is_valid("key1"));
365        assert!(store.is_valid("key2"));
366        assert!(store.is_valid("key3"));
367    }
368
369    #[test]
370    fn test_legacy_key_maps_to_full_control() {
371        // spec §4: a bare `add_key` maps to a wildcard token so legacy
372        // consumers can never trigger a 403.
373        let store = ApiKeyStore::default();
374        store.add_key("legacy-key");
375
376        let caps = store.capabilities("legacy-key").expect("token registered");
377        assert!(caps.is_wildcard());
378        assert!(caps.satisfies("exec"));
379        assert!(caps.satisfies("session.manage"));
380    }
381
382    #[test]
383    fn test_add_key_with_capabilities() {
384        let store = ApiKeyStore::default();
385        let caps: CapabilitySet = ["exec", "session.read"].into_iter().collect();
386        store.add_key_with_capabilities("fine-grained", caps, "operator");
387
388        assert!(store.is_valid("fine-grained"));
389        let caps = store
390            .capabilities("fine-grained")
391            .expect("token registered");
392        assert!(caps.satisfies("exec"));
393        assert!(caps.satisfies("session.read"));
394        // Fine-grained token is NOT wildcard and lacks unlisted capabilities.
395        assert!(!caps.is_wildcard());
396        assert!(!caps.satisfies("session.manage"));
397    }
398
399    #[test]
400    fn test_capabilities_of_unknown_key_is_none() {
401        let store = ApiKeyStore::default();
402        assert!(store.capabilities("nope").is_none());
403    }
404
405    #[test]
406    fn test_token_record_full_control() {
407        let record = TokenRecord::full_control("legacy");
408        assert!(record.capabilities.is_wildcard());
409        assert_eq!(record.label, "legacy");
410    }
411}