Skip to main content

hatchery_server/
m4a_link.rs

1//! Delivery of keystore-signed letters into the real m4a (the mail4agent messenger that holds the
2//! rooms and the human/agent accounts). There is no second messenger for sessions: a session's
3//! letter is posted by that session's own m4a account.
4//!
5//! - The session's m4a account is proven by the session's keystore key (m4a key-proof login,
6//!   `m4a-key-login-v1`), so the key never leaves the keystore and there is no password.
7//! - The first letter enrolls the account: an admin invite for the session's nick from the m4a
8//!   product server, then `m4a-key-enroll-v1` signed by the same key.
9//! - Letters go as a 1:1 room (`is_direct`) with the recipient, reused afterwards.
10//! - [`M4aLink::deliver_e2e`] sends end-to-end encrypted (Olm/Megolm) through the m4a client
11//!   engine; its vault master key and identity key are rooted in the keystore, per session.
12//!
13//! Configuration (env, no defaults that name a deployment):
14//! `HATCHERY_M4A_PRODUCT_URL` (http base of the m4a product server) and
15//! `HATCHERY_M4A_ADMIN_TOKEN_VAR` (NAME of the env var that holds the product admin bearer).
16
17use std::collections::BTreeMap;
18use std::io::{Read, Write};
19use std::path::{Path, PathBuf};
20
21use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22use base64::Engine;
23use serde::{Deserialize, Serialize};
24use serde_json::{json, Value};
25use sha2::{Digest, Sha256};
26
27pub const PRODUCT_URL_ENV: &str = "HATCHERY_M4A_PRODUCT_URL";
28pub const ADMIN_TOKEN_VAR_ENV: &str = "HATCHERY_M4A_ADMIN_TOKEN_VAR";
29
30#[derive(Debug)]
31pub struct LinkError(pub u16, pub String);
32
33fn e(code: u16, m: impl Into<String>) -> LinkError {
34    LinkError(code, m.into())
35}
36
37#[derive(Serialize, Deserialize, Default, Clone)]
38struct Account {
39    m4a_nick: String,
40    key_id: String,
41    /// recipient user id -> direct room id
42    #[serde(default)]
43    rooms: BTreeMap<String, String>,
44}
45
46#[derive(Serialize, Deserialize, Default)]
47struct State {
48    /// keystore nick -> m4a account
49    accounts: BTreeMap<String, Account>,
50}
51
52#[derive(Clone)]
53pub struct M4aLink {
54    host: String,
55    admin_var: String,
56    state_path: PathBuf,
57}
58
59
60pub fn key_id_of(public_key: &[u8]) -> String {
61    format!("k{}", &hex::encode(Sha256::digest(public_key))[..32])
62}
63
64pub fn b64(b: &[u8]) -> String {
65    URL_SAFE_NO_PAD.encode(b)
66}
67
68pub fn login_message(audience: &str, key_id: &str, challenge_id: &str, nonce: &str, expires_ms: i64) -> Vec<u8> {
69    format!("m4a-key-login-v1\n{audience}\n{key_id}\n{challenge_id}\n{nonce}\n{expires_ms}\n").into_bytes()
70}
71
72pub fn enroll_message(audience: &str, invite: &str, public_key_b64: &str, key_id: &str) -> Vec<u8> {
73    let ih = hex::encode(Sha256::digest(invite.as_bytes()));
74    format!("m4a-key-enroll-v1\n{audience}\n{ih}\n{key_id}\n{public_key_b64}\n").into_bytes()
75}
76
77impl M4aLink {
78    pub fn from_env(data_dir: &Path) -> Option<Self> {
79        let url = std::env::var(PRODUCT_URL_ENV).ok().filter(|v| !v.is_empty())?;
80        let host = url.trim_end_matches('/').strip_prefix("http://")?.to_owned();
81        let admin_var = std::env::var(ADMIN_TOKEN_VAR_ENV).ok().filter(|v| !v.is_empty())?;
82        Some(Self { host, admin_var, state_path: data_dir.join("m4a-link.json") })
83    }
84
85    fn load(&self) -> State {
86        std::fs::read(&self.state_path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
87    }
88
89    fn save(&self, s: &State) -> Result<(), LinkError> {
90        let tmp = self.state_path.with_extension("tmp");
91        let mut o = std::fs::OpenOptions::new();
92        o.write(true).create(true).truncate(true);
93        #[cfg(unix)]
94        {
95            use std::os::unix::fs::OpenOptionsExt;
96            o.mode(0o600);
97        }
98        let bytes = serde_json::to_vec(s).map_err(|x| e(500, x.to_string()))?;
99        o.open(&tmp).and_then(|mut f| f.write_all(&bytes)).map_err(|x| e(500, x.kind().to_string()))?;
100        std::fs::rename(&tmp, &self.state_path).map_err(|x| e(500, x.kind().to_string()))
101    }
102
103    fn call(&self, method: &str, path: &str, bearer: Option<&str>, body: Option<&Value>) -> Result<(u16, Value), LinkError> {
104        let mut s = std::net::TcpStream::connect(&self.host).map_err(|x| e(502, format!("m4a unreachable: {}", x.kind())))?;
105        s.set_read_timeout(Some(std::time::Duration::from_secs(20))).ok();
106        let payload = body.map(|b| b.to_string()).unwrap_or_default();
107        let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept: application/json\r\n", self.host);
108        if let Some(t) = bearer {
109            req.push_str(&format!("Authorization: Bearer {t}\r\n"));
110        }
111        if body.is_some() {
112            req.push_str(&format!("Content-Type: application/json\r\nContent-Length: {}\r\n", payload.len()));
113        }
114        req.push_str("\r\n");
115        req.push_str(&payload);
116        s.write_all(req.as_bytes()).map_err(|x| e(502, x.kind().to_string()))?;
117        let mut raw = Vec::new();
118        s.read_to_end(&mut raw).map_err(|x| e(502, x.kind().to_string()))?;
119        let split = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| e(502, "bad m4a answer"))?;
120        let head = String::from_utf8_lossy(&raw[..split]).to_string();
121        let mut body = raw[split + 4..].to_vec();
122        if head.to_ascii_lowercase().contains("transfer-encoding: chunked") {
123            body = dechunk(&body);
124        }
125        let status: u16 = head.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(502);
126        let v = serde_json::from_slice(&body).unwrap_or(Value::Null);
127        Ok((status, v))
128    }
129
130    fn ok(&self, method: &str, path: &str, bearer: Option<&str>, body: Option<&Value>) -> Result<Value, LinkError> {
131        let (st, v) = self.call(method, path, bearer, body)?;
132        if (200..300).contains(&st) {
133            Ok(v)
134        } else {
135            let msg = v.get("error").and_then(Value::as_str).unwrap_or("m4a refused").to_owned();
136            Err(e(st, format!("{method} {}: {msg}", path.split('?').next().unwrap_or(path))))
137        }
138    }
139
140}
141
142/// Signs a key-proof statement as the session, inside the keystore (never exported).
143pub type ExternalSign = Box<dyn Fn(&[u8]) -> Result<[u8; 64], String> + Send + Sync>;
144
145/// The session's m4a client vault: a file vault under a master key derived from the session's
146/// keystore secret, plus the session's identity key, which stays in the keystore and only signs.
147struct HostVault {
148    file: m4a_agent::FileVault,
149    session: String,
150    public_key: [u8; 32],
151    sign: ExternalSign,
152}
153
154impl m4a_agent::KeyVault for HostVault {
155    fn get(&self, label: &str) -> m4a_agent::error::Result<Option<zeroize::Zeroizing<Vec<u8>>>> {
156        self.file.get(label)
157    }
158    fn put(&self, label: &str, value: &[u8]) -> m4a_agent::error::Result<()> {
159        self.file.put(label, value)
160    }
161    fn delete(&self, label: &str) -> m4a_agent::error::Result<bool> {
162        self.file.delete(label)
163    }
164    fn labels(&self) -> m4a_agent::error::Result<Vec<String>> {
165        self.file.labels()
166    }
167    fn kind(&self) -> &'static str {
168        "file+keystore"
169    }
170    fn external_identity(&self, session_id: &str) -> Option<[u8; 32]> {
171        (session_id == self.session).then_some(self.public_key)
172    }
173    fn sign_external(&self, session_id: &str, msg: &[u8]) -> m4a_agent::error::Result<[u8; 64]> {
174        if session_id != self.session {
175            return Err(m4a_agent::AgentError::Vault("not this session's key".into()));
176        }
177        (self.sign)(msg).map_err(m4a_agent::AgentError::Vault)
178    }
179}
180
181impl M4aLink {
182    fn sessions_root(&self) -> PathBuf {
183        self.state_path.parent().unwrap_or(Path::new(".")).join("m4a-sessions")
184    }
185
186    /// End-to-end encrypted letter (Olm/Megolm, the m4a client engine) from the session `nick`
187    /// to `to`. Each session has its own root, vault and device: one session never opens another
188    /// session's keys. Returns (m4a nick, room, event).
189    pub fn deliver_e2e(&self, nick: &str, public_key: [u8; 32], vault_key: zeroize::Zeroizing<[u8; 32]>, sign: ExternalSign, to: &str, text: &str) -> Result<(String, String, String), LinkError> {
190        use mail4agent_messenger_shell::{store_key, HostSession, MachineClient};
191        use m4a_agent::{BackendKind, IdentityStore, KeyVault};
192        let url = format!("http://{}", self.host);
193        let root = self.sessions_root().join(nick);
194        std::fs::create_dir_all(root.join(".m4a-agent")).map_err(|x| e(500, x.kind().to_string()))?;
195        #[cfg(unix)]
196        {
197            use std::os::unix::fs::PermissionsExt;
198            let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));
199        }
200        let file = m4a_agent::FileVault::open(root.join(".m4a-agent").join("vault.enc"), Box::new(m4a_agent::StaticMasterKey(zeroize::Zeroizing::new(vault_key.to_vec()))))
201            .map_err(|x| e(500, format!("session vault: {x}")))?;
202        let vault: std::sync::Arc<dyn KeyVault> = std::sync::Arc::new(HostVault { file, session: nick.to_owned(), public_key, sign });
203        store_key::install_vault(&root, vault.clone());
204        let result = (|| {
205            let ids = IdentityStore::new(vault.clone());
206            let mut id = ids.resolve(nick, BackendKind::Server, &url).map_err(|x| e(500, format!("identity: {x}")))?;
207            let mut st = self.load();
208            // An account this session already enrolled with the same keystore key is reused.
209            if !id.enrolled {
210                if let Some(a) = st.accounts.get(nick).filter(|a| a.key_id == id.key_id).cloned() {
211                    id.enrolled = true;
212                    ids.adopt_nick(&mut id, &a.m4a_nick).map_err(|x| e(500, x.to_string()))?;
213                    ids.save(&id).map_err(|x| e(500, x.to_string()))?;
214                }
215            }
216            let mut hs = HostSession::new(nick, nick).with_tier(BackendKind::Server);
217            hs.store_root = Some(root.clone());
218            if !id.enrolled {
219                let admin = std::env::var(&self.admin_var).ok().filter(|v| !v.is_empty()).ok_or_else(|| e(503, "m4a admin bearer not configured"))?;
220                let inv = self.ok("POST", "/product/v1/admin/invite", Some(&admin), Some(&json!({ "nick": nick })))?;
221                let code = inv.get("invite").and_then(Value::as_str).ok_or_else(|| e(502, "no invite"))?.to_owned();
222                hs = hs.with_invite(code);
223            }
224            let mut client = MachineClient::open(&url, &root, vec![hs]).map_err(|x| e(502, format!("m4a client: {x}")))?;
225            let reply = client.send_blocking(nick, to, text, std::time::Duration::from_secs(120));
226            drop(client);
227            let id = ids.resolve(nick, BackendKind::Server, &url).map_err(|x| e(500, format!("identity: {x}")))?;
228            let m4a_nick = id.nick.clone().unwrap_or_else(|| nick.to_owned());
229            if !st.accounts.contains_key(nick) && id.enrolled {
230                st.accounts.insert(nick.to_owned(), Account { m4a_nick: m4a_nick.clone(), key_id: id.key_id.clone(), rooms: BTreeMap::new() });
231                self.save(&st)?;
232            }
233            if !reply.ok {
234                return Err(e(502, reply.error.unwrap_or_else(|| "m4a send failed".into())));
235            }
236            Ok((m4a_nick, reply.room.unwrap_or_default(), reply.event_id.unwrap_or_default()))
237        })();
238        store_key::remove_vault(&root);
239        result
240    }
241}
242
243fn dechunk(b: &[u8]) -> Vec<u8> {
244    let mut out = Vec::new();
245    let mut i = 0;
246    while i < b.len() {
247        let Some(nl) = b[i..].windows(2).position(|w| w == b"\r\n") else { break };
248        let size = usize::from_str_radix(String::from_utf8_lossy(&b[i..i + nl]).trim(), 16).unwrap_or(0);
249        i += nl + 2;
250        if size == 0 || i + size > b.len() {
251            break;
252        }
253        out.extend_from_slice(&b[i..i + size]);
254        i += size + 2;
255    }
256    out
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn statements_match_the_m4a_contract() {
265        // Same text as m4a_seam::keyproof (fixed UTF-8 lines, newline-terminated).
266        assert_eq!(login_message("a", "k1", "c", "n", 5), b"m4a-key-login-v1\na\nk1\nc\nn\n5\n".to_vec());
267        let m = enroll_message("a", "inv", "PK", "k1");
268        assert!(m.starts_with(b"m4a-key-enroll-v1\na\n"));
269        assert!(m.ends_with(b"\nk1\nPK\n"));
270        assert_eq!(key_id_of(&[0u8; 32]).len(), 33);
271        assert_eq!(dechunk(b"3\r\nabc\r\n0\r\n\r\n"), b"abc");
272    }
273}