relay-proto: the link's shared types, joining, recipient hashing and sealing #244

Merged
jcoffey-dev merged 1 commits from relay/proto into main 2026-10-30 03:14:56 +00:00
11 changed files with 1812 additions and 0 deletions

No files matched your search

Generated
+10
View File
@@ -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"
+1
View File
@@ -32,6 +32,7 @@ members = [
"crates/trc",
"crates/migration",
"crates/features",
"crates/relay-proto",
"tests",
]
+17
View File
@@ -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
+259
View File
@@ -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<String, Value>, pub String);
impl Invocation {
pub fn new(name: impl Into<String>, args: Value, call_id: impl Into<String>) -> 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<String, Value> {
&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<String>) -> 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<String>,
pub method_calls: Vec<Invocation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_ids: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Response {
pub method_responses: Vec<Invocation>,
pub session_state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_ids: Option<BTreeMap<String, String>>,
}
/// 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<String>,
#[serde(flatten)]
request: Request,
},
WebSocketPushEnable {
#[serde(rename = "dataTypes", default)]
data_types: Option<Vec<String>>,
#[serde(rename = "pushState", default, skip_serializing_if = "Option::is_none")]
push_state: Option<String>,
},
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<String>,
#[serde(flatten)]
response: Response,
},
RequestError {
#[serde(rename = "requestId", default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(rename = "type")]
kind: String,
status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
StateChange {
/// accountId → type name → state.
changed: BTreeMap<String, BTreeMap<String, String>>,
#[serde(rename = "pushState", default, skip_serializing_if = "Option::is_none")]
push_state: Option<String>,
},
}
/// 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<String, Value>,
pub accounts: BTreeMap<String, Account>,
pub primary_accounts: BTreeMap<String, String>,
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<String, Value>,
}
/// 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"])
);
}
}
+83
View File
@@ -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<Vec<u8>> {
URL_SAFE_NO_PAD.decode(text).ok()
}
pub fn serialize<S: Serializer, const N: usize>(
bytes: &[u8; N],
serializer: S,
) -> Result<S::Ok, S::Error> {
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<S: Serializer, const N: usize>(
bytes: &Option<[u8; N]>,
serializer: S,
) -> Result<S::Ok, S::Error> {
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<Option<[u8; N]>, D::Error> {
#[derive(Deserialize)]
struct Wrap<const N: usize>(#[serde(with = "super")] [u8; N]);
Ok(Option::<Wrap<N>>::deserialize(deserializer)?.map(|Wrap(bytes)| bytes))
}
}
}
+25
View File
@@ -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()
}
+187
View File
@@ -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 `[email protected]`, or none.
#[serde(default)]
pub subaddress_separator: Option<String>,
}
/// 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<String> {
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("[email protected].", Some("+")).as_deref(),
Some("[email protected]")
);
assert_eq!(
normalize("[email protected]", Some("+")).as_deref(),
Some("[email protected]")
);
assert_eq!(
normalize("[email protected]", None).as_deref(),
Some("[email protected]")
);
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<RecipientHash> = ["[email protected]", "[email protected]"]
.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("[email protected]"), Lookup::Accept);
assert_eq!(decide("[email protected]"), Lookup::Accept);
assert_eq!(decide("[email protected]"), Lookup::Accept);
assert_eq!(decide("[email protected]"), Lookup::UnknownUser);
assert_eq!(decide("[email protected]"), Lookup::Accept);
assert_eq!(decide("[email protected]"), Lookup::NotServed);
assert_eq!(decide("john"), Lookup::BadAddress);
}
#[test]
fn hash_depends_on_key() {
assert_ne!(hash(&KEY, "[email protected]"), hash(&[6; 32], "[email protected]"));
}
}
+508
View File
@@ -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<Vec<u8>, 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<u8> {
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<u8> = 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<u8> {
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<u8>) -> Result<Vec<u8>, 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<Context, SealError> {
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<u8> {
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<u8>,
pending: Vec<u8>,
}
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<u8>), 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<u8>) {
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<u8>) {
self.emit(true, out);
}
fn emit(&mut self, last: bool, out: &mut Vec<u8>) {
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<u8>,
buf: Vec<u8>,
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<Self, SealError> {
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<u8>) -> 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<Vec<u8>, 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<Vec<u8>, 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<u8> {
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<u8> = (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<u8> = (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));
}
}
+118
View File
@@ -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<u64> {
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<usize>| -> Option<u64> {
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}");
}
}
}
+175
View File
@@ -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<String>, 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<Self, TokenError> {
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]
));
}
}
+429
View File
@@ -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<String>,
}
/// 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<String>,
/// The domains served (RL-1, RL-4).
pub domains: Vec<String>,
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<String>,
/// 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<u32>,
/// 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<SmtpCertificate>,
/// 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<String> {
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::<std::net::SocketAddr>().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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
/// 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<Address>,
}
/// 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<BTreeMap<String, Option<String>>>,
}
impl Address {
pub fn new(email: impl Into<String>) -> 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<TlsInfo>,
/// The reverse DNS name, if the relay looked one up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ptr: Option<String>,
}
#[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<String>,
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<DoorCheck>,
}
/// 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::<u16>().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<String, serde_json::Value>,
}
#[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::<RelaySettings>(value).unwrap(), s);
}
}