Skip to main content

nono_proxy/
credential.rs

1//! Credential loading and management for reverse proxy mode.
2//!
3//! Loads API credentials from the system keystore or 1Password at proxy startup.
4//! Credentials are stored in `Zeroizing<String>` and injected into
5//! requests via headers, URL paths, query parameters, or Basic Auth.
6//! The sandboxed agent never sees the real credentials.
7
8use crate::config::{CompiledEndpointRules, InjectMode, RouteConfig};
9use crate::error::{ProxyError, Result};
10use base64::Engine;
11use std::collections::HashMap;
12use tracing::debug;
13use zeroize::Zeroizing;
14
15/// A loaded credential ready for injection.
16pub struct LoadedCredential {
17    /// Injection mode
18    pub inject_mode: InjectMode,
19    /// Upstream URL (e.g., "https://api.openai.com")
20    pub upstream: String,
21    /// Raw credential value from keystore (for modes that need it directly)
22    pub raw_credential: Zeroizing<String>,
23
24    // --- Header mode ---
25    /// Header name to inject (e.g., "Authorization")
26    pub header_name: String,
27    /// Formatted header value (e.g., "Bearer sk-...")
28    pub header_value: Zeroizing<String>,
29
30    // --- URL path mode ---
31    /// Pattern to match in incoming path (with {} placeholder)
32    pub path_pattern: Option<String>,
33    /// Pattern for outgoing path (with {} placeholder)
34    pub path_replacement: Option<String>,
35
36    // --- Query param mode ---
37    /// Query parameter name
38    pub query_param_name: Option<String>,
39
40    // --- L7 endpoint filtering ---
41    /// Pre-compiled endpoint rules for method+path filtering.
42    /// Compiled once at load time to avoid per-request glob compilation.
43    pub endpoint_rules: CompiledEndpointRules,
44}
45
46/// Custom Debug impl that redacts secret values to prevent accidental leakage
47/// in logs, panic messages, or debug output.
48impl std::fmt::Debug for LoadedCredential {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("LoadedCredential")
51            .field("inject_mode", &self.inject_mode)
52            .field("upstream", &self.upstream)
53            .field("raw_credential", &"[REDACTED]")
54            .field("header_name", &self.header_name)
55            .field("header_value", &"[REDACTED]")
56            .field("path_pattern", &self.path_pattern)
57            .field("path_replacement", &self.path_replacement)
58            .field("query_param_name", &self.query_param_name)
59            .field("endpoint_rules", &self.endpoint_rules)
60            .finish()
61    }
62}
63
64/// Credential store for all configured routes.
65#[derive(Debug)]
66pub struct CredentialStore {
67    /// Map from route prefix to loaded credential
68    credentials: HashMap<String, LoadedCredential>,
69}
70
71impl CredentialStore {
72    /// Load credentials for all configured routes from the system keystore.
73    ///
74    /// Routes without a `credential_key` are skipped (no credential injection).
75    /// Routes whose credential is not found (e.g. unset env var) are skipped
76    /// with a warning — this allows profiles to declare optional credentials
77    /// without failing when they are unavailable.
78    ///
79    /// Returns an error only for hard failures (keystore access errors,
80    /// config parse errors, non-UTF-8 values).
81    pub fn load(routes: &[RouteConfig]) -> Result<Self> {
82        let mut credentials = HashMap::new();
83
84        for route in routes {
85            if let Some(ref key) = route.credential_key {
86                debug!(
87                    "Loading credential for route prefix: {} (mode: {:?})",
88                    route.prefix, route.inject_mode
89                );
90
91                let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
92                    Ok(s) => s,
93                    Err(nono::NonoError::SecretNotFound(msg)) => {
94                        debug!(
95                            "Credential '{}' not available, skipping route: {}",
96                            route.prefix, msg
97                        );
98                        continue;
99                    }
100                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
101                };
102
103                // Format header value based on mode
104                let header_value = match route.inject_mode {
105                    InjectMode::Header => {
106                        Zeroizing::new(route.credential_format.replace("{}", &secret))
107                    }
108                    InjectMode::BasicAuth => {
109                        // Base64 encode the credential for Basic auth
110                        let encoded =
111                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
112                        Zeroizing::new(format!("Basic {}", encoded))
113                    }
114                    // For url_path and query_param, header_value is not used
115                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
116                };
117
118                credentials.insert(
119                    route.prefix.clone(),
120                    LoadedCredential {
121                        inject_mode: route.inject_mode.clone(),
122                        upstream: route.upstream.clone(),
123                        raw_credential: secret,
124                        header_name: route.inject_header.clone(),
125                        header_value,
126                        path_pattern: route.path_pattern.clone(),
127                        path_replacement: route.path_replacement.clone(),
128                        query_param_name: route.query_param_name.clone(),
129                        endpoint_rules: CompiledEndpointRules::compile(&route.endpoint_rules)
130                            .map_err(|e| {
131                                ProxyError::Credential(format!("route '{}': {}", route.prefix, e))
132                            })?,
133                    },
134                );
135            }
136        }
137
138        Ok(Self { credentials })
139    }
140
141    /// Create an empty credential store (no credential injection).
142    #[must_use]
143    pub fn empty() -> Self {
144        Self {
145            credentials: HashMap::new(),
146        }
147    }
148
149    /// Get a credential for a route prefix, if configured.
150    #[must_use]
151    pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
152        self.credentials.get(prefix)
153    }
154
155    /// Check if any credentials are loaded.
156    #[must_use]
157    pub fn is_empty(&self) -> bool {
158        self.credentials.is_empty()
159    }
160
161    /// Number of loaded credentials.
162    #[must_use]
163    pub fn len(&self) -> usize {
164        self.credentials.len()
165    }
166
167    /// Returns the set of route prefixes that have loaded credentials.
168    #[must_use]
169    pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
170        self.credentials.keys().cloned().collect()
171    }
172}
173
174/// The keyring service name used by nono for all credentials.
175/// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency.
176const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_empty_credential_store() {
185        let store = CredentialStore::empty();
186        assert!(store.is_empty());
187        assert_eq!(store.len(), 0);
188        assert!(store.get("/openai").is_none());
189    }
190
191    #[test]
192    fn test_loaded_credential_debug_redacts_secrets() {
193        // Security: Debug output must NEVER contain real secret values.
194        // This prevents accidental leakage in logs, panic messages, or
195        // tracing output at debug level.
196        let cred = LoadedCredential {
197            inject_mode: InjectMode::Header,
198            upstream: "https://api.openai.com".to_string(),
199            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
200            header_name: "Authorization".to_string(),
201            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
202            path_pattern: None,
203            path_replacement: None,
204            query_param_name: None,
205            endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
206        };
207
208        let debug_output = format!("{:?}", cred);
209
210        // Must contain REDACTED markers
211        assert!(
212            debug_output.contains("[REDACTED]"),
213            "Debug output should contain [REDACTED], got: {}",
214            debug_output
215        );
216        // Must NOT contain the actual secret
217        assert!(
218            !debug_output.contains("sk-secret-12345"),
219            "Debug output must not contain the real secret"
220        );
221        assert!(
222            !debug_output.contains("Bearer sk-secret"),
223            "Debug output must not contain the formatted secret"
224        );
225        // Non-secret fields should still be visible
226        assert!(debug_output.contains("api.openai.com"));
227        assert!(debug_output.contains("Authorization"));
228    }
229
230    #[test]
231    fn test_load_no_credential_routes() {
232        let routes = vec![RouteConfig {
233            prefix: "/test".to_string(),
234            upstream: "https://example.com".to_string(),
235            credential_key: None,
236            inject_mode: InjectMode::Header,
237            inject_header: "Authorization".to_string(),
238            credential_format: "Bearer {}".to_string(),
239            path_pattern: None,
240            path_replacement: None,
241            query_param_name: None,
242            env_var: None,
243            endpoint_rules: vec![],
244        }];
245        let store = CredentialStore::load(&routes);
246        assert!(store.is_ok());
247        let store = store.unwrap_or_else(|_| CredentialStore::empty());
248        assert!(store.is_empty());
249    }
250}