relay-proto: the link's shared types, joining, recipient hashing and sealing #244
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
@@ -32,6 +32,7 @@ members = [
|
||||
"crates/trc",
|
||||
"crates/migration",
|
||||
"crates/features",
|
||||
"crates/relay-proto",
|
||||
"tests",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user