1use std::collections::{BTreeMap, HashMap};
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12
13use aes_gcm::aead::{Aead, KeyInit, Payload};
14use aes_gcm::{Aes256Gcm, Key, Nonce};
15use base64::engine::general_purpose::STANDARD;
16use base64::Engine;
17use rand::RngCore;
18use zeroize::Zeroizing;
19
20use crate::error::{AgentError, Result};
21
22pub fn random_bytes(n: usize) -> Zeroizing<Vec<u8>> {
23 let mut b = Zeroizing::new(vec![0u8; n]);
24 rand::thread_rng().fill_bytes(&mut b);
25 b
26}
27
28pub trait KeyVault: Send + Sync {
29 fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>>;
30 fn put(&self, label: &str, value: &[u8]) -> Result<()>;
31 fn delete(&self, label: &str) -> Result<bool>;
32 fn labels(&self) -> Result<Vec<String>> {
34 Err(AgentError::Vault("this vault cannot list its labels".into()))
35 }
36 fn kind(&self) -> &'static str;
38}
39
40#[derive(Default)]
42pub struct MemoryVault(Mutex<HashMap<String, Vec<u8>>>);
43
44impl MemoryVault {
45 pub fn new() -> Self {
46 Self::default()
47 }
48}
49
50impl KeyVault for MemoryVault {
51 fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
52 Ok(self.0.lock().unwrap_or_else(|e| e.into_inner()).get(label).map(|v| Zeroizing::new(v.clone())))
53 }
54 fn put(&self, label: &str, value: &[u8]) -> Result<()> {
55 self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(label.into(), value.to_vec());
56 Ok(())
57 }
58 fn delete(&self, label: &str) -> Result<bool> {
59 Ok(self.0.lock().unwrap_or_else(|e| e.into_inner()).remove(label).is_some())
60 }
61 fn labels(&self) -> Result<Vec<String>> {
62 let mut v: Vec<String> = self.0.lock().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
63 v.sort();
64 Ok(v)
65 }
66 fn kind(&self) -> &'static str {
67 "memory"
68 }
69}
70
71pub trait MasterKeySource: Send + Sync {
73 fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>>;
75 fn name(&self) -> &'static str;
76}
77
78pub struct FileMasterKey(pub PathBuf);
80
81impl MasterKeySource for FileMasterKey {
82 fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
83 match std::fs::read(&self.0) {
84 Ok(b) if b.len() == 32 => Ok(Zeroizing::new(b)),
85 Ok(_) => Err(AgentError::Vault("master key file has the wrong size".into())),
86 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
87 let k = random_bytes(32);
88 write_private(&self.0, &k)?;
89 Ok(k)
90 }
91 Err(e) => Err(AgentError::Vault(format!("master key file: {e}"))),
92 }
93 }
94 fn name(&self) -> &'static str {
95 "keyfile"
96 }
97}
98
99pub const VAULT_KEY_ENV: &str = "M4A_VAULT_KEY_ENV";
104
105pub struct EnvMasterKey {
108 pub var: String,
109}
110
111impl MasterKeySource for EnvMasterKey {
112 fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
113 use sha2::{Digest, Sha256};
114 let value = Zeroizing::new(std::env::var(&self.var).map_err(|_| AgentError::Vault(format!("the vault key variable {} is not set", self.var)))?);
115 if value.trim().len() < 16 {
116 return Err(AgentError::Vault(format!("the vault key variable {} must hold at least 16 characters", self.var)));
117 }
118 let mut h = Sha256::new();
119 h.update(b"m4a-vault-master-v1\0");
120 h.update(value.trim().as_bytes());
121 Ok(Zeroizing::new(h.finalize().to_vec()))
122 }
123 fn name(&self) -> &'static str {
124 "envkey"
125 }
126}
127
128#[cfg(feature = "vault-keychain")]
130pub struct KeychainMasterKey {
131 pub service: String,
132 pub account: String,
133}
134
135#[cfg(feature = "vault-keychain")]
136impl MasterKeySource for KeychainMasterKey {
137 fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
138 let e = |x: keyring::Error| AgentError::Vault(format!("keychain: {x}"));
139 let entry = keyring::Entry::new(&self.service, &self.account).map_err(e)?;
140 match entry.get_secret() {
141 Ok(b) if b.len() == 32 => Ok(Zeroizing::new(b)),
142 Ok(_) => Err(AgentError::Vault("keychain entry has the wrong size".into())),
143 Err(keyring::Error::NoEntry) => {
144 let k = random_bytes(32);
145 entry.set_secret(&k).map_err(e)?;
146 match entry.get_secret() {
148 Ok(b) if b[..] == k[..] => Ok(k),
149 _ => Err(AgentError::Vault("keychain did not keep the key".into())),
150 }
151 }
152 Err(x) => Err(e(x)),
153 }
154 }
155 fn name(&self) -> &'static str {
156 "keychain"
157 }
158}
159
160fn write_private(path: &Path, data: &[u8]) -> Result<()> {
161 let io = |e: std::io::Error| AgentError::Vault(format!("{}: {e}", path.display()));
162 if let Some(d) = path.parent() {
163 std::fs::create_dir_all(d).map_err(io)?;
164 }
165 let tmp = path.with_extension("tmp");
166 {
167 use std::io::Write;
168 let mut opts = std::fs::OpenOptions::new();
169 opts.write(true).create(true).truncate(true);
170 #[cfg(unix)]
171 {
172 use std::os::unix::fs::OpenOptionsExt;
173 opts.mode(0o600);
174 }
175 let mut f = opts.open(&tmp).map_err(io)?;
176 f.write_all(data).map_err(io)?;
177 f.sync_all().map_err(io)?;
178 }
179 std::fs::rename(&tmp, path).map_err(io)
180}
181
182const AAD: &[u8] = b"m4a-vault-v1";
183
184pub struct FileVault {
186 path: PathBuf,
187 master: Box<dyn MasterKeySource>,
188 kind: &'static str,
189 lock: Mutex<()>,
190}
191
192impl FileVault {
193 pub fn open(path: impl Into<PathBuf>, master: Box<dyn MasterKeySource>) -> Result<Self> {
194 let kind = match master.name() {
195 "keychain" => "file+keychain",
196 "envkey" => "file+envkey",
197 _ => "file+keyfile",
198 };
199 let v = Self { path: path.into(), master, kind, lock: Mutex::new(()) };
200 v.load()?; Ok(v)
202 }
203
204 pub fn open_default(dir: &Path, service: &str) -> Result<Self> {
207 let marker = dir.join("vault.home");
208 let want = std::fs::read_to_string(&marker).ok().map(|s| s.trim().to_string());
209 let file = || -> Box<dyn MasterKeySource> { Box::new(FileMasterKey(dir.join("vault.key"))) };
210 #[cfg(feature = "vault-keychain")]
211 let keychain = || -> Box<dyn MasterKeySource> { Box::new(KeychainMasterKey { service: service.to_string(), account: format!("vault:{}", dir.display()) }) };
212 let path = dir.join("vault.enc");
213 let env_var = std::env::var(VAULT_KEY_ENV).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
215 if want.as_deref() == Some("envkey") && env_var.is_none() {
216 return Err(AgentError::Vault(format!("this vault's key comes from the environment: set {VAULT_KEY_ENV} to the name of the secret that holds it")));
217 }
218 if let Some(var) = env_var {
219 match want.as_deref() {
220 None | Some("envkey") => {
221 let v = Self::open(&path, Box::new(EnvMasterKey { var }))?;
222 if want.is_none() {
223 write_private(&marker, b"envkey")?;
224 }
225 return Ok(v);
226 }
227 Some(other) => return Err(AgentError::Vault(format!("this vault lives under {other}; to move it under {VAULT_KEY_ENV} make a backup, start a new vault and restore the backup"))),
228 }
229 }
230 let (v, home) = match want.as_deref() {
231 Some("keyfile") => (Self::open(&path, file())?, "keyfile"),
232 #[cfg(feature = "vault-keychain")]
233 Some("keychain") => (Self::open(&path, keychain())?, "keychain"),
234 Some(other) => return Err(AgentError::Vault(format!("vault home {other:?} is not available in this build"))),
235 None => {
236 #[cfg(feature = "vault-keychain")]
237 {
238 match Self::open(&path, keychain()) {
239 Ok(v) => (v, "keychain"),
240 Err(_) => (Self::open(&path, file())?, "keyfile"),
241 }
242 }
243 #[cfg(not(feature = "vault-keychain"))]
244 {
245 let _ = service;
246 (Self::open(&path, file())?, "keyfile")
247 }
248 }
249 };
250 if want.is_none() {
251 write_private(&marker, home.as_bytes())?;
252 }
253 Ok(v)
254 }
255
256 fn cipher(&self) -> Result<Aes256Gcm> {
257 let k = self.master.load_or_create()?;
258 Ok(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&k)))
259 }
260
261 fn load(&self) -> Result<BTreeMap<String, String>> {
262 let raw = match std::fs::read(&self.path) {
263 Ok(r) => r,
264 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
265 self.cipher()?; return Ok(BTreeMap::new());
267 }
268 Err(e) => return Err(AgentError::Vault(format!("{}: {e}", self.path.display()))),
269 };
270 if raw.len() < 13 {
271 return Err(AgentError::Vault("vault file is truncated".into()));
272 }
273 let (nonce, ct) = raw.split_at(12);
274 let plain = Zeroizing::new(self.cipher()?.decrypt(Nonce::from_slice(nonce), Payload { msg: ct, aad: AAD }).map_err(|_| AgentError::Vault("vault does not open with its master key".into()))?);
275 serde_json::from_slice(&plain).map_err(|e| AgentError::Vault(format!("vault content: {e}")))
276 }
277
278 fn store(&self, map: &BTreeMap<String, String>) -> Result<()> {
279 let plain = Zeroizing::new(serde_json::to_vec(map).map_err(|e| AgentError::Vault(e.to_string()))?);
280 let mut nonce = [0u8; 12];
281 rand::thread_rng().fill_bytes(&mut nonce);
282 let ct = self.cipher()?.encrypt(Nonce::from_slice(&nonce), Payload { msg: &plain, aad: AAD }).map_err(|_| AgentError::Vault("encrypt failed".into()))?;
283 let mut out = nonce.to_vec();
284 out.extend(ct);
285 write_private(&self.path, &out)
286 }
287}
288
289impl KeyVault for FileVault {
290 fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
291 let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
292 Ok(self.load()?.get(label).and_then(|v| STANDARD.decode(v).ok()).map(Zeroizing::new))
293 }
294 fn put(&self, label: &str, value: &[u8]) -> Result<()> {
295 let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
296 let mut m = self.load()?;
297 m.insert(label.into(), STANDARD.encode(value));
298 self.store(&m)
299 }
300 fn delete(&self, label: &str) -> Result<bool> {
301 let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
302 let mut m = self.load()?;
303 let had = m.remove(label).is_some();
304 if had {
305 self.store(&m)?;
306 }
307 Ok(had)
308 }
309 fn labels(&self) -> Result<Vec<String>> {
310 let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
311 Ok(self.load()?.keys().cloned().collect())
312 }
313 fn kind(&self) -> &'static str {
314 self.kind
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 static ENV_LOCK: Mutex<()> = Mutex::new(());
324
325 #[test]
326 fn file_vault_round_trips_and_never_writes_plaintext() {
327 let d = tempfile::tempdir().unwrap();
328 let v = FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join("v.key")))).unwrap();
329 assert!(v.get("a").unwrap().is_none());
330 v.put("a", b"super-secret-seed").unwrap();
331 v.put("b", &[1, 2, 3]).unwrap();
332 assert_eq!(&v.get("a").unwrap().unwrap()[..], b"super-secret-seed");
333 let raw = std::fs::read(d.path().join("v.enc")).unwrap();
334 assert!(!raw.windows(12).any(|w| w == b"super-secret"));
335 let v2 = FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join("v.key")))).unwrap();
337 assert_eq!(&v2.get("b").unwrap().unwrap()[..], &[1, 2, 3]);
338 assert!(v2.delete("a").unwrap() && !v2.delete("a").unwrap());
339 assert!(v2.get("a").unwrap().is_none());
340 #[cfg(unix)]
341 {
342 use std::os::unix::fs::PermissionsExt;
343 assert_eq!(std::fs::metadata(d.path().join("v.key")).unwrap().permissions().mode() & 0o777, 0o600);
344 assert_eq!(std::fs::metadata(d.path().join("v.enc")).unwrap().permissions().mode() & 0o777, 0o600);
345 }
346 }
347
348 #[test]
349 fn a_wrong_or_lost_master_key_is_an_error_not_an_empty_vault() {
350 let d = tempfile::tempdir().unwrap();
351 let open = |k: &str| FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join(k))));
352 open("k1").unwrap().put("a", b"x").unwrap();
353 assert!(open("k2").is_err());
354 let mut raw = std::fs::read(d.path().join("v.enc")).unwrap();
356 let n = raw.len() - 1;
357 raw[n] ^= 1;
358 std::fs::write(d.path().join("v.enc"), raw).unwrap();
359 assert!(open("k1").is_err());
360 }
361
362 #[test]
363 fn open_default_remembers_where_the_master_key_lives() {
364 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
365 let d = tempfile::tempdir().unwrap();
366 let v = FileVault::open_default(d.path(), "m4a-agent-test").unwrap();
369 v.put("a", b"1").unwrap();
370 let kind = v.kind();
371 let v = FileVault::open_default(d.path(), "m4a-agent-test").unwrap();
372 assert_eq!(v.kind(), kind);
373 assert_eq!(&v.get("a").unwrap().unwrap()[..], b"1");
374 }
375
376 #[test]
377 fn the_master_key_can_come_from_the_environment_and_never_from_a_file_beside_the_vault() {
378 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
379 let d = tempfile::tempdir().unwrap();
380 std::env::set_var("M4A_TEST_VAULT_SECRET_A", "a-long-enough-secret-value-1");
382 std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SECRET_A");
383 let v = FileVault::open_default(d.path(), "svc").unwrap();
384 v.put("x", b"1").unwrap();
385 assert_eq!(v.kind(), "file+envkey");
386 assert!(!d.path().join("vault.key").exists(), "no key file");
387 drop(v);
388 assert!(FileVault::open_default(d.path(), "svc").unwrap().get("x").unwrap().is_some());
390 std::env::set_var("M4A_TEST_VAULT_SECRET_B", "another-long-secret-value-22");
391 std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SECRET_B");
392 assert!(FileVault::open_default(d.path(), "svc").is_err());
393 std::env::remove_var(VAULT_KEY_ENV);
394 let e = FileVault::open_default(d.path(), "svc").err().unwrap().to_string();
395 assert!(e.contains(VAULT_KEY_ENV), "{e}");
396 let d2 = tempfile::tempdir().unwrap();
398 FileVault::open_default(d2.path(), "svc").unwrap().put("y", b"2").unwrap();
399 std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SECRET_A");
400 assert!(FileVault::open_default(d2.path(), "svc").is_err());
401 std::env::remove_var(VAULT_KEY_ENV);
402 std::env::set_var("M4A_TEST_VAULT_SHORT", "short");
404 std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SHORT");
405 let d3 = tempfile::tempdir().unwrap();
406 assert!(FileVault::open_default(d3.path(), "svc").is_err());
407 std::env::remove_var(VAULT_KEY_ENV);
408 }
409}