1pub mod keyproof;
36
37use std::collections::HashMap;
38use std::sync::Mutex;
39
40use base64::engine::general_purpose::URL_SAFE_NO_PAD;
41use base64::Engine;
42use hmac::{Hmac, Mac};
43use serde::{Deserialize, Serialize};
44use sha2::Sha256;
45
46type HmacSha256 = Hmac<Sha256>;
47
48pub const DEFAULT_ASSERTION_HEADER: &str = "x-m4a-assertion";
50pub const DEFAULT_EVENT_SIG_HEADER: &str = "x-m4a-event-sig";
52pub const ASSERTION_TTL_S: i64 = 60;
54
55#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct Reconcile {
60 pub id: String,
61 pub complete: bool,
64 pub nicks: Vec<LiveNick>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
68pub struct LiveNick {
69 pub nick: String,
70 pub creds: Vec<String>,
72}
73
74pub const LINK_TOKEN_HEADER: &str = "x-m4a-link-token";
78
79pub fn link_path_is_open(path: &str) -> bool {
82 path == "/edge/healthz"
83 || path.starts_with("/_matrix/federation/")
84 || path.starts_with("/_matrix/key/")
85 || path.starts_with("/.well-known/")
86 || path == "/_matrix/client/versions"
87 || path == "/client/versions"
88}
89
90pub const ANON_READ_HEADER: &str = "x-m4a-anon-read";
94
95pub fn anon_read_path_ok(method: &str, path: &str) -> bool {
98 if method != "GET" {
99 return false;
100 }
101 let p = path.split('?').next().unwrap_or("");
102 let p = p.strip_prefix("/_matrix").filter(|r| r.starts_with('/')).unwrap_or(p);
103 if matches!(p, "/client/versions" | "/client/v3/publicRooms") || p.starts_with("/client/v3/directory/room/") {
104 return true;
105 }
106 if let Some(rest) = p.strip_prefix("/client/v3/rooms/").or_else(|| p.strip_prefix("/client/v1/rooms/")) {
107 let mut it = rest.splitn(2, '/');
108 let (room, tail) = (it.next().unwrap_or(""), it.next().unwrap_or(""));
109 return !room.is_empty() && (tail == "messages" || tail == "hierarchy" || tail == "state" || tail.starts_with("state/") || tail.starts_with("event/"));
110 }
111 false
112}
113
114pub fn link_token_ok(presented: Option<&str>, expected: &str) -> bool {
116 match presented {
117 Some(p) if p.len() == expected.len() => p.bytes().zip(expected.bytes()).fold(0u8, |a, (x, y)| a | (x ^ y)) == 0,
118 _ => false,
119 }
120}
121
122pub const VECTORS: &str = include_str!("../vectors/assertion_v1.json");
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Assertion {
128 pub nick: String,
129 pub cred_ref: String,
130 pub authenticated: u8,
132 pub paid: u8,
134 pub iat: i64,
135 pub exp: i64,
136 pub nonce: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum SeamError {
142 Malformed(&'static str),
143 BadSignature,
144 Expired,
145 Replay,
146}
147
148fn mac(secret: &[u8], parts: &[&[u8]]) -> HmacSha256 {
149 let mut m = HmacSha256::new_from_slice(secret).expect("hmac takes any key length");
150 for p in parts {
151 m.update(p);
152 }
153 m
154}
155
156fn signing_text(method: &str, path: &str, a: &Assertion) -> String {
157 format!(
158 "v1\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
159 method.to_ascii_uppercase(),
160 path,
161 a.nick,
162 a.authenticated,
163 a.paid,
164 a.cred_ref,
165 a.iat,
166 a.exp,
167 a.nonce
168 )
169}
170
171pub fn sign_assertion(secret: &[u8], method: &str, path: &str, a: &Assertion) -> String {
174 let json = serde_json::to_vec(a).expect("assertion serializes");
175 let sig = hex::encode(mac(secret, &[signing_text(method, path, a).as_bytes()]).finalize().into_bytes());
176 format!("v1.{}.{}", URL_SAFE_NO_PAD.encode(json), sig)
177}
178
179pub fn verify_assertion(secrets: &[Vec<u8>], value: &str, method: &str, path: &str, now_ms: i64, skew_ms: i64) -> Result<Assertion, SeamError> {
182 let mut it = value.splitn(3, '.');
183 let (v, b64, sig) = (it.next().unwrap_or(""), it.next().ok_or(SeamError::Malformed("parts"))?, it.next().ok_or(SeamError::Malformed("parts"))?);
184 if v != "v1" {
185 return Err(SeamError::Malformed("version"));
186 }
187 let raw = URL_SAFE_NO_PAD.decode(b64).map_err(|_| SeamError::Malformed("base64"))?;
188 let a: Assertion = serde_json::from_slice(&raw).map_err(|_| SeamError::Malformed("json"))?;
189 if a.authenticated != 1 || a.paid > 1 {
190 return Err(SeamError::Malformed("flags"));
191 }
192 if a.nick.is_empty() || a.cred_ref.is_empty() || a.nonce.is_empty() || a.nick.contains('\n') || a.cred_ref.contains('\n') || a.nonce.contains('\n') {
193 return Err(SeamError::Malformed("fields"));
194 }
195 let sig = hex::decode(sig).map_err(|_| SeamError::Malformed("hex"))?;
196 let text = signing_text(method, path, &a);
197 if !secrets.iter().any(|s| mac(s, &[text.as_bytes()]).verify_slice(&sig).is_ok()) {
198 return Err(SeamError::BadSignature);
199 }
200 if a.exp * 1000 < now_ms - skew_ms || a.iat * 1000 > now_ms + skew_ms {
201 return Err(SeamError::Expired);
202 }
203 Ok(a)
204}
205
206#[derive(Default)]
208pub struct NonceCache {
209 seen: Mutex<HashMap<String, i64>>,
210}
211
212impl NonceCache {
213 pub fn new() -> Self {
214 Self::default()
215 }
216 pub fn check_and_insert(&self, a: &Assertion, now_ms: i64, skew_ms: i64) -> Result<(), SeamError> {
218 let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
219 seen.retain(|_, exp| *exp >= now_ms - skew_ms);
220 if seen.insert(a.nonce.clone(), a.exp * 1000).is_some() {
221 return Err(SeamError::Replay);
222 }
223 Ok(())
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(tag = "type")]
230pub enum EventKind {
231 #[serde(rename = "credential.revoked")]
232 CredentialRevoked { cred_ref: String },
233 #[serde(rename = "account.deleted")]
234 AccountDeleted { nick: String },
235 #[serde(rename = "nick.changed")]
236 NickChanged { old: String, new: String },
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct Event {
242 pub id: String,
243 #[serde(flatten)]
244 pub kind: EventKind,
245}
246
247pub fn sign_body(secret: &[u8], body: &[u8]) -> String {
249 hex::encode(mac(secret, &[body]).finalize().into_bytes())
250}
251
252pub fn verify_body(secrets: &[Vec<u8>], body: &[u8], sig_hex: &str) -> bool {
254 match hex::decode(sig_hex) {
255 Ok(sig) => secrets.iter().any(|s| mac(s, &[body]).verify_slice(&sig).is_ok()),
256 Err(_) => false,
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use serde_json::Value;
264
265 fn sample(nonce: &str) -> Assertion {
266 Assertion { nick: "carol".into(), cred_ref: "c1".into(), authenticated: 1, paid: 1, iat: 1_800_000_000, exp: 1_800_000_060, nonce: nonce.into() }
267 }
268 const NOW: i64 = 1_800_000_010_000;
269
270 #[test]
271 fn round_trip_and_binding_to_method_and_path() {
272 let s = b"0123456789abcdef0123".to_vec();
273 let v = sign_assertion(&s, "post", "/client/v3/x?q=1", &sample("n1"));
274 assert_eq!(verify_assertion(&[s.clone()], &v, "POST", "/client/v3/x?q=1", NOW, 30_000).unwrap(), sample("n1"));
275 assert_eq!(verify_assertion(&[s.clone()], &v, "GET", "/client/v3/x?q=1", NOW, 30_000), Err(SeamError::BadSignature));
276 assert_eq!(verify_assertion(&[s], &v, "POST", "/client/v3/y", NOW, 30_000), Err(SeamError::BadSignature));
277 }
278
279 #[test]
280 fn link_token_is_constant_length_checked_and_public_paths_stay_open() {
281 assert!(link_token_ok(Some("abcdefgh"), "abcdefgh"));
282 assert!(!link_token_ok(Some("abcdefgX"), "abcdefgh") && !link_token_ok(Some("abc"), "abcdefgh") && !link_token_ok(None, "abcdefgh"));
283 assert!(link_path_is_open("/_matrix/federation/v1/send/1") && link_path_is_open("/.well-known/matrix/server") && link_path_is_open("/edge/healthz"));
284 assert!(!link_path_is_open("/_matrix/client/v3/sync") && !link_path_is_open("/client/v3/push"));
285 }
286
287 #[test]
288 fn anon_read_allowlist_is_get_only_and_public_data_only() {
289 assert!(anon_read_path_ok("GET", "/_matrix/client/v3/rooms/!r:x/messages?dir=b"));
290 assert!(anon_read_path_ok("GET", "/client/v3/publicRooms") && anon_read_path_ok("GET", "/client/v3/rooms/!r:x/state/m.room.name/"));
291 assert!(!anon_read_path_ok("POST", "/client/v3/publicRooms") && !anon_read_path_ok("GET", "/client/v3/sync"));
292 assert!(!anon_read_path_ok("GET", "/client/v3/rooms/!r:x/members") && !anon_read_path_ok("GET", "/client/v3/account/whoami"));
293 assert!(!anon_read_path_ok("GET", "/client/v3/rooms//messages"));
294 }
295
296 #[test]
297 fn replay_cache_refuses_second_use() {
298 let c = NonceCache::new();
299 c.check_and_insert(&sample("n"), NOW, 30_000).unwrap();
300 assert_eq!(c.check_and_insert(&sample("n"), NOW, 30_000), Err(SeamError::Replay));
301 c.check_and_insert(&sample("m"), NOW, 30_000).unwrap();
302 }
303
304 #[test]
305 fn event_bodies_round_trip_and_sign() {
306 let e = Event { id: "a".repeat(32), kind: EventKind::NickChanged { old: "a1b".into(), new: "c2d".into() } };
307 let body = serde_json::to_vec(&e).unwrap();
308 let v: Value = serde_json::from_slice(&body).unwrap();
309 assert_eq!(v["type"], "nick.changed");
310 assert_eq!(serde_json::from_slice::<Event>(&body).unwrap(), e);
311 let s = b"0123456789abcdef0123".to_vec();
312 let sig = sign_body(&s, &body);
313 assert!(verify_body(&[s.clone()], &body, &sig));
314 assert!(!verify_body(&[s], b"{}", &sig));
315 }
316
317 #[test]
319 fn shared_vectors() {
320 let v: Value = serde_json::from_str(VECTORS).unwrap();
321 let secret = v["secret"].as_str().unwrap().as_bytes().to_vec();
322 let prev = v["secret_prev"].as_str().unwrap().as_bytes().to_vec();
323 let now_ms = v["now_ms"].as_i64().unwrap();
324 let skew = v["skew_ms"].as_i64().unwrap();
325 for case in v["cases"].as_array().unwrap() {
326 let name = case["name"].as_str().unwrap();
327 let a: Assertion = serde_json::from_value(case["assertion"].clone()).unwrap();
328 let (method, path) = (case["method"].as_str().unwrap(), case["path"].as_str().unwrap());
329 let signer = if case["signed_with"] == "prev" { &prev } else { &secret };
330 let mut value = sign_assertion(signer, case["sign_method"].as_str().unwrap_or(method), case["sign_path"].as_str().unwrap_or(path), &a);
331 if let Some(t) = case["tamper_nick_to"].as_str() {
332 let json = serde_json::to_vec(&Assertion { nick: t.into(), ..a.clone() }).unwrap();
333 let sig = value.rsplit('.').next().unwrap().to_string();
334 value = format!("v1.{}.{}", URL_SAFE_NO_PAD.encode(json), sig);
335 }
336 if case["expected_value"].is_string() {
337 assert_eq!(value, case["expected_value"].as_str().unwrap(), "{name}: pinned wire value");
338 }
339 let accept = [secret.clone(), prev.clone()];
340 let secrets: &[Vec<u8>] = if case["accept_prev"] == true { &accept } else { &accept[..1] };
341 let got = verify_assertion(secrets, &value, method, path, now_ms, skew);
342 match case["expect"].as_str().unwrap() {
343 "ok" => assert!(got.is_ok(), "{name}: {got:?}"),
344 "bad_signature" => assert_eq!(got, Err(SeamError::BadSignature), "{name}"),
345 "expired" => assert_eq!(got, Err(SeamError::Expired), "{name}"),
346 "malformed" => assert!(matches!(got, Err(SeamError::Malformed(_))), "{name}: {got:?}"),
347 other => panic!("{name}: unknown expectation {other}"),
348 }
349 }
350 }
351}