Skip to main content

mail4agent_messenger_shell/cli/
vault.rs

1//! `m4a-agent vault <status|backup|restore>`: the owner's way to look at, save and move the key
2//! vault of `M4A_STORE_ROOT`.
3//!
4//! * `status`: where the master key lives (`file+keyfile`, `file+keychain`, `file+envkey`) and how
5//!   many entries there are. No label values.
6//! * `backup --out <file> --passphrase-env <NAME>`: every entry, encrypted under the passphrase held
7//!   in the environment variable NAME (never on the command line). Refuses to overwrite.
8//! * `restore --from <file> --passphrase-env <NAME>`: writes the entries into the active vault.
9//!
10//! To move a vault to a master key from the environment (`M4A_VAULT_KEY_ENV` names the variable that
11//! holds it): back it up, move the old `vault.enc`, `vault.key` and `vault.home` aside, set the
12//! variable, restore. The identity and store keys come back; copy the session store directories
13//! along (they are sealed under keys the vault holds).
14
15use super::session_arg::take_flag;
16use crate::STORE_ROOT_ENV;
17
18pub fn run(args: Vec<String>) {
19    match go(args) {
20        Ok(t) => println!("{t}"),
21        Err(e) => {
22            eprintln!("vault: {e}");
23            std::process::exit(if e.starts_with("usage") { 64 } else { 1 });
24        }
25    }
26}
27
28const USAGE: &str = "usage: m4a-agent vault status | backup --out <file> --passphrase-env <NAME> | restore --from <file> --passphrase-env <NAME>";
29
30pub(crate) fn go(mut args: Vec<String>) -> Result<String, String> {
31    if args.is_empty() {
32        return Err(USAGE.into());
33    }
34    let sub = args.remove(0);
35    let (out, from, pass_env) = (take_flag(&mut args, "--out")?, take_flag(&mut args, "--from")?, take_flag(&mut args, "--passphrase-env")?);
36    if !args.is_empty() {
37        return Err(USAGE.into());
38    }
39    let root = std::env::var(STORE_ROOT_ENV).ok().filter(|v| !v.is_empty()).map(std::path::PathBuf::from).ok_or_else(|| format!("{STORE_ROOT_ENV} is not set"))?;
40    let vault = crate::store_key::vault(&root).map_err(|e| e.to_string())?;
41    match sub.as_str() {
42        "status" => {
43            let n = vault.labels().map(|l| l.len().to_string()).unwrap_or_else(|_| "?".into());
44            Ok(format!("vault: {}\nentries: {n}", vault.kind()))
45        }
46        #[cfg(feature = "vault-backup")]
47        "backup" | "restore" => {
48            let var = pass_env.ok_or("--passphrase-env <NAME> is required (the passphrase is never taken from the command line)")?;
49            let pass = zeroize::Zeroizing::new(std::env::var(&var).map_err(|_| format!("the variable {var} is not set"))?);
50            if sub == "backup" {
51                let path = std::path::PathBuf::from(out.ok_or("backup needs --out <file>")?);
52                if path.exists() {
53                    return Err("the output file exists; give a new path".into());
54                }
55                let blob = m4a_agent::vault_backup::backup(&*vault, &pass).map_err(|e| e.to_string())?;
56                write_private(&path, &blob)?;
57                Ok(format!("backup written ({} bytes); it opens only with the passphrase", blob.len()))
58            } else {
59                let path = from.ok_or("restore needs --from <file>")?;
60                let blob = std::fs::read(&path).map_err(|_| "the backup file cannot be read".to_string())?;
61                let n = m4a_agent::vault_backup::restore(&*vault, &blob, &pass).map_err(|e| e.to_string())?;
62                Ok(format!("restored {n} entries into the {} vault", vault.kind()))
63            }
64        }
65        #[cfg(not(feature = "vault-backup"))]
66        "backup" | "restore" => {
67            let _ = (out, from, pass_env);
68            Err("this build was made without feature vault-backup".into())
69        }
70        _ => Err(USAGE.into()),
71    }
72}
73
74#[cfg(feature = "vault-backup")]
75fn write_private(path: &std::path::Path, data: &[u8]) -> Result<(), String> {
76    use std::io::Write;
77    let mut o = std::fs::OpenOptions::new();
78    o.write(true).create_new(true);
79    #[cfg(unix)]
80    {
81        use std::os::unix::fs::OpenOptionsExt;
82        o.mode(0o600);
83    }
84    o.open(path).and_then(|mut f| f.write_all(data)).map_err(|e| format!("cannot write the backup: {e}"))
85}
86
87#[cfg(all(test, feature = "vault-backup"))]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn a_vault_moves_from_a_key_file_to_a_key_from_the_environment_through_a_backup() {
93        let _g = super::super::session_arg::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
94        let d = tempfile::tempdir().unwrap();
95        let root = d.path().join("store");
96        std::env::set_var(STORE_ROOT_ENV, &root);
97        std::env::remove_var(m4a_agent::vault::VAULT_KEY_ENV);
98        let say = |a: &[&str]| go(a.iter().map(|s| s.to_string()).collect());
99        crate::store_key::vault(&root).unwrap().put("identity-key/s1", b"seed").unwrap();
100        assert!(say(&["status"]).unwrap().contains("file+key"));
101
102        std::env::set_var("M4A_TEST_BACKUP_PASS", "a good long passphrase");
103        let bk = d.path().join("v.bak");
104        assert!(say(&["backup", "--out", bk.to_str().unwrap()]).is_err(), "a passphrase variable is required");
105        assert!(say(&["backup", "--out", bk.to_str().unwrap(), "--passphrase-env", "M4A_TEST_BACKUP_PASS"]).unwrap().contains("written"));
106        assert!(say(&["backup", "--out", bk.to_str().unwrap(), "--passphrase-env", "M4A_TEST_BACKUP_PASS"]).is_err(), "no overwrite");
107
108        // A new store root whose vault key comes from the environment; restore into it.
109        let root2 = d.path().join("store2");
110        std::env::set_var("M4A_TEST_MASTER", "a master secret from the host env");
111        std::env::set_var(m4a_agent::vault::VAULT_KEY_ENV, "M4A_TEST_MASTER");
112        std::env::set_var(STORE_ROOT_ENV, &root2);
113        let out = say(&["restore", "--from", bk.to_str().unwrap(), "--passphrase-env", "M4A_TEST_BACKUP_PASS"]).unwrap();
114        assert!(out.contains("restored 1 entries into the file+envkey"), "{out}");
115        assert!(!root2.join(".m4a-agent/vault.key").exists(), "no key file beside the vault");
116        assert_eq!(&crate::store_key::vault(&root2).unwrap().get("identity-key/s1").unwrap().unwrap()[..], b"seed");
117        assert!(say(&["status"]).unwrap().contains("file+envkey"));
118        std::env::set_var("M4A_TEST_BACKUP_PASS", "another long passphrase!");
119        assert!(say(&["restore", "--from", bk.to_str().unwrap(), "--passphrase-env", "M4A_TEST_BACKUP_PASS"]).is_err());
120        std::env::remove_var(m4a_agent::vault::VAULT_KEY_ENV);
121    }
122}