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
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:
@@ -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)) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user