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::{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
41/// Custom Debug impl that redacts secret values to prevent accidental leakage
42/// in logs, panic messages, or debug output.
43impl std::fmt::Debug for LoadedCredential {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("LoadedCredential")
46            .field("inject_mode", &self.inject_mode)
47            .field("upstream", &self.upstream)
48            .field("raw_credential", &"[REDACTED]")
49            .field("header_name", &self.header_name)
50            .field("header_value", &"[REDACTED]")
51            .field("path_pattern", &self.path_pattern)
52            .field("path_replacement", &self.path_replacement)
53            .field("query_param_name", &self.query_param_name)
54            .finish()
55    }
56}
57
58/// Credential store for all configured routes.
59#[derive(Debug)]
60pub struct CredentialStore {
61    /// Map from route prefix to loaded credential
62    credentials: HashMap<String, LoadedCredential>,
63}
64
65impl CredentialStore {
66    /// Load credentials for all configured routes from the system keystore.
67    ///
68    /// Routes without a `credential_key` are skipped (no credential injection).
69    /// Routes whose credential is not found (e.g. unset env var) are skipped
70    /// with a warning — this allows profiles to declare optional credentials
71    /// without failing when they are unavailable.
72    ///
73    /// Returns an error only for hard failures (keystore access errors,
74    /// config parse errors, non-UTF-8 values).
75    pub fn load(routes: &[RouteConfig]) -> Result<Self> {
76        let mut credentials = HashMap::new();
77
78        for route in routes {
79            if let Some(ref key) = route.credential_key {
80                debug!(
81                    "Loading credential for route prefix: {} (mode: {:?})",
82                    route.prefix, route.inject_mode
83                );
84
85                let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
86                    Ok(s) => s,
87                    Err(nono::NonoError::SecretNotFound(msg)) => {
88                        debug!(
89                            "Credential '{}' not available, skipping route: {}",
90                            route.prefix, msg
91                        );
92                        continue;
93                    }
94                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
95                };
96
97                // Format header value based on mode
98                let header_value = match route.inject_mode {
99                    InjectMode::Header => {
100                        Zeroizing::new(route.credential_format.replace("{}", &secret))
101                    }
102                    InjectMode::BasicAuth => {
103                        // Base64 encode the credential for Basic auth
104                        let encoded =
105                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
106                        Zeroizing::new(format!("Basic {}", encoded))
107                    }
108                    // For url_path and query_param, header_value is not used
109                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
110                };
111
112                credentials.insert(
113                    route.prefix.clone(),
114                    LoadedCredential {
115                        inject_mode: route.inject_mode.clone(),
116                        upstream: route.upstream.clone(),
117                        raw_credential: secret,
118                        header_name: route.inject_header.clone(),
119                        header_value,
120                        path_pattern: route.path_pattern.clone(),
121                        path_replacement: route.path_replacement.clone(),
122                        query_param_name: route.query_param_name.clone(),
123                    },
124                );
125            }
126        }
127
128        Ok(Self { credentials })
129    }
130
131    /// Create an empty credential store (no credential injection).
132    #[must_use]
133    pub fn empty() -> Self {
134        Self {
135            credentials: HashMap::new(),
136        }
137    }
138
139    /// Get a credential for a route prefix, if configured.
140    #[must_use]
141    pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
142        self.credentials.get(prefix)
143    }
144
145    /// Check if any credentials are loaded.
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.credentials.is_empty()
149    }
150
151    /// Number of loaded credentials.
152    #[must_use]
153    pub fn len(&self) -> usize {
154        self.credentials.len()
155    }
156
157    /// Returns the set of route prefixes that have loaded credentials.
158    #[must_use]
159    pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
160        self.credentials.keys().cloned().collect()
161    }
162}
163
164/// The keyring service name used by nono for all credentials.
165/// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency.
166const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_empty_credential_store() {
174        let store = CredentialStore::empty();
175        assert!(store.is_empty());
176        assert_eq!(store.len(), 0);
177        assert!(store.get("/openai").is_none());
178    }
179
180    #[test]
181    fn test_loaded_credential_debug_redacts_secrets() {
182        // Security: Debug output must NEVER contain real secret values.
183        // This prevents accidental leakage in logs, panic messages, or
184        // tracing output at debug level.
185        let cred = LoadedCredential {
186            inject_mode: InjectMode::Header,
187            upstream: "https://api.openai.com".to_string(),
188            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
189            header_name: "Authorization".to_string(),
190            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
191            path_pattern: None,
192            path_replacement: None,
193            query_param_name: None,
194        };
195
196        let debug_output = format!("{:?}", cred);
197
198        // Must contain REDACTED markers
199        assert!(
200            debug_output.contains("[REDACTED]"),
201            "Debug output should contain [REDACTED], got: {}",
202            debug_output
203        );
204        // Must NOT contain the actual secret
205        assert!(
206            !debug_output.contains("sk-secret-12345"),
207            "Debug output must not contain the real secret"
208        );
209        assert!(
210            !debug_output.contains("Bearer sk-secret"),
211            "Debug output must not contain the formatted secret"
212        );
213        // Non-secret fields should still be visible
214        assert!(debug_output.contains("api.openai.com"));
215        assert!(debug_output.contains("Authorization"));
216    }
217
218    #[test]
219    fn test_load_no_credential_routes() {
220        let routes = vec![RouteConfig {
221            prefix: "/test".to_string(),
222            upstream: "https://example.com".to_string(),
223            credential_key: None,
224            inject_mode: InjectMode::Header,
225            inject_header: "Authorization".to_string(),
226            credential_format: "Bearer {}".to_string(),
227            path_pattern: None,
228            path_replacement: None,
229            query_param_name: None,
230            env_var: None,
231        }];
232        let store = CredentialStore::load(&routes);
233        assert!(store.is_ok());
234        let store = store.unwrap_or_else(|_| CredentialStore::empty());
235        assert!(store.is_empty());
236    }
237}