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    /// The public key of `session_id`'s identity when the private key lives outside this vault
39    /// (a host keystore that signs but never exports). `None` (the default) keeps the seed here.
40    fn external_identity(&self, _session_id: &str) -> Option<[u8; 32]> {
41        None
42    }
43    /// Sign `msg` with the external identity key of `session_id`.
44    fn sign_external(&self, _session_id: &str, _msg: &[u8]) -> Result<[u8; 64]> {
45        Err(AgentError::Vault("this vault holds no external identity".into()))
46    }
47}
48
49/// A vault whose master key the host supplies (for example derived from its own keystore), so the
50/// vault file opens only where that host can derive it again.
51pub struct StaticMasterKey(pub Zeroizing<Vec<u8>>);
52
53impl MasterKeySource for StaticMasterKey {
54    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
55        if self.0.len() != 32 {
56            return Err(AgentError::Vault("master key must be 32 bytes".into()));
57        }
58        Ok(self.0.clone())
59    }
60    fn name(&self) -> &'static str {
61        "hostkey"
62    }
63}
64
65/// Non-persistent vault for tests and throwaway sessions.
66#[derive(Default)]
67pub struct MemoryVault(Mutex<HashMap<String, Vec<u8>>>);
68
69impl MemoryVault {
70    pub fn new() -> Self {
71        Self::default()
72    }
73}
74
75impl KeyVault for MemoryVault {
76    fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
77        Ok(self.0.lock().unwrap_or_else(|e| e.into_inner()).get(label).map(|v| Zeroizing::new(v.clone())))
78    }
79    fn put(&self, label: &str, value: &[u8]) -> Result<()> {
80        self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(label.into(), value.to_vec());
81        Ok(())
82    }
83    fn delete(&self, label: &str) -> Result<bool> {
84        Ok(self.0.lock().unwrap_or_else(|e| e.into_inner()).remove(label).is_some())
85    }
86    fn labels(&self) -> Result<Vec<String>> {
87        let mut v: Vec<String> = self.0.lock().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
88        v.sort();
89        Ok(v)
90    }
91    fn kind(&self) -> &'static str {
92        "memory"
93    }
94}
95
96/// Where the vault's master key is kept.
97pub trait MasterKeySource: Send + Sync {
98    /// The key, created (random, 32 bytes) the first time.
99    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>>;
100    fn name(&self) -> &'static str;
101}
102
103/// The master key in a file readable by the owner only.
104pub struct FileMasterKey(pub PathBuf);
105
106impl MasterKeySource for FileMasterKey {
107    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
108        match std::fs::read(&self.0) {
109            Ok(b) if b.len() == 32 => Ok(Zeroizing::new(b)),
110            Ok(_) => Err(AgentError::Vault("master key file has the wrong size".into())),
111            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
112                let k = random_bytes(32);
113                write_private(&self.0, &k)?;
114                Ok(k)
115            }
116            Err(e) => Err(AgentError::Vault(format!("master key file: {e}"))),
117        }
118    }
119    fn name(&self) -> &'static str {
120        "keyfile"
121    }
122}
123
124/// The name of the environment variable that holds the vault's key material. When this variable
125/// (the one named here, not its value) is set in the process environment, the vault's master key
126/// comes from the variable it names (for example a secret of the box), not from a file next to the
127/// vault; the vault file alone then opens nowhere.
128pub const VAULT_KEY_ENV: &str = "M4A_VAULT_KEY_ENV";
129
130/// The master key derived from a secret the host injects into the environment. It is never
131/// created here: a missing or short value is an error, not a fresh vault under a new key.
132pub struct EnvMasterKey {
133    pub var: String,
134}
135
136impl MasterKeySource for EnvMasterKey {
137    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
138        use sha2::{Digest, Sha256};
139        let value = Zeroizing::new(std::env::var(&self.var).map_err(|_| AgentError::Vault(format!("the vault key variable {} is not set", self.var)))?);
140        if value.trim().len() < 16 {
141            return Err(AgentError::Vault(format!("the vault key variable {} must hold at least 16 characters", self.var)));
142        }
143        let mut h = Sha256::new();
144        h.update(b"m4a-vault-master-v1\0");
145        h.update(value.trim().as_bytes());
146        Ok(Zeroizing::new(h.finalize().to_vec()))
147    }
148    fn name(&self) -> &'static str {
149        "envkey"
150    }
151}
152
153/// The master key in the OS keychain.
154#[cfg(feature = "vault-keychain")]
155pub struct KeychainMasterKey {
156    pub service: String,
157    pub account: String,
158}
159
160#[cfg(feature = "vault-keychain")]
161impl MasterKeySource for KeychainMasterKey {
162    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
163        let e = |x: keyring::Error| AgentError::Vault(format!("keychain: {x}"));
164        let entry = keyring::Entry::new(&self.service, &self.account).map_err(e)?;
165        match entry.get_secret() {
166            Ok(b) if b.len() == 32 => Ok(Zeroizing::new(b)),
167            Ok(_) => Err(AgentError::Vault("keychain entry has the wrong size".into())),
168            Err(keyring::Error::NoEntry) => {
169                let k = random_bytes(32);
170                entry.set_secret(&k).map_err(e)?;
171                // Read it back: a keychain that accepts and forgets would orphan the vault.
172                match entry.get_secret() {
173                    Ok(b) if b[..] == k[..] => Ok(k),
174                    _ => Err(AgentError::Vault("keychain did not keep the key".into())),
175                }
176            }
177            Err(x) => Err(e(x)),
178        }
179    }
180    fn name(&self) -> &'static str {
181        "keychain"
182    }
183}
184
185fn write_private(path: &Path, data: &[u8]) -> Result<()> {
186    let io = |e: std::io::Error| AgentError::Vault(format!("{}: {e}", path.display()));
187    if let Some(d) = path.parent() {
188        std::fs::create_dir_all(d).map_err(io)?;
189    }
190    let tmp = path.with_extension("tmp");
191    {
192        use std::io::Write;
193        let mut opts = std::fs::OpenOptions::new();
194        opts.write(true).create(true).truncate(true);
195        #[cfg(unix)]
196        {
197            use std::os::unix::fs::OpenOptionsExt;
198            opts.mode(0o600);
199        }
200        let mut f = opts.open(&tmp).map_err(io)?;
201        f.write_all(data).map_err(io)?;
202        f.sync_all().map_err(io)?;
203    }
204    std::fs::rename(&tmp, path).map_err(io)
205}
206
207const AAD: &[u8] = b"m4a-vault-v1";
208
209/// One encrypted file holding every secret of the client, under a random master key.
210pub struct FileVault {
211    path: PathBuf,
212    master: Box<dyn MasterKeySource>,
213    kind: &'static str,
214    lock: Mutex<()>,
215}
216
217impl FileVault {
218    pub fn open(path: impl Into<PathBuf>, master: Box<dyn MasterKeySource>) -> Result<Self> {
219        let kind = match master.name() {
220            "keychain" => "file+keychain",
221            "envkey" => "file+envkey",
222            _ => "file+keyfile",
223        };
224        let v = Self { path: path.into(), master, kind, lock: Mutex::new(()) };
225        v.load()?; // fails now when the key does not open the file
226        Ok(v)
227    }
228
229    /// The vault in `dir`: master key in the keychain when this build has one and it works,
230    /// else in a key file. The choice is written down and kept (see the module docs).
231    pub fn open_default(dir: &Path, service: &str) -> Result<Self> {
232        let marker = dir.join("vault.home");
233        let want = std::fs::read_to_string(&marker).ok().map(|s| s.trim().to_string());
234        let file = || -> Box<dyn MasterKeySource> { Box::new(FileMasterKey(dir.join("vault.key"))) };
235        #[cfg(feature = "vault-keychain")]
236        let keychain = || -> Box<dyn MasterKeySource> { Box::new(KeychainMasterKey { service: service.to_string(), account: format!("vault:{}", dir.display()) }) };
237        let path = dir.join("vault.enc");
238        // The vault's key may come from a secret the host injects: the variable names which one.
239        let env_var = std::env::var(VAULT_KEY_ENV).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
240        if want.as_deref() == Some("envkey") && env_var.is_none() {
241            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")));
242        }
243        if let Some(var) = env_var {
244            match want.as_deref() {
245                None | Some("envkey") => {
246                    let v = Self::open(&path, Box::new(EnvMasterKey { var }))?;
247                    if want.is_none() {
248                        write_private(&marker, b"envkey")?;
249                    }
250                    return Ok(v);
251                }
252                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"))),
253            }
254        }
255        let (v, home) = match want.as_deref() {
256            Some("keyfile") => (Self::open(&path, file())?, "keyfile"),
257            #[cfg(feature = "vault-keychain")]
258            Some("keychain") => (Self::open(&path, keychain())?, "keychain"),
259            Some(other) => return Err(AgentError::Vault(format!("vault home {other:?} is not available in this build"))),
260            None => {
261                #[cfg(feature = "vault-keychain")]
262                {
263                    match Self::open(&path, keychain()) {
264                        Ok(v) => (v, "keychain"),
265                        Err(_) => (Self::open(&path, file())?, "keyfile"),
266                    }
267                }
268                #[cfg(not(feature = "vault-keychain"))]
269                {
270                    let _ = service;
271                    (Self::open(&path, file())?, "keyfile")
272                }
273            }
274        };
275        if want.is_none() {
276            write_private(&marker, home.as_bytes())?;
277        }
278        Ok(v)
279    }
280
281    fn cipher(&self) -> Result<Aes256Gcm> {
282        let k = self.master.load_or_create()?;
283        Ok(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&k)))
284    }
285
286    fn load(&self) -> Result<BTreeMap<String, String>> {
287        let raw = match std::fs::read(&self.path) {
288            Ok(r) => r,
289            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
290                self.cipher()?; // make sure the master key exists
291                return Ok(BTreeMap::new());
292            }
293            Err(e) => return Err(AgentError::Vault(format!("{}: {e}", self.path.display()))),
294        };
295        if raw.len() < 13 {
296            return Err(AgentError::Vault("vault file is truncated".into()));
297        }
298        let (nonce, ct) = raw.split_at(12);
299        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()))?);
300        serde_json::from_slice(&plain).map_err(|e| AgentError::Vault(format!("vault content: {e}")))
301    }
302
303    fn store(&self, map: &BTreeMap<String, String>) -> Result<()> {
304        let plain = Zeroizing::new(serde_json::to_vec(map).map_err(|e| AgentError::Vault(e.to_string()))?);
305        let mut nonce = [0u8; 12];
306        rand::thread_rng().fill_bytes(&mut nonce);
307        let ct = self.cipher()?.encrypt(Nonce::from_slice(&nonce), Payload { msg: &plain, aad: AAD }).map_err(|_| AgentError::Vault("encrypt failed".into()))?;
308        let mut out = nonce.to_vec();
309        out.extend(ct);
310        write_private(&self.path, &out)
311    }
312}
313
314impl KeyVault for FileVault {
315    fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
316        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
317        Ok(self.load()?.get(label).and_then(|v| STANDARD.decode(v).ok()).map(Zeroizing::new))
318    }
319    fn put(&self, label: &str, value: &[u8]) -> Result<()> {
320        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
321        let mut m = self.load()?;
322        m.insert(label.into(), STANDARD.encode(value));
323        self.store(&m)
324    }
325    fn delete(&self, label: &str) -> Result<bool> {
326        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
327        let mut m = self.load()?;
328        let had = m.remove(label).is_some();
329        if had {
330            self.store(&m)?;
331        }
332        Ok(had)
333    }
334    fn labels(&self) -> Result<Vec<String>> {
335        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
336        Ok(self.load()?.keys().cloned().collect())
337    }
338    fn kind(&self) -> &'static str {
339        self.kind
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    /// Tests that open a vault by default read the process environment; the one that sets it runs alone.
348    static ENV_LOCK: Mutex<()> = Mutex::new(());
349
350    #[test]
351    fn file_vault_round_trips_and_never_writes_plaintext() {
352        let d = tempfile::tempdir().unwrap();
353        let v = FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join("v.key")))).unwrap();
354        assert!(v.get("a").unwrap().is_none());
355        v.put("a", b"super-secret-seed").unwrap();
356        v.put("b", &[1, 2, 3]).unwrap();
357        assert_eq!(&v.get("a").unwrap().unwrap()[..], b"super-secret-seed");
358        let raw = std::fs::read(d.path().join("v.enc")).unwrap();
359        assert!(!raw.windows(12).any(|w| w == b"super-secret"));
360        // A second handle (a restart) sees the same content.
361        let v2 = FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join("v.key")))).unwrap();
362        assert_eq!(&v2.get("b").unwrap().unwrap()[..], &[1, 2, 3]);
363        assert!(v2.delete("a").unwrap() && !v2.delete("a").unwrap());
364        assert!(v2.get("a").unwrap().is_none());
365        #[cfg(unix)]
366        {
367            use std::os::unix::fs::PermissionsExt;
368            assert_eq!(std::fs::metadata(d.path().join("v.key")).unwrap().permissions().mode() & 0o777, 0o600);
369            assert_eq!(std::fs::metadata(d.path().join("v.enc")).unwrap().permissions().mode() & 0o777, 0o600);
370        }
371    }
372
373    #[test]
374    fn a_wrong_or_lost_master_key_is_an_error_not_an_empty_vault() {
375        let d = tempfile::tempdir().unwrap();
376        let open = |k: &str| FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join(k))));
377        open("k1").unwrap().put("a", b"x").unwrap();
378        assert!(open("k2").is_err());
379        // Tampering is detected too.
380        let mut raw = std::fs::read(d.path().join("v.enc")).unwrap();
381        let n = raw.len() - 1;
382        raw[n] ^= 1;
383        std::fs::write(d.path().join("v.enc"), raw).unwrap();
384        assert!(open("k1").is_err());
385    }
386
387    #[test]
388    fn open_default_remembers_where_the_master_key_lives() {
389        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
390        let d = tempfile::tempdir().unwrap();
391        // The keychain may or may not work on the machine running the tests; either way the second
392        // open must find the same vault.
393        let v = FileVault::open_default(d.path(), "m4a-agent-test").unwrap();
394        v.put("a", b"1").unwrap();
395        let kind = v.kind();
396        let v = FileVault::open_default(d.path(), "m4a-agent-test").unwrap();
397        assert_eq!(v.kind(), kind);
398        assert_eq!(&v.get("a").unwrap().unwrap()[..], b"1");
399    }
400
401    #[test]
402    fn the_master_key_can_come_from_the_environment_and_never_from_a_file_beside_the_vault() {
403        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
404        let d = tempfile::tempdir().unwrap();
405        // One process, one test touching these variables: names are unique to this test.
406        std::env::set_var("M4A_TEST_VAULT_SECRET_A", "a-long-enough-secret-value-1");
407        std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SECRET_A");
408        let v = FileVault::open_default(d.path(), "svc").unwrap();
409        v.put("x", b"1").unwrap();
410        assert_eq!(v.kind(), "file+envkey");
411        assert!(!d.path().join("vault.key").exists(), "no key file");
412        drop(v);
413        // Same secret: opens. Another secret: an error, not an empty vault. No variable: named error.
414        assert!(FileVault::open_default(d.path(), "svc").unwrap().get("x").unwrap().is_some());
415        std::env::set_var("M4A_TEST_VAULT_SECRET_B", "another-long-secret-value-22");
416        std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SECRET_B");
417        assert!(FileVault::open_default(d.path(), "svc").is_err());
418        std::env::remove_var(VAULT_KEY_ENV);
419        let e = FileVault::open_default(d.path(), "svc").err().unwrap().to_string();
420        assert!(e.contains(VAULT_KEY_ENV), "{e}");
421        // A vault made under a key file does not switch to the environment by itself.
422        let d2 = tempfile::tempdir().unwrap();
423        FileVault::open_default(d2.path(), "svc").unwrap().put("y", b"2").unwrap();
424        std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SECRET_A");
425        assert!(FileVault::open_default(d2.path(), "svc").is_err());
426        std::env::remove_var(VAULT_KEY_ENV);
427        // A short secret is refused.
428        std::env::set_var("M4A_TEST_VAULT_SHORT", "short");
429        std::env::set_var(VAULT_KEY_ENV, "M4A_TEST_VAULT_SHORT");
430        let d3 = tempfile::tempdir().unwrap();
431        assert!(FileVault::open_default(d3.path(), "svc").is_err());
432        std::env::remove_var(VAULT_KEY_ENV);
433    }
434}