pub mod keyproof;
use std::collections::HashMap;
use std::sync::Mutex;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const DEFAULT_ASSERTION_HEADER: &str = "x-m4a-assertion";
pub const DEFAULT_EVENT_SIG_HEADER: &str = "x-m4a-event-sig";
pub const ASSERTION_TTL_S: i64 = 60;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Reconcile {
pub id: String,
pub complete: bool,
pub nicks: Vec<LiveNick>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LiveNick {
pub nick: String,
pub creds: Vec<String>,
}
pub const LINK_TOKEN_HEADER: &str = "x-m4a-link-token";
pub fn link_path_is_open(path: &str) -> bool {
path == "/edge/healthz"
|| path.starts_with("/_matrix/federation/")
|| path.starts_with("/_matrix/key/")
|| path.starts_with("/.well-known/")
|| path == "/_matrix/client/versions"
|| path == "/client/versions"
}
pub const ANON_READ_HEADER: &str = "x-m4a-anon-read";
pub fn anon_read_path_ok(method: &str, path: &str) -> bool {
if method != "GET" {
return false;
}
let p = path.split('?').next().unwrap_or("");
let p = p.strip_prefix("/_matrix").filter(|r| r.starts_with('/')).unwrap_or(p);
if matches!(p, "/client/versions" | "/client/v3/publicRooms") || p.starts_with("/client/v3/directory/room/") {
return true;
}
if let Some(rest) = p.strip_prefix("/client/v3/rooms/").or_else(|| p.strip_prefix("/client/v1/rooms/")) {
let mut it = rest.splitn(2, '/');
let (room, tail) = (it.next().unwrap_or(""), it.next().unwrap_or(""));
return !room.is_empty() && (tail == "messages" || tail == "hierarchy" || tail == "state" || tail.starts_with("state/") || tail.starts_with("event/"));
}
false
}
pub fn link_token_ok(presented: Option<&str>, expected: &str) -> bool {
match presented {
Some(p) if p.len() == expected.len() => p.bytes().zip(expected.bytes()).fold(0u8, |a, (x, y)| a | (x ^ y)) == 0,
_ => false,
}
}
pub const VECTORS: &str = include_str!("../vectors/assertion_v1.json");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assertion {
pub nick: String,
pub cred_ref: String,
pub authenticated: u8,
pub paid: u8,
pub iat: i64,
pub exp: i64,
pub nonce: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeamError {
Malformed(&'static str),
BadSignature,
Expired,
Replay,
}
fn mac(secret: &[u8], parts: &[&[u8]]) -> HmacSha256 {
let mut m = HmacSha256::new_from_slice(secret).expect("hmac takes any key length");
for p in parts {
m.update(p);
}
m
}
fn signing_text(method: &str, path: &str, a: &Assertion) -> String {
format!(
"v1\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
method.to_ascii_uppercase(),
path,
a.nick,
a.authenticated,
a.paid,
a.cred_ref,
a.iat,
a.exp,
a.nonce
)
}
pub fn sign_assertion(secret: &[u8], method: &str, path: &str, a: &Assertion) -> String {
let json = serde_json::to_vec(a).expect("assertion serializes");
let sig = hex::encode(mac(secret, &[signing_text(method, path, a).as_bytes()]).finalize().into_bytes());
format!("v1.{}.{}", URL_SAFE_NO_PAD.encode(json), sig)
}
pub fn verify_assertion(secrets: &[Vec<u8>], value: &str, method: &str, path: &str, now_ms: i64, skew_ms: i64) -> Result<Assertion, SeamError> {
let mut it = value.splitn(3, '.');
let (v, b64, sig) = (it.next().unwrap_or(""), it.next().ok_or(SeamError::Malformed("parts"))?, it.next().ok_or(SeamError::Malformed("parts"))?);
if v != "v1" {
return Err(SeamError::Malformed("version"));
}
let raw = URL_SAFE_NO_PAD.decode(b64).map_err(|_| SeamError::Malformed("base64"))?;
let a: Assertion = serde_json::from_slice(&raw).map_err(|_| SeamError::Malformed("json"))?;
if a.authenticated != 1 || a.paid > 1 {
return Err(SeamError::Malformed("flags"));
}
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') {
return Err(SeamError::Malformed("fields"));
}
let sig = hex::decode(sig).map_err(|_| SeamError::Malformed("hex"))?;
let text = signing_text(method, path, &a);
if !secrets.iter().any(|s| mac(s, &[text.as_bytes()]).verify_slice(&sig).is_ok()) {
return Err(SeamError::BadSignature);
}
if a.exp * 1000 < now_ms - skew_ms || a.iat * 1000 > now_ms + skew_ms {
return Err(SeamError::Expired);
}
Ok(a)
}
#[derive(Default)]
pub struct NonceCache {
seen: Mutex<HashMap<String, i64>>,
}
impl NonceCache {
pub fn new() -> Self {
Self::default()
}
pub fn check_and_insert(&self, a: &Assertion, now_ms: i64, skew_ms: i64) -> Result<(), SeamError> {
let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
seen.retain(|_, exp| *exp >= now_ms - skew_ms);
if seen.insert(a.nonce.clone(), a.exp * 1000).is_some() {
return Err(SeamError::Replay);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum EventKind {
#[serde(rename = "credential.revoked")]
CredentialRevoked { cred_ref: String },
#[serde(rename = "account.deleted")]
AccountDeleted { nick: String },
#[serde(rename = "nick.changed")]
NickChanged { old: String, new: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
pub id: String,
#[serde(flatten)]
pub kind: EventKind,
}
pub fn sign_body(secret: &[u8], body: &[u8]) -> String {
hex::encode(mac(secret, &[body]).finalize().into_bytes())
}
pub fn verify_body(secrets: &[Vec<u8>], body: &[u8], sig_hex: &str) -> bool {
match hex::decode(sig_hex) {
Ok(sig) => secrets.iter().any(|s| mac(s, &[body]).verify_slice(&sig).is_ok()),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn sample(nonce: &str) -> Assertion {
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() }
}
const NOW: i64 = 1_800_000_010_000;
#[test]
fn round_trip_and_binding_to_method_and_path() {
let s = b"0123456789abcdef0123".to_vec();
let v = sign_assertion(&s, "post", "/client/v3/x?q=1", &sample("n1"));
assert_eq!(verify_assertion(&[s.clone()], &v, "POST", "/client/v3/x?q=1", NOW, 30_000).unwrap(), sample("n1"));
assert_eq!(verify_assertion(&[s.clone()], &v, "GET", "/client/v3/x?q=1", NOW, 30_000), Err(SeamError::BadSignature));
assert_eq!(verify_assertion(&[s], &v, "POST", "/client/v3/y", NOW, 30_000), Err(SeamError::BadSignature));
}
#[test]
fn link_token_is_constant_length_checked_and_public_paths_stay_open() {
assert!(link_token_ok(Some("abcdefgh"), "abcdefgh"));
assert!(!link_token_ok(Some("abcdefgX"), "abcdefgh") && !link_token_ok(Some("abc"), "abcdefgh") && !link_token_ok(None, "abcdefgh"));
assert!(link_path_is_open("/_matrix/federation/v1/send/1") && link_path_is_open("/.well-known/matrix/server") && link_path_is_open("/edge/healthz"));
assert!(!link_path_is_open("/_matrix/client/v3/sync") && !link_path_is_open("/client/v3/push"));
}
#[test]
fn anon_read_allowlist_is_get_only_and_public_data_only() {
assert!(anon_read_path_ok("GET", "/_matrix/client/v3/rooms/!r:x/messages?dir=b"));
assert!(anon_read_path_ok("GET", "/client/v3/publicRooms") && anon_read_path_ok("GET", "/client/v3/rooms/!r:x/state/m.room.name/"));
assert!(!anon_read_path_ok("POST", "/client/v3/publicRooms") && !anon_read_path_ok("GET", "/client/v3/sync"));
assert!(!anon_read_path_ok("GET", "/client/v3/rooms/!r:x/members") && !anon_read_path_ok("GET", "/client/v3/account/whoami"));
assert!(!anon_read_path_ok("GET", "/client/v3/rooms//messages"));
}
#[test]
fn replay_cache_refuses_second_use() {
let c = NonceCache::new();
c.check_and_insert(&sample("n"), NOW, 30_000).unwrap();
assert_eq!(c.check_and_insert(&sample("n"), NOW, 30_000), Err(SeamError::Replay));
c.check_and_insert(&sample("m"), NOW, 30_000).unwrap();
}
#[test]
fn event_bodies_round_trip_and_sign() {
let e = Event { id: "a".repeat(32), kind: EventKind::NickChanged { old: "a1b".into(), new: "c2d".into() } };
let body = serde_json::to_vec(&e).unwrap();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["type"], "nick.changed");
assert_eq!(serde_json::from_slice::<Event>(&body).unwrap(), e);
let s = b"0123456789abcdef0123".to_vec();
let sig = sign_body(&s, &body);
assert!(verify_body(&[s.clone()], &body, &sig));
assert!(!verify_body(&[s], b"{}", &sig));
}
#[test]
fn shared_vectors() {
let v: Value = serde_json::from_str(VECTORS).unwrap();
let secret = v["secret"].as_str().unwrap().as_bytes().to_vec();
let prev = v["secret_prev"].as_str().unwrap().as_bytes().to_vec();
let now_ms = v["now_ms"].as_i64().unwrap();
let skew = v["skew_ms"].as_i64().unwrap();
for case in v["cases"].as_array().unwrap() {
let name = case["name"].as_str().unwrap();
let a: Assertion = serde_json::from_value(case["assertion"].clone()).unwrap();
let (method, path) = (case["method"].as_str().unwrap(), case["path"].as_str().unwrap());
let signer = if case["signed_with"] == "prev" { &prev } else { &secret };
let mut value = sign_assertion(signer, case["sign_method"].as_str().unwrap_or(method), case["sign_path"].as_str().unwrap_or(path), &a);
if let Some(t) = case["tamper_nick_to"].as_str() {
let json = serde_json::to_vec(&Assertion { nick: t.into(), ..a.clone() }).unwrap();
let sig = value.rsplit('.').next().unwrap().to_string();
value = format!("v1.{}.{}", URL_SAFE_NO_PAD.encode(json), sig);
}
if case["expected_value"].is_string() {
assert_eq!(value, case["expected_value"].as_str().unwrap(), "{name}: pinned wire value");
}
let accept = [secret.clone(), prev.clone()];
let secrets: &[Vec<u8>] = if case["accept_prev"] == true { &accept } else { &accept[..1] };
let got = verify_assertion(secrets, &value, method, path, now_ms, skew);
match case["expect"].as_str().unwrap() {
"ok" => assert!(got.is_ok(), "{name}: {got:?}"),
"bad_signature" => assert_eq!(got, Err(SeamError::BadSignature), "{name}"),
"expired" => assert_eq!(got, Err(SeamError::Expired), "{name}"),
"malformed" => assert!(matches!(got, Err(SeamError::Malformed(_))), "{name}: {got:?}"),
other => panic!("{name}: unknown expectation {other}"),
}
}
}
}