use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use keyring_core::{Entry, Error as KeyringError};
use thiserror::Error;
pub const ACCOUNT_AI_ANTHROPIC: &str = "ai-anthropic";
pub const ACCOUNT_AI_LOCAL_LLM: &str = "ai-local-llm";
pub const DEFAULT_SERVICE: &str = "nyx-agent";
pub const ENV_BACKEND: &str = "NYX_AGENT_SECRETS_BACKEND";
#[derive(Debug, Error)]
pub enum SecretError {
#[error("secret not found for account `{0}`")]
NotFound(String),
#[error("keyring backend rejected access to `{account}`: {source}")]
Backend {
account: String,
#[source]
source: KeyringError,
},
}
#[derive(Debug, Clone)]
enum Backend {
Keyring(String),
Memory(Arc<Mutex<HashMap<String, String>>>),
}
#[derive(Debug, Clone)]
pub struct SecretStore {
backend: Backend,
}
impl Default for SecretStore {
fn default() -> Self {
Self { backend: Backend::Keyring(DEFAULT_SERVICE.to_string()) }
}
}
impl SecretStore {
pub fn with_service(service: impl Into<String>) -> Self {
Self { backend: Backend::Keyring(service.into()) }
}
pub fn memory() -> Self {
Self { backend: Backend::Memory(Arc::new(Mutex::new(HashMap::new()))) }
}
pub fn from_env() -> Self {
match std::env::var(ENV_BACKEND).ok().as_deref() {
Some("memory") => Self::memory(),
_ => Self::default(),
}
}
pub fn service(&self) -> &str {
match &self.backend {
Backend::Keyring(s) => s.as_str(),
Backend::Memory(_) => "memory",
}
}
pub fn set(&self, account: &str, value: &str) -> Result<(), SecretError> {
match &self.backend {
Backend::Keyring(service) => {
let entry = keyring_entry(service, account)?;
entry
.set_password(value)
.map_err(|source| SecretError::Backend { account: account.to_string(), source })
}
Backend::Memory(map) => {
let mut g = map.lock().expect("memory secret store poisoned");
g.insert(account.to_string(), value.to_string());
Ok(())
}
}
}
pub fn get(&self, account: &str) -> Result<Option<String>, SecretError> {
match &self.backend {
Backend::Keyring(service) => {
let entry = keyring_entry(service, account)?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(KeyringError::NoEntry) => Ok(None),
Err(source) => {
Err(SecretError::Backend { account: account.to_string(), source })
}
}
}
Backend::Memory(map) => {
let g = map.lock().expect("memory secret store poisoned");
Ok(g.get(account).cloned())
}
}
}
pub fn delete(&self, account: &str) -> Result<(), SecretError> {
match &self.backend {
Backend::Keyring(service) => {
let entry = keyring_entry(service, account)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(KeyringError::NoEntry) => Ok(()),
Err(source) => {
Err(SecretError::Backend { account: account.to_string(), source })
}
}
}
Backend::Memory(map) => {
let mut g = map.lock().expect("memory secret store poisoned");
g.remove(account);
Ok(())
}
}
}
}
fn keyring_entry(service: &str, account: &str) -> Result<Entry, SecretError> {
keyring::use_native_store(true)
.map_err(|source| SecretError::Backend { account: account.to_string(), source })?;
Entry::new(service, account)
.map_err(|source| SecretError::Backend { account: account.to_string(), source })
}
const SECRET_PREFIXES: &[(&str, usize)] = &[
("sk-", 8),
("sk_", 8),
("ghp_", 16),
("gho_", 16),
("ghu_", 16),
("ghs_", 16),
("ghr_", 16),
("glpat-", 12),
("xoxb-", 16),
("xoxp-", 16),
("xoxa-", 16),
("xoxr-", 16),
("xoxs-", 16),
("AKIA", 20),
("ASIA", 20),
];
pub fn looks_like_secret(s: &str) -> bool {
let trimmed = s.trim_matches(|c: char| c == '"' || c == '\'');
for (prefix, min_len) in SECRET_PREFIXES {
if trimmed.len() >= *min_len && trimmed.starts_with(prefix) {
return true;
}
}
trimmed.len() >= 32 && trimmed.contains('_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn looks_like_secret_recognises_common_shapes() {
assert!(looks_like_secret("sk-ant-api03-aaaaa"));
assert!(looks_like_secret("sk-test-1234"));
assert!(looks_like_secret("ghp_abcdefghijklmnopqrstuvwxyz0123"));
assert!(!looks_like_secret("hello"));
assert!(!looks_like_secret("nyx-agent"));
}
#[test]
fn looks_like_secret_recognises_vendor_prefixes() {
assert!(looks_like_secret("ghp_abcdefghijklmnopqrstuvwxyz0123"));
assert!(looks_like_secret("gho_abcdefghijklmnopqrstuvwxyz0123"));
assert!(looks_like_secret("ghu_abcdefghijklmnopqrstuvwxyz0123"));
assert!(looks_like_secret("ghs_abcdefghijklmnopqrstuvwxyz0123"));
assert!(looks_like_secret("ghr_abcdefghijklmnopqrstuvwxyz0123"));
assert!(looks_like_secret("glpat-abcdEFGH1234ijkl"));
assert!(looks_like_secret("xoxb-1234567890-abcdefghij"));
assert!(looks_like_secret("xoxp-1234567890-abcdefghij"));
assert!(looks_like_secret("xoxa-1234567890-abcdefghij"));
assert!(looks_like_secret("xoxr-1234567890-abcdefghij"));
assert!(looks_like_secret("xoxs-1234567890-abcdefghij"));
assert!(looks_like_secret("AKIAABCDEFGHIJKLMNOP"));
assert!(looks_like_secret("ASIAABCDEFGHIJKLMNOP"));
}
#[test]
fn looks_like_secret_skips_short_prose_matches() {
assert!(!looks_like_secret("ASIA"));
assert!(!looks_like_secret("AKIA"));
assert!(!looks_like_secret("sk-"));
assert!(!looks_like_secret("glpat-"));
}
#[test]
fn looks_like_secret_strips_surrounding_quotes() {
assert!(looks_like_secret("\"ghp_abcdefghijklmnopqrstuvwxyz0123\""));
assert!(looks_like_secret("'sk-ant-api03-aaaaa'"));
}
#[test]
fn memory_backend_round_trips_values() {
let store = SecretStore::memory();
assert_eq!(store.get(ACCOUNT_AI_ANTHROPIC).unwrap(), None);
store.set(ACCOUNT_AI_ANTHROPIC, "sk-ant-test").unwrap();
assert_eq!(store.get(ACCOUNT_AI_ANTHROPIC).unwrap().as_deref(), Some("sk-ant-test"),);
store.delete(ACCOUNT_AI_ANTHROPIC).unwrap();
assert_eq!(store.get(ACCOUNT_AI_ANTHROPIC).unwrap(), None);
}
#[test]
fn memory_backend_is_shared_across_clones() {
let a = SecretStore::memory();
let b = a.clone();
a.set(ACCOUNT_AI_LOCAL_LLM, "bearer-xyz").unwrap();
assert_eq!(b.get(ACCOUNT_AI_LOCAL_LLM).unwrap().as_deref(), Some("bearer-xyz"));
}
#[test]
fn from_env_honours_memory_selector() {
let prior = std::env::var(ENV_BACKEND).ok();
std::env::set_var(ENV_BACKEND, "memory");
let s = SecretStore::from_env();
assert_eq!(s.service(), "memory");
match prior {
Some(v) => std::env::set_var(ENV_BACKEND, v),
None => std::env::remove_var(ENV_BACKEND),
}
}
}