Skip to main content

oxios_kernel/
auth.rs

1//! API key authentication manager.
2//!
3//! Provides bearer token authentication for the HTTP API.
4//! Keys are stored as SHA-256 hashes for security.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12/// Prefix for all generated Oxios API keys.
13const KEY_PREFIX: &str = "oxios_";
14
15/// Metadata about an API key (stored alongside the hash).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct KeyMeta {
18    /// Human-readable name for the key.
19    pub name: String,
20    /// When the key was created.
21    pub created_at: String,
22    /// When the key was last used (ISO 8601).
23    pub last_used: Option<String>,
24}
25
26/// A stored API key entry (hash + metadata).
27#[derive(Debug, Clone, Serialize, Deserialize)]
28struct KeyEntry {
29    /// SHA-256 hash of the full API key.
30    hash_hex: String,
31    #[serde(flatten)]
32    meta: KeyMeta,
33}
34
35/// API key file format.
36#[derive(Debug, Default, Serialize, Deserialize)]
37struct KeyFile {
38    keys: Vec<KeyEntry>,
39}
40
41/// Manages API key authentication.
42pub struct AuthManager {
43    /// SHA-256 hash → KeyMeta lookup.
44    entries: HashMap<String, KeyMeta>,
45    /// Set of all valid hashes for O(1) lookup.
46    valid_hashes: HashSet<String>,
47    /// Path to persist keys (optional for in-memory-only mode).
48    path: Option<std::path::PathBuf>,
49    /// When the key file was last flushed to disk (debounce for `last_used`).
50    ///
51    /// `last_used` is non-security-critical metadata; rewriting the whole key
52    /// file on every validation turns each authenticated request into a
53    /// serialised disk write under the manager Mutex. We update `last_used` in
54    /// memory on every validate() and only flush at most once per minute.
55    last_flush: Option<std::time::Instant>,
56}
57
58impl AuthManager {
59    /// Create a new AuthManager without persistence.
60    pub fn new() -> Self {
61        Self {
62            entries: HashMap::new(),
63            valid_hashes: HashSet::new(),
64            path: None,
65            last_flush: None,
66        }
67    }
68    /// Create an AuthManager that persists keys to a file.
69    pub fn with_persistence(path: impl Into<std::path::PathBuf>) -> Result<Self> {
70        let path = path.into();
71        let mut mgr = Self {
72            entries: HashMap::new(),
73            valid_hashes: HashSet::new(),
74            path: Some(path.clone()),
75            last_flush: None,
76        };
77        if path.exists() {
78            mgr.load_from_file(&path)?;
79        }
80        Ok(mgr)
81    }
82
83    /// Load keys from a JSON file.
84    pub fn load_from_file(&mut self, path: &Path) -> Result<()> {
85        let content = std::fs::read_to_string(path)
86            .with_context(|| format!("Failed to read API keys from {}", path.display()))?;
87        let key_file: KeyFile =
88            serde_json::from_str(&content).with_context(|| "Failed to parse API keys file")?;
89        for entry in key_file.keys {
90            self.valid_hashes.insert(entry.hash_hex.clone());
91            self.entries.insert(entry.hash_hex, entry.meta);
92        }
93        tracing::info!(count = self.valid_hashes.len(), "Loaded API keys");
94        Ok(())
95    }
96
97    /// Save keys to the persistence file.
98    fn save_to_file(&self) -> Result<()> {
99        if let Some(path) = &self.path {
100            let key_file = KeyFile {
101                keys: self
102                    .entries
103                    .iter()
104                    .map(|(hash, meta)| KeyEntry {
105                        hash_hex: hash.clone(),
106                        meta: meta.clone(),
107                    })
108                    .collect(),
109            };
110            let content = serde_json::to_string_pretty(&key_file)?;
111            // Write atomically via temp file with owner-only permissions (0600).
112            // std::fs::write would create the file under the process umask
113            // (typically 022 → world-readable), exposing key hashes.
114            let tmp_path = path.with_extension("tmp");
115            write_secret_file(&tmp_path, &content)?;
116            std::fs::rename(&tmp_path, path)?;
117        }
118        Ok(())
119    }
120
121    /// Generate a new API key.
122    ///
123    /// Returns the full key string (only shown once).
124    pub fn generate_key(&mut self, name: &str) -> Result<String> {
125        let key_bytes = Self::random_key();
126        let full_key = format!("{}{}", KEY_PREFIX, hex::encode(key_bytes));
127        let hash = Self::hash_key(&full_key);
128        let meta = KeyMeta {
129            name: name.to_string(),
130            created_at: chrono::Utc::now().to_rfc3339(),
131            last_used: None,
132        };
133        self.valid_hashes.insert(hash.clone());
134        self.entries.insert(hash, meta);
135        self.save_to_file()?;
136        tracing::info!(name = %name, "Generated new API key");
137        Ok(full_key)
138    }
139
140    /// Validate a bearer token.
141    ///
142    /// `last_used` is refreshed in memory on every call, but the key file is
143    /// only rewritten at most once per minute — turning each authenticated
144    /// request from a serialised O(n) disk write into a cheap HashMap lookup.
145    pub fn validate(&mut self, token: &str) -> bool {
146        let hash = Self::hash_key(token);
147        if self.valid_hashes.contains(&hash) {
148            if let Some(meta) = self.entries.get_mut(&hash) {
149                meta.last_used = Some(chrono::Utc::now().to_rfc3339());
150                let should_flush = self
151                    .last_flush
152                    .map(|t| t.elapsed() >= std::time::Duration::from_secs(60))
153                    .unwrap_or(true);
154                if should_flush && self.save_to_file().is_ok() {
155                    self.last_flush = Some(std::time::Instant::now());
156                }
157            }
158            true
159        } else {
160            false
161        }
162    }
163
164    /// Revoke an API key by name.
165    pub fn revoke_key(&mut self, name: &str) -> Result<()> {
166        let hashes_to_remove: Vec<String> = self
167            .entries
168            .iter()
169            .filter(|(_, meta)| meta.name == name)
170            .map(|(hash, _)| hash.clone())
171            .collect();
172        if hashes_to_remove.is_empty() {
173            anyhow::bail!("Key '{name}' not found");
174        }
175        for hash in hashes_to_remove {
176            self.valid_hashes.remove(&hash);
177            self.entries.remove(&hash);
178        }
179        self.save_to_file()?;
180        tracing::info!(name = %name, "Revoked API key");
181        Ok(())
182    }
183
184    /// List all keys (metadata only, never expose the key itself).
185    pub fn list_keys(&self) -> Vec<&KeyMeta> {
186        self.entries.values().collect()
187    }
188
189    /// Check if any keys are configured.
190    pub fn has_keys(&self) -> bool {
191        !self.valid_hashes.is_empty()
192    }
193
194    /// Hash an API key using SHA-256.
195    ///
196    /// Keys are 256-bit CSPRNG-generated (`random_key`) and the full key is
197    /// returned to the caller exactly once at generation time, so a single
198    /// unsalted SHA-256 is sufficient to protect the at-rest store: an offline
199    /// brute-force of a 32-byte random secret is infeasible. This is not a
200    /// password hash (low-entropy) scheme — do not reuse it for user passwords.
201    fn hash_key(key: &str) -> String {
202        let mut hasher = Sha256::new();
203        hasher.update(key.as_bytes());
204        hex::encode(hasher.finalize())
205    }
206
207    /// Generate random bytes for a new key.
208    fn random_key() -> [u8; 32] {
209        let mut bytes = [0u8; 32];
210        getrandom::getrandom(&mut bytes).expect("failed to generate random bytes");
211        bytes
212    }
213}
214
215/// Write a secret-bearing file with owner-only permissions (0600 on Unix).
216///
217/// Used for the persisted API key store so that the on-disk hash file is not
218/// world-readable under a typical 022 umask.
219fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> {
220    #[cfg(unix)]
221    {
222        use std::io::Write;
223        use std::os::unix::fs::OpenOptionsExt;
224        let mut f = std::fs::OpenOptions::new()
225            .write(true)
226            .create(true)
227            .truncate(true)
228            .mode(0o600)
229            .open(path)?;
230        f.write_all(content.as_bytes())?;
231        Ok(())
232    }
233    #[cfg(not(unix))]
234    {
235        std::fs::write(path, content)?;
236        Ok(())
237    }
238}
239
240impl Default for AuthManager {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn generate_and_validate_key() {
252        let mut mgr = AuthManager::new();
253        let key = mgr.generate_key("test-key").unwrap();
254        assert!(key.starts_with(KEY_PREFIX));
255        assert!(mgr.validate(&key));
256    }
257
258    #[test]
259    fn invalid_key_rejected() {
260        let mut mgr = AuthManager::new();
261        assert!(!mgr.validate("oxios_invalidkey"));
262    }
263
264    #[test]
265    fn revoke_key() {
266        let mut mgr = AuthManager::new();
267        let key = mgr.generate_key("to-revoke").unwrap();
268        assert!(mgr.validate(&key));
269        mgr.revoke_key("to-revoke").unwrap();
270        assert!(!mgr.validate(&key));
271    }
272
273    #[test]
274    fn revoke_nonexistent_key_fails() {
275        let mut mgr = AuthManager::new();
276        assert!(mgr.revoke_key("no-such-key").is_err());
277    }
278
279    #[test]
280    fn has_keys_reflects_state() {
281        let mut mgr = AuthManager::new();
282        assert!(!mgr.has_keys());
283        mgr.generate_key("first").unwrap();
284        assert!(mgr.has_keys());
285    }
286
287    #[test]
288    fn list_keys_returns_metadata() {
289        let mut mgr = AuthManager::new();
290        mgr.generate_key("alpha").unwrap();
291        mgr.generate_key("beta").unwrap();
292        let names: Vec<&str> = mgr.list_keys().iter().map(|m| m.name.as_str()).collect();
293        assert!(names.contains(&"alpha"));
294        assert!(names.contains(&"beta"));
295    }
296
297    #[test]
298    fn persistence_roundtrip() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("keys.json");
301
302        let key = {
303            let mut mgr = AuthManager::with_persistence(&path).unwrap();
304            mgr.generate_key("persist-test").unwrap()
305        };
306
307        // Load from file in a fresh manager
308        let mut mgr2 = AuthManager::with_persistence(&path).unwrap();
309        assert!(mgr2.validate(&key));
310        assert!(mgr2.has_keys());
311    }
312
313    #[test]
314    fn hash_is_deterministic() {
315        let h1 = AuthManager::hash_key("oxios_test123");
316        let h2 = AuthManager::hash_key("oxios_test123");
317        assert_eq!(h1, h2);
318    }
319}