use std::collections::HashMap;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq;
use crate::error::ApiError;
#[derive(Debug, Clone)]
pub struct KeyStore {
secrets: HashMap<String, String>,
}
impl KeyStore {
pub fn parse(contents: &str) -> Self {
let mut secrets = HashMap::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key_id, secret)) = line.split_once(':') {
let key_id = key_id.trim();
let secret = secret.trim();
if !key_id.is_empty() && !secret.is_empty() {
secrets.insert(key_id.to_string(), secret.to_string());
}
}
}
KeyStore { secrets }
}
pub fn load(path: &Path) -> Result<Self, ApiError> {
let contents = std::fs::read_to_string(path).map_err(|err| {
ApiError::Internal(format!("cannot read keys file {}: {err}", path.display()))
})?;
let store = KeyStore::parse(&contents);
if store.is_empty() {
return Err(ApiError::Internal(format!(
"keys file {} has no usable 'key_id:secret' line",
path.display()
)));
}
Ok(store)
}
pub fn len(&self) -> usize {
self.secrets.len()
}
pub fn is_empty(&self) -> bool {
self.secrets.is_empty()
}
pub fn verify(&self, key_id: &str, secret: &str) -> Result<String, ApiError> {
let expected = self.secrets.get(key_id);
let Some(expected) = expected else {
let _ = "unauthenticated".as_bytes().ct_eq(secret.as_bytes());
return Err(ApiError::Unauthorized);
};
if expected.as_bytes().ct_eq(secret.as_bytes()).into() {
Ok(key_id.to_string())
} else {
Err(ApiError::Unauthorized)
}
}
}
pub fn parse_bearer(header: Option<&str>) -> Option<(&str, &str)> {
let raw = header?.trim();
let (scheme, token) = raw.split_once(char::is_whitespace)?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
let token = token.trim();
let (key_id, secret) = token.split_once(':')?;
let key_id = key_id.trim();
let secret = secret.trim();
if key_id.is_empty() || secret.is_empty() {
return None;
}
Some((key_id, secret))
}
pub fn default_keys_file() -> PathBuf {
PathBuf::from("./api_keys")
}