pub mod file_store;
pub use file_store::FileLlmCredentialStore;
use axum::http::{HeaderMap, header};
use crate::error::ApiError;
#[derive(Clone, Debug)]
pub struct ResolvedCredential {
pub credential: String,
pub tenant: Option<String>,
}
#[async_trait::async_trait]
pub trait CredentialResolver: Send + Sync + 'static {
async fn resolve(&self, headers: &HeaderMap) -> Result<ResolvedCredential, ApiError>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
Ok,
Err,
}
impl Outcome {
#[must_use]
pub fn of<T, E>(result: &Result<T, E>) -> Self {
if result.is_ok() { Self::Ok } else { Self::Err }
}
}
#[derive(Debug)]
pub struct MeterEvent<'a> {
pub tenant: Option<&'a str>,
pub op: &'a str,
pub outcome: Outcome,
}
pub trait MeteringSink: Send + Sync + 'static {
fn record(&self, event: &MeterEvent<'_>);
}
#[derive(Clone, Debug)]
pub struct StoredLlmCredential {
pub provider: String,
pub model: String,
pub sealed: crate::crypto::SealedSecret,
pub fingerprint: String,
pub created_at_unix_secs: u64,
}
#[async_trait::async_trait]
pub trait LlmCredentialStore: Send + Sync + 'static {
async fn put(&self, namespace: &str, record: StoredLlmCredential) -> Result<(), ApiError>;
async fn get(&self, namespace: &str) -> Result<Option<StoredLlmCredential>, ApiError>;
async fn delete(&self, namespace: &str) -> Result<bool, ApiError>;
}
#[derive(Debug, Default)]
pub struct InMemoryLlmCredentialStore {
records: std::sync::RwLock<std::collections::HashMap<String, StoredLlmCredential>>,
}
#[async_trait::async_trait]
impl LlmCredentialStore for InMemoryLlmCredentialStore {
async fn put(&self, namespace: &str, record: StoredLlmCredential) -> Result<(), ApiError> {
let mut guard = self
.records
.write()
.map_err(|_| ApiError::internal("credential store lock poisoned"))?;
guard.insert(namespace.to_string(), record);
Ok(())
}
async fn get(&self, namespace: &str) -> Result<Option<StoredLlmCredential>, ApiError> {
let guard = self
.records
.read()
.map_err(|_| ApiError::internal("credential store lock poisoned"))?;
Ok(guard.get(namespace).cloned())
}
async fn delete(&self, namespace: &str) -> Result<bool, ApiError> {
let mut guard = self
.records
.write()
.map_err(|_| ApiError::internal("credential store lock poisoned"))?;
Ok(guard.remove(namespace).is_some())
}
}
#[must_use]
pub fn key_fingerprint(key: &str) -> String {
const TAIL: usize = 4;
let chars: Vec<char> = key.chars().collect();
if chars.len() <= TAIL * 2 {
return "…".to_string();
}
let tail: String = chars[chars.len() - TAIL..].iter().collect();
format!("…{tail}")
}
#[derive(Debug)]
pub struct BearerResolver;
#[async_trait::async_trait]
impl CredentialResolver for BearerResolver {
async fn resolve(&self, headers: &HeaderMap) -> Result<ResolvedCredential, ApiError> {
Ok(ResolvedCredential {
credential: bearer_token(headers)?,
tenant: None,
})
}
}
#[derive(Debug)]
pub struct NoopMeter;
impl MeteringSink for NoopMeter {
fn record(&self, _event: &MeterEvent<'_>) {}
}
pub fn bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(raw) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
let stripped = raw
.strip_prefix("Bearer ")
.or_else(|| raw.strip_prefix("bearer "));
if let Some(tok) = stripped {
let tok = tok.trim();
if !tok.is_empty() {
return Ok(tok.to_string());
}
}
}
if let Some(raw) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
let tok = raw.trim();
if !tok.is_empty() {
return Ok(tok.to_string());
}
}
Err(ApiError::unauthorized(
"missing API key — send `Authorization: Bearer <key>` or `X-API-Key`",
))
}
#[cfg(test)]
mod byok_tests {
use super::*;
#[test]
fn a_fingerprint_reveals_only_the_tail() {
let fp = key_fingerprint("sk-proj-abcdefghijklmnop4f2a");
assert_eq!(fp, "…4f2a");
assert!(!fp.contains("abcdefgh"), "body must not appear: {fp}");
}
#[test]
fn a_short_key_is_fully_masked() {
assert_eq!(key_fingerprint("sk-abc"), "…");
assert_eq!(key_fingerprint(""), "…");
assert_eq!(key_fingerprint("12345678"), "…", "boundary: exactly 2*TAIL");
assert_eq!(
key_fingerprint("123456789"),
"…6789",
"one over the boundary"
);
}
#[test]
fn a_multibyte_key_does_not_panic_on_the_tail() {
let fp = key_fingerprint("sk-ключ-日本語テスト");
assert!(fp.starts_with('…'));
}
#[tokio::test]
async fn the_in_memory_store_round_trips_and_deletes() {
use crate::crypto::{KeyRing, SecretBinding};
use zeroize::Zeroizing;
let ring = KeyRing::new(
[(
"k1".to_string(),
Zeroizing::new(vec![9u8; crate::crypto::KEK_LEN]),
)],
"k1",
)
.unwrap();
let binding = SecretBinding {
namespace: "acme",
provider: "openai",
};
let sealed = ring.seal(b"sk-live-secret", binding).unwrap();
let store = InMemoryLlmCredentialStore::default();
assert!(store.get("acme").await.unwrap().is_none(), "empty to start");
store
.put(
"acme",
StoredLlmCredential {
provider: "openai".into(),
model: "gpt-4o-mini".into(),
sealed: sealed.clone(),
fingerprint: key_fingerprint("sk-live-secret"),
created_at_unix_secs: 1,
},
)
.await
.unwrap();
let got = store.get("acme").await.unwrap().expect("stored");
assert_eq!(got.model, "gpt-4o-mini");
assert_eq!(
&*ring.open(&got.sealed, binding).unwrap(),
b"sk-live-secret"
);
assert!(store.delete("acme").await.unwrap(), "existed");
assert!(
!store.delete("acme").await.unwrap(),
"second delete is false"
);
assert!(store.get("acme").await.unwrap().is_none());
}
#[tokio::test]
async fn one_tenants_credential_is_not_visible_to_another() {
let store = InMemoryLlmCredentialStore::default();
let ring = crate::crypto::KeyRing::new(
[(
"k1".to_string(),
zeroize::Zeroizing::new(vec![3u8; crate::crypto::KEK_LEN]),
)],
"k1",
)
.unwrap();
let sealed = ring
.seal(
b"acme-key",
crate::crypto::SecretBinding {
namespace: "acme",
provider: "openai",
},
)
.unwrap();
store
.put(
"acme",
StoredLlmCredential {
provider: "openai".into(),
model: "gpt-4o-mini".into(),
sealed,
fingerprint: "…key".into(),
created_at_unix_secs: 1,
},
)
.await
.unwrap();
assert!(store.get("globex").await.unwrap().is_none());
}
}