Skip to main content

clawbox_proxy/
store.rs

1//! Encrypted file-based credential storage.
2//!
3//! Uses AES-256-GCM to encrypt credentials at rest. The master key is
4//! sourced from the `CLAWBOX_MASTER_KEY` env var (hex-encoded, 32 bytes).
5
6use aes_gcm::{
7    Aes256Gcm, Nonce,
8    aead::{Aead, KeyInit},
9};
10use rand::RngCore;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13use zeroize::{Zeroize, Zeroizing};
14
15use crate::credentials::CredentialInjector;
16
17fn ser_zeroizing<S: serde::Serializer>(val: &Zeroizing<String>, s: S) -> Result<S::Ok, S::Error> {
18    s.serialize_str(val)
19}
20
21fn de_zeroizing<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Zeroizing<String>, D::Error> {
22    String::deserialize(d).map(Zeroizing::new)
23}
24
25/// Errors from credential store operations.
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum StoreError {
29    /// I/O error reading or writing the credential file.
30    #[error("I/O error: {0}")]
31    Io(#[from] std::io::Error),
32    /// Encryption failed.
33    #[error("encryption error: {0}")]
34    Encryption(String),
35    /// Decryption failed (wrong key or corrupted data).
36    #[error("decryption error: {0}")]
37    Decryption(String),
38    /// JSON serialization/deserialization error.
39    #[error("serialization error: {0}")]
40    Serialization(#[from] serde_json::Error),
41    /// Invalid master key format.
42    #[error("invalid master key: {0}")]
43    InvalidKey(String),
44}
45
46/// A single stored credential entry.
47#[derive(Debug, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct CredentialEntry {
50    /// Human-readable name for this credential.
51    pub name: String,
52    /// The secret value. Wrapped in `Zeroizing` so it is cleared from memory on drop.
53    #[serde(serialize_with = "ser_zeroizing", deserialize_with = "de_zeroizing")]
54    pub value: Zeroizing<String>,
55    /// Domain this credential applies to.
56    pub domain: String,
57    /// HTTP header name to inject.
58    pub header_name: String,
59    /// Prefix for the header value (e.g. "Bearer ").
60    pub header_prefix: String,
61}
62
63/// Encrypted file-based credential storage.
64#[non_exhaustive]
65pub struct CredentialStore {
66    entries: Vec<CredentialEntry>,
67    path: PathBuf,
68    key: [u8; 32],
69}
70
71impl CredentialStore {
72    /// Load from an encrypted file. If the file doesn't exist, returns an empty store.
73    pub fn load(path: impl AsRef<Path>, key: [u8; 32]) -> Result<Self, StoreError> {
74        let path = path.as_ref().to_path_buf();
75        let entries = if path.exists() {
76            let ciphertext = std::fs::read(&path)?;
77            if ciphertext.len() < 12 {
78                return Err(StoreError::Decryption("credential file too short".into()));
79            }
80            let (nonce_bytes, ct) = ciphertext.split_at(12);
81            let cipher = Aes256Gcm::new_from_slice(&key)
82                .map_err(|e| StoreError::InvalidKey(e.to_string()))?;
83            let nonce = Nonce::from_slice(nonce_bytes);
84            let plaintext = cipher
85                .decrypt(nonce, ct)
86                .map_err(|_| StoreError::Decryption("decryption failed — wrong key?".into()))?;
87            serde_json::from_slice(&plaintext)?
88        } else {
89            Vec::new()
90        };
91        Ok(Self { entries, path, key })
92    }
93
94    /// Save the store to its encrypted file.
95    pub fn save(&self) -> Result<(), StoreError> {
96        if let Some(parent) = self.path.parent() {
97            std::fs::create_dir_all(parent)?;
98        }
99        let plaintext = serde_json::to_vec(&self.entries)?;
100        let cipher = Aes256Gcm::new_from_slice(&self.key)
101            .map_err(|e| StoreError::InvalidKey(e.to_string()))?;
102        let mut nonce_bytes = [0u8; 12];
103        rand::rng().fill_bytes(&mut nonce_bytes);
104        let nonce = Nonce::from_slice(&nonce_bytes);
105        let ciphertext = cipher
106            .encrypt(nonce, plaintext.as_ref())
107            .map_err(|_| StoreError::Encryption("encryption failed".into()))?;
108        let mut output = nonce_bytes.to_vec();
109        output.extend(ciphertext);
110        std::fs::write(&self.path, output)?;
111        #[cfg(unix)]
112        {
113            use std::os::unix::fs::PermissionsExt;
114            std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))?;
115        }
116        Ok(())
117    }
118
119    /// Add a credential. Overwrites if name already exists.
120    pub fn add(
121        &mut self,
122        name: impl Into<String>,
123        value: impl Into<String>,
124        domain: impl Into<String>,
125        header_name: impl Into<String>,
126        header_prefix: impl Into<String>,
127    ) {
128        let name = name.into();
129        self.entries.retain(|e| e.name != name);
130        self.entries.push(CredentialEntry {
131            name,
132            value: Zeroizing::new(value.into()),
133            domain: domain.into(),
134            header_name: header_name.into(),
135            header_prefix: header_prefix.into(),
136        });
137    }
138
139    /// Remove a credential by name.
140    pub fn remove(&mut self, name: &str) {
141        self.entries.retain(|e| e.name != name);
142    }
143
144    /// List credential names (never exposes values).
145    pub fn list_names(&self) -> Vec<String> {
146        self.entries.iter().map(|e| e.name.clone()).collect()
147    }
148
149    /// Build a `CredentialInjector` with only the requested credentials.
150    pub fn build_injector(&self, cred_names: &[String]) -> CredentialInjector {
151        let mut injector = CredentialInjector::new();
152        for entry in &self.entries {
153            if cred_names.contains(&entry.name) {
154                let value = Zeroizing::new(format!("{}{}", entry.header_prefix, &*entry.value));
155                injector.add_mapping(&entry.domain, &entry.header_name, &*value);
156            }
157        }
158        injector
159    }
160
161    /// Get all known secret values (for leak detection).
162    /// Returns `Zeroizing<String>` wrappers that automatically zero memory on drop.
163    pub fn secret_values(&self) -> Vec<Zeroizing<String>> {
164        self.entries
165            .iter()
166            .map(|e| Zeroizing::new((*e.value).clone()))
167            .collect()
168    }
169}
170
171impl Drop for CredentialStore {
172    fn drop(&mut self) {
173        self.key.zeroize();
174        // Zeroize credential values in entries
175        // entry.value is Zeroizing<String> and handles its own zeroization on drop
176    }
177}
178
179/// Parse a hex-encoded 32-byte key.
180pub fn parse_master_key(hex: &str) -> Result<[u8; 32], StoreError> {
181    let hex = hex.trim();
182    if hex.len() != 64 {
183        return Err(StoreError::InvalidKey(
184            "CLAWBOX_MASTER_KEY must be 64 hex chars (32 bytes)".into(),
185        ));
186    }
187    let mut key = [0u8; 32];
188    for i in 0..32 {
189        key[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
190            .map_err(|e| StoreError::InvalidKey(e.to_string()))?;
191    }
192    Ok(key)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn test_key() -> [u8; 32] {
200        let mut key = [0u8; 32];
201        for (i, b) in key.iter_mut().enumerate() {
202            *b = i as u8;
203        }
204        key
205    }
206
207    fn temp_path(name: &str) -> std::path::PathBuf {
208        std::env::temp_dir().join(format!("clawbox_test_{}_{}", name, std::process::id()))
209    }
210
211    #[test]
212    fn test_store_roundtrip() {
213        let path = temp_path("creds_rt.enc");
214        let key = test_key();
215
216        {
217            let mut store = CredentialStore::load(&path, key).unwrap();
218            store.add(
219                "GITHUB_TOKEN",
220                "ghp_abc123",
221                "api.github.com",
222                "Authorization",
223                "token ",
224            );
225            store.add(
226                "ANTHROPIC_API_KEY",
227                "sk-ant-xyz",
228                "api.anthropic.com",
229                "x-api-key",
230                "",
231            );
232            store.save().unwrap();
233        }
234
235        let store = CredentialStore::load(&path, key).unwrap();
236        assert_eq!(store.list_names().len(), 2);
237        assert!(store.list_names().contains(&"GITHUB_TOKEN".to_string()));
238    }
239
240    #[test]
241    fn test_store_remove() {
242        let path = temp_path("creds_rm.enc");
243        let key = test_key();
244
245        let mut store = CredentialStore::load(&path, key).unwrap();
246        store.add("A", "val", "d.com", "Auth", "");
247        store.add("B", "val2", "d2.com", "Auth", "");
248        store.remove("A");
249        assert_eq!(store.list_names(), vec!["B".to_string()]);
250    }
251
252    #[test]
253    fn test_build_injector() {
254        let path = temp_path("creds_inj.enc");
255        let key = test_key();
256
257        let mut store = CredentialStore::load(&path, key).unwrap();
258        store.add(
259            "GITHUB_TOKEN",
260            "ghp_abc123",
261            "api.github.com",
262            "Authorization",
263            "token ",
264        );
265        store.add("OTHER", "secret", "other.com", "X-Key", "");
266
267        let injector = store.build_injector(&["GITHUB_TOKEN".to_string()]);
268        assert!(injector.get_mapping("api.github.com").is_some());
269        assert!(injector.get_mapping("other.com").is_none());
270    }
271
272    #[test]
273    fn test_wrong_key_fails() {
274        let path = temp_path("creds_wk.enc");
275        let key = test_key();
276
277        let mut store = CredentialStore::load(&path, key).unwrap();
278        store.add("A", "val", "d.com", "Auth", "");
279        store.save().unwrap();
280
281        let mut bad_key = [0u8; 32];
282        bad_key[0] = 0xFF;
283        assert!(CredentialStore::load(&path, bad_key).is_err());
284    }
285
286    #[test]
287    fn test_parse_master_key() {
288        let hex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
289        let key = parse_master_key(hex).unwrap();
290        assert_eq!(key[0], 0);
291        assert_eq!(key[31], 0x1f);
292    }
293
294    #[test]
295    fn test_store_error_io() {
296        let err = StoreError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
297        assert!(err.to_string().contains("I/O error"));
298    }
299
300    #[test]
301    fn test_store_error_invalid_key() {
302        let err = StoreError::InvalidKey("bad hex".into());
303        assert!(err.to_string().contains("invalid master key"));
304    }
305
306    #[test]
307    fn test_store_error_encryption() {
308        let err = StoreError::Encryption("failed".into());
309        assert!(err.to_string().contains("encryption error"));
310    }
311
312    #[test]
313    fn test_store_error_decryption() {
314        let err = StoreError::Decryption("corrupt".into());
315        assert!(err.to_string().contains("decryption error"));
316    }
317
318    #[test]
319    fn test_parse_master_key_wrong_length() {
320        assert!(parse_master_key("0011").is_err());
321    }
322
323    #[test]
324    fn test_parse_master_key_invalid_hex() {
325        let bad = "zz".repeat(32);
326        assert!(parse_master_key(&bad).is_err());
327    }
328
329    #[test]
330    fn test_secret_values() {
331        let path = temp_path("creds_sv.enc");
332        let key = test_key();
333        let mut store = CredentialStore::load(&path, key).unwrap();
334        store.add("A", "secret1", "d.com", "Auth", "");
335        store.add("B", "secret2", "d2.com", "Auth", "");
336        let secrets = store.secret_values();
337        assert_eq!(secrets.len(), 2);
338    }
339}