agentguard_auth/
api_key.rs

1//! API key management.
2//!
3//! Format: `<prefix>:<id>:<secret>` where the secret is 32 random bytes
4//! encoded base64url. The `:` separator avoids ambiguity with the
5//! base64url alphabet (which includes `_` and `-`). At rest, only the
6//! Argon2id hash of the secret is kept.
7
8use crate::error::{AuthError, Result};
9use argon2::password_hash::rand_core::OsRng;
10use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
11use argon2::{Algorithm, Argon2, Params, Version};
12use base64::Engine as _;
13use parking_lot::RwLock;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::time::Duration;
17
18/// Fixed Argon2id parameters used by all API key operations. Using a
19/// deterministic instance per call (rather than `Argon2::default()`)
20/// prevents global-state interference when tests run in parallel.
21///
22/// Parameters: 64 MiB memory, t=3, p=4. This is OWASP's 2024 production
23/// recommendation for auth secrets. The previous 19 MiB / t=2 / p=1
24/// was at the bottom of OWASP's "acceptable" range and ~10x cheaper
25/// to brute-force; the bump brings verification to ~150ms on server
26/// hardware, which is appropriate for an authentication boundary.
27fn argon2() -> Result<Argon2<'static>> {
28    let params = Params::new(64 * 1024, 3, 4, None)
29        .map_err(|e| AuthError::Other(format!("argon2 params: {e}")))?;
30    Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
31}
32
33/// Global lock that serializes the api_key tests. Argon2 has internal
34/// state that can race under high concurrency, even when we use fresh
35/// `Argon2` instances. The lock is held only for the duration of a single
36/// test, so it doesn't affect production performance.
37#[cfg(test)]
38fn api_key_test_lock() -> &'static parking_lot::Mutex<()> {
39    static LOCK: std::sync::OnceLock<parking_lot::Mutex<()>> = std::sync::OnceLock::new();
40    LOCK.get_or_init(|| parking_lot::Mutex::new(()))
41}
42
43/// A single API key.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ApiKey {
46    pub id: String,
47    pub prefix: String,
48    /// Argon2id hash of the secret half.
49    pub secret_hash: String,
50    pub scopes: Vec<String>,
51    /// Required request identity binding for standalone PDP decision routes.
52    /// The explicit `authorize:any` capability may act on another subject,
53    /// but the key still has a service identity for attribution and tenancy.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub identity: Option<ApiKeyIdentity>,
56    pub created_at: i64,
57    pub expires_at: Option<i64>,
58    pub last_used_at: Option<i64>,
59    pub revoked_at: Option<i64>,
60}
61
62/// Cedar identity a standalone API key is allowed to represent.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ApiKeyIdentity {
65    pub subject_type: String,
66    pub subject_id: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub tenant_id: Option<String>,
69}
70
71impl ApiKeyIdentity {
72    pub fn new(
73        subject_type: impl Into<String>,
74        subject_id: impl Into<String>,
75        tenant_id: Option<String>,
76    ) -> Result<Self> {
77        let subject_type = subject_type.into();
78        let subject_id = subject_id.into();
79        if !matches!(subject_type.as_str(), "User" | "Agent") || subject_id.trim().is_empty() {
80            return Err(AuthError::Other(
81                "API key identity must use User or Agent with a non-empty subject id".into(),
82            ));
83        }
84        if tenant_id
85            .as_ref()
86            .is_some_and(|tenant| tenant.trim().is_empty())
87        {
88            return Err(AuthError::Other(
89                "API key tenant id must be non-empty when provided".into(),
90            ));
91        }
92        let subject_id = subject_id.trim().to_owned();
93        let tenant_id = tenant_id.map(|tenant| tenant.trim().to_owned());
94        Ok(Self {
95            subject_type,
96            subject_id,
97            tenant_id,
98        })
99    }
100}
101
102impl ApiKey {
103    /// Whether the key grants a named capability. `*` grants all scopes.
104    pub fn has_scope(&self, required: &str) -> bool {
105        required.is_empty()
106            || self
107                .scopes
108                .iter()
109                .any(|scope| scope == "*" || scope == required)
110    }
111}
112
113/// In-memory API key store. Persists to JSON.
114#[derive(Debug, Default)]
115pub struct ApiKeyStore {
116    keys: RwLock<HashMap<String, ApiKey>>,
117}
118
119impl ApiKeyStore {
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Load from a JSON file. Missing file → empty store.
125    pub fn load_from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
126        let s = Self::new();
127        s.reload_from_file(path)?;
128        Ok(s)
129    }
130
131    /// Atomically replace the in-memory key set from a complete persisted
132    /// snapshot. A parse or I/O failure leaves the currently active set
133    /// untouched, so a partial secret projection cannot erase valid keys.
134    pub fn reload_from_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
135        let path = path.as_ref();
136        let keys = if path.exists() {
137            let text = std::fs::read_to_string(path)
138                .map_err(|e| AuthError::Other(format!("read: {}", e)))?;
139            let parsed: Vec<ApiKey> = serde_json::from_str(&text)
140                .map_err(|e| AuthError::Other(format!("parse: {}", e)))?;
141            let mut keys = HashMap::with_capacity(parsed.len());
142            for key in parsed {
143                validate_api_key_record(&key)?;
144                let id = key.id.clone();
145                if keys.insert(id.clone(), key).is_some() {
146                    return Err(AuthError::Other(format!(
147                        "duplicate API-key id in store: {id}"
148                    )));
149                }
150            }
151            keys
152        } else {
153            HashMap::new()
154        };
155        *self.keys.write() = keys;
156        Ok(())
157    }
158
159    /// Save to a JSON file.
160    pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
161        use std::io::Write;
162
163        let path = path.as_ref();
164        let guard = self.keys.read();
165        let mut keys: Vec<ApiKey> = guard.values().cloned().collect();
166        drop(guard);
167        keys.sort_by(|left, right| {
168            left.created_at
169                .cmp(&right.created_at)
170                .then(left.id.cmp(&right.id))
171        });
172        let text = serde_json::to_string_pretty(&keys)
173            .map_err(|e| AuthError::Other(format!("serialize: {}", e)))?;
174        let parent = path
175            .parent()
176            .filter(|p| !p.as_os_str().is_empty())
177            .unwrap_or_else(|| std::path::Path::new("."));
178        let name = path
179            .file_name()
180            .ok_or_else(|| AuthError::Other("API-key store path has no filename".into()))?;
181        let temporary = parent.join(format!(
182            ".{}.{}.tmp",
183            name.to_string_lossy(),
184            uuid::Uuid::new_v4()
185        ));
186
187        let write_result = (|| -> std::io::Result<()> {
188            let mut options = std::fs::OpenOptions::new();
189            options.write(true).create_new(true);
190            #[cfg(unix)]
191            {
192                use std::os::unix::fs::OpenOptionsExt;
193                options.mode(0o600);
194            }
195            let mut file = options.open(&temporary)?;
196            file.write_all(text.as_bytes())?;
197            file.sync_all()?;
198            #[cfg(windows)]
199            if path.exists() {
200                std::fs::remove_file(path)?;
201            }
202            std::fs::rename(&temporary, path)?;
203            #[cfg(unix)]
204            std::fs::File::open(parent)?.sync_all()?;
205            Ok(())
206        })();
207        if let Err(error) = write_result {
208            let _ = std::fs::remove_file(&temporary);
209            return Err(AuthError::Other(format!("write: {}", error)));
210        }
211        Ok(())
212    }
213
214    /// Create a new key. Returns the key record and the raw secret string
215    /// (the caller must surface it to the user once; we never store the raw).
216    ///
217    /// # Errors
218    /// Returns `AuthError::Other` if Argon2 fails (extremely rare; only
219    /// happens on out-of-memory).
220    ///
221    /// # Examples
222    /// ```
223    /// use agentguard_auth::ApiKeyStore;
224    /// use std::time::Duration;
225    /// let store = ApiKeyStore::new();
226    /// let (key, raw) = store.create("ag_live", vec!["read".into()], Some(Duration::from_secs(3600))).unwrap();
227    /// assert_eq!(key.prefix, "ag_live");
228    /// // Surface `raw` to the user once. It looks like: ag_live_<uuid>_<base64>
229    /// ```
230    pub fn create(
231        &self,
232        prefix: impl Into<String>,
233        scopes: Vec<String>,
234        ttl: Option<Duration>,
235    ) -> Result<(ApiKey, String)> {
236        self.create_with_identity(prefix.into(), scopes, ttl, None)
237    }
238
239    /// Create a key bound to one Cedar subject and optional tenant.
240    /// Decision routes accept only identity-bound keys and require the
241    /// `authorize` scope (or `*`).
242    pub fn create_bound(
243        &self,
244        prefix: impl Into<String>,
245        scopes: Vec<String>,
246        ttl: Option<Duration>,
247        identity: ApiKeyIdentity,
248    ) -> Result<(ApiKey, String)> {
249        let identity = ApiKeyIdentity::new(
250            identity.subject_type,
251            identity.subject_id,
252            identity.tenant_id,
253        )?;
254        self.create_with_identity(prefix.into(), scopes, ttl, Some(identity))
255    }
256
257    fn create_with_identity(
258        &self,
259        prefix: String,
260        scopes: Vec<String>,
261        ttl: Option<Duration>,
262        identity: Option<ApiKeyIdentity>,
263    ) -> Result<(ApiKey, String)> {
264        if prefix.trim().is_empty() || prefix.trim() != prefix || prefix.contains(':') {
265            return Err(AuthError::Other(
266                "API key prefix must be non-empty and must not contain ':'".into(),
267            ));
268        }
269        if scopes.iter().any(|scope| !valid_scope(scope)) {
270            return Err(AuthError::Other(
271                "API-key scopes must be non-empty and contain no whitespace".into(),
272            ));
273        }
274        let id = uuid::Uuid::new_v4().to_string();
275        let secret_bytes: [u8; 32] = {
276            use argon2::password_hash::rand_core::RngCore;
277            let mut buf = [0u8; 32];
278            OsRng.fill_bytes(&mut buf);
279            buf
280        };
281        let secret_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret_bytes);
282        let salt = SaltString::generate(&mut OsRng);
283        let argon = argon2()?;
284        let secret_hash = argon
285            .hash_password(secret_bytes.as_ref(), &salt)
286            .map_err(|e| AuthError::Other(format!("argon2: {}", e)))?
287            .to_string();
288
289        let now = chrono::Utc::now().timestamp();
290        let expires_at = match ttl {
291            Some(duration) => {
292                let seconds = i64::try_from(duration.as_secs()).map_err(|_| {
293                    AuthError::Other("API key TTL exceeds the supported timestamp range".into())
294                })?;
295                Some(now.checked_add(seconds).ok_or_else(|| {
296                    AuthError::Other("API key expiry exceeds the supported timestamp range".into())
297                })?)
298            }
299            None => None,
300        };
301
302        let key = ApiKey {
303            id: id.clone(),
304            prefix: prefix.clone(),
305            secret_hash,
306            scopes,
307            identity,
308            created_at: now,
309            expires_at,
310            last_used_at: None,
311            revoked_at: None,
312        };
313        self.keys.write().insert(id, key.clone());
314        let raw = format!("{}:{}:{}", prefix, key.id, secret_b64);
315        Ok((key, raw))
316    }
317
318    /// Verify a raw API key string. Returns a cloned `ApiKey` on success.
319    ///
320    /// Format: `<prefix>:<id>:<base64url-secret>`. The `:` separator ensures
321    /// the parse is unambiguous even when the prefix or secret contain `_`
322    /// (which is in the base64url alphabet).
323    pub fn verify(&self, raw: &str) -> Result<ApiKey> {
324        let parts: Vec<&str> = raw.split(':').collect();
325        if parts.len() != 3 {
326            return Err(AuthError::ApiKeyInvalid);
327        }
328        let (prefix, id, secret_b64) = (parts[0], parts[1], parts[2]);
329
330        let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD
331            .decode(secret_b64)
332            .map_err(|_| AuthError::ApiKeyInvalid)?;
333        let guard = self.keys.read();
334        let key = guard.get(id).ok_or(AuthError::ApiKeyInvalid)?.clone();
335        drop(guard);
336        if key.prefix != prefix {
337            return Err(AuthError::Other(format!(
338                "prefix mismatch: {} != {}",
339                key.prefix, prefix
340            )));
341        }
342        if let Some(exp) = key.expires_at {
343            if exp <= chrono::Utc::now().timestamp() {
344                return Err(AuthError::ApiKeyExpired);
345            }
346        }
347        if key.revoked_at.is_some() {
348            return Err(AuthError::ApiKeyRevoked);
349        }
350        let parsed = PasswordHash::new(&key.secret_hash)
351            .map_err(|e| AuthError::Other(format!("hash parse: {}", e)))?;
352        if argon2()?.verify_password(&secret, &parsed).is_err() {
353            return Err(AuthError::ApiKeyInvalid);
354        }
355        Ok(key)
356    }
357
358    /// Revoke a key by id.
359    pub fn revoke(&self, id: &str) -> Result<()> {
360        let mut guard = self.keys.write();
361        let key = guard.get_mut(id).ok_or(AuthError::ApiKeyInvalid)?;
362        key.revoked_at = Some(chrono::Utc::now().timestamp());
363        Ok(())
364    }
365
366    /// List all keys (no secrets).
367    pub fn list(&self) -> Vec<ApiKey> {
368        self.keys.read().values().cloned().collect()
369    }
370}
371
372/// Validate persisted records before they become the live authentication set.
373/// In particular, the PHC parameters control verification cost; an operator
374/// typo or corrupted snapshot must not introduce unbounded Argon2 work on the
375/// request path. Legacy hashes with lower costs remain readable.
376fn validate_api_key_record(key: &ApiKey) -> Result<()> {
377    if key.id.trim().is_empty() || key.id.trim() != key.id || key.id.contains(':') {
378        return Err(AuthError::Other(
379            "API-key id must be non-empty and contain no ':'".into(),
380        ));
381    }
382    if key.prefix.trim().is_empty() || key.prefix.trim() != key.prefix || key.prefix.contains(':') {
383        return Err(AuthError::Other(
384            "API-key prefix must be non-empty and contain no ':'".into(),
385        ));
386    }
387    if key.scopes.iter().any(|scope| !valid_scope(scope)) {
388        return Err(AuthError::Other(
389            "API-key scopes must be non-empty and contain no whitespace".into(),
390        ));
391    }
392    if let Some(identity) = &key.identity {
393        let normalized = ApiKeyIdentity::new(
394            identity.subject_type.clone(),
395            identity.subject_id.clone(),
396            identity.tenant_id.clone(),
397        )?;
398        if &normalized != identity {
399            return Err(AuthError::Other(
400                "API-key identity fields must be trimmed".into(),
401            ));
402        }
403    }
404
405    let hash = PasswordHash::new(&key.secret_hash)
406        .map_err(|_| AuthError::Other("API-key store contains an invalid password hash".into()))?;
407    let params = &hash.params;
408    let memory_kib = params.get_decimal("m");
409    let iterations = params.get_decimal("t");
410    let parallelism = params.get_decimal("p");
411    if hash.algorithm.as_str() != "argon2id"
412        || hash.version != Some(19)
413        || hash.salt.is_none()
414        || hash.hash.is_none()
415        || memory_kib.is_none_or(|value| !(8..=65_536).contains(&value))
416        || iterations.is_none_or(|value| !(1..=3).contains(&value))
417        || parallelism.is_none_or(|value| !(1..=4).contains(&value))
418        || memory_kib.unwrap_or_default() < parallelism.unwrap_or_default() * 8
419    {
420        return Err(AuthError::Other(
421            "API-key hash must use bounded Argon2id v19 parameters".into(),
422        ));
423    }
424    Ok(())
425}
426
427fn valid_scope(scope: &str) -> bool {
428    !scope.trim().is_empty() && !scope.bytes().any(|byte| byte.is_ascii_whitespace())
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn create_and_verify_roundtrip() {
437        let _guard = api_key_test_lock().lock();
438        let s = ApiKeyStore::new();
439        let (key, raw) = s
440            .create("ag_live_roundtrip", vec!["read".into()], None)
441            .unwrap();
442        assert_eq!(key.prefix, "ag_live_roundtrip");
443        let verified = s.verify(&raw).unwrap();
444        assert_eq!(verified.id, key.id);
445    }
446
447    #[test]
448    fn bound_key_persists_identity_and_scope() {
449        let _guard = api_key_test_lock().lock();
450        let store = ApiKeyStore::new();
451        let identity = ApiKeyIdentity::new("Agent", "research", Some("tenant-a".into())).unwrap();
452        let (created, raw) = store
453            .create_bound("ag", vec!["authorize".into()], None, identity.clone())
454            .unwrap();
455        let verified = store.verify(&raw).unwrap();
456        assert_eq!(verified.identity, Some(identity));
457        assert!(verified.has_scope("authorize"));
458        assert!(!verified.has_scope("metrics:read"));
459        assert!(created.has_scope("authorize"));
460    }
461
462    #[test]
463    fn identity_rejects_unknown_subject_type_and_empty_fields() {
464        assert!(ApiKeyIdentity::new("Service", "svc", None).is_err());
465        assert!(ApiKeyIdentity::new("User", "  ", None).is_err());
466        assert!(ApiKeyIdentity::new("Agent", "agent", Some(" ".into())).is_err());
467    }
468
469    #[test]
470    fn key_creation_rejects_invalid_prefix_and_expiry_overflow() {
471        let _guard = api_key_test_lock().lock();
472        let store = ApiKeyStore::new();
473        assert!(store.create("", vec![], None).is_err());
474        assert!(store.create(" ag ", vec![], None).is_err());
475        assert!(store.create("bad:prefix", vec![], None).is_err());
476        assert!(store.create("ag", vec!["bad scope".into()], None).is_err());
477        assert!(store
478            .create("ag", vec![], Some(Duration::from_secs(u64::MAX)))
479            .is_err());
480    }
481
482    #[test]
483    fn legacy_api_key_json_remains_readable_but_unbound() {
484        let legacy = serde_json::json!({
485            "id": "legacy",
486            "prefix": "ag",
487            "secret_hash": "hash",
488            "scopes": [],
489            "created_at": 0,
490            "expires_at": null,
491            "last_used_at": null,
492            "revoked_at": null
493        });
494        let key: ApiKey = serde_json::from_value(legacy).unwrap();
495        assert!(key.identity.is_none());
496    }
497
498    #[test]
499    fn save_to_file_replaces_complete_store_with_restrictive_permissions() {
500        let _guard = api_key_test_lock().lock();
501        let dir = tempfile::tempdir().unwrap();
502        let path = dir.path().join("keys.json");
503        let store = ApiKeyStore::new();
504        store.save_to_file(&path).unwrap();
505        let first = std::fs::read(&path).unwrap();
506        store
507            .create_bound(
508                "ag",
509                vec!["authorize".into()],
510                None,
511                ApiKeyIdentity::new("User", "alice", None).unwrap(),
512            )
513            .unwrap();
514        store.save_to_file(&path).unwrap();
515        let second = std::fs::read(&path).unwrap();
516        assert_ne!(first, second);
517        assert_eq!(ApiKeyStore::load_from_file(&path).unwrap().list().len(), 1);
518        #[cfg(unix)]
519        {
520            use std::os::unix::fs::PermissionsExt;
521            assert_eq!(
522                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
523                0o600
524            );
525        }
526        assert_eq!(
527            std::fs::read_dir(dir.path()).unwrap().count(),
528            1,
529            "temporary files should be removed after atomic replacement"
530        );
531    }
532
533    #[test]
534    fn reload_preserves_last_good_keys_when_replacement_snapshot_is_invalid() {
535        let _guard = api_key_test_lock().lock();
536        let dir = tempfile::tempdir().unwrap();
537        let path = dir.path().join("keys.json");
538        let admin = ApiKeyStore::new();
539        let (key, raw) = admin
540            .create_bound(
541                "ag",
542                vec!["authorize".into()],
543                None,
544                ApiKeyIdentity::new("User", "alice", None).unwrap(),
545            )
546            .unwrap();
547        admin.save_to_file(&path).unwrap();
548        let live = ApiKeyStore::load_from_file(&path).unwrap();
549        live.verify(&raw).unwrap();
550
551        std::fs::write(&path, b"not-json").unwrap();
552        assert!(live.reload_from_file(&path).is_err());
553        live.verify(&raw)
554            .expect("invalid snapshots retain the last known-good key set");
555
556        let mut malformed = admin.list();
557        malformed[0].secret_hash = "not-a-phc-hash".into();
558        std::fs::write(&path, serde_json::to_vec(&malformed).unwrap()).unwrap();
559        assert!(live.reload_from_file(&path).is_err());
560        live.verify(&raw)
561            .expect("malformed hash snapshots retain the last known-good key set");
562
563        let mut unbounded_cost = admin.list();
564        unbounded_cost[0].secret_hash = key.secret_hash.replace("m=65536", "m=65537");
565        assert_ne!(unbounded_cost[0].secret_hash, key.secret_hash);
566        std::fs::write(&path, serde_json::to_vec(&unbounded_cost).unwrap()).unwrap();
567        assert!(live.reload_from_file(&path).is_err());
568        live.verify(&raw)
569            .expect("unbounded hash-cost snapshots retain the last known-good key set");
570
571        let mut duplicate = admin.list();
572        duplicate.push(duplicate[0].clone());
573        std::fs::write(&path, serde_json::to_vec(&duplicate).unwrap()).unwrap();
574        assert!(live.reload_from_file(&path).is_err());
575        live.verify(&raw)
576            .expect("duplicate ids must not overwrite the active key set");
577
578        admin.revoke(&key.id).unwrap();
579        admin.save_to_file(&path).unwrap();
580        live.reload_from_file(&path).unwrap();
581        assert!(matches!(live.verify(&raw), Err(AuthError::ApiKeyRevoked)));
582    }
583
584    #[test]
585    fn wrong_secret_rejected() {
586        let _guard = api_key_test_lock().lock();
587        let s = ApiKeyStore::new();
588        let (_, mut raw) = s.create("ag", vec![], None).unwrap();
589        // Corrupt the secret by flipping the last character of the base64.
590        let last = raw.pop().unwrap();
591        let replacement = if last == 'A' { 'B' } else { 'A' };
592        let bad = format!("{}{}", raw, replacement);
593        let res = s.verify(&bad);
594        assert!(matches!(res, Err(AuthError::ApiKeyInvalid)));
595    }
596
597    #[test]
598    fn revoked_key_rejected() {
599        let _guard = api_key_test_lock().lock();
600        let s = ApiKeyStore::new();
601        let (key, raw) = s.create("ag", vec![], None).unwrap();
602        s.revoke(&key.id).unwrap();
603        let res = s.verify(&raw);
604        assert!(matches!(res, Err(AuthError::ApiKeyRevoked)));
605    }
606
607    #[test]
608    fn expired_key_rejected() {
609        let _guard = api_key_test_lock().lock();
610        let s = ApiKeyStore::new();
611        let (key, raw) = s.create("ag", vec![], None).unwrap();
612        s.keys.write().get_mut(&key.id).unwrap().expires_at = Some(chrono::Utc::now().timestamp());
613        assert!(matches!(s.verify(&raw), Err(AuthError::ApiKeyExpired)));
614    }
615}