Skip to main content

m4a_agent/
vault.rs

1//! The key vault: where the client keeps what the agent must never see (identity seeds, store keys,
2//! session tokens). Values are looked up by label.
3//!
4//! [`FileVault`] is one encrypted file (AES-256-GCM) under a random master key; the master key
5//! lives in the OS keychain when there is one (feature `vault-keychain`), else in a 0600 file next
6//! to the vault. Once a vault has chosen its master-key home it never silently switches: a vault
7//! made under the keychain that cannot reach it again is an error, not a fresh empty vault.
8
9use 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    /// Every label held (for backup). A vault that cannot list says so.
33    fn labels(&self) -> Result<Vec<String>> {
34        Err(AgentError::Vault("this vault cannot list its labels".into()))
35    }
36    /// A short name for logs ("memory", "file+keychain", "file+keyfile").
37    fn kind(&self) -> &'static str;
38}
39
40/// Non-persistent vault for tests and throwaway sessions.
41#[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
71/// Where the vault's master key is kept.
72pub trait MasterKeySource: Send + Sync {
73    /// The key, created (random, 32 bytes) the first time.
74    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>>;
75    fn name(&self) -> &'static str;
76}
77
78/// The master key in a file readable by the owner only.
79pub 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
99/// The name of the environment variable that holds the vault's key material. When this variable
100/// (the one named here, not its value) is set in the process environment, the vault's master key
101/// comes from the variable it names (for example a secret of the box), not from a file next to the
102/// vault; the vault file alone then opens nowhere.
103pub const VAULT_KEY_ENV: &str = "M4A_VAULT_KEY_ENV";
104
105/// The master key derived from a secret the host injects into the environment. It is never
106/// created here: a missing or short value is an error, not a fresh vault under a new key.
107pub 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/// The master key in the OS keychain.
129#[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                // Read it back: a keychain that accepts and forgets would orphan the vault.
147                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
184/// One encrypted file holding every secret of the client, under a random master key.
185pub 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()?; // fails now when the key does not open the file
201        Ok(v)
202    }
203
204    /// The vault in `dir`: master key in the keychain when this build has one and it works,
205    /// else in a key file. The choice is written down and kept (see the module docs).
206    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        // The vault's key may come from a secret the host injects: the variable names which one.
214        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()?; // make sure the master key exists
266                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    /// Tests that open a vault by default read the process environment; the one that sets it runs alone.
323    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        // A second handle (a restart) sees the same content.
336        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        // Tampering is detected too.
355        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        // The keychain may or may not work on the machine running the tests; either way the second
367        // open must find the same vault.
368        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        // One process, one test touching these variables: names are unique to this test.
381        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        // Same secret: opens. Another secret: an error, not an empty vault. No variable: named error.
389        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        // A vault made under a key file does not switch to the environment by itself.
397        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        // A short secret is refused.
403        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}