1use 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#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum StoreError {
29 #[error("I/O error: {0}")]
31 Io(#[from] std::io::Error),
32 #[error("encryption error: {0}")]
34 Encryption(String),
35 #[error("decryption error: {0}")]
37 Decryption(String),
38 #[error("serialization error: {0}")]
40 Serialization(#[from] serde_json::Error),
41 #[error("invalid master key: {0}")]
43 InvalidKey(String),
44}
45
46#[derive(Debug, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct CredentialEntry {
50 pub name: String,
52 #[serde(serialize_with = "ser_zeroizing", deserialize_with = "de_zeroizing")]
54 pub value: Zeroizing<String>,
55 pub domain: String,
57 pub header_name: String,
59 pub header_prefix: String,
61}
62
63#[non_exhaustive]
65pub struct CredentialStore {
66 entries: Vec<CredentialEntry>,
67 path: PathBuf,
68 key: [u8; 32],
69}
70
71impl CredentialStore {
72 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 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 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 pub fn remove(&mut self, name: &str) {
141 self.entries.retain(|e| e.name != name);
142 }
143
144 pub fn list_names(&self) -> Vec<String> {
146 self.entries.iter().map(|e| e.name.clone()).collect()
147 }
148
149 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 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 }
177}
178
179pub 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}