Skip to main content

nyx_agent_core/
secrets.rs

1//! OS-keychain backed secret storage.
2//!
3//! AI provider API keys live in the platform keychain via the
4//! `keyring` crate (Keychain on macOS, libsecret/secret-service on Linux,
5//! Credential Manager on Windows) so the operator's tokens never land
6//! in `nyx-agent.toml` or the JSON log. The keychain entry name is
7//! `<service>:<account>` where `service` defaults to `nyx-agent` and the
8//! account is a stable identifier such as `ai-anthropic`.
9//!
10//! Tracing redaction lives in [`super::log_init`] so even a stray
11//! `tracing::info!(token = ?secret)` cannot leak the bytes.
12//!
13//! In addition to the keyring backend, the store also supports an
14//! in-process [`SecretStore::memory`] backend for CI / unattended
15//! environments where the platform keychain is unavailable. The runtime
16//! selector is `NYX_AGENT_SECRETS_BACKEND`: set to `memory` to make
17//! [`SecretStore::from_env`] return the in-process backend instead of
18//! the keyring.
19
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22
23use keyring_core::{Entry, Error as KeyringError};
24use thiserror::Error;
25
26/// Account-name slot for the Anthropic API key.
27pub const ACCOUNT_AI_ANTHROPIC: &str = "ai-anthropic";
28/// Account-name slot for a local OpenAI-compatible runtime endpoint.
29/// Stored as a secret because operators commonly include a bearer
30/// token in the URL itself.
31pub const ACCOUNT_AI_LOCAL_LLM: &str = "ai-local-llm";
32
33/// Default keychain service identifier used by every entry. Operators
34/// running multiple installations can override via
35/// [`SecretStore::with_service`].
36pub const DEFAULT_SERVICE: &str = "nyx-agent";
37
38/// Environment variable that selects the secret backend at startup.
39/// Recognised values: `keyring` (default) and `memory`.
40pub 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/// Thin wrapper around `keyring::Entry` (or an in-process `HashMap` for
61/// CI / unattended environments) that scopes every secret under a
62/// single namespace. Cloning is cheap: the keyring variant clones the
63/// service string, the memory variant clones the `Arc`.
64#[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    /// Override the keyring service identifier. Useful in tests where
77    /// the suite wants its own namespace so it never clobbers a real
78    /// operator install.
79    pub fn with_service(service: impl Into<String>) -> Self {
80        Self { backend: Backend::Keyring(service.into()) }
81    }
82
83    /// In-process backend that stores secrets in a `HashMap` shared
84    /// between clones via an `Arc<Mutex<_>>`. Intended for CI and the
85    /// integration test suite, where the platform keychain is either
86    /// unavailable (Linux containers without `secret-service`) or
87    /// would prompt for unlock (macOS).
88    pub fn memory() -> Self {
89        Self { backend: Backend::Memory(Arc::new(Mutex::new(HashMap::new()))) }
90    }
91
92    /// Select the backend from the `NYX_AGENT_SECRETS_BACKEND` environment
93    /// variable: `memory` returns the in-process backend, anything else
94    /// (including unset) falls back to the keyring under the default
95    /// service name.
96    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    /// Persist `value` under `account`. Overwrites any existing value.
111    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    /// Fetch the stored value, or `Ok(None)` if no entry exists yet.
128    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    /// Remove the entry. Idempotent: missing entries are not an error.
148    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
176/// Known vendor-specific token prefixes. Matching is case-sensitive;
177/// upstream issuers all mint these in fixed case, so a case-insensitive
178/// match would only widen false positives.
179///
180/// Sources:
181/// * `sk-`, `sk_`: Anthropic and OpenAI style API keys.
182/// * `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`: GitHub personal-access,
183///   OAuth, user-server, server, and refresh tokens.
184/// * `glpat-`: GitLab personal access tokens.
185/// * `xoxb-`, `xoxp-`, `xoxa-`, `xoxr-`, `xoxs-`: Slack bot, user,
186///   app, refresh, and signing tokens.
187/// * `AKIA`, `ASIA`: AWS long-lived and short-lived access key IDs.
188///   Paired with a minimum length so the bare four-letter prefix in
189///   normal prose does not trip the filter.
190const 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
208/// Returns true if the given byte sequence looks like an issued credential.
209/// Used by the tracing redaction layer as a cheap pre-filter; callers
210/// that already know a value is a secret should redact it unconditionally.
211pub 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    // Fallback: 32+ char token containing an underscore. Catches
219    // workspace-scoped Anthropic keys without the `sk-` prefix and
220    // anything that follows the generic `prefix_random` convention.
221    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        // GitHub fine-grained / oauth / user-server / server / refresh.
240        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        // GitLab personal access token.
246        assert!(looks_like_secret("glpat-abcdEFGH1234ijkl"));
247        // Slack bot / user / app / refresh / signing tokens.
248        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        // AWS long-lived and short-lived access key ids.
254        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        // The bare four-letter AWS prefixes appear in legitimate prose
261        // ("ASIA region", "AKIA module") and must not trip the filter
262        // without the full 20-char access-key id length.
263        assert!(!looks_like_secret("ASIA"));
264        assert!(!looks_like_secret("AKIA"));
265        // Same for short `sk-` / `glpat-` strings that are too short to
266        // be real credentials.
267        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        // Save and restore the env var so we don't pollute sibling tests.
298        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}