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    /// A short name for logs ("memory", "file+keychain", "file+keyfile").
33    fn kind(&self) -> &'static str;
34}
35
36/// Non-persistent vault for tests and throwaway sessions.
37#[derive(Default)]
38pub struct MemoryVault(Mutex<HashMap<String, Vec<u8>>>);
39
40impl MemoryVault {
41    pub fn new() -> Self {
42        Self::default()
43    }
44}
45
46impl KeyVault for MemoryVault {
47    fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
48        Ok(self.0.lock().unwrap_or_else(|e| e.into_inner()).get(label).map(|v| Zeroizing::new(v.clone())))
49    }
50    fn put(&self, label: &str, value: &[u8]) -> Result<()> {
51        self.0.lock().unwrap_or_else(|e| e.into_inner()).insert(label.into(), value.to_vec());
52        Ok(())
53    }
54    fn delete(&self, label: &str) -> Result<bool> {
55        Ok(self.0.lock().unwrap_or_else(|e| e.into_inner()).remove(label).is_some())
56    }
57    fn kind(&self) -> &'static str {
58        "memory"
59    }
60}
61
62/// Where the vault's master key is kept.
63pub trait MasterKeySource: Send + Sync {
64    /// The key, created (random, 32 bytes) the first time.
65    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>>;
66    fn name(&self) -> &'static str;
67}
68
69/// The master key in a file readable by the owner only.
70pub struct FileMasterKey(pub PathBuf);
71
72impl MasterKeySource for FileMasterKey {
73    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
74        match std::fs::read(&self.0) {
75            Ok(b) if b.len() == 32 => Ok(Zeroizing::new(b)),
76            Ok(_) => Err(AgentError::Vault("master key file has the wrong size".into())),
77            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
78                let k = random_bytes(32);
79                write_private(&self.0, &k)?;
80                Ok(k)
81            }
82            Err(e) => Err(AgentError::Vault(format!("master key file: {e}"))),
83        }
84    }
85    fn name(&self) -> &'static str {
86        "keyfile"
87    }
88}
89
90/// The master key in the OS keychain.
91#[cfg(feature = "vault-keychain")]
92pub struct KeychainMasterKey {
93    pub service: String,
94    pub account: String,
95}
96
97#[cfg(feature = "vault-keychain")]
98impl MasterKeySource for KeychainMasterKey {
99    fn load_or_create(&self) -> Result<Zeroizing<Vec<u8>>> {
100        let e = |x: keyring::Error| AgentError::Vault(format!("keychain: {x}"));
101        let entry = keyring::Entry::new(&self.service, &self.account).map_err(e)?;
102        match entry.get_secret() {
103            Ok(b) if b.len() == 32 => Ok(Zeroizing::new(b)),
104            Ok(_) => Err(AgentError::Vault("keychain entry has the wrong size".into())),
105            Err(keyring::Error::NoEntry) => {
106                let k = random_bytes(32);
107                entry.set_secret(&k).map_err(e)?;
108                // Read it back: a keychain that accepts and forgets would orphan the vault.
109                match entry.get_secret() {
110                    Ok(b) if b[..] == k[..] => Ok(k),
111                    _ => Err(AgentError::Vault("keychain did not keep the key".into())),
112                }
113            }
114            Err(x) => Err(e(x)),
115        }
116    }
117    fn name(&self) -> &'static str {
118        "keychain"
119    }
120}
121
122fn write_private(path: &Path, data: &[u8]) -> Result<()> {
123    let io = |e: std::io::Error| AgentError::Vault(format!("{}: {e}", path.display()));
124    if let Some(d) = path.parent() {
125        std::fs::create_dir_all(d).map_err(io)?;
126    }
127    let tmp = path.with_extension("tmp");
128    {
129        use std::io::Write;
130        let mut opts = std::fs::OpenOptions::new();
131        opts.write(true).create(true).truncate(true);
132        #[cfg(unix)]
133        {
134            use std::os::unix::fs::OpenOptionsExt;
135            opts.mode(0o600);
136        }
137        let mut f = opts.open(&tmp).map_err(io)?;
138        f.write_all(data).map_err(io)?;
139        f.sync_all().map_err(io)?;
140    }
141    std::fs::rename(&tmp, path).map_err(io)
142}
143
144const AAD: &[u8] = b"m4a-vault-v1";
145
146/// One encrypted file holding every secret of the client, under a random master key.
147pub struct FileVault {
148    path: PathBuf,
149    master: Box<dyn MasterKeySource>,
150    kind: &'static str,
151    lock: Mutex<()>,
152}
153
154impl FileVault {
155    pub fn open(path: impl Into<PathBuf>, master: Box<dyn MasterKeySource>) -> Result<Self> {
156        let kind = if master.name() == "keychain" { "file+keychain" } else { "file+keyfile" };
157        let v = Self { path: path.into(), master, kind, lock: Mutex::new(()) };
158        v.load()?; // fails now when the key does not open the file
159        Ok(v)
160    }
161
162    /// The vault in `dir`: master key in the keychain when this build has one and it works,
163    /// else in a key file. The choice is written down and kept (see the module docs).
164    pub fn open_default(dir: &Path, service: &str) -> Result<Self> {
165        let marker = dir.join("vault.home");
166        let want = std::fs::read_to_string(&marker).ok().map(|s| s.trim().to_string());
167        let file = || -> Box<dyn MasterKeySource> { Box::new(FileMasterKey(dir.join("vault.key"))) };
168        #[cfg(feature = "vault-keychain")]
169        let keychain = || -> Box<dyn MasterKeySource> { Box::new(KeychainMasterKey { service: service.to_string(), account: format!("vault:{}", dir.display()) }) };
170        let path = dir.join("vault.enc");
171        let (v, home) = match want.as_deref() {
172            Some("keyfile") => (Self::open(&path, file())?, "keyfile"),
173            #[cfg(feature = "vault-keychain")]
174            Some("keychain") => (Self::open(&path, keychain())?, "keychain"),
175            Some(other) => return Err(AgentError::Vault(format!("vault home {other:?} is not available in this build"))),
176            None => {
177                #[cfg(feature = "vault-keychain")]
178                {
179                    match Self::open(&path, keychain()) {
180                        Ok(v) => (v, "keychain"),
181                        Err(_) => (Self::open(&path, file())?, "keyfile"),
182                    }
183                }
184                #[cfg(not(feature = "vault-keychain"))]
185                {
186                    let _ = service;
187                    (Self::open(&path, file())?, "keyfile")
188                }
189            }
190        };
191        if want.is_none() {
192            write_private(&marker, home.as_bytes())?;
193        }
194        Ok(v)
195    }
196
197    fn cipher(&self) -> Result<Aes256Gcm> {
198        let k = self.master.load_or_create()?;
199        Ok(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&k)))
200    }
201
202    fn load(&self) -> Result<BTreeMap<String, String>> {
203        let raw = match std::fs::read(&self.path) {
204            Ok(r) => r,
205            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
206                self.cipher()?; // make sure the master key exists
207                return Ok(BTreeMap::new());
208            }
209            Err(e) => return Err(AgentError::Vault(format!("{}: {e}", self.path.display()))),
210        };
211        if raw.len() < 13 {
212            return Err(AgentError::Vault("vault file is truncated".into()));
213        }
214        let (nonce, ct) = raw.split_at(12);
215        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()))?);
216        serde_json::from_slice(&plain).map_err(|e| AgentError::Vault(format!("vault content: {e}")))
217    }
218
219    fn store(&self, map: &BTreeMap<String, String>) -> Result<()> {
220        let plain = Zeroizing::new(serde_json::to_vec(map).map_err(|e| AgentError::Vault(e.to_string()))?);
221        let mut nonce = [0u8; 12];
222        rand::thread_rng().fill_bytes(&mut nonce);
223        let ct = self.cipher()?.encrypt(Nonce::from_slice(&nonce), Payload { msg: &plain, aad: AAD }).map_err(|_| AgentError::Vault("encrypt failed".into()))?;
224        let mut out = nonce.to_vec();
225        out.extend(ct);
226        write_private(&self.path, &out)
227    }
228}
229
230impl KeyVault for FileVault {
231    fn get(&self, label: &str) -> Result<Option<Zeroizing<Vec<u8>>>> {
232        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
233        Ok(self.load()?.get(label).and_then(|v| STANDARD.decode(v).ok()).map(Zeroizing::new))
234    }
235    fn put(&self, label: &str, value: &[u8]) -> Result<()> {
236        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
237        let mut m = self.load()?;
238        m.insert(label.into(), STANDARD.encode(value));
239        self.store(&m)
240    }
241    fn delete(&self, label: &str) -> Result<bool> {
242        let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
243        let mut m = self.load()?;
244        let had = m.remove(label).is_some();
245        if had {
246            self.store(&m)?;
247        }
248        Ok(had)
249    }
250    fn kind(&self) -> &'static str {
251        self.kind
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn file_vault_round_trips_and_never_writes_plaintext() {
261        let d = tempfile::tempdir().unwrap();
262        let v = FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join("v.key")))).unwrap();
263        assert!(v.get("a").unwrap().is_none());
264        v.put("a", b"super-secret-seed").unwrap();
265        v.put("b", &[1, 2, 3]).unwrap();
266        assert_eq!(&v.get("a").unwrap().unwrap()[..], b"super-secret-seed");
267        let raw = std::fs::read(d.path().join("v.enc")).unwrap();
268        assert!(!raw.windows(12).any(|w| w == b"super-secret"));
269        // A second handle (a restart) sees the same content.
270        let v2 = FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join("v.key")))).unwrap();
271        assert_eq!(&v2.get("b").unwrap().unwrap()[..], &[1, 2, 3]);
272        assert!(v2.delete("a").unwrap() && !v2.delete("a").unwrap());
273        assert!(v2.get("a").unwrap().is_none());
274        #[cfg(unix)]
275        {
276            use std::os::unix::fs::PermissionsExt;
277            assert_eq!(std::fs::metadata(d.path().join("v.key")).unwrap().permissions().mode() & 0o777, 0o600);
278            assert_eq!(std::fs::metadata(d.path().join("v.enc")).unwrap().permissions().mode() & 0o777, 0o600);
279        }
280    }
281
282    #[test]
283    fn a_wrong_or_lost_master_key_is_an_error_not_an_empty_vault() {
284        let d = tempfile::tempdir().unwrap();
285        let open = |k: &str| FileVault::open(d.path().join("v.enc"), Box::new(FileMasterKey(d.path().join(k))));
286        open("k1").unwrap().put("a", b"x").unwrap();
287        assert!(open("k2").is_err());
288        // Tampering is detected too.
289        let mut raw = std::fs::read(d.path().join("v.enc")).unwrap();
290        let n = raw.len() - 1;
291        raw[n] ^= 1;
292        std::fs::write(d.path().join("v.enc"), raw).unwrap();
293        assert!(open("k1").is_err());
294    }
295
296    #[test]
297    fn open_default_remembers_where_the_master_key_lives() {
298        let d = tempfile::tempdir().unwrap();
299        // The keychain may or may not work on the machine running the tests; either way the second
300        // open must find the same vault.
301        let v = FileVault::open_default(d.path(), "m4a-agent-test").unwrap();
302        v.put("a", b"1").unwrap();
303        let kind = v.kind();
304        let v = FileVault::open_default(d.path(), "m4a-agent-test").unwrap();
305        assert_eq!(v.kind(), kind);
306        assert_eq!(&v.get("a").unwrap().unwrap()[..], b"1");
307    }
308}