#[cfg(feature = "http-api")]
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
#[cfg(feature = "http-api")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "http-api")]
use std::collections::HashMap;
#[cfg(feature = "http-api")]
use std::path::Path;
#[cfg(feature = "http-api")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeyRecord {
pub key_id: String,
pub key_hash: String,
pub agent_scope: Option<Vec<String>>,
pub description: String,
pub created_at: String,
#[serde(default)]
pub revoked: bool,
}
#[cfg(feature = "http-api")]
#[derive(Debug, Clone)]
pub struct ValidatedKey {
pub key_id: String,
pub agent_scope: Option<Vec<String>>,
}
#[cfg(feature = "http-api")]
pub struct ApiKeyStore {
records_by_id: HashMap<String, ApiKeyRecord>,
records: Vec<ApiKeyRecord>,
}
#[cfg(feature = "http-api")]
impl ApiKeyStore {
pub fn load_from_file(path: &Path) -> Result<Self, String> {
if !path.exists() {
tracing::info!(
"API keys file not found at {} — empty store",
path.display()
);
return Ok(Self {
records_by_id: HashMap::new(),
records: Vec::new(),
});
}
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read API keys file: {}", e))?;
let records: Vec<ApiKeyRecord> = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse API keys JSON: {}", e))?;
tracing::info!("Loaded {} API key record(s)", records.len());
let mut records_by_id = HashMap::with_capacity(records.len());
for record in &records {
records_by_id.insert(record.key_id.clone(), record.clone());
}
Ok(Self {
records_by_id,
records,
})
}
pub fn empty() -> Self {
Self {
records_by_id: HashMap::new(),
records: Vec::new(),
}
}
#[cfg(test)]
fn from_records(records: Vec<ApiKeyRecord>) -> Self {
let mut records_by_id = HashMap::with_capacity(records.len());
for record in &records {
records_by_id.insert(record.key_id.clone(), record.clone());
}
Self {
records_by_id,
records,
}
}
pub fn validate_key(&self, raw_key: &str) -> Option<ValidatedKey> {
let argon2 = Argon2::default();
if let Some((key_id, secret)) = raw_key.split_once('.') {
if !key_id.is_empty() && !secret.is_empty() {
if let Some(record) = self.records_by_id.get(key_id) {
if record.revoked {
return None;
}
let parsed_hash = match PasswordHash::new(&record.key_hash) {
Ok(h) => h,
Err(_) => return None,
};
if argon2
.verify_password(secret.as_bytes(), &parsed_hash)
.is_ok()
{
return Some(ValidatedKey {
key_id: record.key_id.clone(),
agent_scope: record.agent_scope.clone(),
});
}
}
return None;
}
}
tracing::warn!(
"API key without 'keyid.secret' prefix — using legacy O(n) scan. \
Re-issue keys in 'keyid.secret' format to avoid per-request DoS risk."
);
for record in &self.records {
if record.revoked {
continue;
}
let parsed_hash = match PasswordHash::new(&record.key_hash) {
Ok(h) => h,
Err(_) => continue,
};
if argon2
.verify_password(raw_key.as_bytes(), &parsed_hash)
.is_ok()
{
return Some(ValidatedKey {
key_id: record.key_id.clone(),
agent_scope: record.agent_scope.clone(),
});
}
}
None
}
pub fn hash_key(raw_key: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut rand::thread_rng());
let argon2 = Argon2::default();
let hash = argon2
.hash_password(raw_key.as_bytes(), &salt)
.map_err(|e| format!("Failed to hash key: {}", e))?;
Ok(hash.to_string())
}
pub fn has_records(&self) -> bool {
!self.records.is_empty()
}
}
#[cfg(all(test, feature = "http-api"))]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_legacy() {
let raw_key = "sk-test-super-secret-key-12345";
let hash = ApiKeyStore::hash_key(raw_key).unwrap();
let store = ApiKeyStore::from_records(vec![ApiKeyRecord {
key_id: "test-key".to_string(),
key_hash: hash,
agent_scope: None,
description: "Test key".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
revoked: false,
}]);
let result = store.validate_key(raw_key);
assert!(result.is_some());
assert_eq!(result.unwrap().key_id, "test-key");
}
#[test]
fn test_prefixed_key_o1_lookup() {
let secret = "super-secret-part";
let hash = ApiKeyStore::hash_key(secret).unwrap();
let store = ApiKeyStore::from_records(vec![ApiKeyRecord {
key_id: "sk_abc123".to_string(),
key_hash: hash,
agent_scope: Some(vec!["agent-1".to_string()]),
description: "Prefixed key".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
revoked: false,
}]);
let result = store.validate_key("sk_abc123.super-secret-part");
assert!(result.is_some());
let validated = result.unwrap();
assert_eq!(validated.key_id, "sk_abc123");
assert_eq!(validated.agent_scope, Some(vec!["agent-1".to_string()]));
}
#[test]
fn test_prefixed_key_wrong_secret() {
let secret = "correct-secret";
let hash = ApiKeyStore::hash_key(secret).unwrap();
let store = ApiKeyStore::from_records(vec![ApiKeyRecord {
key_id: "sk_abc123".to_string(),
key_hash: hash,
agent_scope: None,
description: "Test".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
revoked: false,
}]);
assert!(store.validate_key("sk_abc123.wrong-secret").is_none());
}
#[test]
fn test_prefixed_key_unknown_id() {
let secret = "some-secret";
let hash = ApiKeyStore::hash_key(secret).unwrap();
let store = ApiKeyStore::from_records(vec![ApiKeyRecord {
key_id: "sk_abc123".to_string(),
key_hash: hash,
agent_scope: None,
description: "Test".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
revoked: false,
}]);
assert!(store.validate_key("sk_unknown.some-secret").is_none());
}
#[test]
fn test_revoked_key_rejected() {
let secret = "revoked-secret";
let hash = ApiKeyStore::hash_key(secret).unwrap();
let store = ApiKeyStore::from_records(vec![ApiKeyRecord {
key_id: "revoked-key".to_string(),
key_hash: hash,
agent_scope: Some(vec!["agent-1".to_string()]),
description: "Revoked key".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
revoked: true,
}]);
assert!(store.validate_key("revoked-key.revoked-secret").is_none());
assert!(store.validate_key("revoked-secret").is_none());
}
#[test]
fn test_wrong_key_rejected() {
let raw_key = "sk-correct-key";
let hash = ApiKeyStore::hash_key(raw_key).unwrap();
let store = ApiKeyStore::from_records(vec![ApiKeyRecord {
key_id: "test-key".to_string(),
key_hash: hash,
agent_scope: None,
description: "Test key".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
revoked: false,
}]);
assert!(store.validate_key("sk-wrong-key").is_none());
}
#[test]
fn test_empty_store() {
let store = ApiKeyStore::empty();
assert!(store.validate_key("any-key").is_none());
assert!(store.validate_key("prefix.any-key").is_none());
assert!(!store.has_records());
}
#[test]
fn test_nonexistent_file() {
let store =
ApiKeyStore::load_from_file(Path::new("/tmp/nonexistent-api-keys-12345.json")).unwrap();
assert!(!store.has_records());
}
}