Skip to main content

aa_auth/
api_key.rs

1//! API key generation, validation, and storage.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use argon2::password_hash::SaltString;
7use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
8use dashmap::DashMap;
9use rand::RngExt as _;
10use rand_core::OsRng;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14
15use super::scope::Scope;
16
17/// Prefix for all Agent Assembly API keys.
18const API_KEY_PREFIX: &str = "aa_";
19
20/// Expected length of the hex portion of an API key.
21const API_KEY_HEX_LEN: usize = 32;
22
23/// Hex length of the per-key lookup index derived by [`ApiKey::lookup`].
24///
25/// 16 hex chars = 64 bits of SHA-256 output — enough that unrelated keys almost
26/// never share a bucket (so a bogus token normally selects zero candidates and
27/// runs argon2 zero times), while a collision only ever adds one extra argon2
28/// verification, never restoring the O(N) fan-out.
29const KEY_LOOKUP_HEX_LEN: usize = 16;
30
31/// An Agent Assembly API key in `aa_<32-hex-chars>` format.
32#[derive(Debug, Clone)]
33pub struct ApiKey(String);
34
35impl ApiKey {
36    /// Parse and validate a raw API key string.
37    ///
38    /// Returns an error if the key doesn't match `aa_<32-hex-chars>`.
39    pub fn parse(raw: &str) -> Result<Self, ApiKeyError> {
40        let hex_part = raw.strip_prefix(API_KEY_PREFIX).ok_or(ApiKeyError::InvalidPrefix)?;
41
42        if hex_part.len() != API_KEY_HEX_LEN {
43            return Err(ApiKeyError::InvalidLength {
44                expected: API_KEY_HEX_LEN,
45                actual: hex_part.len(),
46            });
47        }
48
49        if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
50            return Err(ApiKeyError::InvalidHex);
51        }
52
53        Ok(Self(raw.to_string()))
54    }
55
56    /// Generate a new cryptographically random API key.
57    ///
58    /// Returns the `ApiKey` and the plaintext string (for display to the user).
59    pub fn generate() -> Self {
60        let mut rng = rand::rng();
61        let mut hex_bytes = [0u8; API_KEY_HEX_LEN / 2];
62        rng.fill(&mut hex_bytes);
63        let hex = hex::encode(&hex_bytes);
64        Self(format!("{API_KEY_PREFIX}{hex}"))
65    }
66
67    /// Return the raw key string.
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71
72    /// Hash this key using argon2 for secure storage.
73    pub fn hash(&self) -> Result<String, ApiKeyError> {
74        let salt = SaltString::generate(&mut OsRng);
75        let argon2 = Argon2::default();
76        let hash = argon2
77            .hash_password(self.0.as_bytes(), &salt)
78            .map_err(|e| ApiKeyError::HashError(e.to_string()))?;
79        Ok(hash.to_string())
80    }
81
82    /// Verify this key against a stored argon2 hash.
83    pub fn verify(&self, hash: &str) -> bool {
84        let Ok(parsed) = PasswordHash::new(hash) else {
85            return false;
86        };
87        Argon2::default().verify_password(self.0.as_bytes(), &parsed).is_ok()
88    }
89
90    /// Derive the fast, non-secret lookup index for this key (AAASM-4075).
91    ///
92    /// A truncated SHA-256 of the raw key. Because SHA-256 is preimage-resistant,
93    /// persisting this alongside the argon2 hash leaks nothing about the key even
94    /// if the key store is exfiltrated, yet it lets [`ApiKeyStore::validate_detailed`]
95    /// select the single candidate entry in O(1) and run the expensive argon2
96    /// verification only on that candidate — never once per stored key. argon2
97    /// remains the sole authority for the final match.
98    pub fn lookup(&self) -> String {
99        let digest = Sha256::digest(self.0.as_bytes());
100        let mut out = String::with_capacity(KEY_LOOKUP_HEX_LEN);
101        for b in &digest[..KEY_LOOKUP_HEX_LEN / 2] {
102            out.push_str(&format!("{b:02x}"));
103        }
104        out
105    }
106}
107
108/// A stored API key entry with metadata.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ApiKeyEntry {
111    /// Unique identifier for this key.
112    pub id: String,
113    /// Argon2 hash of the key (never store plaintext).
114    pub key_hash: String,
115    /// Scopes granted to this key.
116    pub scopes: Vec<Scope>,
117    /// Unix timestamp when the key was created.
118    pub created_at: u64,
119    /// Optional human-readable label.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub label: Option<String>,
122    /// AAASM-3139 — the team this key is scoped to. When present, a non-admin
123    /// key is confined to its own team for per-tenant data. `None` leaves the
124    /// key unscoped (admin-gated for cross-tenant data), preserving legacy keys.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub team_id: Option<String>,
127    /// AAASM-3139 — the org this key is scoped to. See [`ApiKeyEntry::team_id`].
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub org_id: Option<String>,
130    /// AAASM-4075 — the fast lookup index ([`ApiKey::lookup`]) for this key.
131    ///
132    /// Populated when the entry is created from a known raw key so the store can
133    /// select it in O(1) and run argon2 only on this candidate. `None` marks a
134    /// legacy entry (e.g. loaded from a key file written before this field
135    /// existed); such entries fall back to the pre-index argon2 scan, so they do
136    /// not benefit from the anti-fan-out guarantee but remain fully valid.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub key_lookup: Option<String>,
139}
140
141/// In-memory store of API key entries loaded from a JSON file.
142pub struct ApiKeyStore {
143    entries: Vec<ApiKeyEntry>,
144    /// AAASM-4075 — maps a key's [`ApiKey::lookup`] index to the entry indices
145    /// sharing it, so `validate_detailed` selects candidates in O(1) instead of
146    /// running argon2 against every entry. Built once at construction.
147    lookup_index: HashMap<String, Vec<usize>>,
148    /// AAASM-4075 — indices of entries with no `key_lookup` (legacy). These are
149    /// unavoidably argon2-scanned on every call; the set is normally empty.
150    unindexed: Vec<usize>,
151    /// Runtime-revoked key IDs; checked during every `validate_detailed` call.
152    revoked_ids: DashMap<String, ()>,
153}
154
155impl ApiKeyStore {
156    /// Build a store directly from in-memory entries.
157    ///
158    /// Used by the local single-process entrypoint (AAASM-3369), which seeds a
159    /// single admin key from the environment (or a generated one) without a
160    /// keys-on-disk file.
161    pub fn from_entries(entries: Vec<ApiKeyEntry>) -> Self {
162        let (lookup_index, unindexed) = Self::build_index(&entries);
163        Self {
164            entries,
165            lookup_index,
166            unindexed,
167            revoked_ids: DashMap::new(),
168        }
169    }
170
171    /// Partition entries into the [`ApiKey::lookup`] index and the legacy
172    /// (no-`key_lookup`) fallback set. Shared by every constructor so the two
173    /// stay in sync (AAASM-4075).
174    fn build_index(entries: &[ApiKeyEntry]) -> (HashMap<String, Vec<usize>>, Vec<usize>) {
175        let mut lookup_index: HashMap<String, Vec<usize>> = HashMap::new();
176        let mut unindexed = Vec::new();
177        for (idx, entry) in entries.iter().enumerate() {
178            match &entry.key_lookup {
179                Some(lookup) => lookup_index.entry(lookup.clone()).or_default().push(idx),
180                None => unindexed.push(idx),
181            }
182        }
183        (lookup_index, unindexed)
184    }
185
186    /// Load API key entries from a JSON file.
187    ///
188    /// Returns an empty store if the file does not exist.
189    pub fn load(path: &Path) -> Result<Self, ApiKeyError> {
190        if !path.exists() {
191            return Ok(Self::from_entries(Vec::new()));
192        }
193
194        let content = std::fs::read_to_string(path).map_err(|e| ApiKeyError::Io(e.to_string()))?;
195        let entries: Vec<ApiKeyEntry> =
196            serde_json::from_str(&content).map_err(|e| ApiKeyError::ParseError(e.to_string()))?;
197        Ok(Self::from_entries(entries))
198    }
199
200    /// Mark a key ID as revoked; subsequent `validate_detailed` calls for that
201    /// key will return `Err(KeyNotValid::Revoked)`.
202    pub fn revoke(&self, key_id: &str) {
203        self.revoked_ids.insert(key_id.to_string(), ());
204    }
205
206    /// Validate a raw API key and distinguish *revoked* from *not-found*.
207    ///
208    /// Returns `Ok(&ApiKeyEntry)` on success, `Err(KeyNotValid::Revoked)` if the
209    /// key exists but has been revoked, or `Err(KeyNotValid::NotFound)` for any
210    /// other failure (parse error, wrong hash, unknown key).
211    pub fn validate_detailed(&self, raw_key: &str) -> Result<&ApiKeyEntry, KeyNotValid> {
212        let key = ApiKey::parse(raw_key).map_err(|_| KeyNotValid::NotFound)?;
213
214        // AAASM-4075 — select candidates by the cheap SHA-256 lookup index so an
215        // unauthenticated attacker bursting well-formed-but-invalid tokens cannot
216        // force one argon2 verification per stored key (a CPU/memory DoS). Only
217        // entries whose lookup matches, plus any legacy entries lacking the index,
218        // are argon2-verified; a bogus token normally selects zero indexed
219        // candidates. argon2 stays the sole authority for the final match.
220        let lookup = key.lookup();
221        let candidates = self
222            .lookup_index
223            .get(&lookup)
224            .into_iter()
225            .flatten()
226            .chain(self.unindexed.iter())
227            .filter_map(|&idx| self.entries.get(idx));
228
229        match candidates.into_iter().find(|entry| key.verify(&entry.key_hash)) {
230            None => Err(KeyNotValid::NotFound),
231            Some(entry) => {
232                if self.revoked_ids.contains_key(&entry.id) {
233                    Err(KeyNotValid::Revoked)
234                } else {
235                    Ok(entry)
236                }
237            }
238        }
239    }
240
241    /// Validate a raw API key string against stored entries.
242    ///
243    /// Returns the matching entry if the key is valid, or `None` if no match.
244    /// Use [`validate_detailed`](Self::validate_detailed) when the caller needs
245    /// to distinguish revoked keys from unknown keys.
246    pub fn validate(&self, raw_key: &str) -> Option<&ApiKeyEntry> {
247        self.validate_detailed(raw_key).ok()
248    }
249
250    /// Return the number of stored entries.
251    pub fn len(&self) -> usize {
252        self.entries.len()
253    }
254
255    /// Check if the store is empty.
256    pub fn is_empty(&self) -> bool {
257        self.entries.is_empty()
258    }
259}
260
261/// Reason a key lookup failed during authentication.
262#[derive(Debug)]
263pub enum KeyNotValid {
264    /// No entry matched the supplied raw key.
265    NotFound,
266    /// The key exists but has been revoked.
267    Revoked,
268}
269
270/// Errors related to API key operations.
271#[derive(Debug, Error)]
272pub enum ApiKeyError {
273    #[error("API key must start with '{API_KEY_PREFIX}'")]
274    InvalidPrefix,
275    #[error("API key hex portion must be {expected} characters (got {actual})")]
276    InvalidLength { expected: usize, actual: usize },
277    #[error("API key hex portion contains non-hex characters")]
278    InvalidHex,
279    #[error("failed to hash API key: {0}")]
280    HashError(String),
281    #[error("I/O error reading API keys file: {0}")]
282    Io(String),
283    #[error("failed to parse API keys file: {0}")]
284    ParseError(String),
285}
286
287/// Hex encoding helper (avoids adding `hex` crate dependency).
288mod hex {
289    pub fn encode(bytes: &[u8]) -> String {
290        bytes.iter().map(|b| format!("{b:02x}")).collect()
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_api_key_generate_format() {
300        let key = ApiKey::generate();
301        let raw = key.as_str();
302        assert!(raw.starts_with("aa_"), "key should start with aa_");
303        assert_eq!(raw.len(), 3 + API_KEY_HEX_LEN, "key should be aa_ + 32 hex chars");
304        assert!(
305            raw[3..].chars().all(|c| c.is_ascii_hexdigit()),
306            "hex portion should be valid hex"
307        );
308    }
309
310    #[test]
311    fn test_api_key_parse_valid() {
312        let key = ApiKey::generate();
313        let parsed = ApiKey::parse(key.as_str());
314        assert!(parsed.is_ok());
315    }
316
317    #[test]
318    fn test_api_key_parse_invalid_prefix() {
319        let result = ApiKey::parse("bb_00112233445566778899aabbccddeeff");
320        assert!(matches!(result, Err(ApiKeyError::InvalidPrefix)));
321    }
322
323    #[test]
324    fn test_api_key_parse_invalid_length() {
325        let result = ApiKey::parse("aa_0011");
326        assert!(matches!(
327            result,
328            Err(ApiKeyError::InvalidLength {
329                expected: 32,
330                actual: 4
331            })
332        ));
333    }
334
335    #[test]
336    fn test_api_key_parse_invalid_hex() {
337        let result = ApiKey::parse("aa_gggggggggggggggggggggggggggggggg");
338        assert!(matches!(result, Err(ApiKeyError::InvalidHex)));
339    }
340
341    #[test]
342    fn test_api_key_hash_verify_roundtrip() {
343        let key = ApiKey::generate();
344        let hash = key.hash().expect("hashing should succeed");
345        assert!(key.verify(&hash), "key should verify against its own hash");
346    }
347
348    #[test]
349    fn test_api_key_verify_wrong_key() {
350        let key1 = ApiKey::generate();
351        let key2 = ApiKey::generate();
352        let hash = key1.hash().expect("hashing should succeed");
353        assert!(!key2.verify(&hash), "different key should not verify");
354    }
355
356    #[test]
357    fn test_api_key_store_load_missing_file() {
358        let store = ApiKeyStore::load(Path::new("/nonexistent/path/keys.json"));
359        assert!(store.is_ok());
360        assert!(store.unwrap().is_empty());
361    }
362
363    #[test]
364    fn test_api_key_store_validate_roundtrip() {
365        let key = ApiKey::generate();
366        let hash = key.hash().expect("hashing should succeed");
367        let entry = ApiKeyEntry {
368            id: "test-key-1".to_string(),
369            key_hash: hash,
370            scopes: vec![Scope::Read, Scope::Write],
371            created_at: 1700000000,
372            label: Some("test key".to_string()),
373            team_id: None,
374            org_id: None,
375            key_lookup: Some(key.lookup()),
376        };
377        let store = ApiKeyStore::from_entries(vec![entry]);
378
379        let result = store.validate(key.as_str());
380        assert!(result.is_some());
381        assert_eq!(result.unwrap().id, "test-key-1");
382    }
383
384    #[test]
385    fn test_api_key_store_validate_wrong_key() {
386        let key1 = ApiKey::generate();
387        let key2 = ApiKey::generate();
388        let hash = key1.hash().expect("hashing should succeed");
389        let entry = ApiKeyEntry {
390            id: "test-key-1".to_string(),
391            key_hash: hash,
392            scopes: vec![Scope::Read],
393            created_at: 1700000000,
394            label: None,
395            team_id: None,
396            org_id: None,
397            key_lookup: Some(key1.lookup()),
398        };
399        let store = ApiKeyStore::from_entries(vec![entry]);
400
401        let result = store.validate(key2.as_str());
402        assert!(result.is_none());
403    }
404
405    #[test]
406    fn verify_returns_false_for_unparseable_hash() {
407        let key = ApiKey::generate();
408        // A non-PHC string can't be parsed into a PasswordHash → verify is false.
409        assert!(!key.verify("not-a-valid-argon2-hash"));
410    }
411
412    #[test]
413    fn verify_round_trips_against_its_own_hash() {
414        let key = ApiKey::generate();
415        let hash = key.hash().expect("hash");
416        assert!(key.verify(&hash));
417    }
418
419    #[test]
420    fn store_len_is_empty_and_validate_detailed_distinguishes_revoked() {
421        let key = ApiKey::generate();
422        let entry = ApiKeyEntry {
423            id: "key-1".to_string(),
424            key_hash: key.hash().expect("hash"),
425            scopes: vec![Scope::Admin],
426            created_at: 1700000000,
427            label: None,
428            team_id: None,
429            org_id: None,
430            key_lookup: Some(key.lookup()),
431        };
432        let store = ApiKeyStore::from_entries(vec![entry]);
433        assert_eq!(store.len(), 1);
434        assert!(!store.is_empty());
435
436        // Valid before revocation.
437        assert!(store.validate(key.as_str()).is_some());
438
439        // After revoking the matching key id, validate_detailed reports Revoked.
440        store.revoke("key-1");
441        assert!(matches!(
442            store.validate_detailed(key.as_str()),
443            Err(KeyNotValid::Revoked)
444        ));
445
446        // An entirely unknown key is NotFound.
447        let other = ApiKey::generate();
448        assert!(matches!(
449            store.validate_detailed(other.as_str()),
450            Err(KeyNotValid::NotFound)
451        ));
452    }
453
454    #[test]
455    fn empty_store_reports_is_empty() {
456        let store = ApiKeyStore::from_entries(vec![]);
457        assert!(store.is_empty());
458        assert_eq!(store.len(), 0);
459    }
460
461    /// AAASM-4075 (anti-fan-out DoS): `validate_detailed` must argon2-verify only
462    /// the entries selected by the incoming key's lookup bucket, never every
463    /// stored key. We prove the fan-out is gone by filing an entry whose argon2
464    /// hash *would* match the probe token but under a *different* lookup bucket: a
465    /// naive fan-out scan would verify and match it, whereas the indexed scan must
466    /// not even reach it — so the probe is `NotFound`.
467    #[test]
468    fn invalid_token_is_not_verified_against_mismatched_lookup_bucket() {
469        let probe = ApiKey::generate();
470        let probe_hash = probe.hash().expect("hash");
471
472        // Entry whose hash matches `probe`, deliberately filed under a bucket that
473        // does not match `probe.lookup()`.
474        let wrong_bucket = "deadbeefdeadbeef".to_string();
475        assert_ne!(
476            wrong_bucket,
477            probe.lookup(),
478            "fixture bucket must differ from the probe's real lookup"
479        );
480        let trap = ApiKeyEntry {
481            id: "trap".to_string(),
482            key_hash: probe_hash,
483            scopes: vec![Scope::Admin],
484            created_at: 0,
485            label: None,
486            team_id: None,
487            org_id: None,
488            key_lookup: Some(wrong_bucket),
489        };
490        let store = ApiKeyStore::from_entries(vec![trap]);
491
492        // The probe's own hash is present, but the index never selects it (and so
493        // argon2 never runs on it), proving there is no per-key fan-out.
494        assert!(matches!(
495            store.validate_detailed(probe.as_str()),
496            Err(KeyNotValid::NotFound)
497        ));
498    }
499
500    /// AAASM-4075 backward-compat: an entry with no `key_lookup` (e.g. loaded from
501    /// a key file written before the index existed) still authenticates via the
502    /// legacy argon2 fallback scan.
503    #[test]
504    fn legacy_entry_without_lookup_still_validates() {
505        let key = ApiKey::generate();
506        let entry = ApiKeyEntry {
507            id: "legacy".to_string(),
508            key_hash: key.hash().expect("hash"),
509            scopes: vec![Scope::Read],
510            created_at: 0,
511            label: None,
512            team_id: None,
513            org_id: None,
514            key_lookup: None,
515        };
516        let store = ApiKeyStore::from_entries(vec![entry]);
517        assert_eq!(store.validate(key.as_str()).map(|e| e.id.as_str()), Some("legacy"));
518    }
519}