diff --git a/crates/ruma-server-util/CHANGELOG.md b/crates/ruma-server-util/CHANGELOG.md new file mode 100644 index 00000000..a10fb8ca --- /dev/null +++ b/crates/ruma-server-util/CHANGELOG.md @@ -0,0 +1,5 @@ +# [unreleased] + +Improvements: + +* Provide `XMatrix` type for Matrix federation authorization headers. diff --git a/crates/ruma-server-util/Cargo.toml b/crates/ruma-server-util/Cargo.toml new file mode 100644 index 00000000..4f66d4b9 --- /dev/null +++ b/crates/ruma-server-util/Cargo.toml @@ -0,0 +1,25 @@ +[package] +categories = ["api-bindings", "web-programming"] +description = "Utilities for implementing Matrix server applications." +homepage = "https://www.ruma.io/" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-server-util" +readme = "README.md" +repository = "https://github.com/ruma/ruma" +version = "0.1.0" +edition = "2021" +rust-version = "1.60" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[dependencies] +headers = "0.3" +ruma-common = { version = "0.9.2", path = "../ruma-common" } +tracing = "0.1.25" +yap = "0.7.2" + +[dev-dependencies] +tracing-subscriber = "0.3.3" diff --git a/crates/ruma-server-util/README.md b/crates/ruma-server-util/README.md new file mode 100644 index 00000000..51b90550 --- /dev/null +++ b/crates/ruma-server-util/README.md @@ -0,0 +1,7 @@ +# ruma-server-util + +[![crates.io page](https://img.shields.io/crates/v/ruma-server-util.svg)](https://crates.io/crates/ruma-server-util) +[![docs.rs page](https://docs.rs/ruma-server-util/badge.svg)](https://docs.rs/ruma-server-util/) +![license: MIT](https://img.shields.io/crates/l/ruma-server-util.svg) + +**ruma-server-util** contains types and trait implementations useful for implementing a Matrix homeserver. diff --git a/crates/ruma-server-util/src/authorization.rs b/crates/ruma-server-util/src/authorization.rs new file mode 100644 index 00000000..4dbb2509 --- /dev/null +++ b/crates/ruma-server-util/src/authorization.rs @@ -0,0 +1,284 @@ +//! Common types for implementing federation authorization. + +use headers::{authorization::Credentials, HeaderValue}; + +use ruma_common::{OwnedServerName, OwnedServerSigningKeyId}; +use tracing::debug; +use yap::{IntoTokens, TokenLocation, Tokens}; + +/// Typed representation of an `Authorization` header of scheme `X-Matrix`, as defined in the +/// [Matrix Server-Server API][spec]. Includes an implementation of +/// [`headers::authorization::Credentials`] for automatically handling the encoding and decoding +/// when using a web framework that supports typed headers. +/// +/// [spec]: https://spec.matrix.org/v1.3/server-server-api/#request-authentication +#[non_exhaustive] +pub struct XMatrix { + /// The server name of the sending server. + pub origin: OwnedServerName, + /// The server name of the receiving sender. For compatibility with older servers, recipients + /// should accept requests without this parameter, but MUST always send it. If this property is + /// included, but the value does not match the receiving server's name, the receiving server + /// must deny the request with an HTTP status code 401 Unauthorized. + pub destination: Option, + /// The ID - including the algorithm name - of the sending server's key that was used to sign + /// the request. + pub key: OwnedServerSigningKeyId, + /// The signature of the JSON. + pub sig: String, +} + +impl XMatrix { + /// Construct a new X-Matrix Authorization header. + pub fn new( + origin: OwnedServerName, + destination: Option, + key: OwnedServerSigningKeyId, + sig: String, + ) -> Self { + Self { origin, destination, key, sig } + } +} + +fn parse_token<'a>(tokens: &mut impl Tokens) -> Option> { + tokens.optional(|t| { + let token: Vec = t.tokens_while(|c| is_tchar(**c)).copied().collect(); + if !token.is_empty() { + Some(token) + } else { + debug!("Returning early because of empty token at {}", t.location().offset()); + None + } + }) +} + +fn parse_token_with_colons<'a>(tokens: &mut impl Tokens) -> Option> { + tokens.optional(|t| { + let token: Vec = t.tokens_while(|c| is_tchar(**c) || **c == b':').copied().collect(); + if !token.is_empty() { + Some(token) + } else { + debug!("Returning early because of empty token at {}", t.location().offset()); + None + } + }) +} + +fn parse_quoted<'a>(tokens: &mut impl Tokens) -> Option> { + tokens.optional(|t| { + if !(t.token(&b'"')) { + return None; + } + let mut buffer = Vec::new(); + loop { + match t.next()? { + // quoted pair + b'\\' => { + let escaped = t.next().filter(|c| { + if is_quoted_pair(**c) { + true + } else { + debug!( + "Encountered an illegal character {} at location {}", + **c as char, + t.location().offset() + ); + false + } + })?; + buffer.push(*escaped) + } + // end of quote + b'"' => break, + // regular character + c if is_qdtext(*c) => buffer.push(*c), + // Invalid character + c => { + debug!( + "Encountered an illegal character {} at location {}", + *c as char, + t.location().offset() + ); + return None; + } + } + } + Some(buffer) + }) +} + +fn parse_xmatrix_field<'a>(tokens: &mut impl Tokens) -> Option<(String, Vec)> { + tokens.optional(|t| { + let name = parse_token(t).and_then(|name| { + let name = std::str::from_utf8(&name).ok()?.to_ascii_lowercase(); + match name.as_str() { + "origin" | "destination" | "key" | "sig" => Some(name), + name => { + debug!( + "Encountered an invalid field name {} at location {}", + name, + t.location().offset() + ); + None + } + } + })?; + + if !t.token(&b'=') { + return None; + } + + let value = parse_quoted(t).or_else(|| parse_token_with_colons(t))?; + + Some((name, value)) + }) +} + +fn parse_xmatrix<'a>(tokens: &mut impl Tokens) -> Option { + tokens.optional(|t| { + if !t.tokens(b"X-Matrix ".into_tokens()) { + debug!("Failed to parse X-Matrix credentials, didn't start with 'X-Matrix '"); + return None; + } + let mut origin = None; + let mut destination = None; + let mut key = None; + let mut sig = None; + + for (name, value) in t.sep_by(|t| parse_xmatrix_field(t), |t| t.token(&b',')) { + match name.as_str() { + "origin" => { + if origin.is_some() { + debug!("Field origin duplicated in X-Matrix Authorization header"); + } + origin = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); + } + "destination" => { + if destination.is_some() { + debug!("Field destination duplicated in X-Matrix Authorization header"); + } + destination = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); + } + "key" => { + if key.is_some() { + debug!("Field key duplicated in X-Matrix Authorization header"); + } + key = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); + } + "sig" => { + if sig.is_some() { + debug!("Field sig duplicated in X-Matrix Authorization header"); + } + sig = Some(std::str::from_utf8(&value).ok()?.to_owned()); + } + name => { + debug!("Unknown field {} found in X-Matrix Authorization header", name); + } + } + } + + Some(XMatrix { origin: origin?, destination, key: key?, sig: sig? }) + }) +} + +fn is_alpha(c: u8) -> bool { + (0x41..=0x5A).contains(&c) || (0x61..=0x7A).contains(&c) +} + +fn is_digit(c: u8) -> bool { + (0x30..=0x39).contains(&c) +} + +fn is_tchar(c: u8) -> bool { + const TOKEN_CHARS: [u8; 15] = + [b'!', b'#', b'$', b'%', b'&', b'\'', b'*', b'+', b'-', b'.', b'^', b'_', b'`', b'|', b'~']; + is_alpha(c) || is_digit(c) || TOKEN_CHARS.contains(&c) +} + +fn is_qdtext(c: u8) -> bool { + c == b'\t' + || c == b' ' + || c == 0x21 + || (0x23..=0x5B).contains(&c) + || (0x5D..=0x7E).contains(&c) + || is_obs_text(c) +} + +fn is_obs_text(c: u8) -> bool { + c >= 0x80 // The spec does contain an upper limit of 0xFF here, but that's enforced by the type +} + +fn is_vchar(c: u8) -> bool { + (0x21..=0x7E).contains(&c) +} + +fn is_quoted_pair(c: u8) -> bool { + c == b'\t' || c == b' ' || is_vchar(c) || is_obs_text(c) +} + +impl Credentials for XMatrix { + const SCHEME: &'static str = "X-Matrix"; + + fn decode(value: &HeaderValue) -> Option { + let value: Vec = value.as_bytes().to_vec(); + parse_xmatrix(&mut value.into_tokens()) + } + + fn encode(&self) -> HeaderValue { + if let Some(destination) = &self.destination { + format!( + "X-Matrix origin=\"{}\",destination=\"{}\",key=\"{}\",sig=\"{}\"", + self.origin, destination, self.key, self.sig + ) + } else { + format!("X-Matrix origin=\"{}\",key=\"{}\",sig=\"{}\"", self.origin, self.key, self.sig) + } + .try_into() + .expect("header format is static") + } +} + +#[cfg(test)] +mod tests { + use headers::{authorization::Credentials, HeaderValue}; + use ruma_common::OwnedServerName; + + use super::XMatrix; + + #[test] + fn xmatrix_auth_pre_1_3() { + let header = HeaderValue::from_static( + "X-Matrix origin=\"origin.hs.example.com\",key=\"ed25519:key1\",sig=\"ABCDEF...\"", + ); + let origin = "origin.hs.example.com".try_into().unwrap(); + let key = "ed25519:key1".try_into().unwrap(); + let sig = "ABCDEF...".to_string(); + let credentials: XMatrix = Credentials::decode(&header).unwrap(); + assert_eq!(credentials.origin, origin); + assert_eq!(credentials.destination, None); + assert_eq!(credentials.key, key); + assert_eq!(credentials.sig, sig); + + let credentials = XMatrix::new(origin, None, key, sig); + + assert_eq!(credentials.encode(), header); + } + + #[test] + fn xmatrix_auth_1_3() { + let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\",destination=\"destination.hs.example.com\",key=\"ed25519:key1\",sig=\"ABCDEF...\""); + let origin: OwnedServerName = "origin.hs.example.com".try_into().unwrap(); + let destination: OwnedServerName = "destination.hs.example.com".try_into().unwrap(); + let key = "ed25519:key1".try_into().unwrap(); + let sig = "ABCDEF...".to_string(); + let credentials: XMatrix = Credentials::decode(&header).unwrap(); + assert_eq!(credentials.origin, origin); + assert_eq!(credentials.destination, Some(destination.clone())); + assert_eq!(credentials.key, key); + assert_eq!(credentials.sig, sig); + + let credentials = XMatrix::new(origin, Some(destination), key, sig); + + assert_eq!(credentials.encode(), header); + } +} diff --git a/crates/ruma-server-util/src/lib.rs b/crates/ruma-server-util/src/lib.rs new file mode 100644 index 00000000..18e9e613 --- /dev/null +++ b/crates/ruma-server-util/src/lib.rs @@ -0,0 +1,6 @@ +#![doc(html_favicon_url = "https://www.ruma.io/favicon.ico")] +#![doc(html_logo_url = "https://www.ruma.io/images/logo.png")] +//! Collection of helpers for implementing Matrix homeservers using Ruma. + +#![warn(missing_docs)] +pub mod authorization;