Use of Voluntary Application Server Identification (VAPID) in JMAP Web Push
Some checks failed
CI / Build / x86_64-unknown-linux-gnu (push) Has been cancelled
CI / Build / x86_64-unknown-linux-musl (push) Has been cancelled
CI / Build / arm-unknown-linux-gnueabihf (push) Has been cancelled
CI / Build / arm-unknown-linux-musleabihf (push) Has been cancelled
CI / Build / armv7-unknown-linux-gnueabihf (push) Has been cancelled
CI / Build / armv7-unknown-linux-musleabihf (push) Has been cancelled
CI / Build / aarch64-unknown-linux-gnu (push) Has been cancelled
CI / Build / aarch64-unknown-linux-musl (push) Has been cancelled
CI / Build / x86_64-pc-windows-msvc (push) Has been cancelled
CI / Build / aarch64-apple-darwin (push) Has been cancelled
CI / Build / x86_64-apple-darwin (push) Has been cancelled
CI / Build / x86_64-unknown-freebsd (push) Has been cancelled
CI / Merge image / gnu (push) Has been cancelled
CI / Merge image / musl (push) Has been cancelled
CI / Release (push) Has been cancelled
CI / Publish release (push) Has been cancelled
CI / Cleanup failed release (push) Has been cancelled

This commit is contained in:
Maurus Decimus
2026-07-20 13:55:00 +02:00
parent 2553d8ee4c
commit 425d034ae7
27 changed files with 532 additions and 45 deletions

View File

@@ -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<Arc<PushServer>> for SessionManager {
pub struct PushServer {
keypair: EcKeyComponents,
auth_secret: Vec<u8>,
vapid_public_key: String,
endpoint_origin: String,
tx: mpsc::Sender<PushMessage>,
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=<jwt>, k=<key>'");
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::<Vec<_>>();
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>) -> PushMessage {
match tokio::time::timeout(Duration::from_millis(1500), event_rx.recv()).await {
Ok(Some(push)) => {

View File

@@ -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 {

View File

@@ -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

View File

@@ -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},