Skip to main content

m4a_seam/
lib.rs

1//! The seam between a product server and the messenger server.
2//!
3//! One small crate used by BOTH sides so the wire cannot drift: the product
4//! server signs, the messenger core verifies. No I/O, no database.
5//!
6//! # Assertion v1
7//!
8//! Header value: `v1.<base64url(json)>.<hex hmac-sha256>`. The JSON carries
9//! `nick`, `cred_ref`, `authenticated` (1), `paid` (0 or 1), `iat`, `exp`
10//! (unix seconds) and `nonce`. The HMAC key is the raw UTF-8 of the shared
11//! secret. The HMAC input is this UTF-8 text, every line ending in a newline:
12//!
13//! ```text
14//! v1
15//! <METHOD>
16//! <path and query as the receiver sees it>
17//! <nick>
18//! 1
19//! <0 or 1>
20//! <cred_ref>
21//! <iat>
22//! <exp>
23//! <nonce>
24//! ```
25//!
26//! The verifier rebuilds the text from the parsed JSON, so the JSON values are
27//! exactly what was signed.
28//!
29//! # Events
30//!
31//! JSON bodies with `id` and `type` (`credential.revoked`, `account.deleted`,
32//! `nick.changed`); the signature header carries the hex HMAC-SHA256 of the
33//! raw body bytes under the same secret.
34
35pub 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
48/// Default header carrying the assertion. Products may configure another name.
49pub const DEFAULT_ASSERTION_HEADER: &str = "x-m4a-assertion";
50/// Default header carrying the event body signature.
51pub const DEFAULT_EVENT_SIG_HEADER: &str = "x-m4a-event-sig";
52/// Lifetime of an assertion the signer makes, in seconds.
53pub const ASSERTION_TTL_S: i64 = 60;
54
55/// Startup reconciliation: the product's snapshot of live credentials, so the messenger can
56/// catch up on `credential.revoked` / `account.deleted` events it never received.
57/// Posted to `/account-source/v1/reconcile` with the same body signature as events.
58#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59pub struct Reconcile {
60    pub id: String,
61    /// True when `nicks` lists EVERY live account: identities absent from it are retired.
62    /// False for a partial chunk: only the listed nicks are reconciled.
63    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    /// Every credential reference that is still valid for this nick.
71    pub creds: Vec<String>,
72}
73
74/// Header carrying the barrier token on private links (product -> edge, product -> core).
75/// Every receiver of a link checks it inside, on top of the transport (WireGuard or a
76/// unix socket) that already protects the link.
77pub const LINK_TOKEN_HEADER: &str = "x-m4a-link-token";
78
79/// Paths that stay reachable without the barrier token because they are public
80/// protocol surfaces (federation peers, key documents, discovery, health).
81pub 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
90/// Marker a product puts on an unauthenticated, read-only forward (no assertion). The core
91/// honours it only when anonymous read is switched on (`M4A_ANON_READ=on`), only for `GET`,
92/// and only for the paths of [`anon_read_path_ok`].
93pub const ANON_READ_HEADER: &str = "x-m4a-anon-read";
94
95/// Anonymous read allowlist: `GET`s that answer from public, world-readable data only. `path`
96/// may carry a query string and the `/_matrix` prefix.
97pub 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
114/// Constant-time comparison of a presented barrier token with the expected one.
115pub 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
122/// Shared accept/reject vectors for assertion v1 (JSON; see the file).
123pub const VECTORS: &str = include_str!("../vectors/assertion_v1.json");
124
125/// What an assertion states about the caller.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Assertion {
128    pub nick: String,
129    pub cred_ref: String,
130    /// Always 1 on the wire; an unauthenticated person is not asserted.
131    pub authenticated: u8,
132    /// Opaque flag (0 or 1) that the receiving policy hook may read.
133    pub paid: u8,
134    pub iat: i64,
135    pub exp: i64,
136    pub nonce: String,
137}
138
139/// Why verification failed.
140#[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
171/// Make the header value for one request. `path` is the path and query as the
172/// receiver will see it.
173pub 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
179/// Verify a header value against any of `secrets` (current first). `now_ms` and
180/// `skew_ms` bound `[iat, exp]`. Replay is the caller's concern ([`NonceCache`]).
181pub 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/// Remembers nonces until their assertion expires.
207#[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    /// Record `a`'s nonce; `Err(Replay)` when it was already used.
217    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/// Lifecycle events, product -> messenger.
228#[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/// One event with its idempotency id.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct Event {
242    pub id: String,
243    #[serde(flatten)]
244    pub kind: EventKind,
245}
246
247/// Hex HMAC-SHA256 of a raw event body.
248pub fn sign_body(secret: &[u8], body: &[u8]) -> String {
249    hex::encode(mac(secret, &[body]).finalize().into_bytes())
250}
251
252/// True when `sig_hex` is valid for `body` under any of `secrets`.
253pub 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    /// The shared vector file: every party runs this same table.
318    #[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}