use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use dashmap::DashMap;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tracing::debug;
use zeroize::Zeroize;
use crate::auth::error::AuthError;
fn key_prefix(raw_key: &str) -> [u8; 8] {
let hash = Sha256::digest(raw_key.as_bytes());
let mut prefix = [0u8; 8];
prefix.copy_from_slice(&hash[..8]);
prefix
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ApiKeyEntry {
pub name: String,
pub hash: String,
pub expires_at: Option<u64>,
pub created_at: u64,
#[serde(default)]
pub namespaces: Vec<String>,
#[serde(default)]
pub admin: bool,
#[serde(default)]
pub roles: Vec<String>,
}
impl ApiKeyEntry {
#[must_use]
pub fn allows_namespace(&self, namespace: &str) -> bool {
self.namespaces.is_empty() || self.namespaces.iter().any(|n| n == namespace)
}
}
#[derive(Clone)]
pub struct ApiKeyStore {
keys: HashMap<String, ApiKeyEntry>,
prefix_index: HashMap<[u8; 8], Vec<String>>,
per_prefix_failures: Arc<DashMap<[u8; 8], (u64, u64)>>,
failure_count: Arc<AtomicU64>,
failure_window_start: Arc<AtomicU64>,
}
impl std::fmt::Debug for ApiKeyStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKeyStore")
.field("keys", &self.keys.len())
.finish()
}
}
const MAX_FAILURES_PER_WINDOW: u64 = 20;
const MAX_PREFIX_FAILURES_PER_WINDOW: u64 = 5;
const FAILURE_WINDOW_SECS: u64 = 60;
impl ApiKeyStore {
#[must_use]
pub fn new() -> Self {
Self {
keys: HashMap::new(),
prefix_index: HashMap::new(),
per_prefix_failures: Arc::new(DashMap::new()),
failure_count: Arc::new(AtomicU64::new(0)),
failure_window_start: Arc::new(AtomicU64::new(0)),
}
}
#[must_use]
pub fn key_exists(&self, name: &str) -> bool {
self.keys.contains_key(name)
}
pub fn create_key(&mut self, name: &str, expires_at: Option<u64>) -> Result<String, AuthError> {
if self.keys.contains_key(name) {
return Err(AuthError::Config(format!(
"API key with name '{name}' already exists"
)));
}
let mut key_bytes = [0u8; 32];
super::fill_random(&mut key_bytes);
use base64::Engine;
let raw_key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key_bytes);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(raw_key.as_bytes())
.map_err(|e| AuthError::Config(format!("hash error: {e}")))?
.to_string();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let entry = ApiKeyEntry {
name: name.to_string(),
hash,
expires_at,
created_at: now,
namespaces: Vec::new(),
admin: false,
roles: Vec::new(),
};
self.keys.insert(name.to_string(), entry);
self.prefix_index
.entry(key_prefix(&raw_key))
.or_default()
.push(name.to_string());
debug!(name, "created API key");
Ok(raw_key)
}
pub fn bind_namespaces(&mut self, name: &str, namespaces: Vec<String>) -> bool {
match self.keys.get_mut(name) {
Some(entry) => {
entry.namespaces = namespaces;
true
}
None => false,
}
}
pub fn set_admin(&mut self, name: &str, admin: bool) -> bool {
match self.keys.get_mut(name) {
Some(entry) => {
entry.admin = admin;
true
}
None => false,
}
}
pub fn authenticate(&self, token: &str) -> Result<crate::auth::AuthContext, AuthError> {
let name = self.validate(token)?;
Ok(crate::auth::AuthContext {
namespaces: self.namespaces_for(&name).to_vec(),
admin: self.is_admin(&name),
roles: self.roles(&name),
principal: name,
method: crate::auth::AuthMethod::ApiKey,
claims: std::collections::HashMap::new(),
})
}
#[must_use]
pub fn entry(&self, name: &str) -> Option<&ApiKeyEntry> {
self.keys.get(name)
}
pub fn set_roles(&mut self, name: &str, roles: Vec<String>) -> bool {
match self.keys.get_mut(name) {
Some(entry) => {
entry.roles = roles;
true
}
None => false,
}
}
#[must_use]
pub fn roles(&self, name: &str) -> Vec<String> {
self.keys
.get(name)
.map(|e| e.roles.clone())
.unwrap_or_default()
}
#[must_use]
pub fn is_admin(&self, name: &str) -> bool {
self.keys.get(name).is_some_and(|e| e.admin)
}
#[must_use]
pub fn namespaces_for(&self, name: &str) -> &[String] {
self.keys
.get(name)
.map_or(&[][..], |entry| entry.namespaces.as_slice())
}
#[must_use]
pub fn unconfined_keys(&self) -> Vec<&str> {
self.keys
.values()
.filter(|e| e.namespaces.is_empty())
.map(|e| e.name.as_str())
.collect()
}
pub fn add_entry(&mut self, entry: ApiKeyEntry) {
self.keys.insert(entry.name.clone(), entry);
}
pub fn register_plaintext(
&mut self,
name: &str,
raw_key: &str,
expires_at: Option<u64>,
) -> Result<(), AuthError> {
if self.keys.contains_key(name) {
return Err(AuthError::Config(format!(
"API key with name '{name}' already exists"
)));
}
let argon2 = Argon2::default();
let mut hash = argon2
.hash_password(raw_key.as_bytes())
.map_err(|e| AuthError::Config(format!("hash error: {e}")))?
.to_string();
tracing::warn!(
name,
"registering API key from plaintext config — prefer runtime key generation",
);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let entry = ApiKeyEntry {
name: name.to_string(),
hash: hash.clone(),
expires_at,
created_at: now,
namespaces: Vec::new(),
admin: false,
roles: Vec::new(),
};
hash.zeroize();
self.keys.insert(name.to_string(), entry);
self.prefix_index
.entry(key_prefix(raw_key))
.or_default()
.push(name.to_string());
debug!(name, "registered plaintext API key");
Ok(())
}
pub fn validate(&self, raw_key: &str) -> Result<String, AuthError> {
let prefix = key_prefix(raw_key);
self.check_rate_limit(prefix)?;
let argon2 = Argon2::default();
let mut matched_names: Vec<&str> = Vec::new();
for (stored_prefix, names) in &self.prefix_index {
if bool::from(stored_prefix.ct_eq(&prefix)) {
matched_names.extend(names.iter().map(String::as_str));
}
}
for name in &matched_names {
if let Some(entry) = self.keys.get(*name) {
match self.try_verify(&argon2, entry, raw_key) {
Ok(()) => return Ok(entry.name.clone()),
Err(AuthError::InvalidApiKey(_)) => continue,
Err(other) => return Err(other),
}
}
}
for entry in self.keys.values() {
if matched_names.contains(&entry.name.as_str()) {
continue;
}
if let Err(e) = self.try_verify(&argon2, entry, raw_key) {
match e {
AuthError::InvalidApiKey(_) => continue,
other => return Err(other),
}
} else {
return Ok(entry.name.clone());
}
}
self.record_failure(prefix);
Err(AuthError::InvalidApiKey("key not found".into()))
}
fn check_rate_limit(&self, prefix: [u8; 8]) -> Result<(), AuthError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Some(entry) = self.per_prefix_failures.get(&prefix) {
let (count, window_start) = *entry;
if now.saturating_sub(window_start) < FAILURE_WINDOW_SECS
&& count >= MAX_PREFIX_FAILURES_PER_WINDOW
{
tracing::warn!("API key validation per-prefix rate limit exceeded");
return Err(AuthError::InvalidApiKey("rate limit exceeded".into()));
}
}
let window_start = self.failure_window_start.load(Ordering::Relaxed);
if now.saturating_sub(window_start) >= FAILURE_WINDOW_SECS {
self.failure_window_start.store(now, Ordering::Relaxed);
self.failure_count.store(0, Ordering::Relaxed);
}
if self.failure_count.load(Ordering::Relaxed) >= MAX_FAILURES_PER_WINDOW {
tracing::warn!("API key validation global rate limit exceeded");
return Err(AuthError::InvalidApiKey("rate limit exceeded".into()));
}
Ok(())
}
fn record_failure(&self, prefix: [u8; 8]) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut entry = self.per_prefix_failures.entry(prefix).or_insert((0, now));
let (ref mut count, ref mut window_start) = *entry;
if now.saturating_sub(*window_start) >= FAILURE_WINDOW_SECS {
*count = 1;
*window_start = now;
} else {
*count += 1;
}
self.failure_count.fetch_add(1, Ordering::Relaxed);
}
fn try_verify(
&self,
argon2: &Argon2<'_>,
entry: &ApiKeyEntry,
raw_key: &str,
) -> Result<(), AuthError> {
let parsed_hash = match PasswordHash::new(&entry.hash) {
Ok(h) => h,
Err(e) => {
tracing::warn!(
name = %entry.name,
error = %e,
"skipping API key with corrupted hash"
);
return Err(AuthError::InvalidApiKey("corrupted hash".into()));
}
};
if argon2
.verify_password(raw_key.as_bytes(), &parsed_hash)
.is_ok()
{
if let Some(expires) = entry.expires_at {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now > expires {
return Err(AuthError::ExpiredApiKey(entry.name.clone()));
}
}
return Ok(());
}
Err(AuthError::InvalidApiKey("no match".into()))
}
pub fn revoke(&mut self, name: &str) -> bool {
let removed = self.keys.remove(name).is_some();
if removed {
self.prefix_index.retain(|_prefix, names| {
names.retain(|n| n != name);
!names.is_empty()
});
}
removed
}
pub fn list_keys(&self) -> Vec<&str> {
self.keys.keys().map(String::as_str).collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.keys.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
impl Default for ApiKeyStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_and_validate() {
let mut store = ApiKeyStore::new();
let key = store.create_key("test", None).unwrap();
assert_eq!(store.len(), 1);
let name = store.validate(&key).unwrap();
assert_eq!(name, "test");
}
#[test]
fn invalid_key_rejected() {
let mut store = ApiKeyStore::new();
let _key = store.create_key("test", None).unwrap();
let result = store.validate("wrong-key");
assert!(result.is_err());
assert!(matches!(result, Err(AuthError::InvalidApiKey(_))));
}
#[test]
fn expired_key_rejected() {
let mut store = ApiKeyStore::new();
let key = store.create_key("expired", Some(0)).unwrap();
let result = store.validate(&key);
assert!(result.is_err());
assert!(matches!(result, Err(AuthError::ExpiredApiKey(_))));
}
#[test]
fn revoke_key() {
let mut store = ApiKeyStore::new();
let key = store.create_key("revokable", None).unwrap();
assert!(store.revoke("revokable"));
assert!(store.validate(&key).is_err());
assert!(!store.revoke("revokable")); }
#[test]
fn multiple_keys() {
let mut store = ApiKeyStore::new();
let key1 = store.create_key("admin", None).unwrap();
let key2 = store.create_key("reader", None).unwrap();
assert_eq!(store.len(), 2);
assert_eq!(store.validate(&key1).unwrap(), "admin");
assert_eq!(store.validate(&key2).unwrap(), "reader");
}
#[test]
fn list_keys() {
let mut store = ApiKeyStore::new();
let _k1 = store.create_key("a", None).unwrap();
let _k2 = store.create_key("b", None).unwrap();
let mut names = store.list_keys();
names.sort();
assert_eq!(names, vec!["a", "b"]);
}
#[test]
fn register_plaintext_duplicate_rejected() {
let mut store = ApiKeyStore::new();
store
.register_plaintext("admin", "my-secret", None)
.unwrap();
let err = store
.register_plaintext("admin", "other-secret", None)
.unwrap_err();
assert!(matches!(err, AuthError::Config(_)));
}
#[test]
fn per_prefix_rate_limit_isolates_keys() {
let mut store = ApiKeyStore::new();
let good_key = store.create_key("good", None).unwrap();
let bad_key = "definitely-wrong-key";
for _ in 0..MAX_PREFIX_FAILURES_PER_WINDOW {
let _ = store.validate(bad_key);
}
let result = store.validate(bad_key);
assert!(result.is_err());
let result = store.validate(&good_key);
assert!(result.is_ok());
}
}