1use 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
17const API_KEY_PREFIX: &str = "aa_";
19
20const API_KEY_HEX_LEN: usize = 32;
22
23const KEY_LOOKUP_HEX_LEN: usize = 16;
30
31#[derive(Debug, Clone)]
33pub struct ApiKey(String);
34
35impl ApiKey {
36 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 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 pub fn as_str(&self) -> &str {
69 &self.0
70 }
71
72 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct ApiKeyEntry {
111 pub id: String,
113 pub key_hash: String,
115 pub scopes: Vec<Scope>,
117 pub created_at: u64,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub label: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub team_id: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub org_id: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub key_lookup: Option<String>,
139}
140
141pub struct ApiKeyStore {
143 entries: Vec<ApiKeyEntry>,
144 lookup_index: HashMap<String, Vec<usize>>,
148 unindexed: Vec<usize>,
151 revoked_ids: DashMap<String, ()>,
153}
154
155impl ApiKeyStore {
156 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 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 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 pub fn revoke(&self, key_id: &str) {
203 self.revoked_ids.insert(key_id.to_string(), ());
204 }
205
206 pub fn validate_detailed(&self, raw_key: &str) -> Result<&ApiKeyEntry, KeyNotValid> {
212 let key = ApiKey::parse(raw_key).map_err(|_| KeyNotValid::NotFound)?;
213
214 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 pub fn validate(&self, raw_key: &str) -> Option<&ApiKeyEntry> {
247 self.validate_detailed(raw_key).ok()
248 }
249
250 pub fn len(&self) -> usize {
252 self.entries.len()
253 }
254
255 pub fn is_empty(&self) -> bool {
257 self.entries.is_empty()
258 }
259}
260
261#[derive(Debug)]
263pub enum KeyNotValid {
264 NotFound,
266 Revoked,
268}
269
270#[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
287mod 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 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 assert!(store.validate(key.as_str()).is_some());
438
439 store.revoke("key-1");
441 assert!(matches!(
442 store.validate_detailed(key.as_str()),
443 Err(KeyNotValid::Revoked)
444 ));
445
446 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 #[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 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 assert!(matches!(
495 store.validate_detailed(probe.as_str()),
496 Err(KeyNotValid::NotFound)
497 ));
498 }
499
500 #[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}