Skip to main content

openclaw_core/secrets/
mod.rs

1//! Secrets management with encryption at rest.
2//!
3//! - `ApiKey`: Wrapper that prevents accidental logging
4//! - `CredentialStore`: Encrypted storage for credentials
5//! - `scrub_secrets`: Redact secrets from error messages
6
7use 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/// Errors from credential operations.
17#[derive(Error, Debug)]
18pub enum CredentialError {
19    /// IO error reading/writing credentials.
20    #[error("IO error: {0}")]
21    Io(#[from] std::io::Error),
22
23    /// Encryption/decryption failed.
24    #[error("Crypto error: {0}")]
25    Crypto(String),
26
27    /// Invalid UTF-8 in decrypted data.
28    #[error("UTF-8 error: {0}")]
29    Utf8(#[from] std::string::FromUtf8Error),
30
31    /// Credential not found.
32    #[error("Credential not found: {0}")]
33    NotFound(String),
34}
35
36/// API key wrapper that prevents accidental logging.
37///
38/// The inner value is wrapped with `secrecy::SecretBox` to ensure
39/// it's not accidentally printed in logs or debug output.
40#[derive(Clone)]
41pub struct ApiKey(SecretBox<str>);
42
43impl ApiKey {
44    /// Create a new API key.
45    #[must_use]
46    pub fn new(key: String) -> Self {
47        Self(SecretBox::new(key.into_boxed_str()))
48    }
49
50    /// Expose the secret for actual API calls.
51    ///
52    /// Use sparingly - only when actually sending to an API.
53    #[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
71/// Credential storage with encryption at rest.
72///
73/// Uses AES-256-GCM for authenticated encryption.
74pub struct CredentialStore {
75    encryption_key: SecretBox<[u8; 32]>,
76    store_path: PathBuf,
77}
78
79impl CredentialStore {
80    /// Create a new credential store.
81    ///
82    /// # Arguments
83    ///
84    /// * `encryption_key` - 32-byte encryption key (derive from master password with Argon2)
85    /// * `store_path` - Directory to store encrypted credentials
86    #[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    /// Store an encrypted credential.
95    ///
96    /// The credential is encrypted with AES-256-GCM and written to disk
97    /// with restrictive permissions (0600 on Unix).
98    ///
99    /// # Errors
100    ///
101    /// Returns error if encryption or file write fails.
102    pub fn store(&self, name: &str, credential: &ApiKey) -> Result<(), CredentialError> {
103        // Ensure store directory exists
104        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        // Set restrictive permissions on Unix
112        #[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    /// Load and decrypt a credential.
122    ///
123    /// # Errors
124    ///
125    /// Returns error if file not found, decryption fails, or invalid UTF-8.
126    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        // Clear decrypted data from memory
139        decrypted.zeroize();
140
141        Ok(key)
142    }
143
144    /// Delete a stored credential.
145    ///
146    /// # Errors
147    ///
148    /// Returns error if file deletion fails.
149    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    /// List all stored credential names.
158    ///
159    /// # Errors
160    ///
161    /// Returns error if directory read fails.
162    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    /// Encrypt data with AES-256-GCM.
180    fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>, CredentialError> {
181        let cipher = Aes256Gcm::new(self.encryption_key.expose_secret().into());
182
183        // Generate random 12-byte nonce
184        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        // Prepend nonce to ciphertext
192        Ok([nonce_bytes.as_slice(), &ciphertext].concat())
193    }
194
195    /// Decrypt data with AES-256-GCM.
196    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/// Scrub secrets from error messages and logs.
212///
213/// Replaces values after known secret patterns with `[REDACTED]`.
214///
215/// # Arguments
216///
217/// * `text` - Text to scrub
218/// * `patterns` - Patterns to look for (e.g., `["api_key=", "token="]`)
219#[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        // Find all occurrences of the pattern
225        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            // Find the end of the value (whitespace, quote, or end of string)
230            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            // Replace the value with [REDACTED]
235            result.replace_range(abs_start..end, "[REDACTED]");
236
237            search_start = abs_start + "[REDACTED]".len();
238        }
239    }
240
241    result
242}
243
244/// Common secret patterns to scrub from logs.
245pub 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        // Debug output should be redacted
267        assert_eq!(format!("{key:?}"), "ApiKey([REDACTED])");
268        assert_eq!(format!("{key}"), "[REDACTED]");
269
270        // But we can still expose when needed
271        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}