openclaw_core/secrets/
mod.rs1use aes_gcm::{
8 Aes256Gcm, Nonce,
9 aead::{Aead, KeyInit},
10};
11use secrecy::{ExposeSecret, SecretBox};
12use std::path::PathBuf;
13use thiserror::Error;
14use zeroize::Zeroize;
15
16#[derive(Error, Debug)]
18pub enum CredentialError {
19 #[error("IO error: {0}")]
21 Io(#[from] std::io::Error),
22
23 #[error("Crypto error: {0}")]
25 Crypto(String),
26
27 #[error("UTF-8 error: {0}")]
29 Utf8(#[from] std::string::FromUtf8Error),
30
31 #[error("Credential not found: {0}")]
33 NotFound(String),
34}
35
36#[derive(Clone)]
41pub struct ApiKey(SecretBox<str>);
42
43impl ApiKey {
44 #[must_use]
46 pub fn new(key: String) -> Self {
47 Self(SecretBox::new(key.into_boxed_str()))
48 }
49
50 #[must_use]
54 pub fn expose(&self) -> &str {
55 self.0.expose_secret()
56 }
57}
58
59impl std::fmt::Debug for ApiKey {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "ApiKey([REDACTED])")
62 }
63}
64
65impl std::fmt::Display for ApiKey {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 write!(f, "[REDACTED]")
68 }
69}
70
71pub struct CredentialStore {
75 encryption_key: SecretBox<[u8; 32]>,
76 store_path: PathBuf,
77}
78
79impl CredentialStore {
80 #[must_use]
87 pub fn new(encryption_key: [u8; 32], store_path: PathBuf) -> Self {
88 Self {
89 encryption_key: SecretBox::new(Box::new(encryption_key)),
90 store_path,
91 }
92 }
93
94 pub fn store(&self, name: &str, credential: &ApiKey) -> Result<(), CredentialError> {
103 std::fs::create_dir_all(&self.store_path)?;
105
106 let encrypted = self.encrypt(credential.expose().as_bytes())?;
107
108 let path = self.store_path.join(format!("{name}.enc"));
109 std::fs::write(&path, &encrypted)?;
110
111 #[cfg(unix)]
113 {
114 use std::os::unix::fs::PermissionsExt;
115 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
116 }
117
118 Ok(())
119 }
120
121 pub fn load(&self, name: &str) -> Result<ApiKey, CredentialError> {
127 let path = self.store_path.join(format!("{name}.enc"));
128
129 if !path.exists() {
130 return Err(CredentialError::NotFound(name.to_string()));
131 }
132
133 let encrypted = std::fs::read(&path)?;
134 let mut decrypted = self.decrypt(&encrypted)?;
135
136 let key = ApiKey::new(String::from_utf8(decrypted.clone())?);
137
138 decrypted.zeroize();
140
141 Ok(key)
142 }
143
144 pub fn delete(&self, name: &str) -> Result<(), CredentialError> {
150 let path = self.store_path.join(format!("{name}.enc"));
151 if path.exists() {
152 std::fs::remove_file(&path)?;
153 }
154 Ok(())
155 }
156
157 pub fn list(&self) -> Result<Vec<String>, CredentialError> {
163 if !self.store_path.exists() {
164 return Ok(vec![]);
165 }
166
167 let mut names = Vec::new();
168 for entry in std::fs::read_dir(&self.store_path)? {
169 let entry = entry?;
170 if let Some(name) = entry.file_name().to_str() {
171 if let Some(name) = name.strip_suffix(".enc") {
172 names.push(name.to_string());
173 }
174 }
175 }
176 Ok(names)
177 }
178
179 fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, CredentialError> {
181 let cipher = Aes256Gcm::new(self.encryption_key.expose_secret().into());
182
183 let nonce_bytes: [u8; 12] = rand::random();
185 let nonce = Nonce::from_slice(&nonce_bytes);
186
187 let ciphertext = cipher
188 .encrypt(nonce, data)
189 .map_err(|e| CredentialError::Crypto(e.to_string()))?;
190
191 Ok([nonce_bytes.as_slice(), &ciphertext].concat())
193 }
194
195 fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>, CredentialError> {
197 if data.len() < 12 {
198 return Err(CredentialError::Crypto("Data too short".to_string()));
199 }
200
201 let (nonce_bytes, ciphertext) = data.split_at(12);
202 let cipher = Aes256Gcm::new(self.encryption_key.expose_secret().into());
203 let nonce = Nonce::from_slice(nonce_bytes);
204
205 cipher
206 .decrypt(nonce, ciphertext)
207 .map_err(|e| CredentialError::Crypto(e.to_string()))
208 }
209}
210
211#[must_use]
220pub fn scrub_secrets(text: &str, patterns: &[&str]) -> String {
221 let mut result = text.to_string();
222
223 for pattern in patterns {
224 let mut search_start = 0;
226 while let Some(start) = result[search_start..].find(pattern) {
227 let abs_start = search_start + start + pattern.len();
228
229 let end = result[abs_start..]
231 .find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '&' || c == ',')
232 .map_or(result.len(), |e| abs_start + e);
233
234 result.replace_range(abs_start..end, "[REDACTED]");
236
237 search_start = abs_start + "[REDACTED]".len();
238 }
239 }
240
241 result
242}
243
244pub const COMMON_SECRET_PATTERNS: &[&str] = &[
246 "api_key=",
247 "apikey=",
248 "api-key=",
249 "token=",
250 "secret=",
251 "password=",
252 "Authorization: Bearer ",
253 "Authorization: Basic ",
254 "x-api-key: ",
255];
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use tempfile::tempdir;
261
262 #[test]
263 fn test_api_key_redaction() {
264 let key = ApiKey::new("sk-secret-key-12345".to_string());
265
266 assert_eq!(format!("{key:?}"), "ApiKey([REDACTED])");
268 assert_eq!(format!("{key}"), "[REDACTED]");
269
270 assert_eq!(key.expose(), "sk-secret-key-12345");
272 }
273
274 #[test]
275 fn test_credential_store_roundtrip() {
276 let temp = tempdir().unwrap();
277 let encryption_key: [u8; 32] = rand::random();
278 let store = CredentialStore::new(encryption_key, temp.path().to_path_buf());
279
280 let original = ApiKey::new("my-secret-api-key".to_string());
281 store.store("test-cred", &original).unwrap();
282
283 let loaded = store.load("test-cred").unwrap();
284 assert_eq!(loaded.expose(), "my-secret-api-key");
285 }
286
287 #[test]
288 fn test_credential_not_found() {
289 let temp = tempdir().unwrap();
290 let encryption_key: [u8; 32] = rand::random();
291 let store = CredentialStore::new(encryption_key, temp.path().to_path_buf());
292
293 let result = store.load("nonexistent");
294 assert!(matches!(result, Err(CredentialError::NotFound(_))));
295 }
296
297 #[test]
298 fn test_credential_list() {
299 let temp = tempdir().unwrap();
300 let encryption_key: [u8; 32] = rand::random();
301 let store = CredentialStore::new(encryption_key, temp.path().to_path_buf());
302
303 store
304 .store("cred1", &ApiKey::new("value1".to_string()))
305 .unwrap();
306 store
307 .store("cred2", &ApiKey::new("value2".to_string()))
308 .unwrap();
309
310 let names = store.list().unwrap();
311 assert_eq!(names.len(), 2);
312 assert!(names.contains(&"cred1".to_string()));
313 assert!(names.contains(&"cred2".to_string()));
314 }
315
316 #[test]
317 fn test_scrub_secrets() {
318 let text = "Error: api_key=sk-12345 failed with token=abc123";
319 let scrubbed = scrub_secrets(text, &["api_key=", "token="]);
320 assert_eq!(
321 scrubbed,
322 "Error: api_key=[REDACTED] failed with token=[REDACTED]"
323 );
324 }
325
326 #[test]
327 fn test_scrub_secrets_with_quotes() {
328 let text = r#"{"api_key":"sk-secret","other":"value"}"#;
329 let scrubbed = scrub_secrets(text, &["api_key\":\""]);
330 assert!(scrubbed.contains("[REDACTED]"));
331 assert!(!scrubbed.contains("sk-secret"));
332 }
333}