use std::collections::HashMap;
use std::path::Path;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use dashmap::DashMap;
use rand::RngExt as _;
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use super::scope::Scope;
const API_KEY_PREFIX: &str = "aa_";
const API_KEY_HEX_LEN: usize = 32;
const KEY_LOOKUP_HEX_LEN: usize = 16;
#[derive(Debug, Clone)]
pub struct ApiKey(String);
impl ApiKey {
pub fn parse(raw: &str) -> Result<Self, ApiKeyError> {
let hex_part = raw.strip_prefix(API_KEY_PREFIX).ok_or(ApiKeyError::InvalidPrefix)?;
if hex_part.len() != API_KEY_HEX_LEN {
return Err(ApiKeyError::InvalidLength {
expected: API_KEY_HEX_LEN,
actual: hex_part.len(),
});
}
if !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(ApiKeyError::InvalidHex);
}
Ok(Self(raw.to_string()))
}
pub fn generate() -> Self {
let mut rng = rand::rng();
let mut hex_bytes = [0u8; API_KEY_HEX_LEN / 2];
rng.fill(&mut hex_bytes);
let hex = hex::encode(&hex_bytes);
Self(format!("{API_KEY_PREFIX}{hex}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn hash(&self) -> Result<String, ApiKeyError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(self.0.as_bytes(), &salt)
.map_err(|e| ApiKeyError::HashError(e.to_string()))?;
Ok(hash.to_string())
}
pub fn verify(&self, hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default().verify_password(self.0.as_bytes(), &parsed).is_ok()
}
pub fn lookup(&self) -> String {
let digest = Sha256::digest(self.0.as_bytes());
let mut out = String::with_capacity(KEY_LOOKUP_HEX_LEN);
for b in &digest[..KEY_LOOKUP_HEX_LEN / 2] {
out.push_str(&format!("{b:02x}"));
}
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyEntry {
pub id: String,
pub key_hash: String,
pub scopes: Vec<Scope>,
pub created_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_lookup: Option<String>,
}
pub struct ApiKeyStore {
entries: Vec<ApiKeyEntry>,
lookup_index: HashMap<String, Vec<usize>>,
unindexed: Vec<usize>,
revoked_ids: DashMap<String, ()>,
}
impl ApiKeyStore {
pub fn from_entries(entries: Vec<ApiKeyEntry>) -> Self {
let (lookup_index, unindexed) = Self::build_index(&entries);
Self {
entries,
lookup_index,
unindexed,
revoked_ids: DashMap::new(),
}
}
fn build_index(entries: &[ApiKeyEntry]) -> (HashMap<String, Vec<usize>>, Vec<usize>) {
let mut lookup_index: HashMap<String, Vec<usize>> = HashMap::new();
let mut unindexed = Vec::new();
for (idx, entry) in entries.iter().enumerate() {
match &entry.key_lookup {
Some(lookup) => lookup_index.entry(lookup.clone()).or_default().push(idx),
None => unindexed.push(idx),
}
}
(lookup_index, unindexed)
}
pub fn load(path: &Path) -> Result<Self, ApiKeyError> {
if !path.exists() {
return Ok(Self::from_entries(Vec::new()));
}
let content = std::fs::read_to_string(path).map_err(|e| ApiKeyError::Io(e.to_string()))?;
let entries: Vec<ApiKeyEntry> =
serde_json::from_str(&content).map_err(|e| ApiKeyError::ParseError(e.to_string()))?;
Ok(Self::from_entries(entries))
}
pub fn revoke(&self, key_id: &str) {
self.revoked_ids.insert(key_id.to_string(), ());
}
pub fn validate_detailed(&self, raw_key: &str) -> Result<&ApiKeyEntry, KeyNotValid> {
let key = ApiKey::parse(raw_key).map_err(|_| KeyNotValid::NotFound)?;
let lookup = key.lookup();
let candidates = self
.lookup_index
.get(&lookup)
.into_iter()
.flatten()
.chain(self.unindexed.iter())
.filter_map(|&idx| self.entries.get(idx));
match candidates.into_iter().find(|entry| key.verify(&entry.key_hash)) {
None => Err(KeyNotValid::NotFound),
Some(entry) => {
if self.revoked_ids.contains_key(&entry.id) {
Err(KeyNotValid::Revoked)
} else {
Ok(entry)
}
}
}
}
pub fn validate(&self, raw_key: &str) -> Option<&ApiKeyEntry> {
self.validate_detailed(raw_key).ok()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug)]
pub enum KeyNotValid {
NotFound,
Revoked,
}
#[derive(Debug, Error)]
pub enum ApiKeyError {
#[error("API key must start with '{API_KEY_PREFIX}'")]
InvalidPrefix,
#[error("API key hex portion must be {expected} characters (got {actual})")]
InvalidLength { expected: usize, actual: usize },
#[error("API key hex portion contains non-hex characters")]
InvalidHex,
#[error("failed to hash API key: {0}")]
HashError(String),
#[error("I/O error reading API keys file: {0}")]
Io(String),
#[error("failed to parse API keys file: {0}")]
ParseError(String),
}
mod hex {
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_key_generate_format() {
let key = ApiKey::generate();
let raw = key.as_str();
assert!(raw.starts_with("aa_"), "key should start with aa_");
assert_eq!(raw.len(), 3 + API_KEY_HEX_LEN, "key should be aa_ + 32 hex chars");
assert!(
raw[3..].chars().all(|c| c.is_ascii_hexdigit()),
"hex portion should be valid hex"
);
}
#[test]
fn test_api_key_parse_valid() {
let key = ApiKey::generate();
let parsed = ApiKey::parse(key.as_str());
assert!(parsed.is_ok());
}
#[test]
fn test_api_key_parse_invalid_prefix() {
let result = ApiKey::parse("bb_00112233445566778899aabbccddeeff");
assert!(matches!(result, Err(ApiKeyError::InvalidPrefix)));
}
#[test]
fn test_api_key_parse_invalid_length() {
let result = ApiKey::parse("aa_0011");
assert!(matches!(
result,
Err(ApiKeyError::InvalidLength {
expected: 32,
actual: 4
})
));
}
#[test]
fn test_api_key_parse_invalid_hex() {
let result = ApiKey::parse("aa_gggggggggggggggggggggggggggggggg");
assert!(matches!(result, Err(ApiKeyError::InvalidHex)));
}
#[test]
fn test_api_key_hash_verify_roundtrip() {
let key = ApiKey::generate();
let hash = key.hash().expect("hashing should succeed");
assert!(key.verify(&hash), "key should verify against its own hash");
}
#[test]
fn test_api_key_verify_wrong_key() {
let key1 = ApiKey::generate();
let key2 = ApiKey::generate();
let hash = key1.hash().expect("hashing should succeed");
assert!(!key2.verify(&hash), "different key should not verify");
}
#[test]
fn test_api_key_store_load_missing_file() {
let store = ApiKeyStore::load(Path::new("/nonexistent/path/keys.json"));
assert!(store.is_ok());
assert!(store.unwrap().is_empty());
}
#[test]
fn test_api_key_store_validate_roundtrip() {
let key = ApiKey::generate();
let hash = key.hash().expect("hashing should succeed");
let entry = ApiKeyEntry {
id: "test-key-1".to_string(),
key_hash: hash,
scopes: vec![Scope::Read, Scope::Write],
created_at: 1700000000,
label: Some("test key".to_string()),
team_id: None,
org_id: None,
key_lookup: Some(key.lookup()),
};
let store = ApiKeyStore::from_entries(vec![entry]);
let result = store.validate(key.as_str());
assert!(result.is_some());
assert_eq!(result.unwrap().id, "test-key-1");
}
#[test]
fn test_api_key_store_validate_wrong_key() {
let key1 = ApiKey::generate();
let key2 = ApiKey::generate();
let hash = key1.hash().expect("hashing should succeed");
let entry = ApiKeyEntry {
id: "test-key-1".to_string(),
key_hash: hash,
scopes: vec![Scope::Read],
created_at: 1700000000,
label: None,
team_id: None,
org_id: None,
key_lookup: Some(key1.lookup()),
};
let store = ApiKeyStore::from_entries(vec![entry]);
let result = store.validate(key2.as_str());
assert!(result.is_none());
}
#[test]
fn verify_returns_false_for_unparseable_hash() {
let key = ApiKey::generate();
assert!(!key.verify("not-a-valid-argon2-hash"));
}
#[test]
fn verify_round_trips_against_its_own_hash() {
let key = ApiKey::generate();
let hash = key.hash().expect("hash");
assert!(key.verify(&hash));
}
#[test]
fn store_len_is_empty_and_validate_detailed_distinguishes_revoked() {
let key = ApiKey::generate();
let entry = ApiKeyEntry {
id: "key-1".to_string(),
key_hash: key.hash().expect("hash"),
scopes: vec![Scope::Admin],
created_at: 1700000000,
label: None,
team_id: None,
org_id: None,
key_lookup: Some(key.lookup()),
};
let store = ApiKeyStore::from_entries(vec![entry]);
assert_eq!(store.len(), 1);
assert!(!store.is_empty());
assert!(store.validate(key.as_str()).is_some());
store.revoke("key-1");
assert!(matches!(
store.validate_detailed(key.as_str()),
Err(KeyNotValid::Revoked)
));
let other = ApiKey::generate();
assert!(matches!(
store.validate_detailed(other.as_str()),
Err(KeyNotValid::NotFound)
));
}
#[test]
fn empty_store_reports_is_empty() {
let store = ApiKeyStore::from_entries(vec![]);
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn invalid_token_is_not_verified_against_mismatched_lookup_bucket() {
let probe = ApiKey::generate();
let probe_hash = probe.hash().expect("hash");
let wrong_bucket = "deadbeefdeadbeef".to_string();
assert_ne!(
wrong_bucket,
probe.lookup(),
"fixture bucket must differ from the probe's real lookup"
);
let trap = ApiKeyEntry {
id: "trap".to_string(),
key_hash: probe_hash,
scopes: vec![Scope::Admin],
created_at: 0,
label: None,
team_id: None,
org_id: None,
key_lookup: Some(wrong_bucket),
};
let store = ApiKeyStore::from_entries(vec![trap]);
assert!(matches!(
store.validate_detailed(probe.as_str()),
Err(KeyNotValid::NotFound)
));
}
#[test]
fn legacy_entry_without_lookup_still_validates() {
let key = ApiKey::generate();
let entry = ApiKeyEntry {
id: "legacy".to_string(),
key_hash: key.hash().expect("hash"),
scopes: vec![Scope::Read],
created_at: 0,
label: None,
team_id: None,
org_id: None,
key_lookup: None,
};
let store = ApiKeyStore::from_entries(vec![entry]);
assert_eq!(store.validate(key.as_str()).map(|e| e.id.as_str()), Some("legacy"));
}
}