diff --git a/Cargo.lock b/Cargo.lock index 1b62a5e8..f7ce97c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3971,6 +3971,16 @@ dependencies = [ "zip", ] +[[package]] +name = "inbuxa-relay-proto" +version = "27.1.0-rc.4" +dependencies = [ + "aws-lc-rs", + "base64 0.23.1", + "serde", + "serde_json", +] + [[package]] name = "include-flate" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index daf19859..3affb2f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/trc", "crates/migration", "crates/features", + "crates/relay-proto", "tests", ] diff --git a/crates/relay-proto/Cargo.toml b/crates/relay-proto/Cargo.toml new file mode 100644 index 00000000..78b5263f --- /dev/null +++ b/crates/relay-proto/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "inbuxa-relay-proto" +description = "The link between inbuxa-relay and its downstream: JMAP types, joining, recipient hashing and spool sealing (docs/spec/features/relay.md)" +license = "AGPL-3.0-only" +version.workspace = true +edition = "2024" + +# Shared by the relay and the server, so it stays small: no inbuxa crate, +# and nothing the relay's footprint budget (RL-30) would notice. +[dependencies] +aws-lc-rs = { version = "1" } +base64 = "0.23" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[lints] +workspace = true diff --git a/crates/relay-proto/src/jmap.rs b/crates/relay-proto/src/jmap.rs new file mode 100644 index 00000000..1d8bc2e7 --- /dev/null +++ b/crates/relay-proto/src/jmap.rs @@ -0,0 +1,259 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The JMAP envelope the link uses: requests and responses (RFC 8620 §3), +//! the WebSocket frames (RFC 8887 §4), and the session resource. Method +//! arguments stay as JSON values; each side parses the ones it handles. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// Method names, beyond the standard `/get`, `/set` and `/changes`. +pub mod method { + pub const RELAY_GET: &str = "Relay/get"; + pub const RELAY_SET: &str = "Relay/set"; + pub const RELAY_JOIN: &str = "Relay/join"; + pub const RELAY_UPGRADE: &str = "Relay/upgrade"; + pub const RECIPIENT_DOMAIN_GET: &str = "RecipientDomain/get"; + pub const RECIPIENT_DOMAIN_SET: &str = "RecipientDomain/set"; + pub const RECIPIENT_SET: &str = "Recipient/set"; + pub const RECIPIENT_CHANGES: &str = "Recipient/changes"; + pub const RECIPIENT_REPLACE: &str = "Recipient/replace"; + pub const INBOUND_GET: &str = "Inbound/get"; + pub const INBOUND_CHANGES: &str = "Inbound/changes"; + pub const INBOUND_QUERY: &str = "Inbound/query"; + pub const INBOUND_CLAIM: &str = "Inbound/claim"; + pub const INBOUND_SET: &str = "Inbound/set"; + pub const RELAY_EVENT_GET: &str = "RelayEvent/get"; + pub const RELAY_EVENT_CHANGES: &str = "RelayEvent/changes"; + pub const RELAY_EVENT_SET: &str = "RelayEvent/set"; +} + +/// One method call: `[name, arguments, callId]`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Invocation(pub String, pub Map, pub String); + +impl Invocation { + pub fn new(name: impl Into, args: Value, call_id: impl Into) -> Self { + let args = match args { + Value::Object(map) => map, + _ => Map::new(), + }; + Invocation(name.into(), args, call_id.into()) + } + + pub fn name(&self) -> &str { + &self.0 + } + + pub fn args(&self) -> &Map { + &self.1 + } + + pub fn call_id(&self) -> &str { + &self.2 + } + + /// A method-level error response for this call (RFC 8620 §3.6.2). + pub fn error(&self, kind: MethodError, description: Option) -> Invocation { + let mut args = Map::new(); + args.insert("type".into(), Value::String(kind.as_str().into())); + if let Some(description) = description { + args.insert("description".into(), Value::String(description)); + } + Invocation("error".into(), args, self.2.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub using: Vec, + pub method_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_ids: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Response { + pub method_responses: Vec, + pub session_state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_ids: Option>, +} + +/// The method-level errors the relay gives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MethodError { + UnknownMethod, + InvalidArguments, + AccountNotFound, + Forbidden, + ServerFail, + StateMismatch, + CannotCalculateChanges, + /// The relay has no room: the spool is full (spec "The spool"). + OverQuota, +} + +impl MethodError { + pub fn as_str(&self) -> &'static str { + match self { + MethodError::UnknownMethod => "unknownMethod", + MethodError::InvalidArguments => "invalidArguments", + MethodError::AccountNotFound => "accountNotFound", + MethodError::Forbidden => "forbidden", + MethodError::ServerFail => "serverFail", + MethodError::StateMismatch => "stateMismatch", + MethodError::CannotCalculateChanges => "cannotCalculateChanges", + MethodError::OverQuota => "overQuota", + } + } +} + +/// Request-level problem types (RFC 8620 §3.6.1). +pub mod request_error { + pub const UNKNOWN_CAPABILITY: &str = "urn:ietf:params:jmap:error:unknownCapability"; + pub const NOT_JSON: &str = "urn:ietf:params:jmap:error:notJSON"; + pub const NOT_REQUEST: &str = "urn:ietf:params:jmap:error:notRequest"; + pub const LIMIT: &str = "urn:ietf:params:jmap:error:limit"; +} + +/// A client-to-server WebSocket frame (RFC 8887 §4.3). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum ClientFrame { + Request { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(flatten)] + request: Request, + }, + WebSocketPushEnable { + #[serde(rename = "dataTypes", default)] + data_types: Option>, + #[serde(rename = "pushState", default, skip_serializing_if = "Option::is_none")] + push_state: Option, + }, + WebSocketPushDisable, +} + +/// A server-to-client WebSocket frame (RFC 8887 §4.3). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "@type")] +pub enum ServerFrame { + Response { + #[serde(rename = "requestId", default, skip_serializing_if = "Option::is_none")] + request_id: Option, + #[serde(flatten)] + response: Response, + }, + RequestError { + #[serde(rename = "requestId", default, skip_serializing_if = "Option::is_none")] + request_id: Option, + #[serde(rename = "type")] + kind: String, + status: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + StateChange { + /// accountId → type name → state. + changed: BTreeMap>, + #[serde(rename = "pushState", default, skip_serializing_if = "Option::is_none")] + push_state: Option, + }, +} + +/// The session resource (RFC 8620 §2), trimmed to what the link needs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub capabilities: BTreeMap, + pub accounts: BTreeMap, + pub primary_accounts: BTreeMap, + pub username: String, + pub api_url: String, + pub download_url: String, + pub upload_url: String, + pub event_source_url: String, + pub state: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub name: String, + pub is_personal: bool, + pub is_read_only: bool, + pub account_capabilities: BTreeMap, +} + +/// The WebSocket capability object (RFC 8887 §4.1). +pub fn websocket_capability(url: &str) -> Value { + serde_json::json!({ "url": url, "supportsPush": true }) +} + +/// The WebSocket capability's URI. +pub const WEBSOCKET_CAPABILITY: &str = "urn:ietf:params:jmap:websocket"; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn frames_match_rfc8887() { + let frame: ClientFrame = serde_json::from_value(json!({ + "@type": "Request", + "id": "r1", + "using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:relay"], + "methodCalls": [["Relay/get", {"accountId": "relay"}, "c0"]] + })) + .unwrap(); + let ClientFrame::Request { id, request } = frame else { + panic!("not a request") + }; + assert_eq!(id.as_deref(), Some("r1")); + assert_eq!(request.method_calls[0].name(), "Relay/get"); + + let frame: ClientFrame = + serde_json::from_value(json!({"@type": "WebSocketPushEnable", "dataTypes": null})) + .unwrap(); + assert_eq!( + frame, + ClientFrame::WebSocketPushEnable { + data_types: None, + push_state: None + } + ); + + let change = ServerFrame::StateChange { + changed: [( + "relay".to_string(), + [("Inbound".to_string(), "s7".to_string())].into(), + )] + .into(), + push_state: None, + }; + assert_eq!( + serde_json::to_value(&change).unwrap(), + json!({"@type": "StateChange", "changed": {"relay": {"Inbound": "s7"}}}) + ); + } + + #[test] + fn method_errors() { + let call = Invocation::new("Nope/get", json!({}), "c1"); + let err = call.error(MethodError::UnknownMethod, None); + assert_eq!( + serde_json::to_value(&err).unwrap(), + json!(["error", {"type": "unknownMethod"}, "c1"]) + ); + } +} diff --git a/crates/relay-proto/src/lib.rs b/crates/relay-proto/src/lib.rs new file mode 100644 index 00000000..c5b32dd5 --- /dev/null +++ b/crates/relay-proto/src/lib.rs @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The link between inbuxa-relay and its downstream server or cluster +//! (docs/spec/features/relay.md). +//! +//! The relay is a small JMAP server; the downstream is a JMAP client that +//! dials out to it. Both sides use this crate, so the wire types, the join +//! proof, recipient hashing and the sealed spool format are defined once. + +pub mod jmap; +pub mod pin; +pub mod recipient; +pub mod seal; +pub mod time; +pub mod token; +pub mod types; + +/// The relay's JMAP capability. +pub const CAPABILITY: &str = "urn:inbuxa:jmap:relay"; + +/// The link protocol's version. A downstream refuses a relay with a higher +/// major version than it knows (RL-28). +pub const PROTOCOL_VERSION: u32 = 1; + +/// The JMAP core capability, which every request also uses. +pub const CORE_CAPABILITY: &str = "urn:ietf:params:jmap:core"; + +/// Base64url without padding, for every byte field on the wire. +pub(crate) mod b64 { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use serde::{Deserialize, Deserializer, Serializer, de::Error}; + + pub fn encode(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) + } + + pub fn decode(text: &str) -> Option> { + URL_SAFE_NO_PAD.decode(text).ok() + } + + pub fn serialize( + bytes: &[u8; N], + serializer: S, + ) -> Result { + serializer.serialize_str(&encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>, const N: usize>( + deserializer: D, + ) -> Result<[u8; N], D::Error> { + let text = String::deserialize(deserializer)?; + decode(&text) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| D::Error::custom(format!("expected {N} bytes in base64url"))) + } + + /// The same, for an optional field. + pub mod option { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize( + bytes: &Option<[u8; N]>, + serializer: S, + ) -> Result { + match bytes { + Some(bytes) => super::serialize(bytes, serializer), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>, const N: usize>( + deserializer: D, + ) -> Result, D::Error> { + #[derive(Deserialize)] + struct Wrap(#[serde(with = "super")] [u8; N]); + Ok(Option::>::deserialize(deserializer)?.map(|Wrap(bytes)| bytes)) + } + } +} diff --git a/crates/relay-proto/src/pin.rs b/crates/relay-proto/src/pin.rs new file mode 100644 index 00000000..9f417769 --- /dev/null +++ b/crates/relay-proto/src/pin.rs @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Key pins (RL-6, RL-7): the SHA-256 of a SubjectPublicKeyInfo. The pin is +//! the identity on the link; names in link certificates are never checked. + +use aws_lc_rs::digest; + +/// A pin: SHA-256 over a DER SubjectPublicKeyInfo. +pub type Pin = [u8; 32]; + +pub fn spki_pin(spki_der: &[u8]) -> Pin { + let digest = digest::digest(&digest::SHA256, spki_der); + let mut pin = [0u8; 32]; + pin.copy_from_slice(digest.as_ref()); + pin +} + +/// Compares two pins in constant time. +pub fn pins_match(a: &Pin, b: &Pin) -> bool { + aws_lc_rs::constant_time::verify_slices_are_equal(a, b).is_ok() +} diff --git a/crates/relay-proto/src/recipient.rs b/crates/relay-proto/src/recipient.rs new file mode 100644 index 00000000..8478565a --- /dev/null +++ b/crates/relay-proto/src/recipient.rs @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The accepted-recipient set (RL-1, RL-11). +//! +//! The downstream pushes every address it accepts as +//! `HMAC-SHA256(recipientKey, address)`, over the address lowercased and with +//! any sub-address removed, plus per-domain rules. The hashes keep the +//! directory from being read off a seized relay as a list. They don't stop +//! someone holding the key from testing guessed addresses: nothing on the +//! relay could, since the relay has to check real ones. + +use crate::b64; +use aws_lc_rs::hmac; +use serde::{Deserialize, Serialize}; + +pub type RecipientHash = [u8; 32]; + +/// Domain-wide rules, one per served domain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipientDomain { + pub domain: String, + /// Every address at the domain is accepted. + #[serde(default)] + pub catch_all: bool, + /// `+` for `user+tag@example.com`, or none. + #[serde(default)] + pub subaddress_separator: Option, +} + +/// One accepted address. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Recipient { + #[serde(with = "b64")] + pub hash: RecipientHash, +} + +/// Splits `address` into a lowercased local part and domain, or `None` if +/// it isn't an address. +pub fn split(address: &str) -> Option<(String, String)> { + let (local, domain) = address.rsplit_once('@')?; + if local.is_empty() || domain.is_empty() || domain.contains('@') { + return None; + } + let domain = domain.trim_end_matches('.').to_lowercase(); + if domain.is_empty() { + return None; + } + Some((local.to_lowercase(), domain)) +} + +/// The form an address is hashed in: lowercased, without its sub-address. +pub fn normalize(address: &str, separator: Option<&str>) -> Option { + let (local, domain) = split(address)?; + let local = match separator.filter(|s| !s.is_empty()) { + Some(sep) => match local.split_once(sep) { + Some((base, _)) if !base.is_empty() => base.to_string(), + _ => local, + }, + None => local, + }; + Some(format!("{local}@{domain}")) +} + +/// The hash of an already normalized address. +pub fn hash(key: &[u8; 32], normalized: &str) -> RecipientHash { + let tag = hmac::sign( + &hmac::Key::new(hmac::HMAC_SHA256, key), + normalized.as_bytes(), + ); + let mut out = [0u8; 32]; + out.copy_from_slice(tag.as_ref()); + out +} + +/// What the relay answers at RCPT (RL-1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lookup { + Accept, + /// The domain is served but the address isn't known: `550 5.1.1`. + UnknownUser, + /// The domain isn't served: `550 5.7.1`. + NotServed, + /// Not an address at all: `501 5.1.3`. + BadAddress, +} + +/// Decides a recipient against the pushed rules. `domain_rules` finds a +/// served domain's rules; `known` says whether a hash is in the set. +pub fn lookup<'a>( + address: &str, + key: &[u8; 32], + domain_rules: impl Fn(&str) -> Option<&'a RecipientDomain>, + known: impl Fn(&RecipientHash) -> bool, +) -> Lookup { + let Some((_, domain)) = split(address) else { + return Lookup::BadAddress; + }; + let Some(rules) = domain_rules(&domain) else { + return Lookup::NotServed; + }; + if rules.catch_all { + return Lookup::Accept; + } + let Some(normalized) = normalize(address, rules.subaddress_separator.as_deref()) else { + return Lookup::BadAddress; + }; + // The full local part first: an address may itself contain the separator. + let full = normalize(address, None).filter(|full| *full != normalized); + if known(&hash(key, &normalized)) || full.is_some_and(|full| known(&hash(key, &full))) { + Lookup::Accept + } else { + Lookup::UnknownUser + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + const KEY: [u8; 32] = [5; 32]; + + #[test] + fn normalizes() { + assert_eq!( + normalize("John+News@Example.COM.", Some("+")).as_deref(), + Some("john@example.com") + ); + assert_eq!( + normalize("+tag@example.com", Some("+")).as_deref(), + Some("+tag@example.com") + ); + assert_eq!( + normalize("a+b@example.com", None).as_deref(), + Some("a+b@example.com") + ); + assert_eq!(normalize("nobody", None), None); + assert_eq!(normalize("@example.com", None), None); + assert_eq!(normalize("a@", None), None); + } + + #[test] + fn decides() { + let domains = [ + RecipientDomain { + domain: "example.com".into(), + catch_all: false, + subaddress_separator: Some("+".into()), + }, + RecipientDomain { + domain: "example.org".into(), + catch_all: true, + subaddress_separator: None, + }, + ]; + let set: HashSet = ["john@example.com", "sales+eu@example.com"] + .iter() + .map(|a| hash(&KEY, a)) + .collect(); + let decide = |addr: &str| { + lookup( + addr, + &KEY, + |d| domains.iter().find(|r| r.domain == d), + |h| set.contains(h), + ) + }; + assert_eq!(decide("John@example.com"), Lookup::Accept); + assert_eq!(decide("john+news@example.com"), Lookup::Accept); + assert_eq!(decide("sales+eu@example.com"), Lookup::Accept); + assert_eq!(decide("jane@example.com"), Lookup::UnknownUser); + assert_eq!(decide("anyone@example.org"), Lookup::Accept); + assert_eq!(decide("john@example.net"), Lookup::NotServed); + assert_eq!(decide("john"), Lookup::BadAddress); + } + + #[test] + fn hash_depends_on_key() { + assert_ne!(hash(&KEY, "a@example.com"), hash(&[6; 32], "a@example.com")); + } +} diff --git a/crates/relay-proto/src/seal.rs b/crates/relay-proto/src/seal.rs new file mode 100644 index 00000000..c5747e50 --- /dev/null +++ b/crates/relay-proto/src/seal.rs @@ -0,0 +1,508 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Sealing held inbound mail to the downstream (RL-21). +//! +//! HPKE (RFC 9180) in base mode, with DHKEM(X25519, HKDF-SHA256), +//! HKDF-SHA256 and ChaCha20-Poly1305: the suite of RFC 9180 Appendix A.2, +//! whose vectors the tests check. The relay holds only the downstream's +//! public key, so it can't read a message once it is sealed. +//! +//! The stream is a header, `"IXRS" 0x01 enc`, then chunks of up to 64 KiB of +//! plaintext, each `u32 frame || ciphertext`, where the frame's top bit marks +//! the last chunk and the rest is the ciphertext's length. Each chunk is +//! sealed with the next HPKE sequence number and the associated data +//! `aad || last`, where `aad` names the message (its `Inbound` id) and `last` +//! is 1 on the final chunk. So chunks can't be reordered, a stream can't be cut short, and one +//! message's file can't pass for another's. + +use aws_lc_rs::{ + aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey}, + agreement::{self, PrivateKey, UnparsedPublicKey, X25519}, + encoding::AsBigEndian, + hmac, +}; + +/// Plaintext bytes per chunk. +pub const CHUNK_SIZE: usize = 64 * 1024; +const TAG_LEN: usize = 16; +const MAGIC: &[u8; 4] = b"IXRS"; +const FORMAT: u8 = 1; +/// Header length: magic, format, encapsulated key. +pub const HEADER_LEN: usize = 4 + 1 + 32; +/// HPKE `info` for the relay's inbound spool. +pub const INFO: &[u8] = b"inbuxa-relay inbound v1"; + +const LAST_BIT: u32 = 1 << 31; + +const KEM_SUITE: &[u8] = b"KEM\x00\x20"; +const HPKE_SUITE: &[u8] = b"HPKE\x00\x20\x00\x01\x00\x03"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SealError { + /// A key that isn't a valid X25519 key. + BadKey, + /// Not a sealed stream, or one that fails authentication. + Corrupt, + /// The stream ends before its last chunk. + Truncated, +} + +impl std::fmt::Display for SealError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SealError::BadKey => "not a valid X25519 key", + SealError::Corrupt => "sealed data is corrupt or was altered", + SealError::Truncated => "sealed data is cut short", + }) + } +} + +impl std::error::Error for SealError {} + +/// A new sealing key pair: `(secret, public)`. The secret stays on the +/// downstream. +pub fn generate_keypair() -> (Box<[u8; 32]>, [u8; 32]) { + let sk = PrivateKey::generate(&X25519).expect("X25519 key generation"); + let secret = secret_bytes(&sk); + let public = public_bytes(&sk); + (secret, public) +} + +/// The public half of a secret sealing key. +pub fn public_key(secret: &[u8; 32]) -> Result<[u8; 32], SealError> { + let sk = PrivateKey::from_private_key(&X25519, secret).map_err(|_| SealError::BadKey)?; + Ok(public_bytes(&sk)) +} + +fn secret_bytes(sk: &PrivateKey) -> Box<[u8; 32]> { + let seed: aws_lc_rs::encoding::Curve25519SeedBin<'_> = + sk.as_be_bytes().expect("X25519 secret export"); + let mut out = Box::new([0u8; 32]); + out.copy_from_slice(seed.as_ref()); + out +} + +fn public_bytes(sk: &PrivateKey) -> [u8; 32] { + let pk = sk.compute_public_key().expect("X25519 public key"); + let mut out = [0u8; 32]; + out.copy_from_slice(pk.as_ref()); + out +} + +fn dh(sk: &PrivateKey, pk: &[u8; 32]) -> Result, SealError> { + agreement::agree( + sk, + UnparsedPublicKey::new(&X25519, pk), + SealError::BadKey, + |shared| Ok(shared.to_vec()), + ) +} + +fn hmac_sha256(key: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut ctx = hmac::Context::with_key(&hmac::Key::new(hmac::HMAC_SHA256, key)); + for part in parts { + ctx.update(part); + } + let mut out = [0u8; 32]; + out.copy_from_slice(ctx.sign().as_ref()); + out +} + +fn labeled_extract(suite: &[u8], salt: &[u8], label: &[u8], ikm: &[u8]) -> [u8; 32] { + hmac_sha256(salt, &[b"HPKE-v1", suite, label, ikm]) +} + +fn labeled_expand(suite: &[u8], prk: &[u8; 32], label: &[u8], info: &[u8], len: usize) -> Vec { + debug_assert!(len <= 255 * 32); + let len_be = (len as u16).to_be_bytes(); + let mut out = Vec::with_capacity(len); + let mut t: Vec = Vec::new(); + let mut counter = 1u8; + while out.len() < len { + let block = hmac_sha256( + prk, + &[&t, &len_be, b"HPKE-v1", suite, label, info, &[counter]], + ); + t = block.to_vec(); + out.extend_from_slice(&block); + counter += 1; + } + out.truncate(len); + out +} + +/// An HPKE context in base mode: the AEAD key, base nonce and sequence. +struct Context { + key: LessSafeKey, + base_nonce: [u8; 12], + seq: u64, +} + +impl Context { + fn new(shared_secret: &[u8; 32], info: &[u8]) -> Self { + let psk_id_hash = labeled_extract(HPKE_SUITE, b"", b"psk_id_hash", b""); + let info_hash = labeled_extract(HPKE_SUITE, b"", b"info_hash", info); + let mut ksc = Vec::with_capacity(65); + ksc.push(0u8); + ksc.extend_from_slice(&psk_id_hash); + ksc.extend_from_slice(&info_hash); + let secret = labeled_extract(HPKE_SUITE, shared_secret, b"secret", b""); + let key = labeled_expand(HPKE_SUITE, &secret, b"key", &ksc, 32); + let nonce = labeled_expand(HPKE_SUITE, &secret, b"base_nonce", &ksc, 12); + let mut base_nonce = [0u8; 12]; + base_nonce.copy_from_slice(&nonce); + Context { + key: LessSafeKey::new(UnboundKey::new(&CHACHA20_POLY1305, &key).expect("32-byte key")), + base_nonce, + seq: 0, + } + } + + fn next_nonce(&mut self) -> Nonce { + let mut nonce = self.base_nonce; + for (n, s) in nonce[4..].iter_mut().zip(self.seq.to_be_bytes()) { + *n ^= s; + } + self.seq += 1; + Nonce::assume_unique_for_key(nonce) + } + + fn seal(&mut self, aad: &[u8], plaintext: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(plaintext.len() + TAG_LEN); + buf.extend_from_slice(plaintext); + let nonce = self.next_nonce(); + self.key + .seal_in_place_append_tag(nonce, Aad::from(aad), &mut buf) + .expect("ChaCha20-Poly1305 seal"); + buf + } + + fn open(&mut self, aad: &[u8], mut ciphertext: Vec) -> Result, SealError> { + let nonce = self.next_nonce(); + let len = self + .key + .open_in_place(nonce, Aad::from(aad), &mut ciphertext) + .map_err(|_| SealError::Corrupt)? + .len(); + ciphertext.truncate(len); + Ok(ciphertext) + } +} + +fn shared_secret(dh: &[u8], enc: &[u8; 32], pk_r: &[u8; 32]) -> [u8; 32] { + let mut kem_context = [0u8; 64]; + kem_context[..32].copy_from_slice(enc); + kem_context[32..].copy_from_slice(pk_r); + let eae_prk = labeled_extract(KEM_SUITE, b"", b"eae_prk", dh); + let ss = labeled_expand(KEM_SUITE, &eae_prk, b"shared_secret", &kem_context, 32); + let mut out = [0u8; 32]; + out.copy_from_slice(&ss); + out +} + +/// SetupBaseS with a given ephemeral key: returns `(enc, context)`. +fn setup_sender( + sk_e: &PrivateKey, + pk_r: &[u8; 32], + info: &[u8], +) -> Result<([u8; 32], Context), SealError> { + let enc = public_bytes(sk_e); + let dh = dh(sk_e, pk_r)?; + Ok((enc, Context::new(&shared_secret(&dh, &enc, pk_r), info))) +} + +/// SetupBaseR. +fn setup_receiver(enc: &[u8; 32], sk_r: &[u8; 32], info: &[u8]) -> Result { + let sk = PrivateKey::from_private_key(&X25519, sk_r).map_err(|_| SealError::BadKey)?; + let pk_r = public_bytes(&sk); + let dh = dh(&sk, enc)?; + Ok(Context::new(&shared_secret(&dh, enc, &pk_r), info)) +} + +fn chunk_aad(aad: &[u8], last: bool) -> Vec { + let mut out = Vec::with_capacity(aad.len() + 1); + out.extend_from_slice(aad); + out.push(u8::from(last)); + out +} + +/// Seals a stream, a chunk at a time, as the message arrives. +pub struct Sealer { + ctx: Context, + aad: Vec, + pending: Vec, +} + +impl Sealer { + /// Starts a stream sealed to `recipient` (the downstream's public key), + /// for the message named by `aad`. Returns the sealer and the header, + /// which comes first in the output. + pub fn new(recipient: &[u8; 32], aad: &[u8]) -> Result<(Self, Vec), SealError> { + let sk_e = PrivateKey::generate(&X25519).map_err(|_| SealError::BadKey)?; + let (enc, ctx) = setup_sender(&sk_e, recipient, INFO)?; + let mut header = Vec::with_capacity(HEADER_LEN); + header.extend_from_slice(MAGIC); + header.push(FORMAT); + header.extend_from_slice(&enc); + Ok(( + Sealer { + ctx, + aad: aad.to_vec(), + pending: Vec::with_capacity(CHUNK_SIZE), + }, + header, + )) + } + + /// Takes more plaintext, and appends any whole chunks to `out`. + pub fn update(&mut self, mut data: &[u8], out: &mut Vec) { + while !data.is_empty() { + let take = (CHUNK_SIZE - self.pending.len()).min(data.len()); + self.pending.extend_from_slice(&data[..take]); + data = &data[take..]; + // A full chunk is held back until more arrives, so the final + // chunk is never empty unless the whole message is. + if self.pending.len() == CHUNK_SIZE && !data.is_empty() { + self.emit(false, out); + } + } + } + + /// Seals what is left as the last chunk. + pub fn finish(mut self, out: &mut Vec) { + self.emit(true, out); + } + + fn emit(&mut self, last: bool, out: &mut Vec) { + let sealed = self.ctx.seal(&chunk_aad(&self.aad, last), &self.pending); + let frame = sealed.len() as u32 | if last { LAST_BIT } else { 0 }; + out.extend_from_slice(&frame.to_be_bytes()); + out.extend_from_slice(&sealed); + self.pending.clear(); + } +} + +/// Opens a stream, as its bytes arrive. +pub struct Opener { + ctx: Context, + aad: Vec, + buf: Vec, + done: bool, +} + +impl Opener { + /// Starts opening a stream with the downstream's secret key. `header` is + /// the stream's first `HEADER_LEN` bytes. + pub fn new(secret: &[u8; 32], header: &[u8], aad: &[u8]) -> Result { + if header.len() != HEADER_LEN || &header[..4] != MAGIC || header[4] != FORMAT { + return Err(SealError::Corrupt); + } + let enc: [u8; 32] = header[5..].try_into().expect("32 bytes"); + Ok(Opener { + ctx: setup_receiver(&enc, secret, INFO)?, + aad: aad.to_vec(), + buf: Vec::new(), + done: false, + }) + } + + /// Takes more sealed bytes, and appends the plaintext of each whole chunk + /// to `out`. + pub fn update(&mut self, data: &[u8], out: &mut Vec) -> Result<(), SealError> { + self.buf.extend_from_slice(data); + loop { + if self.buf.len() < 4 { + return Ok(()); + } + if self.done { + return Err(SealError::Corrupt); + } + let frame = u32::from_be_bytes(self.buf[..4].try_into().expect("4 bytes")); + let last = frame & LAST_BIT != 0; + let len = (frame & !LAST_BIT) as usize; + if !(TAG_LEN..=CHUNK_SIZE + TAG_LEN).contains(&len) { + return Err(SealError::Corrupt); + } + if self.buf.len() < 4 + len { + return Ok(()); + } + let chunk = self.buf[4..4 + len].to_vec(); + self.buf.drain(..4 + len); + let plaintext = self.ctx.open(&chunk_aad(&self.aad, last), chunk)?; + self.done = last; + out.extend_from_slice(&plaintext); + } + } + + /// Checks the stream ended with its last chunk and nothing after it. + pub fn finish(self) -> Result<(), SealError> { + match (self.done, self.buf.is_empty()) { + (true, true) => Ok(()), + (true, false) => Err(SealError::Corrupt), + (false, _) => Err(SealError::Truncated), + } + } +} + +/// Seals a whole message at once. +pub fn seal(recipient: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result, SealError> { + let (mut sealer, mut out) = Sealer::new(recipient, aad)?; + sealer.update(plaintext, &mut out); + sealer.finish(&mut out); + Ok(out) +} + +/// Opens a whole sealed message at once. +pub fn open(secret: &[u8; 32], aad: &[u8], sealed: &[u8]) -> Result, SealError> { + if sealed.len() < HEADER_LEN { + return Err(SealError::Corrupt); + } + let mut opener = Opener::new(secret, &sealed[..HEADER_LEN], aad)?; + let mut out = Vec::with_capacity(sealed.len()); + opener.update(&sealed[HEADER_LEN..], &mut out)?; + opener.finish()?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(s: &str) -> Vec { + let s: String = s.split_whitespace().collect(); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + fn arr(s: &str) -> [u8; 32] { + hex(s).try_into().unwrap() + } + + // RFC 9180, Appendix A.2.1. + const INFO_A21: &str = "4f6465206f6e2061204772656369616e2055726e"; + const SK_EM: &str = "f4ec9b33b792c372c1d2c2063507b684ef925b8c75a42dbcbf57d63ccd381600"; + const PK_EM: &str = "1afa08d3dec047a643885163f1180476fa7ddb54c6a8029ea33f95796bf2ac4a"; + const SK_RM: &str = "8057991eef8f1f1af18f4a9491d16a1ce333f695d4db8e38da75975c4478e0fb"; + const PK_RM: &str = "4310ee97d88cc1f088a5576c77ab0cf5c3ac797f3d95139c6c84b5429c59662a"; + const PT: &str = "4265617574792069732074727574682c20747275746820626561757479"; + const ENCRYPTIONS: [(u64, &str, &str); 3] = [ + ( + 0, + "436f756e742d30", + "1c5250d8034ec2b784ba2cfd69dbdb8af406cfe3ff938e131f0def8c8b60b4db + 21993c62ce81883d2dd1b51a28", + ), + ( + 1, + "436f756e742d31", + "6b53c051e4199c518de79594e1c4ab18b96f081549d45ce015be002090bb119e + 85285337cc95ba5f59992dc98c", + ), + ( + 2, + "436f756e742d32", + "71146bd6795ccc9c49ce25dda112a48f202ad220559502cef1f34271e0cb4b02 + b4f10ecac6f48c32f878fae86b", + ), + ]; + + #[test] + fn rfc9180_a21_vectors() { + assert_eq!(public_key(&arr(SK_RM)).unwrap(), arr(PK_RM)); + let sk_e = PrivateKey::from_private_key(&X25519, &hex(SK_EM)).unwrap(); + let (enc, mut sender) = setup_sender(&sk_e, &arr(PK_RM), &hex(INFO_A21)).unwrap(); + assert_eq!(enc, arr(PK_EM)); + let mut receiver = setup_receiver(&enc, &arr(SK_RM), &hex(INFO_A21)).unwrap(); + for (seq, aad, ct) in ENCRYPTIONS { + assert_eq!(sender.seq, seq); + let sealed = sender.seal(&hex(aad), &hex(PT)); + assert_eq!(sealed, hex(ct), "sequence {seq}"); + assert_eq!(receiver.open(&hex(aad), sealed).unwrap(), hex(PT)); + } + } + + #[test] + fn stream_round_trips_at_every_size() { + let (secret, public) = generate_keypair(); + for len in [ + 0, + 1, + CHUNK_SIZE - 1, + CHUNK_SIZE, + CHUNK_SIZE + 1, + 3 * CHUNK_SIZE + 7, + ] { + let data: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + let sealed = seal(&public, b"id-1", &data).unwrap(); + assert_eq!(open(&secret, b"id-1", &sealed).unwrap(), data, "len {len}"); + } + } + + #[test] + fn stream_fed_in_pieces() { + let (secret, public) = generate_keypair(); + let data: Vec = (0..200_000).map(|i| (i % 13) as u8).collect(); + let (mut sealer, mut sealed) = Sealer::new(&public, b"id").unwrap(); + for piece in data.chunks(977) { + sealer.update(piece, &mut sealed); + } + sealer.finish(&mut sealed); + let mut opener = Opener::new(&secret, &sealed[..HEADER_LEN], b"id").unwrap(); + let mut out = Vec::new(); + for piece in sealed[HEADER_LEN..].chunks(1013) { + opener.update(piece, &mut out).unwrap(); + } + opener.finish().unwrap(); + assert_eq!(out, data); + } + + #[test] + fn no_plaintext_in_the_sealed_bytes() { + let (_, public) = generate_keypair(); + let marker = b"MARKER-7f3a-held-mail"; + let sealed = seal(&public, b"id", marker).unwrap(); + assert!(!sealed.windows(marker.len()).any(|w| w == marker)); + } + + #[test] + fn tampering_is_caught() { + let (secret, public) = generate_keypair(); + let data = vec![42u8; 2 * CHUNK_SIZE + 10]; + let sealed = seal(&public, b"id", &data).unwrap(); + + // Another message's id. + assert_eq!(open(&secret, b"other", &sealed), Err(SealError::Corrupt)); + // A flipped byte. + let mut bad = sealed.clone(); + bad[HEADER_LEN + 100] ^= 1; + assert_eq!(open(&secret, b"id", &bad), Err(SealError::Corrupt)); + // Cut after the first chunk. + let first = HEADER_LEN + 4 + CHUNK_SIZE + TAG_LEN; + assert_eq!( + open(&secret, b"id", &sealed[..first]), + Err(SealError::Truncated) + ); + // Trailing bytes. + let mut long = sealed.clone(); + long.extend_from_slice(&[0, 0, 0, 0]); + assert_eq!(open(&secret, b"id", &long), Err(SealError::Corrupt)); + // Chunks swapped. + let c = 4 + CHUNK_SIZE + TAG_LEN; + let mut swapped = sealed[..HEADER_LEN].to_vec(); + swapped.extend_from_slice(&sealed[HEADER_LEN + c..HEADER_LEN + 2 * c]); + swapped.extend_from_slice(&sealed[HEADER_LEN..HEADER_LEN + c]); + swapped.extend_from_slice(&sealed[HEADER_LEN + 2 * c..]); + assert_eq!(open(&secret, b"id", &swapped), Err(SealError::Corrupt)); + // The wrong key. + let (other, _) = generate_keypair(); + assert_eq!(open(&other, b"id", &sealed), Err(SealError::Corrupt)); + // Not a stream. + assert_eq!(open(&secret, b"id", b"hello"), Err(SealError::Corrupt)); + } +} diff --git a/crates/relay-proto/src/time.rs b/crates/relay-proto/src/time.rs new file mode 100644 index 00000000..5718f1b9 --- /dev/null +++ b/crates/relay-proto/src/time.rs @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! JMAP `UTCDate` (RFC 8620 §1.4) to and from Unix seconds, without a date +//! library: the relay needs nothing more. + +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// Unix seconds as `YYYY-MM-DDTHH:MM:SSZ`. +pub fn utc_date(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let rem = secs % 86_400; + let (y, m, d) = civil_from_days(days); + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + (rem / 60) % 60, + rem % 60 + ) +} + +/// Parses `YYYY-MM-DDTHH:MM:SSZ`, with optional fractional seconds, which +/// are dropped. Anything else is `None`. +pub fn parse_utc_date(text: &str) -> Option { + let b = text.as_bytes(); + if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' { + return None; + } + if b[16] != b':' || *b.last()? != b'Z' { + return None; + } + if b.len() > 20 + && (b[19] != b'.' || b.len() == 21 || !b[20..b.len() - 1].iter().all(u8::is_ascii_digit)) + { + return None; + } + let num = |r: std::ops::Range| -> Option { + let s = text.get(r)?; + s.bytes() + .all(|c| c.is_ascii_digit()) + .then(|| s.parse().ok())? + }; + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); + if !(1..=12).contains(&mo) || !(1..=31).contains(&d) || h > 23 || mi > 59 || s > 60 { + return None; + } + let days = days_from_civil(y as i64, mo as u32, d as u32); + if days < 0 || civil_from_days(days) != (y as i64, mo as u32, d as u32) { + return None; + } + Some(days as u64 * 86_400 + h * 3600 + mi * 60 + s) +} + +// Howard Hinnant's civil-date algorithms, proleptic Gregorian. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, m, d) +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let mp = i64::from(if m > 2 { m - 3 } else { m + 9 }); + let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips() { + for secs in [0, 951_782_400, 1_793_275_200, 4_102_444_799] { + assert_eq!(parse_utc_date(&utc_date(secs)), Some(secs)); + } + assert_eq!(utc_date(0), "1970-01-01T00:00:00Z"); + assert_eq!(utc_date(951_782_400), "2000-02-29T00:00:00Z"); + } + + #[test] + fn parses_fractions_and_rejects_junk() { + assert_eq!( + parse_utc_date("2026-10-29T12:00:00.250Z"), + Some(1_793_275_200) + ); + for bad in [ + "2026-10-29 12:00:00Z", + "2026-10-29T12:00:00", + "2026-02-30T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-10-29T12:00:00.Z", + "+026-10-29T12:00:00Z", + ] { + assert_eq!(parse_utc_date(bad), None, "{bad}"); + } + } +} diff --git a/crates/relay-proto/src/token.rs b/crates/relay-proto/src/token.rs new file mode 100644 index 00000000..8c7f4354 --- /dev/null +++ b/crates/relay-proto/src/token.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Joining (spec "Joining and reset", RL-8). +//! +//! inbuxa-admin shows the join token once. The relay keeps the downstream's +//! CA pin and the secret from it; on the first link the downstream sends a +//! nonce, and the relay proves it holds the token with +//! `HMAC-SHA256(secret, nonce || relay link pin)`. The downstream then pins +//! the relay's link key. + +use crate::{b64, pin::Pin}; +use aws_lc_rs::{hmac, rand::SecureRandom}; +use serde::{Deserialize, Serialize}; + +/// How long a join token is good for. +pub const TOKEN_LIFETIME_SECS: u64 = 24 * 3600; + +/// The only token version so far. +pub const TOKEN_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct JoinToken { + pub v: u32, + /// The `inbuxa:Relay` id on the downstream. + pub relay_id: String, + /// The downstream link CA's key pin (RL-6). + #[serde(with = "b64")] + pub ca_spki: Pin, + /// Single use; deleted on both sides once the join succeeds. + #[serde(with = "b64")] + pub secret: [u8; 32], + /// Unix seconds. + pub expires_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenError { + Malformed, + UnknownVersion, + Expired, +} + +impl std::fmt::Display for TokenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + TokenError::Malformed => "the join token is malformed", + TokenError::UnknownVersion => "the join token is from a newer release", + TokenError::Expired => "the join token has expired", + }) + } +} + +impl std::error::Error for TokenError {} + +impl JoinToken { + /// A new token for `relay_id`, valid from `now` for a day. + pub fn issue(relay_id: impl Into, ca_spki: Pin, now: u64) -> Self { + let mut secret = [0u8; 32]; + aws_lc_rs::rand::SystemRandom::new() + .fill(&mut secret) + .expect("system random source"); + JoinToken { + v: TOKEN_VERSION, + relay_id: relay_id.into(), + ca_spki, + secret, + expires_at: now + TOKEN_LIFETIME_SECS, + } + } + + /// The text an administrator copies: base64url of the JSON. + pub fn encode(&self) -> String { + b64::encode(&serde_json::to_vec(self).expect("token serializes")) + } + + /// Reads a token, and checks its version and expiry against `now`. + pub fn decode(text: &str, now: u64) -> Result { + let bytes = b64::decode(text.trim()).ok_or(TokenError::Malformed)?; + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| TokenError::Malformed)?; + if value.get("v").and_then(|v| v.as_u64()) != Some(u64::from(TOKEN_VERSION)) { + return Err(if value.get("v").is_some_and(|v| v.is_u64()) { + TokenError::UnknownVersion + } else { + TokenError::Malformed + }); + } + let token: JoinToken = serde_json::from_value(value).map_err(|_| TokenError::Malformed)?; + if token.relay_id.is_empty() { + return Err(TokenError::Malformed); + } + if now >= token.expires_at { + return Err(TokenError::Expired); + } + Ok(token) + } +} + +/// The relay's answer to `Relay/join`. +pub fn join_proof(secret: &[u8; 32], nonce: &[u8], relay_pin: &Pin) -> [u8; 32] { + let key = hmac::Key::new(hmac::HMAC_SHA256, secret); + let mut ctx = hmac::Context::with_key(&key); + ctx.update(nonce); + ctx.update(relay_pin); + let mut proof = [0u8; 32]; + proof.copy_from_slice(ctx.sign().as_ref()); + proof +} + +/// The downstream's check of that answer, in constant time. +pub fn verify_join_proof(secret: &[u8; 32], nonce: &[u8], relay_pin: &Pin, proof: &[u8]) -> bool { + let key = hmac::Key::new(hmac::HMAC_SHA256, secret); + let mut data = Vec::with_capacity(nonce.len() + relay_pin.len()); + data.extend_from_slice(nonce); + data.extend_from_slice(relay_pin); + hmac::verify(&key, &data, proof).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_expiry() { + let token = JoinToken::issue("r1", [7; 32], 1000); + let text = token.encode(); + assert_eq!(JoinToken::decode(&text, 1000), Ok(token.clone())); + assert_eq!( + JoinToken::decode(&text, 1000 + TOKEN_LIFETIME_SECS), + Err(TokenError::Expired) + ); + assert_eq!( + JoinToken::decode("not a token", 1000), + Err(TokenError::Malformed) + ); + } + + #[test] + fn newer_versions_are_named() { + let text = b64::encode(br#"{"v":2,"relayId":"r1"}"#); + assert_eq!(JoinToken::decode(&text, 0), Err(TokenError::UnknownVersion)); + let text = b64::encode(br#"{"v":"1"}"#); + assert_eq!(JoinToken::decode(&text, 0), Err(TokenError::Malformed)); + } + + #[test] + fn extra_fields_are_refused() { + let token = JoinToken::issue("r1", [7; 32], 0); + let mut value = serde_json::to_value(&token).unwrap(); + value["extra"] = serde_json::Value::Bool(true); + let text = b64::encode(&serde_json::to_vec(&value).unwrap()); + assert_eq!(JoinToken::decode(&text, 0), Err(TokenError::Malformed)); + } + + #[test] + fn proof_binds_secret_nonce_and_pin() { + let secret = [1u8; 32]; + let proof = join_proof(&secret, b"nonce", &[2; 32]); + assert!(verify_join_proof(&secret, b"nonce", &[2; 32], &proof)); + assert!(!verify_join_proof(&[9; 32], b"nonce", &[2; 32], &proof)); + assert!(!verify_join_proof(&secret, b"other", &[2; 32], &proof)); + assert!(!verify_join_proof(&secret, b"nonce", &[3; 32], &proof)); + assert!(!verify_join_proof( + &secret, + b"nonce", + &[2; 32], + &proof[..31] + )); + } +} diff --git a/crates/relay-proto/src/types.rs b/crates/relay-proto/src/types.rs new file mode 100644 index 00000000..4a796a10 --- /dev/null +++ b/crates/relay-proto/src/types.rs @@ -0,0 +1,429 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The relay's data types (spec "The relay's JMAP API"). `Envelope` follows +//! RFC 8621 §7 field for field. + +use crate::b64; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, net::IpAddr}; + +/// Type names, as they appear in method names and `StateChange`. +pub mod type_name { + pub const RELAY: &str = "Relay"; + pub const RECIPIENT_DOMAIN: &str = "RecipientDomain"; + pub const RECIPIENT: &str = "Recipient"; + pub const INBOUND: &str = "Inbound"; + pub const OUTBOUND: &str = "Outbound"; + pub const RELAY_EVENT: &str = "RelayEvent"; +} + +/// The `urn:inbuxa:jmap:relay` capability object in the session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Capability { + pub protocol_version: u32, + pub relay_version: String, + pub max_blob_size: u64, + #[serde(default)] + pub features: Vec, +} + +/// The `Relay` object's id: there is only one. +pub const RELAY_SINGLETON: &str = "singleton"; + +/// Bounds the relay enforces on settings. +pub mod limits { + pub const LIVE_VERDICT_TIMEOUT_MAX: u32 = 120; + pub const HELD_MAX_AGE_DAYS_MAX: u32 = 90; +} + +/// What the downstream sets on `Relay` (spec table "`Relay`"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct RelaySettings { + /// Public name: EHLO, banner, and the SMTP certificate's name. + pub hostname: String, + /// Port 25 listen addresses. + pub smtp_listen: Vec, + /// The domains served (RL-1, RL-4). + pub domains: Vec, + pub max_message_size: u64, + pub max_recipients: u32, + pub rate_limits: RateLimits, + /// DNSBL zones, checked at connect in held mode. Empty by default (RL-14). + pub dnsbl: Vec, + /// Seconds to wait for a live verdict (RL-13). + pub live_verdict_timeout: u32, + /// Days held inbound mail waits for the downstream. + pub held_max_age: u32, + pub spool_max_bytes: u64, + /// Seconds between outbound attempts, in order; the last repeats. + pub outbound_retry: Vec, + /// Seconds before outbound mail is given up. + pub outbound_expiry: u32, + /// The STARTTLS certificate for port 25. + #[serde(skip_serializing_if = "Option::is_none")] + pub smtp_certificate: Option, + /// The downstream's public sealing key (RL-21). + #[serde(with = "b64::option", skip_serializing_if = "Option::is_none")] + pub sealing_key: Option<[u8; 32]>, + /// The key recipient hashes are made with (RL-11). + #[serde(with = "b64::option", skip_serializing_if = "Option::is_none")] + pub recipient_key: Option<[u8; 32]>, +} + +impl Default for RelaySettings { + fn default() -> Self { + RelaySettings { + hostname: String::new(), + smtp_listen: vec!["[::]:25".into()], + domains: Vec::new(), + max_message_size: 50 * 1024 * 1024, + max_recipients: 100, + rate_limits: RateLimits::default(), + dnsbl: Vec::new(), + live_verdict_timeout: 30, + held_max_age: 30, + spool_max_bytes: 2 * 1024 * 1024 * 1024, + outbound_retry: vec![120, 300, 600, 1800, 3600, 7200, 14_400], + outbound_expiry: 3 * 86_400, + smtp_certificate: None, + sealing_key: None, + recipient_key: None, + } + } +} + +impl RelaySettings { + /// What is wrong with these settings, or nothing. + pub fn problems(&self) -> Vec { + let mut out = Vec::new(); + if self.hostname.is_empty() { + out.push("hostname is required".into()); + } + if self.smtp_listen.is_empty() { + out.push("smtpListen needs at least one address".into()); + } + for addr in &self.smtp_listen { + if addr.parse::().is_err() { + out.push(format!("smtpListen: {addr} is not an address and port")); + } + } + if self.domains.is_empty() { + out.push("domains needs at least one domain".into()); + } + if self.max_message_size == 0 || self.max_recipients == 0 { + out.push("maxMessageSize and maxRecipients must be above zero".into()); + } + if !(1..=limits::LIVE_VERDICT_TIMEOUT_MAX).contains(&self.live_verdict_timeout) { + out.push(format!( + "liveVerdictTimeout must be 1 to {} seconds", + limits::LIVE_VERDICT_TIMEOUT_MAX + )); + } + if !(1..=limits::HELD_MAX_AGE_DAYS_MAX).contains(&self.held_max_age) { + out.push(format!( + "heldMaxAge must be 1 to {} days", + limits::HELD_MAX_AGE_DAYS_MAX + )); + } + if self.outbound_retry.is_empty() || self.outbound_retry.contains(&0) { + out.push("outboundRetry needs intervals above zero".into()); + } + if self.sealing_key.is_none() { + out.push("sealingKey is required".into()); + } + if self.recipient_key.is_none() { + out.push("recipientKey is required".into()); + } + out + } + + /// Whether `domain` (lowercased) is served. + pub fn serves(&self, domain: &str) -> bool { + self.domains.iter().any(|d| d.eq_ignore_ascii_case(domain)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct RateLimits { + /// New connections a minute from one IP. + pub connections_per_minute: u32, + /// Messages a minute from one IP. + pub messages_per_minute: u32, + /// Sessions open at once from one IP. + pub concurrent_sessions: u32, +} + +impl Default for RateLimits { + fn default() -> Self { + RateLimits { + connections_per_minute: 60, + messages_per_minute: 60, + concurrent_sessions: 10, + } + } +} + +/// A PEM chain and its private key. `/get` never returns the key. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SmtpCertificate { + pub chain_pem: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub key_pem: String, +} + +impl std::fmt::Debug for SmtpCertificate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SmtpCertificate") + .field("chain_pem", &format_args!("{} bytes", self.chain_pem.len())) + .finish_non_exhaustive() + } +} + +/// What the relay sets on `Relay` (`status`). +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct RelayStatus { + pub started_at: String, + pub relay_version: String, + pub link_clients: u32, + pub mode: Mode, + pub spool: SpoolStatus, + /// The `Recipient` state string the relay holds. + pub recipient_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate_not_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, + /// False in a container image: update the image instead (RL-29). + pub self_upgrade: bool, +} + +/// Live while a link client has push on for `Inbound` (spec "Live mode"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Mode { + /// Port 25 stays closed until the first settings and recipient set. + #[default] + Unconfigured, + Live, + Held, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SpoolStatus { + pub inbound: u64, + pub outbound: u64, + pub bytes: u64, +} + +/// RFC 8621 `Envelope`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Envelope { + pub mail_from: Address, + pub rcpt_to: Vec
, +} + +/// RFC 8621 `Address`: an address and its SMTP parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Address { + pub email: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option>>, +} + +impl Address { + pub fn new(email: impl Into) -> Self { + Address { + email: email.into(), + parameters: None, + } + } +} + +/// The SMTP session a message arrived in: what the downstream judges it by +/// (RL-23). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub remote_ip: IpAddr, + pub remote_port: u16, + pub local_ip: IpAddr, + pub helo: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, + /// The reverse DNS name, if the relay looked one up. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ptr: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TlsInfo { + pub version: String, + pub cipher: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InboundState { + /// Live: waiting for the downstream's verdict. + Pending, + /// Waiting for the downstream to take it. + Held, + /// Leased to one link connection (RL-15). + Claimed, +} + +/// A message the relay accepted on port 25. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Inbound { + pub id: String, + pub received_at: String, + pub state: InboundState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claimed_until: Option, + pub envelope: Envelope, + pub session: Session, + pub size: u64, + /// The sealed message (RL-21). + pub blob_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub door_checks: Vec, +} + +/// One check the relay ran at the door, and what it found. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DoorCheck { + /// e.g. `dnsbl:zen.example.org`. + pub check: String, + pub result: String, +} + +/// The downstream's answer for a pending message (RL-12). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum Verdict { + Accept, + Reject(Reply), + Tempfail(Reply), +} + +/// An SMTP reply for the relay to give. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Reply { + pub code: u16, + /// e.g. `5.7.1`. + pub enhanced: String, + pub text: String, +} + +impl Verdict { + /// The reply, if it is a valid one for its kind. + pub fn is_valid(&self) -> bool { + let ok = |r: &Reply, class: u16, first: char| { + r.code / 100 == class + && r.enhanced.starts_with(first) + && r.enhanced.split('.').count() == 3 + && r.enhanced.split('.').all(|p| p.parse::().is_ok()) + && !r.text.contains(['\r', '\n']) + }; + match self { + Verdict::Accept => true, + Verdict::Reject(r) => ok(r, 5, '5'), + Verdict::Tempfail(r) => ok(r, 4, '4'), + } + } +} + +/// A relay event, for the downstream's history and tracer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayEvent { + pub id: String, + pub at: String, + /// e.g. `inbound.accepted`, `inbound.expired`, `smtp.rejected`. + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub data: serde_json::Map, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn verdict_wire_form() { + assert_eq!( + serde_json::to_value(Verdict::Accept).unwrap(), + json!({"type": "accept"}) + ); + let v: Verdict = serde_json::from_value( + json!({"type": "reject", "code": 550, "enhanced": "5.7.1", "text": "spam"}), + ) + .unwrap(); + assert!(v.is_valid()); + let bad = Verdict::Reject(Reply { + code: 450, + enhanced: "5.7.1".into(), + text: "x".into(), + }); + assert!(!bad.is_valid()); + let injected = Verdict::Tempfail(Reply { + code: 451, + enhanced: "4.3.0".into(), + text: "a\r\n250 ok".into(), + }); + assert!(!injected.is_valid()); + } + + #[test] + fn settings_defaults_need_the_downstream_parts() { + let mut s = RelaySettings::default(); + assert_eq!(s.problems().len(), 4); + s.hostname = "relay.example.com".into(); + s.domains = vec!["example.com".into()]; + s.sealing_key = Some([1; 32]); + s.recipient_key = Some([2; 32]); + assert!(s.problems().is_empty()); + s.live_verdict_timeout = 121; + s.held_max_age = 91; + assert_eq!(s.problems().len(), 2); + assert!(s.serves("EXAMPLE.com")); + } + + #[test] + fn certificate_key_stays_out_of_debug() { + let cert = SmtpCertificate { + chain_pem: "chain".into(), + key_pem: "SECRET".into(), + }; + assert!(!format!("{cert:?}").contains("SECRET")); + } + + #[test] + fn settings_round_trip() { + let s = RelaySettings { + sealing_key: Some([3; 32]), + ..Default::default() + }; + let value = serde_json::to_value(&s).unwrap(); + assert!(value.get("recipientKey").is_none()); + assert_eq!(serde_json::from_value::(value).unwrap(), s); + } +}