Skip to main content

chronix_security/auth/
api_key.rs

1//! API key authentication with Argon2 password hashing.
2//!
3//! Keys are stored as Argon2 hashes. Each key has a name, optional expiry,
4//! and optional role. Multiple keys can be valid simultaneously for rotation.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use chronix_security::auth::api_key::ApiKeyStore;
10//!
11//! let mut store = ApiKeyStore::new();
12//! let raw_key = store.create_key("admin", None).unwrap();
13//! assert!(store.validate(&raw_key).is_ok());
14//! ```
15
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::Arc;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
22use dashmap::DashMap;
23use sha2::{Digest, Sha256};
24use subtle::ConstantTimeEq;
25use tracing::debug;
26use zeroize::Zeroize;
27
28use crate::auth::error::AuthError;
29
30/// Compute the 8-byte prefix fingerprint of a raw API key.
31///
32/// This is a fast, non-cryptographic lookup key used to narrow the
33/// candidate set before running the expensive Argon2 verification.
34/// With 8 bytes (64 bits) the probability of a false prefix match
35/// is ~$5.4 \times 10^{-20}$ per key pair, so the Argon2 scan runs
36/// on at most 1 candidate in practice.
37fn key_prefix(raw_key: &str) -> [u8; 8] {
38    let hash = Sha256::digest(raw_key.as_bytes());
39    let mut prefix = [0u8; 8];
40    prefix.copy_from_slice(&hash[..8]);
41    prefix
42}
43
44/// A stored API key entry with metadata.
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
46pub struct ApiKeyEntry {
47    /// Human-readable name for this key.
48    pub name: String,
49    /// Argon2 hash of the key.
50    pub hash: String,
51    /// Optional Unix timestamp (seconds) when this key expires.
52    pub expires_at: Option<u64>,
53    /// When the key was created (Unix seconds).
54    pub created_at: u64,
55    /// Namespaces this key may act in.
56    ///
57    /// Empty means **unrestricted** — the key may name any namespace. That is
58    /// the right default for a single-tenant deployment, where the header is
59    /// always `default` and binding it would be ceremony. It is the wrong
60    /// default for a multi-tenant one, which is why `chronixd` refuses to
61    /// start multi-tenant with an unrestricted key rather than silently
62    /// handing every tenant's data to every key.
63    #[serde(default)]
64    pub namespaces: Vec<String>,
65    /// Whether this key may perform administrative operations.
66    ///
67    /// Restore, namespace management and key management are all reachable
68    /// with an ordinary key otherwise, because the Cedar authorization
69    /// engine is optional and its absence used to mean "permit".
70    #[serde(default)]
71    pub admin: bool,
72}
73
74impl ApiKeyEntry {
75    /// Whether this key may act in `namespace`.
76    #[must_use]
77    pub fn allows_namespace(&self, namespace: &str) -> bool {
78        self.namespaces.is_empty() || self.namespaces.iter().any(|n| n == namespace)
79    }
80}
81
82/// In-memory store for API keys with Argon2 hashing.
83///
84/// Validation uses a two-stage lookup: a fast SHA-256 prefix narrows
85/// the candidate set to (usually) one entry, avoiding a linear Argon2
86/// scan that would otherwise take `O(N * ~100ms)` and create a
87/// CPU-exhaustion denial-of-service vector.
88///
89/// **** Rate limiting is applied **per key prefix**, so brute-force
90/// attempts targeting one key cannot lock out unrelated keys. A global
91/// counter provides an additional safety net against distributed attacks.
92#[derive(Clone)]
93pub struct ApiKeyStore {
94    keys: HashMap<String, ApiKeyEntry>,
95    /// SHA-256 prefix → entry name(s) for O(1) candidate narrowing.
96    prefix_index: HashMap<[u8; 8], Vec<String>>,
97    /// Per-prefix failure counters — `(failure_count, window_start_epoch_secs)`.
98    per_prefix_failures: Arc<DashMap<[u8; 8], (u64, u64)>>,
99    /// Global consecutive failed validation count in the current window.
100    failure_count: Arc<AtomicU64>,
101    /// Epoch-second at which the current global failure window started.
102    failure_window_start: Arc<AtomicU64>,
103}
104
105impl std::fmt::Debug for ApiKeyStore {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.debug_struct("ApiKeyStore")
108            .field("keys", &self.keys.len())
109            .finish()
110    }
111}
112
113/// Maximum failed validations allowed per sliding window (per-prefix and global).
114const MAX_FAILURES_PER_WINDOW: u64 = 20;
115/// Per-prefix failures threshold (lower than global for targeted brute-force isolation).
116const MAX_PREFIX_FAILURES_PER_WINDOW: u64 = 5;
117/// Sliding window duration in seconds.
118const FAILURE_WINDOW_SECS: u64 = 60;
119
120impl ApiKeyStore {
121    /// Create a new empty key store.
122    #[must_use]
123    pub fn new() -> Self {
124        Self {
125            keys: HashMap::new(),
126            prefix_index: HashMap::new(),
127            per_prefix_failures: Arc::new(DashMap::new()),
128            failure_count: Arc::new(AtomicU64::new(0)),
129            failure_window_start: Arc::new(AtomicU64::new(0)),
130        }
131    }
132
133    /// Check whether a key with the given name is registered (not revoked).
134    ///
135    /// This is an O(1) lookup that avoids the expensive Argon2 re-hash
136    /// performed by [`validate`](Self::validate). Use this for runtime
137    /// revocation checks when the key has already been authenticated.
138    #[must_use]
139    pub fn key_exists(&self, name: &str) -> bool {
140        self.keys.contains_key(name)
141    }
142
143    /// Create a new API key with the given name and optional expiry.
144    ///
145    /// Returns the raw key string that should be given to the user.
146    /// The store only keeps the Argon2 hash.
147    pub fn create_key(&mut self, name: &str, expires_at: Option<u64>) -> Result<String, AuthError> {
148        if self.keys.contains_key(name) {
149            return Err(AuthError::Config(format!(
150                "API key with name '{name}' already exists"
151            )));
152        }
153        // Generate 256-bit (32-byte) random key for full
154        // cryptographic strength instead of UUID v4 (122 bits).
155        let mut key_bytes = [0u8; 32];
156        super::fill_random(&mut key_bytes);
157        use base64::Engine;
158        let raw_key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key_bytes);
159        let argon2 = Argon2::default();
160        // `hash_password` draws its own salt from the OS. It is the salt this
161        // tree used to build by hand, from the same source, at the same
162        // length — the crate simply owns it now.
163        let hash = argon2
164            .hash_password(raw_key.as_bytes())
165            .map_err(|e| AuthError::Config(format!("hash error: {e}")))?
166            .to_string();
167
168        let now = SystemTime::now()
169            .duration_since(UNIX_EPOCH)
170            .unwrap_or_default()
171            .as_secs();
172
173        let entry = ApiKeyEntry {
174            name: name.to_string(),
175            hash,
176            expires_at,
177            created_at: now,
178            namespaces: Vec::new(),
179            admin: false,
180        };
181
182        self.keys.insert(name.to_string(), entry);
183        self.prefix_index
184            .entry(key_prefix(&raw_key))
185            .or_default()
186            .push(name.to_string());
187        debug!(name, "created API key");
188        Ok(raw_key)
189    }
190
191    /// Confine an existing key to a set of namespaces.
192    ///
193    /// An empty list leaves the key unrestricted. Returns `false` when no
194    /// key of that name is stored.
195    pub fn bind_namespaces(&mut self, name: &str, namespaces: Vec<String>) -> bool {
196        match self.keys.get_mut(name) {
197            Some(entry) => {
198                entry.namespaces = namespaces;
199                true
200            }
201            None => false,
202        }
203    }
204
205    /// Grant or revoke the administrative capability on an existing key.
206    ///
207    /// Returns `false` when no key of that name is stored.
208    pub fn set_admin(&mut self, name: &str, admin: bool) -> bool {
209        match self.keys.get_mut(name) {
210            Some(entry) => {
211                entry.admin = admin;
212                true
213            }
214            None => false,
215        }
216    }
217
218    /// Whether a key carries the administrative capability.
219    #[must_use]
220    pub fn is_admin(&self, name: &str) -> bool {
221        self.keys.get(name).is_some_and(|e| e.admin)
222    }
223
224    /// The namespaces a key is confined to; empty means unrestricted.
225    #[must_use]
226    pub fn namespaces_for(&self, name: &str) -> &[String] {
227        self.keys
228            .get(name)
229            .map_or(&[][..], |entry| entry.namespaces.as_slice())
230    }
231
232    /// Names of keys that may act in **any** namespace.
233    ///
234    /// A multi-tenant server refuses to start while this is non-empty: an
235    /// unconfined key is the whole isolation boundary gone, and it fails
236    /// open, so nothing in normal operation would reveal it.
237    #[must_use]
238    pub fn unconfined_keys(&self) -> Vec<&str> {
239        self.keys
240            .values()
241            .filter(|e| e.namespaces.is_empty())
242            .map(|e| e.name.as_str())
243            .collect()
244    }
245
246    /// Add a pre-hashed key entry (for loading from config).
247    ///
248    /// **Note:** pre-hashed entries are not indexed by prefix and will
249    /// only be found via the fallback linear Argon2 scan. Use
250    /// [`register_plaintext`](Self::register_plaintext) when the raw
251    /// key is available.
252    pub fn add_entry(&mut self, entry: ApiKeyEntry) {
253        self.keys.insert(entry.name.clone(), entry);
254    }
255
256    /// Register a plaintext key (hashing it with Argon2).
257    ///
258    /// Used to pre-configure keys from server configuration files
259    /// where the raw key is specified in cleartext.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if a key with the same name already exists.
264    pub fn register_plaintext(
265        &mut self,
266        name: &str,
267        raw_key: &str,
268        expires_at: Option<u64>,
269    ) -> Result<(), AuthError> {
270        if self.keys.contains_key(name) {
271            return Err(AuthError::Config(format!(
272                "API key with name '{name}' already exists"
273            )));
274        }
275        let argon2 = Argon2::default();
276        let mut hash = argon2
277            .hash_password(raw_key.as_bytes())
278            .map_err(|e| AuthError::Config(format!("hash error: {e}")))?
279            .to_string();
280
281        // Log a production warning — plaintext keys in config are
282        // a deployment smell.  The raw_key &str is caller-owned so we cannot
283        // zeroize it here, but we do zeroize the intermediate hash clone
284        // after it is moved into the entry.
285        tracing::warn!(
286            name,
287            "registering API key from plaintext config — prefer runtime key generation",
288        );
289
290        let now = SystemTime::now()
291            .duration_since(UNIX_EPOCH)
292            .unwrap_or_default()
293            .as_secs();
294
295        let entry = ApiKeyEntry {
296            name: name.to_string(),
297            hash: hash.clone(),
298            expires_at,
299            created_at: now,
300            namespaces: Vec::new(),
301            admin: false,
302        };
303
304        // Zeroize the local hash copy now that it is stored in the entry.
305        hash.zeroize();
306
307        self.keys.insert(name.to_string(), entry);
308        self.prefix_index
309            .entry(key_prefix(raw_key))
310            .or_default()
311            .push(name.to_string());
312        debug!(name, "registered plaintext API key");
313        Ok(())
314    }
315
316    /// Validate a raw API key against the store.
317    ///
318    /// Uses a two-stage strategy:
319    /// 1. **Fast path** – look up the SHA-256 prefix of `raw_key` in
320    ///    the prefix index and Argon2-verify only the (usually ≤ 1)
321    ///    matching candidates.  This covers keys created via
322    ///    [`create_key`](Self::create_key) and
323    ///    [`register_plaintext`](Self::register_plaintext).
324    /// 2. **Fallback** – if no indexed candidate matched, fall back to
325    ///    a linear Argon2 scan for entries loaded via
326    ///    [`add_entry`](Self::add_entry) (pre-hashed, no prefix).
327    ///
328    /// Returns the key name on success.
329    pub fn validate(&self, raw_key: &str) -> Result<String, AuthError> {
330        let prefix = key_prefix(raw_key);
331        self.check_rate_limit(prefix)?;
332
333        let argon2 = Argon2::default();
334
335        // Constant-time prefix scan: avoid timing oracle from HashMap lookup.
336        // We iterate all prefix entries and use `subtle::ConstantTimeEq` so
337        // the comparison time is independent of the input prefix value.
338        let mut matched_names: Vec<&str> = Vec::new();
339        for (stored_prefix, names) in &self.prefix_index {
340            if bool::from(stored_prefix.ct_eq(&prefix)) {
341                matched_names.extend(names.iter().map(String::as_str));
342            }
343        }
344
345        // Verify matched candidates
346        for name in &matched_names {
347            if let Some(entry) = self.keys.get(*name) {
348                match self.try_verify(&argon2, entry, raw_key) {
349                    Ok(()) => return Ok(entry.name.clone()),
350                    Err(AuthError::InvalidApiKey(_)) => continue,
351                    Err(other) => return Err(other),
352                }
353            }
354        }
355
356        // Fallback: linear scan for add_entry (pre-hashed) keys not in prefix index
357        for entry in self.keys.values() {
358            // Skip entries already checked via prefix
359            if matched_names.contains(&entry.name.as_str()) {
360                continue;
361            }
362            if let Err(e) = self.try_verify(&argon2, entry, raw_key) {
363                match e {
364                    AuthError::InvalidApiKey(_) => continue,
365                    other => return Err(other),
366                }
367            } else {
368                return Ok(entry.name.clone());
369            }
370        }
371
372        self.record_failure(prefix);
373        Err(AuthError::InvalidApiKey("key not found".into()))
374    }
375
376    /// Check both per-prefix and global rate limits.
377    fn check_rate_limit(&self, prefix: [u8; 8]) -> Result<(), AuthError> {
378        let now = SystemTime::now()
379            .duration_since(UNIX_EPOCH)
380            .unwrap_or_default()
381            .as_secs();
382
383        // Per-prefix rate limit (isolates brute-force per key)
384        if let Some(entry) = self.per_prefix_failures.get(&prefix) {
385            let (count, window_start) = *entry;
386            if now.saturating_sub(window_start) < FAILURE_WINDOW_SECS
387                && count >= MAX_PREFIX_FAILURES_PER_WINDOW
388            {
389                tracing::warn!("API key validation per-prefix rate limit exceeded");
390                return Err(AuthError::InvalidApiKey("rate limit exceeded".into()));
391            }
392        }
393
394        // Global rate limit (safety net against distributed attacks)
395        let window_start = self.failure_window_start.load(Ordering::Relaxed);
396        if now.saturating_sub(window_start) >= FAILURE_WINDOW_SECS {
397            // Window expired — reset.
398            self.failure_window_start.store(now, Ordering::Relaxed);
399            self.failure_count.store(0, Ordering::Relaxed);
400        }
401        if self.failure_count.load(Ordering::Relaxed) >= MAX_FAILURES_PER_WINDOW {
402            tracing::warn!("API key validation global rate limit exceeded");
403            return Err(AuthError::InvalidApiKey("rate limit exceeded".into()));
404        }
405        Ok(())
406    }
407
408    /// Record a failed validation attempt against both per-prefix and global counters.
409    fn record_failure(&self, prefix: [u8; 8]) {
410        let now = SystemTime::now()
411            .duration_since(UNIX_EPOCH)
412            .unwrap_or_default()
413            .as_secs();
414
415        // Per-prefix counter
416        let mut entry = self.per_prefix_failures.entry(prefix).or_insert((0, now));
417        let (ref mut count, ref mut window_start) = *entry;
418        if now.saturating_sub(*window_start) >= FAILURE_WINDOW_SECS {
419            *count = 1;
420            *window_start = now;
421        } else {
422            *count += 1;
423        }
424
425        // Global counter
426        self.failure_count.fetch_add(1, Ordering::Relaxed);
427    }
428
429    /// Attempt to verify `raw_key` against a single entry.
430    ///
431    /// Returns `Ok(())` on match, `Err(ExpiredApiKey)` if the key is
432    /// expired, or `Err(InvalidApiKey)` if the hash doesn't match or
433    /// is corrupted.
434    fn try_verify(
435        &self,
436        argon2: &Argon2<'_>,
437        entry: &ApiKeyEntry,
438        raw_key: &str,
439    ) -> Result<(), AuthError> {
440        let parsed_hash = match PasswordHash::new(&entry.hash) {
441            Ok(h) => h,
442            Err(e) => {
443                tracing::warn!(
444                    name = %entry.name,
445                    error = %e,
446                    "skipping API key with corrupted hash"
447                );
448                return Err(AuthError::InvalidApiKey("corrupted hash".into()));
449            }
450        };
451
452        if argon2
453            .verify_password(raw_key.as_bytes(), &parsed_hash)
454            .is_ok()
455        {
456            // Check expiry
457            if let Some(expires) = entry.expires_at {
458                let now = SystemTime::now()
459                    .duration_since(UNIX_EPOCH)
460                    .unwrap_or_default()
461                    .as_secs();
462                if now > expires {
463                    return Err(AuthError::ExpiredApiKey(entry.name.clone()));
464                }
465            }
466            return Ok(());
467        }
468
469        Err(AuthError::InvalidApiKey("no match".into()))
470    }
471
472    /// Revoke a key by name.
473    ///
474    /// Also cleans up the prefix index. Stale prefix entries from
475    /// pre-hashed keys (added via [`add_entry`](Self::add_entry)) are
476    /// harmless — they simply won't find a matching `keys` entry
477    /// during validation.
478    pub fn revoke(&mut self, name: &str) -> bool {
479        let removed = self.keys.remove(name).is_some();
480        if removed {
481            // Remove name from every prefix bucket it appears in.
482            self.prefix_index.retain(|_prefix, names| {
483                names.retain(|n| n != name);
484                !names.is_empty()
485            });
486        }
487        removed
488    }
489
490    /// List all key names.
491    pub fn list_keys(&self) -> Vec<&str> {
492        self.keys.keys().map(String::as_str).collect()
493    }
494
495    /// Get the number of stored keys.
496    #[must_use]
497    pub fn len(&self) -> usize {
498        self.keys.len()
499    }
500
501    /// Check if the store is empty.
502    #[must_use]
503    pub fn is_empty(&self) -> bool {
504        self.keys.is_empty()
505    }
506}
507
508impl Default for ApiKeyStore {
509    fn default() -> Self {
510        Self::new()
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn create_and_validate() {
520        let mut store = ApiKeyStore::new();
521        let key = store.create_key("test", None).unwrap();
522        assert_eq!(store.len(), 1);
523
524        let name = store.validate(&key).unwrap();
525        assert_eq!(name, "test");
526    }
527
528    #[test]
529    fn invalid_key_rejected() {
530        let mut store = ApiKeyStore::new();
531        let _key = store.create_key("test", None).unwrap();
532
533        let result = store.validate("wrong-key");
534        assert!(result.is_err());
535        assert!(matches!(result, Err(AuthError::InvalidApiKey(_))));
536    }
537
538    #[test]
539    fn expired_key_rejected() {
540        let mut store = ApiKeyStore::new();
541        // Expire in the past
542        let key = store.create_key("expired", Some(0)).unwrap();
543
544        let result = store.validate(&key);
545        assert!(result.is_err());
546        assert!(matches!(result, Err(AuthError::ExpiredApiKey(_))));
547    }
548
549    #[test]
550    fn revoke_key() {
551        let mut store = ApiKeyStore::new();
552        let key = store.create_key("revokable", None).unwrap();
553
554        assert!(store.revoke("revokable"));
555        assert!(store.validate(&key).is_err());
556        assert!(!store.revoke("revokable")); // already revoked
557    }
558
559    #[test]
560    fn multiple_keys() {
561        let mut store = ApiKeyStore::new();
562        let key1 = store.create_key("admin", None).unwrap();
563        let key2 = store.create_key("reader", None).unwrap();
564        assert_eq!(store.len(), 2);
565
566        assert_eq!(store.validate(&key1).unwrap(), "admin");
567        assert_eq!(store.validate(&key2).unwrap(), "reader");
568    }
569
570    #[test]
571    fn list_keys() {
572        let mut store = ApiKeyStore::new();
573        let _k1 = store.create_key("a", None).unwrap();
574        let _k2 = store.create_key("b", None).unwrap();
575
576        let mut names = store.list_keys();
577        names.sort();
578        assert_eq!(names, vec!["a", "b"]);
579    }
580
581    #[test]
582    fn register_plaintext_duplicate_rejected() {
583        let mut store = ApiKeyStore::new();
584        store
585            .register_plaintext("admin", "my-secret", None)
586            .unwrap();
587        let err = store
588            .register_plaintext("admin", "other-secret", None)
589            .unwrap_err();
590        assert!(matches!(err, AuthError::Config(_)));
591    }
592
593    #[test]
594    fn per_prefix_rate_limit_isolates_keys() {
595        let mut store = ApiKeyStore::new();
596        let good_key = store.create_key("good", None).unwrap();
597
598        // Exhaust per-prefix limit with a specific bad key
599        let bad_key = "definitely-wrong-key";
600        for _ in 0..MAX_PREFIX_FAILURES_PER_WINDOW {
601            let _ = store.validate(bad_key);
602        }
603
604        // The bad key prefix is now rate-limited
605        let result = store.validate(bad_key);
606        assert!(result.is_err());
607
608        // But the good key still works (different prefix)
609        let result = store.validate(&good_key);
610        assert!(result.is_ok());
611    }
612}