mail4agent_messenger_shell/cli/
vault.rs1use 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 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}