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