#[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::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: 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: 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());
Ok(Self { records })
}
pub fn empty() -> Self {
Self {
records: Vec::new(),
}
}
pub fn validate_key(&self, raw_key: &str) -> Option<ValidatedKey> {
let argon2 = Argon2::default();
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() {
let raw_key = "sk-test-super-secret-key-12345";
let hash = ApiKeyStore::hash_key(raw_key).unwrap();
let store = ApiKeyStore {
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_revoked_key_rejected() {
let raw_key = "sk-revoked-key-12345";
let hash = ApiKeyStore::hash_key(raw_key).unwrap();
let store = ApiKeyStore {
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(raw_key).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 {
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.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());
}
}