Skip to main content

hatchery_server/
m4a.rs

1//! m4a subservice: the node-side messenger key vault (`m4a-agent` 0.4).
2//!
3//! Keys live in the vault under `<data_dir>/m4a/.m4a-agent/`. The vault master
4//! key is never on disk and never created here: it comes from the environment
5//! variable *named* by `HATCHERY_M4A_MASTER_KEY_VAR` (a secret the host
6//! injects). Identity is by key signature; no password exists. Agents never see
7//! this service, its vault or the master key.
8
9use crate::error::ServerError;
10use crate::subservice::{State, Status, Subservice};
11use m4a_agent::vault::EnvMasterKey;
12use m4a_agent::{FileVault, KeyVault};
13use std::path::{Path, PathBuf};
14
15pub struct M4aSubservice {
16    store_root: PathBuf,
17    master_key_var: Option<String>,
18    vault_kind: Option<&'static str>,
19    failed: Option<String>,
20}
21
22impl M4aSubservice {
23    pub fn new(data_dir: &Path, master_key_var: Option<String>) -> Self {
24        Self {
25            store_root: data_dir.join("m4a"),
26            master_key_var,
27            vault_kind: None,
28            failed: None,
29        }
30    }
31
32    pub fn vault_dir(&self) -> PathBuf {
33        self.store_root.join(".m4a-agent")
34    }
35
36    fn open(&self) -> Result<&'static str, ServerError> {
37        let var = self
38            .master_key_var
39            .as_deref()
40            .filter(|v| !v.is_empty())
41            .ok_or_else(|| {
42                ServerError::sub("m4a", "refusing to start: no master key variable configured")
43            })?;
44        let dir = self.vault_dir();
45        std::fs::create_dir_all(&dir)?;
46        let marker = dir.join("vault.home");
47        if !marker.exists() {
48            write_private(&marker, b"envkey")?;
49        }
50        let vault = FileVault::open(dir.join("vault.enc"), Box::new(EnvMasterKey { var: var.to_owned() }))
51            .map_err(|e| ServerError::sub("m4a", format!("key vault: {e}")))?;
52        Ok(vault.kind())
53    }
54}
55
56fn write_private(path: &Path, data: &[u8]) -> std::io::Result<()> {
57    use std::io::Write;
58    #[cfg(unix)]
59    use std::os::unix::fs::OpenOptionsExt;
60    let mut options = std::fs::OpenOptions::new();
61    options.write(true).create_new(true);
62    #[cfg(unix)]
63    options.mode(0o600);
64    options.open(path)?.write_all(data)
65}
66
67impl Subservice for M4aSubservice {
68    fn name(&self) -> &'static str {
69        "m4a"
70    }
71
72    fn start(&mut self) -> Result<(), ServerError> {
73        match self.open() {
74            Ok(kind) => {
75                self.vault_kind = Some(kind);
76                self.failed = None;
77                Ok(())
78            }
79            Err(error) => {
80                self.vault_kind = None;
81                self.failed = Some(error.to_string());
82                Err(error)
83            }
84        }
85    }
86
87    fn stop(&mut self) {
88        self.vault_kind = None;
89    }
90
91    fn status(&mut self) -> Status {
92        let state = if self.vault_kind.is_some() {
93            State::Running
94        } else if self.failed.is_some() {
95            State::Failed
96        } else {
97            State::Stopped
98        };
99        // Never report key material: only the vault kind or the failure text.
100        let detail = self.vault_kind.map(str::to_owned).or_else(|| self.failed.clone());
101        Status { name: "m4a", state, port: None, detail }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn refuses_without_master_key_variable() {
111        let dir = tempfile::tempdir().unwrap();
112        let mut service = M4aSubservice::new(dir.path(), None);
113        assert!(service.start().is_err());
114        assert_eq!(service.status().state, State::Failed);
115    }
116
117    #[test]
118    fn refuses_when_variable_is_unset_or_short() {
119        let dir = tempfile::tempdir().unwrap();
120        let mut unset = M4aSubservice::new(dir.path(), Some("HATCHERY_TEST_M4A_UNSET_KEY".into()));
121        assert!(unset.start().is_err());
122        assert!(!dir.path().join("m4a/.m4a-agent/vault.enc").exists(), "no vault under a missing key");
123    }
124
125    #[test]
126    fn opens_vault_with_env_master_key_and_never_reports_it() {
127        let dir = tempfile::tempdir().unwrap();
128        let var = "HATCHERY_TEST_M4A_MASTER_OK";
129        // Test-local secret value, not a real key.
130        std::env::set_var(var, "test-master-secret-0123456789");
131        let mut service = M4aSubservice::new(dir.path(), Some(var.into()));
132        service.start().unwrap();
133        let status = service.status();
134        assert_eq!(status.state, State::Running);
135        let json = serde_json::to_string(&status).unwrap();
136        assert!(!json.contains("test-master-secret"), "{json}");
137        assert!(dir.path().join("m4a/.m4a-agent/vault.home").exists());
138        // Put a record so the vault file exists, then restart with the same key.
139        let vault = FileVault::open(
140            service.vault_dir().join("vault.enc"),
141            Box::new(EnvMasterKey { var: var.to_owned() }),
142        )
143        .unwrap();
144        vault.put("test-label", b"value").unwrap();
145        service.stop();
146        service.start().unwrap();
147        // A different master key must not open the vault.
148        let other = "HATCHERY_TEST_M4A_MASTER_OTHER";
149        std::env::set_var(other, "another-master-secret-9876543210");
150        let mut wrong = M4aSubservice::new(dir.path(), Some(other.into()));
151        assert!(wrong.start().is_err(), "a wrong master key must not open the vault");
152        std::env::remove_var(var);
153        std::env::remove_var(other);
154    }
155}