diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a60dc9c..fb887560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. This projec If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. ## Added +- Use of Voluntary Application Server Identification (VAPID) in JMAP Web Push ([RFC 9749](https://datatracker.ietf.org/doc/html/rfc9749)). ## Changed diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7f0a135a..e35eb47f 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -67,7 +67,7 @@ psl = "2" aes-gcm-siv = "0.11.1" biscuit = "0.8.0" rsa = "0.9.2" -p256 = { version = "0.13", features = ["ecdh"] } +p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pkcs8", "pem"] } p384 = { version = "0.13", features = ["ecdh"] } num_cpus = "1.13.1" hashify = "0.2" diff --git a/crates/common/src/config/mailstore/capabilities.rs b/crates/common/src/config/mailstore/capabilities.rs index 500014a9..0312e7ad 100644 --- a/crates/common/src/config/mailstore/capabilities.rs +++ b/crates/common/src/config/mailstore/capabilities.rs @@ -13,7 +13,7 @@ use jmap_proto::{ BlobCapabilities, CalendarCapabilities, Capabilities, Capability, ContactsCapabilities, CoreCapabilities, EmptyCapabilities, FileNodeCapabilities, MailCapabilities, PrincipalAvailabilityCapabilities, PrincipalCapabilities, SieveAccountCapabilities, - SieveSessionCapabilities, SubmissionCapabilities, + SieveSessionCapabilities, SubmissionCapabilities, WebPushCapabilities, }, types::date::UTCDate, }; @@ -295,5 +295,19 @@ impl JmapConfig { Capability::Quota, Capabilities::Empty(EmptyCapabilities::default()), ); + + // Add Web Push VAPID capabilities + if let Some(application_server_key) = self + .vapid + .as_ref() + .map(|vapid| vapid.public_key().to_string()) + { + self.capabilities.session.append( + Capability::WebPushVapid, + Capabilities::WebPush(WebPushCapabilities { + application_server_key, + }), + ); + } } } diff --git a/crates/common/src/config/mailstore/jmap.rs b/crates/common/src/config/mailstore/jmap.rs index 098656e2..886a3d58 100644 --- a/crates/common/src/config/mailstore/jmap.rs +++ b/crates/common/src/config/mailstore/jmap.rs @@ -4,9 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::network::webpush::{Vapid, VapidKey}; use jmap_proto::request::capability::BaseCapabilities; -use registry::schema::structs::Jmap; -use std::time::Duration; +use registry::schema::{prelude::ObjectType, structs::Jmap}; +use std::{sync::Arc, time::Duration}; use store::registry::bootstrap::Bootstrap; #[derive(Default, Clone)] @@ -46,12 +47,27 @@ pub struct JmapConfig { pub web_socket_timeout: Duration, pub web_socket_heartbeat: Duration, + pub vapid: Option>, + pub capabilities: BaseCapabilities, } impl JmapConfig { pub async fn parse(bp: &mut Bootstrap) -> Self { let jmap = bp.setting_infallible::().await; + let web_push_key = jmap + .web_push_key + .secret() + .await + .map_err(|err| { + bp.build_error( + ObjectType::Jmap.singleton(), + format!("Unable to retrieve Web Push key: {err}"), + ); + }) + .unwrap_or_default() + .map(|k| k.into_owned()); + let web_push_contact = jmap.web_push_contact; let mut jmap = JmapConfig { query_max_results: jmap.query_max_results as usize, @@ -81,9 +97,33 @@ impl JmapConfig { push_verify_timeout: jmap.push_verify_timeout.into_inner(), push_throttle: jmap.push_throttle.into_inner(), push_total_shards: jmap.push_shards_total as u32, + vapid: None, capabilities: BaseCapabilities::default(), }; + // Enable Web Push VAPID only when a signing key is configured + jmap.vapid = web_push_key + .as_deref() + .map(str::trim) + .filter(|pem| !pem.is_empty()) + .and_then(|pem| match VapidKey::from_pkcs8_pem(pem) { + Ok(key) => Some(key), + Err(err) => { + bp.build_error( + ObjectType::Jmap.singleton(), + format!("Invalid Web Push VAPID key: {err}"), + ); + None + } + }) + .map(|key| { + let contact = web_push_contact.or_else(|| { + let hostname = bp.registry.local_hostname(); + (!hostname.is_empty()).then(|| format!("mailto:postmaster@{hostname}")) + }); + Arc::new(Vapid::new(key, contact)) + }); + // Add capabilities jmap.add_capabilities(bp).await; jmap diff --git a/crates/common/src/manager/defaults.rs b/crates/common/src/manager/defaults.rs index 66d4fca8..661d96e7 100644 --- a/crates/common/src/manager/defaults.rs +++ b/crates/common/src/manager/defaults.rs @@ -369,6 +369,32 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> { .into(), )) .await?; + + // Generate a Web Push VAPID signing key (RFC 9749) + if bp.registry.count_object(ObjectType::Jmap).await? == 0 { + match crate::network::webpush::generate_pkcs8_pem() { + Ok(web_push_pem) => { + bp.registry + .write(RegistryWrite::insert( + &Jmap { + web_push_key: SecretTextOptional::Text(SecretTextValue { + secret: web_push_pem, + }), + ..Default::default() + } + .into(), + )) + .await?; + } + Err(err) => { + trc::event!( + Server(trc::ServerEvent::Startup), + Details = "Failed to generate Web Push VAPID key", + Reason = err + ); + } + } + } } if bp.registry.count_object(ObjectType::Role).await? == 0 { diff --git a/crates/common/src/network/mod.rs b/crates/common/src/network/mod.rs index 0a9be5b6..a9ba5aec 100644 --- a/crates/common/src/network/mod.rs +++ b/crates/common/src/network/mod.rs @@ -34,6 +34,7 @@ pub mod mta; pub mod security; pub mod stream; pub mod tls; +pub mod webpush; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] pub enum RcptResolution { diff --git a/crates/common/src/network/webpush.rs b/crates/common/src/network/webpush.rs new file mode 100644 index 00000000..f6225287 --- /dev/null +++ b/crates/common/src/network/webpush.rs @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use p256::{ + ecdsa::{Signature, SigningKey, signature::Signer}, + pkcs8::DecodePrivateKey, +}; + +const VAPID_TOKEN_TTL: u64 = 12 * 60 * 60; + +pub fn generate_pkcs8_pem() -> Result { + use p256::elliptic_curve::rand_core::OsRng; + use p256::pkcs8::{EncodePrivateKey, LineEnding}; + + SigningKey::random(&mut OsRng) + .to_pkcs8_pem(LineEnding::LF) + .map(|pem| pem.to_string()) + .map_err(|err| err.to_string()) +} + +#[derive(Clone)] +pub struct Vapid { + key: VapidKey, + contact: Option, +} + +impl Vapid { + pub fn new(key: VapidKey, contact: Option) -> Self { + Self { key, contact } + } + + pub fn public_key(&self) -> &str { + self.key.public_key() + } + + pub fn authorization(&self, endpoint: &str, now: u64) -> Option { + self.key + .authorization(endpoint, self.contact.as_deref(), now) + } +} + +#[derive(Clone)] +pub struct VapidKey { + signing_key: SigningKey, + public_key: String, +} + +impl VapidKey { + pub fn from_pkcs8_pem(pem: &str) -> Result { + SigningKey::from_pkcs8_pem(pem) + .map(Self::from_signing_key) + .map_err(|err| err.to_string()) + } + + fn from_signing_key(signing_key: SigningKey) -> Self { + let public_key = URL_SAFE_NO_PAD.encode( + signing_key + .verifying_key() + .to_encoded_point(false) + .as_bytes(), + ); + Self { + signing_key, + public_key, + } + } + + pub fn public_key(&self) -> &str { + &self.public_key + } + + pub fn authorization(&self, endpoint: &str, contact: Option<&str>, now: u64) -> Option { + let mut claims = serde_json::Map::new(); + claims.insert("aud".into(), endpoint_origin(endpoint)?.into()); + claims.insert("exp".into(), (now + VAPID_TOKEN_TTL).into()); + if let Some(sub) = contact { + claims.insert("sub".into(), sub.into()); + } + + let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).ok()?); + let signing_input = format!("{header}.{payload}"); + let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); + + Some(format!( + "vapid t={signing_input}.{}, k={}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()), + self.public_key + )) + } +} + +fn endpoint_origin(url: &str) -> Option { + let (scheme, rest) = url.split_once("://")?; + let scheme = scheme.to_ascii_lowercase(); + let authority = rest.split(['/', '?', '#']).next()?; + let authority = authority + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(authority); + if authority.is_empty() { + return None; + } + + let (host, port) = if let Some(rest) = authority.strip_prefix('[') { + let (addr, tail) = rest.split_once(']')?; + ( + format!("[{}]", addr.to_ascii_lowercase()), + tail.strip_prefix(':').filter(|port| !port.is_empty()), + ) + } else if let Some((host, port)) = authority.rsplit_once(':') { + ( + host.to_ascii_lowercase(), + Some(port).filter(|p| !p.is_empty()), + ) + } else { + (authority.to_ascii_lowercase(), None) + }; + + match port { + Some(port) + if !((scheme == "https" && port == "443") || (scheme == "http" && port == "80")) => + { + Some(format!("{scheme}://{host}:{port}")) + } + _ => Some(format!("{scheme}://{host}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + + fn test_key() -> VapidKey { + VapidKey::from_pkcs8_pem(&generate_pkcs8_pem().unwrap()).unwrap() + } + + #[test] + fn generated_key_round_trips_through_pkcs8_pem() { + let pem = generate_pkcs8_pem().unwrap(); + assert_eq!( + VapidKey::from_pkcs8_pem(&pem).unwrap().public_key(), + VapidKey::from_pkcs8_pem(&pem).unwrap().public_key() + ); + } + + #[test] + fn endpoint_origin_normalizes() { + assert_eq!( + endpoint_origin("HTTPS://Push.Example.COM:443/push?x=1").unwrap(), + "https://push.example.com" + ); + assert_eq!( + endpoint_origin("https://127.0.0.1:19000/push").unwrap(), + "https://127.0.0.1:19000" + ); + assert_eq!( + endpoint_origin("https://user:pass@fcm.googleapis.com/fcm/send/x").unwrap(), + "https://fcm.googleapis.com" + ); + assert_eq!( + endpoint_origin("http://[2001:DB8::1]:80/p").unwrap(), + "http://[2001:db8::1]" + ); + assert!(endpoint_origin("not-a-url").is_none()); + } + + #[test] + fn authorization_signs_a_verifiable_es256_token() { + let key = test_key(); + let now = 1_700_000_000; + let header = key + .authorization( + "https://push.example.com/push/abc?token=1", + Some("mailto:admin@example.org"), + now, + ) + .unwrap(); + + let (token, advertised_key) = header + .strip_prefix("vapid ") + .and_then(|rest| rest.split_once(", ")) + .unwrap(); + let jwt = token.strip_prefix("t=").unwrap(); + assert_eq!(advertised_key.strip_prefix("k=").unwrap(), key.public_key()); + + let parts = jwt.split('.').collect::>(); + assert_eq!(parts.len(), 3); + + let verifying_key = + VerifyingKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(key.public_key()).unwrap()) + .unwrap(); + let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap(); + verifying_key + .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature) + .unwrap(); + + assert_eq!( + URL_SAFE_NO_PAD.decode(parts[0]).unwrap(), + br#"{"typ":"JWT","alg":"ES256"}"# + ); + let claims: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(claims["aud"], "https://push.example.com"); + assert_eq!(claims["sub"], "mailto:admin@example.org"); + assert_eq!(claims["exp"], now + VAPID_TOKEN_TTL); + } + + #[test] + fn authorization_omits_subject_when_no_contact() { + let key = test_key(); + let header = key + .authorization("https://fcm.googleapis.com/fcm/send/xyz", None, 0) + .unwrap(); + let payload = header.split('.').nth(1).unwrap(); + let claims: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap(); + assert_eq!(claims["aud"], "https://fcm.googleapis.com"); + assert!(claims.get("sub").is_none()); + } +} diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 1757a1ac..08c5275b 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -88,7 +88,8 @@ impl SessionData { if mailbox_count + params.path.len() > self .server - .object_quota(account.object_quotas(), StorageQuota::MaxMailboxes) as usize + .object_quota(account.object_quotas(), StorageQuota::MaxMailboxes) + as usize { return Err(trc::ImapEvent::Error .into_err() diff --git a/crates/imap/src/op/rename.rs b/crates/imap/src/op/rename.rs index 41bc9202..704f1000 100644 --- a/crates/imap/src/op/rename.rs +++ b/crates/imap/src/op/rename.rs @@ -9,12 +9,12 @@ use crate::{ spawn_op, }; use common::{network::SessionStream, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder}; +use email::cache::MessageCacheFetch; use imap_proto::{ Command, ResponseCode, StatusResponse, protocol::{ObjectId, rename::Arguments}, receiver::Request, }; -use email::cache::MessageCacheFetch; use registry::schema::enums::{Permission, StorageQuota}; use std::time::Instant; use store::{ diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index f8e180dd..77151e9d 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -91,6 +91,8 @@ pub enum Capability { MailShare = 1 << 16, #[serde(rename(serialize = "urn:stalwart:jmap"))] Stalwart = 1 << 17, + #[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))] + WebPushVapid = 1 << 18, } #[derive(Debug, Clone, Copy, Default)] @@ -119,6 +121,7 @@ pub enum Capabilities { PrincipalsAvailability(PrincipalAvailabilityCapabilities), Calendar(CalendarCapabilities), FileNode(FileNodeCapabilities), + WebPush(WebPushCapabilities), Empty(EmptyCapabilities), } @@ -291,6 +294,12 @@ pub struct FileNodeCapabilities { pub web_write_url_template: Option, } +#[derive(Debug, Clone, serde::Serialize)] +pub struct WebPushCapabilities { + #[serde(rename(serialize = "applicationServerKey"))] + pub application_server_key: String, +} + #[derive(Debug, Clone, Default, serde::Serialize)] pub struct EmptyCapabilities {} @@ -321,6 +330,7 @@ impl Capability { Capability::FileNode => "urn:ietf:params:jmap:filenode", Capability::MailShare => "urn:ietf:params:jmap:mail:share", Capability::Stalwart => "urn:stalwart:jmap", + Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid", } } @@ -343,6 +353,7 @@ impl Capability { Capability::FileNode, Capability::MailShare, Capability::Stalwart, + Capability::WebPushVapid, ] } } @@ -463,6 +474,7 @@ impl Capability { "urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse, "urn:ietf:params:jmap:mail:share" => Capability::MailShare, "urn:stalwart:jmap" => Capability::Stalwart, + "urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid, ) } } diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index d62b8c7b..41530677 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -121,7 +121,9 @@ impl AccountCapabilities for AccessToken { | Capability::Principals | Capability::PrincipalsAvailability | Capability::Stalwart => return true, - Capability::Core | Capability::PrincipalsOwner => return false, + Capability::Core | Capability::PrincipalsOwner | Capability::WebPushVapid => { + return false; + } }; self.has_permission(permission) }) diff --git a/crates/registry/src/pickle.rs b/crates/registry/src/pickle.rs index dcfcc9f3..931d1838 100644 --- a/crates/registry/src/pickle.rs +++ b/crates/registry/src/pickle.rs @@ -273,3 +273,4 @@ impl Pickle for trc::Key { u16::unpickle(stream).and_then(Self::from_id) } } + diff --git a/crates/registry/src/schema/properties.rs b/crates/registry/src/schema/properties.rs index c307a98b..5d81471f 100644 --- a/crates/registry/src/schema/properties.rs +++ b/crates/registry/src/schema/properties.rs @@ -1169,6 +1169,8 @@ pub enum Property { Vrfy = 526, WaitOnFail = 548, WapiVersion = 893, + WebPushContact = 922, + WebPushKey = 921, WebsocketHeartbeat = 455, WebsocketThrottle = 456, WebsocketTimeout = 457, diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index fe4be8d3..e99c8674 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -1322,6 +1322,8 @@ impl EnumImpl for Property { b"vrfy" => Property::Vrfy, b"waitOnFail" => Property::WaitOnFail, b"wapiVersion" => Property::WapiVersion, + b"webPushContact" => Property::WebPushContact, + b"webPushKey" => Property::WebPushKey, b"websocketHeartbeat" => Property::WebsocketHeartbeat, b"websocketThrottle" => Property::WebsocketThrottle, b"websocketTimeout" => Property::WebsocketTimeout, @@ -2248,6 +2250,8 @@ impl EnumImpl for Property { Property::Vrfy => "vrfy", Property::WaitOnFail => "waitOnFail", Property::WapiVersion => "wapiVersion", + Property::WebPushContact => "webPushContact", + Property::WebPushKey => "webPushKey", Property::WebsocketHeartbeat => "websocketHeartbeat", Property::WebsocketThrottle => "websocketThrottle", Property::WebsocketTimeout => "websocketTimeout", @@ -3178,6 +3182,8 @@ impl EnumImpl for Property { 526 => Some(Property::Vrfy), 548 => Some(Property::WaitOnFail), 893 => Some(Property::WapiVersion), + 922 => Some(Property::WebPushContact), + 921 => Some(Property::WebPushKey), 455 => Some(Property::WebsocketHeartbeat), 456 => Some(Property::WebsocketThrottle), 457 => Some(Property::WebsocketTimeout), @@ -3188,7 +3194,7 @@ impl EnumImpl for Property { } } - const COUNT: usize = 921; + const COUNT: usize = 923; } impl serde::Serialize for Property { diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs index 108cc45f..f8d2a414 100644 --- a/crates/registry/src/schema/structs.rs +++ b/crates/registry/src/schema/structs.rs @@ -3181,6 +3181,10 @@ pub struct Jmap { pub websocket_timeout: Duration, #[serde(rename = "maxSubscriptions")] pub max_subscriptions: Option, + #[serde(rename = "webPushKey")] + pub web_push_key: SecretTextOptional, + #[serde(rename = "webPushContact")] + pub web_push_contact: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index a2980872..f7d1ac29 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -22798,7 +22798,7 @@ impl InMemoryStoreBase { impl ObjectImpl for Jmap { const FLAGS: u64 = OBJ_SINGLETON; - const VERSION: u8 = 0; + const VERSION: u8 = 1; const OBJECT: ObjectType = ObjectType::Jmap; fn validate(&self, errors: &mut Vec) -> bool { @@ -22884,6 +22884,13 @@ impl ObjectImpl for Jmap { errors.push(ValidationError::min_value(Property::MaxSubscriptions, 1)); } } + let value = &self.web_push_key; + value.validate(errors); + if let Some(value) = &self.web_push_contact { + if value.is_empty() { + errors.push(ValidationError::required(Property::WebPushContact)); + } + } errors.len() == neb } @@ -22920,6 +22927,8 @@ impl Pickle for Jmap { self.websocket_throttle.pickle(out); self.websocket_timeout.pickle(out); self.max_subscriptions.pickle(out); + self.web_push_key.pickle(out); + self.web_push_contact.pickle(out); } fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { @@ -22952,6 +22961,12 @@ impl Pickle for Jmap { this.websocket_throttle = Pickle::unpickle(stream)?; this.websocket_timeout = Pickle::unpickle(stream)?; this.max_subscriptions = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.web_push_key = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.web_push_contact = Pickle::unpickle(stream)?; + } Some(this) } } @@ -22987,13 +23002,15 @@ impl Default for Jmap { websocket_throttle: Duration::from_millis(1000), websocket_timeout: Duration::from_millis(600000), max_subscriptions: Some(15u64), + web_push_key: Default::default(), + web_push_contact: Default::default(), } } } impl IntoValue for Jmap { fn into_value(self) -> JmapValue<'static> { - let mut map = jmap_tools::Map::with_capacity(30); + let mut map = jmap_tools::Map::with_capacity(32); map.insert_unchecked( Property::ParseLimitEvent, self.parse_limit_event.into_value(), @@ -23076,6 +23093,8 @@ impl IntoValue for Jmap { Property::MaxSubscriptions, self.max_subscriptions.into_value(), ); + map.insert_unchecked(Property::WebPushKey, self.web_push_key.into_value()); + map.insert_unchecked(Property::WebPushContact, self.web_push_contact.into_value()); JmapValue::Object(map) } } @@ -23119,6 +23138,10 @@ impl RegistryJsonPropertyPatch for Jmap { Some(Property::WebsocketThrottle) => self.websocket_throttle.patch(pointer, value), Some(Property::WebsocketTimeout) => self.websocket_timeout.patch(pointer, value), Some(Property::MaxSubscriptions) => self.max_subscriptions.patch(pointer, value), + Some(Property::WebPushKey) => self.web_push_key.patch(pointer, value), + Some(Property::WebPushContact) => self + .web_push_contact + .patch(pointer.with_validators(&[StringValidator::Trim]), value), Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { property: Property::Type, value, diff --git a/crates/services/src/state_manager/http.rs b/crates/services/src/state_manager/http.rs index 3d583dd8..c2c3b699 100644 --- a/crates/services/src/state_manager/http.rs +++ b/crates/services/src/state_manager/http.rs @@ -7,21 +7,31 @@ use super::{Event, ece::ece_encrypt}; use crate::state_manager::PushRegistration; use calcard::jscalendar::JSCalendarDateTime; -use common::ipc::PushNotification; +use common::{ipc::PushNotification, network::webpush::Vapid}; use email::push::PushSubscription; use jmap_proto::{ response::status::{EmailPushObject, PushObject}, types::state::State, }; -use reqwest::header::{CONTENT_ENCODING, CONTENT_TYPE}; -use std::time::{Duration, Instant}; +use reqwest::header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_TYPE}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use store::write::now; use tokio::sync::mpsc; use trc::PushSubscriptionEvent; use types::{id::Id, type_state::DataType}; use utils::map::vec_map::VecMap; impl PushRegistration { - pub fn send(&mut self, id: Id, push_tx: mpsc::Sender, push_timeout: Duration) { + pub fn send( + &mut self, + id: Id, + push_tx: mpsc::Sender, + push_timeout: Duration, + vapid: Option>, + ) { let server = self.server.clone(); let notifications = std::mem::take(&mut self.notifications); @@ -81,6 +91,7 @@ impl PushRegistration { &server, serde_json::to_string(&response).unwrap().into_bytes(), push_timeout, + vapid.as_deref(), ) .await { @@ -99,6 +110,7 @@ pub(crate) async fn http_request( details: &PushSubscription, mut body: Vec, push_timeout: Duration, + vapid: Option<&Vapid>, ) -> bool { let client_builder = reqwest::Client::builder().timeout(push_timeout); @@ -112,6 +124,10 @@ pub(crate) async fn http_request( .header(CONTENT_TYPE, "application/json") .header("TTL", "86400"); + if let Some(authorization) = vapid.and_then(|vapid| vapid.authorization(&details.url, now())) { + client = client.header(AUTHORIZATION, authorization); + } + if let Some(keys) = &details.keys { match ece_encrypt(&keys.p256dh, &keys.auth, &body) { Ok(body_) => { diff --git a/crates/services/src/state_manager/push.rs b/crates/services/src/state_manager/push.rs index 9cdbf87d..3733652a 100644 --- a/crates/services/src/state_manager/push.rs +++ b/crates/services/src/state_manager/push.rs @@ -220,6 +220,7 @@ pub fn spawn_push_manager(inner: Arc) -> mpsc::Sender { }) .unwrap_or(true) { + let vapid = server.core.jmap.vapid.clone(); tokio::spawn(async move { http_request( &subscription, @@ -234,6 +235,7 @@ pub fn spawn_push_manager(inner: Arc) -> mpsc::Sender { ) .into_bytes(), push_timeout, + vapid.as_deref(), ) .await; }); @@ -350,6 +352,7 @@ pub fn spawn_push_manager(inner: Arc) -> mpsc::Sender { *id, push_tx.clone(), push_timeout, + server.core.jmap.vapid.clone(), ); retry_ids.remove(id); } else { @@ -436,7 +439,12 @@ pub fn spawn_push_manager(inner: Arc) -> mpsc::Sender { && last_request >= push_attempt_interval)) { if subscription.num_attempts < push_attempts_max { - subscription.send(*retry_id, push_tx.clone(), push_timeout); + subscription.send( + *retry_id, + push_tx.clone(), + push_timeout, + server.core.jmap.vapid.clone(), + ); } else { trc::event!( PushSubscription(PushSubscriptionEvent::Error), diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs index f1468081..af8db952 100644 --- a/crates/smtp/src/outbound/delivery.rs +++ b/crates/smtp/src/outbound/delivery.rs @@ -184,10 +184,7 @@ impl QueuedMessage { // Throttle sender for throttle in &server.core.smtp.queue.outbound_limiters.sender { - if let Err(retry_at) = server - .is_allowed(throttle, &message, message.span_id) - .await - { + if let Err(retry_at) = server.is_allowed(throttle, &message, message.span_id).await { trc::event!( Delivery(DeliveryEvent::RateLimitExceeded), Id = throttle.id.to_string(), diff --git a/crates/smtp/src/queue/mod.rs b/crates/smtp/src/queue/mod.rs index ee1a52cb..563934c8 100644 --- a/crates/smtp/src/queue/mod.rs +++ b/crates/smtp/src/queue/mod.rs @@ -393,9 +393,7 @@ impl ResolveVariable for MessageWrapper { .into(), ExpressionVariable::Priority => self.message.priority.into(), ExpressionVariable::QueueName => self.queue_name.as_str().into(), - ExpressionVariable::QueueAge => { - now().saturating_sub(self.message.created).into() - } + ExpressionVariable::QueueAge => now().saturating_sub(self.message.created).into(), ExpressionVariable::Source => if (self.message.flags & FROM_AUTHENTICATED) != 0 { "authenticated" } else if (self.message.flags & FROM_UNAUTHENTICATED_DMARC) != 0 { diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index 666812b6..47f0278e 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -6,7 +6,7 @@ // This file is auto-generated. Do not edit directly. -use crate::{Level, event::enums::*}; +use crate::{event::enums::*, Level}; use std::borrow::Cow; impl EventType { @@ -3887,9 +3887,7 @@ impl EventType { EventType::Auth(AuthEvent::Success) => "Authentication error", EventType::Auth(AuthEvent::Failed) => "Authentication failed", EventType::Auth(AuthEvent::TokenExpired) => "Authentication error", - EventType::Auth(AuthEvent::MfaRequired) => { - "This account requires multi-factor authentication. Alternatively, you can use an app password if your account has one." - } + EventType::Auth(AuthEvent::MfaRequired) => "This account requires multi-factor authentication. Alternatively, you can use an app password if your account has one.", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error", @@ -3942,9 +3940,7 @@ impl EventType { EventType::Jmap(JmapEvent::InvalidResultReference) => "Invalid result reference", EventType::Jmap(JmapEvent::Forbidden) => "Forbidden", EventType::Jmap(JmapEvent::AccountNotFound) => "Account not found", - EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => { - "Account not supported by method" - } + EventType::Jmap(JmapEvent::AccountNotSupportedByMethod) => "Account not supported by method", EventType::Jmap(JmapEvent::AccountReadOnly) => "Account read-only", EventType::Jmap(JmapEvent::NotFound) => "Not found", EventType::Jmap(JmapEvent::CannotCalculateChanges) => "Cannot calculate changes", @@ -4113,9 +4109,7 @@ impl EventType { EventType::Smtp(SmtpEvent::UnsupportedParameter) => "SMTP error", EventType::Smtp(SmtpEvent::SyntaxError) => "SMTP error", EventType::Smtp(SmtpEvent::RequestTooLarge) => "SMTP error", - EventType::Store(StoreEvent::AssertValueFailed) => { - "Another process has modified the value" - } + EventType::Store(StoreEvent::AssertValueFailed) => "Another process has modified the value", EventType::Store(StoreEvent::FoundationdbError) => "FoundationDB error", EventType::Store(StoreEvent::MysqlError) => "MySQL error", EventType::Store(StoreEvent::PostgresqlError) => "PostgreSQL error", diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 079ae814..bcb80b53 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 08484c62..5099acf2 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -sEuYGmxMEGuoMkdYNGUNw9kpFzscVRfeSRk44yRmcU8 \ No newline at end of file +qQQwt8D3vhw4vce7JomKDhDe2qHJF57xVqRrzVNvfoQ \ No newline at end of file diff --git a/tests/src/jmap/core/push_subscription.rs b/tests/src/jmap/core/push_subscription.rs index cb050f49..3a4e656a 100644 --- a/tests/src/jmap/core/push_subscription.rs +++ b/tests/src/jmap/core/push_subscription.rs @@ -5,13 +5,23 @@ */ use crate::{AssertConfig, utils::server::TestServer}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use common::{config::server::Listeners, network::SessionData}; use ece::EcKeyComponents; use http_proto::{HtmlResponse, ToHttpResponse, request::fetch_body}; -use hyper::{StatusCode, body, header::CONTENT_ENCODING, server::conn::http1, service::service_fn}; +use hyper::{ + StatusCode, body, + header::{AUTHORIZATION, CONTENT_ENCODING}, + server::conn::http1, + service::service_fn, +}; use hyper_util::rt::TokioIo; use jmap_client::{mailbox::Role, push_subscription::Keys}; -use jmap_proto::{response::status::PushObject, types::state::State}; +use jmap_proto::{ + request::capability::{Capabilities, Capability}, + response::status::PushObject, + types::state::State, +}; use registry::{ schema::{ enums::NetworkListenerProtocol, @@ -55,9 +65,42 @@ pub async fn test(test: &TestServer) { let pubkey = keypair.pub_as_raw().unwrap(); let keys = Keys::new(&pubkey, &auth_secret); + // The server must expose a VAPID key and advertise it in the session capabilities + let vapid_public_key = test + .server + .core + .jmap + .vapid + .as_ref() + .expect("A VAPID key must be configured") + .public_key() + .to_string(); + let advertised_key = test + .server + .core + .jmap + .capabilities + .session + .iter() + .find_map( + |(capability, capabilities)| match (capability, capabilities) { + (Capability::WebPushVapid, Capabilities::WebPush(webpush)) => { + Some(webpush.application_server_key.as_str()) + } + _ => None, + }, + ) + .expect("The webpush-vapid capability must be advertised"); + assert_eq!( + advertised_key, vapid_public_key, + "The advertised applicationServerKey must match the signing key" + ); + let push_server = Arc::new(PushServer { keypair: keypair.raw_components().unwrap(), auth_secret: auth_secret.to_vec(), + vapid_public_key, + endpoint_origin: "https://127.0.0.1:19000".to_string(), tx: event_tx, fail_requests: false.into(), }); @@ -217,6 +260,8 @@ impl From> for SessionManager { pub struct PushServer { keypair: EcKeyComponents, auth_secret: Vec, + vapid_public_key: String, + endpoint_origin: String, tx: mpsc::Sender, fail_requests: AtomicBool, } @@ -283,6 +328,19 @@ impl common::network::SessionManager for SessionManager { .into_http_response() .build()); } + + // Every push POST must be authenticated with a VAPID token (RFC 9749) + let authorization = req + .headers() + .get(AUTHORIZATION) + .map(|value| value.to_str().unwrap().to_string()) + .expect("Push POST must carry a VAPID Authorization header"); + assert_vapid_authorization( + &authorization, + &push.vapid_public_key, + &push.endpoint_origin, + ); + let is_encrypted = req .headers() .get(CONTENT_ENCODING) @@ -317,6 +375,46 @@ impl common::network::SessionManager for SessionManager { } } +fn assert_vapid_authorization(header: &str, expected_key: &str, expected_origin: &str) { + let (token, key) = header + .strip_prefix("vapid ") + .and_then(|rest| rest.split_once(", ")) + .expect("VAPID header must be 'vapid t=, k='"); + let jwt = token.strip_prefix("t=").expect("Missing t= parameter"); + let key = key.strip_prefix("k=").expect("Missing k= parameter"); + assert_eq!( + key, expected_key, + "The k= parameter must match the advertised applicationServerKey" + ); + + let parts = jwt.split('.').collect::>(); + assert_eq!(parts.len(), 3, "A JWT must have three parts"); + let decode = |part: &str| { + URL_SAFE_NO_PAD + .decode(part) + .expect("Each JWT part must be base64url encoded") + }; + assert_eq!( + decode(parts[0]), + br#"{"typ":"JWT","alg":"ES256"}"#, + "The JWT header must declare typ JWT and alg ES256" + ); + + let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap(); + assert_eq!( + claims["aud"], expected_origin, + "The aud claim must be the push endpoint origin" + ); + let now = store::write::now(); + let exp = claims["exp"] + .as_u64() + .expect("The exp claim must be a number"); + assert!( + exp > now && exp <= now + 24 * 3600, + "The exp claim must be no more than 24 hours in the future (exp={exp}, now={now})" + ); +} + async fn expect_push(event_rx: &mut mpsc::Receiver) -> PushMessage { match tokio::time::timeout(Duration::from_millis(1500), event_rx.recv()).await { Ok(Some(push)) => { diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index b0169a6e..77ab25b9 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -8,6 +8,7 @@ use crate::utils::server::TestServerBuilder; use registry::{ schema::{ enums::{MtaProtocol, Permission}, + properties::Property, structs::{ CalendarAlarm, Expression, ExpressionMatch, Imap, Jmap, MtaExtensions, MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageAuth, Sharing, @@ -90,15 +91,25 @@ pub async fn jmap_tests() { }) .await; admin - .registry_create_object(Jmap { - set_max_objects: 100_000, - get_max_results: 100_000, - event_source_throttle: 500u64.into(), - push_throttle: 500u64.into(), - websocket_throttle: 500u64.into(), - push_attempt_wait: 500u64.into(), - ..Default::default() - }) + .registry_update_setting( + Jmap { + set_max_objects: 100_000, + get_max_results: 100_000, + event_source_throttle: 500u64.into(), + push_throttle: 500u64.into(), + websocket_throttle: 500u64.into(), + push_attempt_wait: 500u64.into(), + ..Default::default() + }, + &[ + Property::SetMaxObjects, + Property::GetMaxResults, + Property::EventSourceThrottle, + Property::PushThrottle, + Property::WebsocketThrottle, + Property::PushAttemptWait, + ], + ) .await; admin .registry_create_object(MtaStageAuth { diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index 218f3dca..cf98e515 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -23,6 +23,9 @@ pub async fn test(test: &TestServer) { // Validate session object capabilities let response = john.jmap_session_object().await.into_inner(); + let application_server_key = + response["capabilities"]["urn:ietf:params:jmap:webpush-vapid"]["applicationServerKey"] + .clone(); response.assert_is_equal(json!({ "capabilities": { "urn:ietf:params:jmap:core": { @@ -54,6 +57,9 @@ pub async fn test(test: &TestServer) { }, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:quota": {}, + "urn:ietf:params:jmap:webpush-vapid": { + "applicationServerKey": application_server_key + }, "urn:ietf:params:jmap:websocket": { "url": "wss://127.0.0.1:8899/jmap/ws", "supportsPush": true diff --git a/tests/src/smtp/outbound/throttle.rs b/tests/src/smtp/outbound/throttle.rs index 6b9e0c56..43146e1a 100644 --- a/tests/src/smtp/outbound/throttle.rs +++ b/tests/src/smtp/outbound/throttle.rs @@ -12,6 +12,7 @@ use crate::{ }, utils::{dns::DnsCache, server::TestServerBuilder}, }; +use common::config::smtp::queue::QueueName; use mail_auth::{DnssecStatus, MX}; use registry::{ schema::{ @@ -25,7 +26,6 @@ use registry::{ }, types::{list::List, map::Map}, }; -use common::config::smtp::queue::QueueName; use smtp::queue::{Message, QueueEnvelope, Recipient, throttle::IsAllowed}; use std::{ net::{IpAddr, Ipv4Addr},