nyx_agent_core/
secrets.rs1use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22
23use keyring_core::{Entry, Error as KeyringError};
24use thiserror::Error;
25
26pub const ACCOUNT_AI_ANTHROPIC: &str = "ai-anthropic";
28pub const ACCOUNT_AI_LOCAL_LLM: &str = "ai-local-llm";
32
33pub const DEFAULT_SERVICE: &str = "nyx-agent";
37
38pub const ENV_BACKEND: &str = "NYX_AGENT_SECRETS_BACKEND";
41
42#[derive(Debug, Error)]
43pub enum SecretError {
44 #[error("secret not found for account `{0}`")]
45 NotFound(String),
46 #[error("keyring backend rejected access to `{account}`: {source}")]
47 Backend {
48 account: String,
49 #[source]
50 source: KeyringError,
51 },
52}
53
54#[derive(Debug, Clone)]
55enum Backend {
56 Keyring(String),
57 Memory(Arc<Mutex<HashMap<String, String>>>),
58}
59
60#[derive(Debug, Clone)]
65pub struct SecretStore {
66 backend: Backend,
67}
68
69impl Default for SecretStore {
70 fn default() -> Self {
71 Self { backend: Backend::Keyring(DEFAULT_SERVICE.to_string()) }
72 }
73}
74
75impl SecretStore {
76 pub fn with_service(service: impl Into<String>) -> Self {
80 Self { backend: Backend::Keyring(service.into()) }
81 }
82
83 pub fn memory() -> Self {
89 Self { backend: Backend::Memory(Arc::new(Mutex::new(HashMap::new()))) }
90 }
91
92 pub fn from_env() -> Self {
97 match std::env::var(ENV_BACKEND).ok().as_deref() {
98 Some("memory") => Self::memory(),
99 _ => Self::default(),
100 }
101 }
102
103 pub fn service(&self) -> &str {
104 match &self.backend {
105 Backend::Keyring(s) => s.as_str(),
106 Backend::Memory(_) => "memory",
107 }
108 }
109
110 pub fn set(&self, account: &str, value: &str) -> Result<(), SecretError> {
112 match &self.backend {
113 Backend::Keyring(service) => {
114 let entry = keyring_entry(service, account)?;
115 entry
116 .set_password(value)
117 .map_err(|source| SecretError::Backend { account: account.to_string(), source })
118 }
119 Backend::Memory(map) => {
120 let mut g = map.lock().expect("memory secret store poisoned");
121 g.insert(account.to_string(), value.to_string());
122 Ok(())
123 }
124 }
125 }
126
127 pub fn get(&self, account: &str) -> Result<Option<String>, SecretError> {
129 match &self.backend {
130 Backend::Keyring(service) => {
131 let entry = keyring_entry(service, account)?;
132 match entry.get_password() {
133 Ok(value) => Ok(Some(value)),
134 Err(KeyringError::NoEntry) => Ok(None),
135 Err(source) => {
136 Err(SecretError::Backend { account: account.to_string(), source })
137 }
138 }
139 }
140 Backend::Memory(map) => {
141 let g = map.lock().expect("memory secret store poisoned");
142 Ok(g.get(account).cloned())
143 }
144 }
145 }
146
147 pub fn delete(&self, account: &str) -> Result<(), SecretError> {
149 match &self.backend {
150 Backend::Keyring(service) => {
151 let entry = keyring_entry(service, account)?;
152 match entry.delete_credential() {
153 Ok(()) => Ok(()),
154 Err(KeyringError::NoEntry) => Ok(()),
155 Err(source) => {
156 Err(SecretError::Backend { account: account.to_string(), source })
157 }
158 }
159 }
160 Backend::Memory(map) => {
161 let mut g = map.lock().expect("memory secret store poisoned");
162 g.remove(account);
163 Ok(())
164 }
165 }
166 }
167}
168
169fn keyring_entry(service: &str, account: &str) -> Result<Entry, SecretError> {
170 keyring::use_native_store(true)
171 .map_err(|source| SecretError::Backend { account: account.to_string(), source })?;
172 Entry::new(service, account)
173 .map_err(|source| SecretError::Backend { account: account.to_string(), source })
174}
175
176const SECRET_PREFIXES: &[(&str, usize)] = &[
191 ("sk-", 8),
192 ("sk_", 8),
193 ("ghp_", 16),
194 ("gho_", 16),
195 ("ghu_", 16),
196 ("ghs_", 16),
197 ("ghr_", 16),
198 ("glpat-", 12),
199 ("xoxb-", 16),
200 ("xoxp-", 16),
201 ("xoxa-", 16),
202 ("xoxr-", 16),
203 ("xoxs-", 16),
204 ("AKIA", 20),
205 ("ASIA", 20),
206];
207
208pub fn looks_like_secret(s: &str) -> bool {
212 let trimmed = s.trim_matches(|c: char| c == '"' || c == '\'');
213 for (prefix, min_len) in SECRET_PREFIXES {
214 if trimmed.len() >= *min_len && trimmed.starts_with(prefix) {
215 return true;
216 }
217 }
218 trimmed.len() >= 32 && trimmed.contains('_')
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn looks_like_secret_recognises_common_shapes() {
230 assert!(looks_like_secret("sk-ant-api03-aaaaa"));
231 assert!(looks_like_secret("sk-test-1234"));
232 assert!(looks_like_secret("ghp_abcdefghijklmnopqrstuvwxyz0123"));
233 assert!(!looks_like_secret("hello"));
234 assert!(!looks_like_secret("nyx-agent"));
235 }
236
237 #[test]
238 fn looks_like_secret_recognises_vendor_prefixes() {
239 assert!(looks_like_secret("ghp_abcdefghijklmnopqrstuvwxyz0123"));
241 assert!(looks_like_secret("gho_abcdefghijklmnopqrstuvwxyz0123"));
242 assert!(looks_like_secret("ghu_abcdefghijklmnopqrstuvwxyz0123"));
243 assert!(looks_like_secret("ghs_abcdefghijklmnopqrstuvwxyz0123"));
244 assert!(looks_like_secret("ghr_abcdefghijklmnopqrstuvwxyz0123"));
245 assert!(looks_like_secret("glpat-abcdEFGH1234ijkl"));
247 assert!(looks_like_secret("xoxb-1234567890-abcdefghij"));
249 assert!(looks_like_secret("xoxp-1234567890-abcdefghij"));
250 assert!(looks_like_secret("xoxa-1234567890-abcdefghij"));
251 assert!(looks_like_secret("xoxr-1234567890-abcdefghij"));
252 assert!(looks_like_secret("xoxs-1234567890-abcdefghij"));
253 assert!(looks_like_secret("AKIAABCDEFGHIJKLMNOP"));
255 assert!(looks_like_secret("ASIAABCDEFGHIJKLMNOP"));
256 }
257
258 #[test]
259 fn looks_like_secret_skips_short_prose_matches() {
260 assert!(!looks_like_secret("ASIA"));
264 assert!(!looks_like_secret("AKIA"));
265 assert!(!looks_like_secret("sk-"));
268 assert!(!looks_like_secret("glpat-"));
269 }
270
271 #[test]
272 fn looks_like_secret_strips_surrounding_quotes() {
273 assert!(looks_like_secret("\"ghp_abcdefghijklmnopqrstuvwxyz0123\""));
274 assert!(looks_like_secret("'sk-ant-api03-aaaaa'"));
275 }
276
277 #[test]
278 fn memory_backend_round_trips_values() {
279 let store = SecretStore::memory();
280 assert_eq!(store.get(ACCOUNT_AI_ANTHROPIC).unwrap(), None);
281 store.set(ACCOUNT_AI_ANTHROPIC, "sk-ant-test").unwrap();
282 assert_eq!(store.get(ACCOUNT_AI_ANTHROPIC).unwrap().as_deref(), Some("sk-ant-test"),);
283 store.delete(ACCOUNT_AI_ANTHROPIC).unwrap();
284 assert_eq!(store.get(ACCOUNT_AI_ANTHROPIC).unwrap(), None);
285 }
286
287 #[test]
288 fn memory_backend_is_shared_across_clones() {
289 let a = SecretStore::memory();
290 let b = a.clone();
291 a.set(ACCOUNT_AI_LOCAL_LLM, "bearer-xyz").unwrap();
292 assert_eq!(b.get(ACCOUNT_AI_LOCAL_LLM).unwrap().as_deref(), Some("bearer-xyz"));
293 }
294
295 #[test]
296 fn from_env_honours_memory_selector() {
297 let prior = std::env::var(ENV_BACKEND).ok();
299 std::env::set_var(ENV_BACKEND, "memory");
300 let s = SecretStore::from_env();
301 assert_eq!(s.service(), "memory");
302 match prior {
303 Some(v) => std::env::set_var(ENV_BACKEND, v),
304 None => std::env::remove_var(ENV_BACKEND),
305 }
306 }
307}