1use 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 #[serde(default)]
43 rooms: BTreeMap<String, String>,
44}
45
46#[derive(Serialize, Deserialize, Default)]
47struct State {
48 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
142pub type ExternalSign = Box<dyn Fn(&[u8]) -> Result<[u8; 64], String> + Send + Sync>;
144
145struct 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 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 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 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}