Skip to main content

kavach/credential/
mod.rs

1//! Credential proxy — inject secrets into sandboxes without exposing them.
2//!
3//! Two injection mechanisms:
4//! - **Direct injection** — env vars, files, or stdin via [`CredentialProxy`]
5//! - **HTTP proxy** — transparent HTTP/HTTPS proxy that injects auth headers
6//!   for known hosts via [`http_proxy::start_proxy`]
7
8pub mod http_proxy;
9
10use serde::{Deserialize, Serialize};
11
12/// Reference to a secret (name only — the actual value is never in the sandbox config).
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SecretRef {
15    /// Secret name (e.g. "OPENAI_API_KEY", "DATABASE_URL").
16    pub name: String,
17    /// Injection method.
18    pub inject_via: InjectionMethod,
19}
20
21/// How a secret is delivered to the sandboxed process.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[non_exhaustive]
24pub enum InjectionMethod {
25    /// Set as an environment variable.
26    EnvVar {
27        /// Name of the environment variable.
28        var_name: String,
29    },
30    /// Write to a file inside the sandbox.
31    File {
32        /// Path inside the sandbox to write the secret to.
33        path: String,
34        /// File permissions (e.g. 0o600).
35        mode: u32,
36    },
37    /// Pipe through stdin.
38    Stdin,
39}
40
41/// Descriptor for a file-based secret injection.
42#[derive(Debug, Clone)]
43pub struct FileInjection {
44    /// Path inside the sandbox to write the secret to.
45    pub path: String,
46    /// Secret content.
47    pub content: String,
48    /// File permissions (e.g. 0o600).
49    pub mode: u32,
50}
51
52/// The credential proxy manages secret lifecycle for sandboxed execution.
53pub struct CredentialProxy {
54    secrets: std::collections::HashMap<String, String>,
55}
56
57impl CredentialProxy {
58    /// Create an empty credential proxy.
59    pub fn new() -> Self {
60        Self {
61            secrets: std::collections::HashMap::new(),
62        }
63    }
64
65    /// Register a secret (stored in memory, never written to disk).
66    pub fn register(&mut self, name: impl Into<String>, value: impl Into<String>) {
67        self.secrets.insert(name.into(), value.into());
68    }
69
70    /// Resolve a secret reference to its value.
71    #[inline]
72    #[must_use]
73    pub fn resolve(&self, secret_ref: &SecretRef) -> Option<&str> {
74        self.secrets.get(&secret_ref.name).map(|s| s.as_str())
75    }
76
77    /// Build environment variables for a set of secret refs.
78    /// Only returns refs with `InjectionMethod::EnvVar`.
79    #[must_use]
80    pub fn env_vars(&self, refs: &[SecretRef]) -> Vec<(String, String)> {
81        refs.iter()
82            .filter_map(|r| {
83                let value = self.resolve(r)?;
84                match &r.inject_via {
85                    InjectionMethod::EnvVar { var_name } => {
86                        Some((var_name.clone(), value.to_owned()))
87                    }
88                    _ => None,
89                }
90            })
91            .collect()
92    }
93
94    /// Build file injection descriptors for a set of secret refs.
95    /// Returns (path, content, mode) tuples for refs with `InjectionMethod::File`.
96    /// The caller is responsible for writing these files inside the sandbox.
97    #[must_use]
98    pub fn file_injections(&self, refs: &[SecretRef]) -> Vec<FileInjection> {
99        refs.iter()
100            .filter_map(|r| {
101                let value = self.resolve(r)?;
102                match &r.inject_via {
103                    InjectionMethod::File { path, mode } => Some(FileInjection {
104                        path: path.clone(),
105                        content: value.to_owned(),
106                        mode: *mode,
107                    }),
108                    _ => None,
109                }
110            })
111            .collect()
112    }
113
114    /// Build a stdin payload from all refs with `InjectionMethod::Stdin`.
115    /// Secrets are concatenated with newline separators.
116    /// Returns None if no stdin-injected secrets exist.
117    #[must_use]
118    pub fn stdin_payload(&self, refs: &[SecretRef]) -> Option<String> {
119        let parts: Vec<&str> = refs
120            .iter()
121            .filter_map(|r| {
122                if matches!(r.inject_via, InjectionMethod::Stdin) {
123                    self.resolve(r)
124                } else {
125                    None
126                }
127            })
128            .collect();
129
130        if parts.is_empty() {
131            None
132        } else {
133            Some(parts.join("\n"))
134        }
135    }
136
137    /// Number of registered secrets.
138    #[inline]
139    #[must_use]
140    pub fn len(&self) -> usize {
141        self.secrets.len()
142    }
143
144    /// Whether no secrets are registered.
145    #[inline]
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.secrets.is_empty()
149    }
150}
151
152impl Default for CredentialProxy {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn register_and_resolve() {
164        let mut proxy = CredentialProxy::new();
165        proxy.register("API_KEY", "sk-12345");
166        let r = SecretRef {
167            name: "API_KEY".into(),
168            inject_via: InjectionMethod::EnvVar {
169                var_name: "OPENAI_API_KEY".into(),
170            },
171        };
172        assert_eq!(proxy.resolve(&r), Some("sk-12345"));
173    }
174
175    #[test]
176    fn resolve_missing() {
177        let proxy = CredentialProxy::new();
178        let r = SecretRef {
179            name: "NOPE".into(),
180            inject_via: InjectionMethod::Stdin,
181        };
182        assert!(proxy.resolve(&r).is_none());
183    }
184
185    #[test]
186    fn env_vars_generation() {
187        let mut proxy = CredentialProxy::new();
188        proxy.register("KEY1", "val1");
189        proxy.register("KEY2", "val2");
190        let refs = vec![
191            SecretRef {
192                name: "KEY1".into(),
193                inject_via: InjectionMethod::EnvVar {
194                    var_name: "MY_KEY_1".into(),
195                },
196            },
197            SecretRef {
198                name: "KEY2".into(),
199                inject_via: InjectionMethod::File {
200                    path: "/tmp/secret".into(),
201                    mode: 0o600,
202                },
203            },
204        ];
205        let vars = proxy.env_vars(&refs);
206        assert_eq!(vars.len(), 1); // Only EnvVar injection
207        assert_eq!(vars[0], ("MY_KEY_1".into(), "val1".into()));
208    }
209
210    #[test]
211    fn empty_proxy() {
212        let proxy = CredentialProxy::new();
213        assert!(proxy.is_empty());
214        assert_eq!(proxy.len(), 0);
215    }
216
217    #[test]
218    fn file_injections() {
219        let mut proxy = CredentialProxy::new();
220        proxy.register("DB_CERT", "-----BEGIN CERTIFICATE-----\nMII...");
221        proxy.register("API_KEY", "sk-12345");
222
223        let refs = vec![
224            SecretRef {
225                name: "DB_CERT".into(),
226                inject_via: InjectionMethod::File {
227                    path: "/etc/ssl/db.pem".into(),
228                    mode: 0o600,
229                },
230            },
231            SecretRef {
232                name: "API_KEY".into(),
233                inject_via: InjectionMethod::EnvVar {
234                    var_name: "KEY".into(),
235                },
236            },
237        ];
238
239        let files = proxy.file_injections(&refs);
240        assert_eq!(files.len(), 1);
241        assert_eq!(files[0].path, "/etc/ssl/db.pem");
242        assert!(files[0].content.contains("CERTIFICATE"));
243        assert_eq!(files[0].mode, 0o600);
244    }
245
246    #[test]
247    fn file_injection_missing_secret() {
248        let proxy = CredentialProxy::new();
249        let refs = vec![SecretRef {
250            name: "MISSING".into(),
251            inject_via: InjectionMethod::File {
252                path: "/tmp/secret".into(),
253                mode: 0o400,
254            },
255        }];
256        assert!(proxy.file_injections(&refs).is_empty());
257    }
258
259    #[test]
260    fn stdin_payload() {
261        let mut proxy = CredentialProxy::new();
262        proxy.register("TOKEN_A", "secret-a");
263        proxy.register("TOKEN_B", "secret-b");
264
265        let refs = vec![
266            SecretRef {
267                name: "TOKEN_A".into(),
268                inject_via: InjectionMethod::Stdin,
269            },
270            SecretRef {
271                name: "TOKEN_B".into(),
272                inject_via: InjectionMethod::Stdin,
273            },
274        ];
275
276        let payload = proxy.stdin_payload(&refs).unwrap();
277        assert_eq!(payload, "secret-a\nsecret-b");
278    }
279
280    #[test]
281    fn stdin_payload_none_when_empty() {
282        let proxy = CredentialProxy::new();
283        let refs = vec![SecretRef {
284            name: "KEY".into(),
285            inject_via: InjectionMethod::EnvVar {
286                var_name: "X".into(),
287            },
288        }];
289        assert!(proxy.stdin_payload(&refs).is_none());
290    }
291
292    #[test]
293    fn stdin_payload_missing_secret() {
294        let proxy = CredentialProxy::new();
295        let refs = vec![SecretRef {
296            name: "MISSING".into(),
297            inject_via: InjectionMethod::Stdin,
298        }];
299        assert!(proxy.stdin_payload(&refs).is_none());
300    }
301
302    #[test]
303    fn mixed_injection_methods() {
304        let mut proxy = CredentialProxy::new();
305        proxy.register("ENV_SECRET", "env-val");
306        proxy.register("FILE_SECRET", "file-val");
307        proxy.register("STDIN_SECRET", "stdin-val");
308
309        let refs = vec![
310            SecretRef {
311                name: "ENV_SECRET".into(),
312                inject_via: InjectionMethod::EnvVar {
313                    var_name: "MY_ENV".into(),
314                },
315            },
316            SecretRef {
317                name: "FILE_SECRET".into(),
318                inject_via: InjectionMethod::File {
319                    path: "/run/secrets/key".into(),
320                    mode: 0o400,
321                },
322            },
323            SecretRef {
324                name: "STDIN_SECRET".into(),
325                inject_via: InjectionMethod::Stdin,
326            },
327        ];
328
329        assert_eq!(proxy.env_vars(&refs).len(), 1);
330        assert_eq!(proxy.file_injections(&refs).len(), 1);
331        assert!(proxy.stdin_payload(&refs).is_some());
332    }
333}