atagen

malice

public
Star0
Watch0

Commit aa3dce5114d0

tarball

atagen <boss@atagen.co>2026-10-26 06:54 UTC

31 files changed+2521-23
server: deliver Web Push from both builds through malice's own gateway

Matrix push needs a gateway and none of the public ones reach browsers, so malice-server resolves event_id_only notifies with the session's client, and malice-static relays full-format notifies statelessly with the subscription in the pusher data. Continuwuity ignores the rejected pushkeys in a notify response, so the hosted gateway removes gone pushers itself. Also moves to macltok a586532, and builds the PBKDF2 and AEAD crates optimised in dev and in the size-optimised worker because seven 200k-round derivations made a debug cold restore take 8.7s.
Modified file: Cargo.lock+45 additions−3 deletions
@@ -265,6 +265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
265265 checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
266266 dependencies = [
267267 "aws-lc-sys",
268+ "untrusted 0.7.1",
268269 "zeroize",
269270 ]
270271
@@ -2249,7 +2250,7 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
22492250 [[package]]
22502251 name = "macltok"
22512252 version = "0.1.0"
2252-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=419c65426a62191a26b47d07a0e0661e047317da#419c65426a62191a26b47d07a0e0661e047317da"
2253+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=a5865326c81b53de3d590e28dd59f07f2de00a09#a5865326c81b53de3d590e28dd59f07f2de00a09"
22532254 dependencies = [
22542255 "futures-util",
22552256 "getrandom 0.2.17",
@@ -2280,6 +2281,16 @@ dependencies = [
22802281 ]
22812282
22822283 [[package]]
2284+name = "macltok-push-gateway"
2285+version = "0.1.0"
2286+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=a5865326c81b53de3d590e28dd59f07f2de00a09#a5865326c81b53de3d590e28dd59f07f2de00a09"
2287+dependencies = [
2288+ "ruma-common",
2289+ "serde",
2290+ "serde_json",
2291+]
2292+
2293+[[package]]
22832294 name = "macroific"
22842295 version = "2.0.0"
22852296 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2384,7 +2395,9 @@ dependencies = [
23842395 "hex",
23852396 "http-body-util",
23862397 "macltok",
2398+ "macltok-push-gateway",
23872399 "malice-core",
2400+ "malice-push",
23882401 "malice-static",
23892402 "matrix-sdk",
23902403 "rand_core 0.6.4",
@@ -2409,22 +2422,45 @@ dependencies = [
24092422 "futures-util",
24102423 "image",
24112424 "macltok",
2425+ "malice-push",
2426+ "serde",
2427+ "serde_json",
2428+]
2429+
2430+[[package]]
2431+name = "malice-push"
2432+version = "0.1.0"
2433+dependencies = [
2434+ "aws-lc-rs",
2435+ "base64 0.22.1",
2436+ "reqwest",
24122437 "serde",
24132438 "serde_json",
2439+ "tempfile",
2440+ "tokio",
2441+ "tracing",
2442+ "url",
2443+ "wiremock",
24142444 ]
24152445
24162446 [[package]]
24172447 name = "malice-static"
24182448 version = "0.1.0"
24192449 dependencies = [
2450+ "aws-lc-rs",
24202451 "axum",
2452+ "base64 0.22.1",
24212453 "listenfd",
2454+ "macltok-push-gateway",
2455+ "malice-push",
24222456 "reqwest",
2457+ "serde_json",
24232458 "tempfile",
24242459 "tokio",
24252460 "tower-http",
24262461 "tracing",
24272462 "tracing-subscriber",
2463+ "wiremock",
24282464 ]
24292465
24302466 [[package]]
@@ -3680,7 +3716,7 @@ dependencies = [
36803716 "cfg-if",
36813717 "getrandom 0.2.17",
36823718 "libc",
3683- "untrusted",
3719+ "untrusted 0.9.0",
36843720 "windows-sys 0.52.0",
36853721 ]
36863722
@@ -3952,7 +3988,7 @@ dependencies = [
39523988 "aws-lc-rs",
39533989 "ring",
39543990 "rustls-pki-types",
3955- "untrusted",
3991+ "untrusted 0.9.0",
39563992 ]
39573993
39583994 [[package]]
@@ -4976,6 +5012,12 @@ dependencies = [
49765012
49775013 [[package]]
49785014 name = "untrusted"
5015+version = "0.7.1"
5016+source = "registry+https://github.com/rust-lang/crates.io-index"
5017+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
5018+
5019+[[package]]
5020+name = "untrusted"
49795021 version = "0.9.0"
49805022 source = "registry+https://github.com/rust-lang/crates.io-index"
49815023 checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
Modified file: Cargo.toml+15 additions−2 deletions
@@ -1,9 +1,10 @@
11 [workspace]
22 resolver = "3"
3-members = ["crates/malice-core", "crates/malice-server", "crates/malice-static"]
3+members = ["crates/malice-core", "crates/malice-push", "crates/malice-server", "crates/malice-static"]
44
55 [workspace.dependencies]
6-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "419c65426a62191a26b47d07a0e0661e047317da" }
6+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "a5865326c81b53de3d590e28dd59f07f2de00a09" }
7+macltok-push-gateway = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "a5865326c81b53de3d590e28dd59f07f2de00a09" }
78 ruma = { version = "0.16", default-features = false }
89 serde = { version = "1", features = ["derive"] }
910 serde_json = "1"
@@ -13,3 +14,15 @@ http = "1"
1314
1415 [workspace.lints.rust]
1516 unsafe_code = "forbid"
17+
18+[profile.dev.package.pbkdf2]
19+opt-level = 3
20+
21+[profile.dev.package.sha2]
22+opt-level = 3
23+
24+[profile.dev.package.hmac]
25+opt-level = 3
26+
27+[profile.dev.package.chacha20poly1305]
28+opt-level = 3
Modified file: crates/malice-core/Cargo.toml+1 additions
@@ -8,6 +8,7 @@ name = "malice_core"
88
99 [dependencies]
1010 macltok = { workspace = true, features = ["serde", "mirror"] }
11+malice-push = { path = "../malice-push", default-features = false }
1112 futures-util = { version = "0.3", default-features = false, features = ["std"] }
1213 serde = { workspace = true }
1314 serde_json = { workspace = true }
Modified file: crates/malice-core/src/lib.rs+1 additions
@@ -3,4 +3,5 @@ pub mod feed;
33 pub mod login;
44 pub mod media;
55 pub mod parse;
6+pub mod push;
67 pub mod request;
Added file: crates/malice-core/src/push.rs+124 additions
@@ -0,0 +1,124 @@
1+use macltok::{
2+ HttpPusher, PushFormat, PushNotification, PushNotificationEvent, PushNotificationRoom,
3+ PushNotificationStatus, RoomMembership, State,
4+};
5+use malice_push::{Content, Notice};
6+use serde::{Deserialize, Serialize};
7+use serde_json::{Value, json};
8+
9+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10+#[serde(rename_all = "snake_case")]
11+pub enum PusherFormat {
12+ #[default]
13+ #[serde(alias = "Full")]
14+ Full,
15+ #[serde(alias = "EventIdOnly")]
16+ EventIdOnly,
17+}
18+
19+impl From<PusherFormat> for PushFormat {
20+ fn from(format: PusherFormat) -> Self {
21+ match format {
22+ PusherFormat::Full => Self::Full,
23+ PusherFormat::EventIdOnly => Self::EventIdOnly,
24+ }
25+ }
26+}
27+
28+impl From<PushFormat> for PusherFormat {
29+ fn from(format: PushFormat) -> Self {
30+ match format {
31+ PushFormat::Full => Self::Full,
32+ PushFormat::EventIdOnly => Self::EventIdOnly,
33+ }
34+ }
35+}
36+
37+pub fn pusher_json(pusher: &HttpPusher) -> Value {
38+ json!({
39+ "app_id": pusher.app_id,
40+ "pushkey": pusher.pushkey,
41+ "app_display_name": pusher.app_display_name,
42+ "device_display_name": pusher.device_display_name,
43+ "lang": pusher.lang,
44+ "url": pusher.url,
45+ "format": PusherFormat::from(pusher.format),
46+ "data": pusher.data,
47+ })
48+}
49+
50+/// Unread notifications across joined rooms, or `None` before the mirror has
51+/// any rooms. The pushed room counts at least the pushed event, which neither
52+/// the mirror nor the store had synced when tried against continuwuity.
53+pub fn unread_total(state: &State, pushed: &PushNotificationRoom) -> Option<u64> {
54+ if state.rooms.is_empty() {
55+ return None;
56+ }
57+ let mut pushed_count = pushed.unread_notifications.unwrap_or(0);
58+ let mut others = 0;
59+ for room in state
60+ .rooms
61+ .iter()
62+ .filter(|room| room.membership == RoomMembership::Joined)
63+ {
64+ if room.room_id == pushed.room_id {
65+ pushed_count = pushed_count.max(room.unread_notifications);
66+ } else {
67+ others += room.unread_notifications;
68+ }
69+ }
70+ Some(others + pushed_count.max(1))
71+}
72+
73+pub fn notice(notification: &PushNotification, unread: Option<u64>) -> Notice {
74+ let sender = notification
75+ .sender
76+ .display_name
77+ .clone()
78+ .filter(|name| !name.trim().is_empty())
79+ .unwrap_or_else(|| notification.sender.user_id.localpart().to_owned());
80+ let (event, invite) = match &notification.event {
81+ PushNotificationEvent::Timeline(raw) => (serde_json::from_str(raw.json().get()), false),
82+ PushNotificationEvent::Invite(raw) => (serde_json::from_str(raw.json().get()), true),
83+ };
84+ let event: Value = event.unwrap_or_default();
85+ let content = Content::of(
86+ event.get("type").and_then(Value::as_str),
87+ event.get("content"),
88+ invite,
89+ );
90+ let room = &notification.room;
91+ Notice {
92+ title: if room.direct {
93+ sender.clone()
94+ } else {
95+ room.name.clone()
96+ },
97+ body: content.body(&sender, room.direct),
98+ room_id: room.room_id.to_string(),
99+ event_id: notification.event_id.to_string(),
100+ encrypted: content == Content::Encrypted,
101+ mention: notification.has_mention.unwrap_or(false),
102+ silent: notification.noisy == Some(false),
103+ unread,
104+ }
105+}
106+
107+/// `origin` prefixes the payload's `navigate` URL.
108+pub fn status_json(status: &PushNotificationStatus, state: &State, origin: &str) -> Value {
109+ match status {
110+ PushNotificationStatus::Event(notification) => {
111+ let notice = notice(notification, unread_total(state, &notification.room));
112+ json!({
113+ "status": "event",
114+ "title": notice.title,
115+ "body": notice.body,
116+ "payload": notice.to_value(origin),
117+ "thread_id": notification.thread_id,
118+ })
119+ }
120+ PushNotificationStatus::NotFound => json!({ "status": "not_found" }),
121+ PushNotificationStatus::FilteredOut => json!({ "status": "filtered_out" }),
122+ PushNotificationStatus::Redacted => json!({ "status": "redacted" }),
123+ }
124+}
Modified file: crates/malice-core/src/request.rs+131 additions−1 deletions
@@ -1,6 +1,7 @@
11 use macltok::ruma::{OwnedTransactionId, events::GlobalAccountDataEventType, serde::Raw};
22 use macltok::{
3- AttachmentUpload, ClientHandle, CreateRoomOptions, ImagePackSource, MediaId, MediaSize,
3+ AttachmentUpload, ClientHandle, CreateRoomOptions, HttpPusher, ImagePackSource, MediaId,
4+ MediaSize, PusherKey,
45 MemberFilter, MentionKey, MentionsBoundary, MessageContent, MessageEditContent, MirrorHandle,
56 PackImageInfo, PackUsage, Patch, RichText, RoomNotificationMode, SearchScope, TimelineId, VerificationAction,
67 VerificationTarget,
@@ -10,6 +11,7 @@ use serde_json::json;
1011
1112 use crate::{
1213 error::CoreError,
14+ push::{PusherFormat, pusher_json, status_json},
1315 media::{
1416 AVATAR_MAX_SIZE, AVATAR_MIN_SIZE, AVATAR_SIZE, AvatarSource, THUMBNAIL_SIZE,
1517 attachment_content_type, image_content_type,
@@ -487,6 +489,32 @@ pub enum Request {
487489 Unignore {
488490 user_id: String,
489491 },
492+ SetPusher {
493+ app_id: String,
494+ pushkey: String,
495+ url: String,
496+ #[serde(default)]
497+ format: PusherFormat,
498+ #[serde(default)]
499+ data: serde_json::Map<String, serde_json::Value>,
500+ #[serde(default)]
501+ app_display_name: Option<String>,
502+ #[serde(default)]
503+ device_display_name: Option<String>,
504+ #[serde(default)]
505+ lang: Option<String>,
506+ },
507+ RemovePusher {
508+ app_id: String,
509+ pushkey: String,
510+ },
511+ Pushers,
512+ ResolvePushNotification {
513+ room_id: String,
514+ event_id: String,
515+ #[serde(default)]
516+ origin: String,
517+ },
490518 }
491519
492520 #[derive(Debug, Deserialize)]
@@ -1477,7 +1505,76 @@ pub async fn dispatch(
14771505 handle.unignore_user(parse_user_id(&user_id)?).await?;
14781506 Ok(Reply::Done)
14791507 }
1508+ Request::SetPusher {
1509+ app_id,
1510+ pushkey,
1511+ url,
1512+ format,
1513+ mut data,
1514+ app_display_name,
1515+ device_display_name,
1516+ lang,
1517+ } => {
1518+ data.remove("url");
1519+ data.remove("format");
1520+ handle
1521+ .set_pusher(HttpPusher {
1522+ app_id: pusher_text(app_id, "app_id", 64)?,
1523+ pushkey: pusher_text(pushkey, "pushkey", 512)?,
1524+ app_display_name: app_display_name.unwrap_or_else(|| "malice".to_owned()),
1525+ device_display_name: device_display_name
1526+ .unwrap_or_else(|| "Browser".to_owned()),
1527+ lang: lang.unwrap_or_else(|| "en".to_owned()),
1528+ url: pusher_url(url)?,
1529+ format: format.into(),
1530+ data,
1531+ })
1532+ .await?;
1533+ Ok(Reply::Done)
1534+ }
1535+ Request::RemovePusher { app_id, pushkey } => {
1536+ handle.remove_pusher(PusherKey { app_id, pushkey }).await?;
1537+ Ok(Reply::Done)
1538+ }
1539+ Request::Pushers => {
1540+ let pushers = handle.pushers().await?;
1541+ let pushers: Vec<_> = pushers.iter().map(pusher_json).collect();
1542+ Ok(Reply::Json(json!({ "pushers": pushers })))
1543+ }
1544+ Request::ResolvePushNotification {
1545+ room_id,
1546+ event_id,
1547+ origin,
1548+ } => {
1549+ let status = handle
1550+ .resolve_push_notification(&parse_room_id(&room_id)?, &parse_event_id(&event_id)?)
1551+ .await?;
1552+ Ok(Reply::Json(status_json(
1553+ &status,
1554+ &mirror.snapshot(),
1555+ origin.trim_end_matches('/'),
1556+ )))
1557+ }
1558+ }
1559+}
1560+
1561+fn pusher_text(value: String, what: &str, max: usize) -> Result<String, CoreError> {
1562+ if value.is_empty() || value.len() > max {
1563+ return Err(CoreError::bad_request(format!(
1564+ "{what} must be 1..{max} bytes"
1565+ )));
14801566 }
1567+ Ok(value)
1568+}
1569+
1570+fn pusher_url(url: String) -> Result<String, CoreError> {
1571+ if !(url.starts_with("https://") || url.starts_with("http://"))
1572+ || url.len() > MAX_PREVIEW_URL_BYTES
1573+ || url.chars().any(|c| c.is_whitespace() || c.is_control())
1574+ {
1575+ return Err(CoreError::bad_request("url must be a short http(s) url"));
1576+ }
1577+ Ok(url)
14811578 }
14821579
14831580 fn image_upload_type(bytes: &[u8]) -> Result<&'static str, CoreError> {
@@ -1578,6 +1675,39 @@ mod tests {
15781675 }
15791676
15801677 #[test]
1678+ fn pusher_requests_parse() {
1679+ let set: Request = serde_json::from_str(
1680+ r#"{"op":"set_pusher","app_id":"dev.malice.device","pushkey":"BKey","url":"https://m.example/_matrix/push/v1/notify","data":{"endpoint":"https://fcm.googleapis.com/x","auth":"a"}}"#,
1681+ )
1682+ .unwrap();
1683+ assert!(matches!(
1684+ set,
1685+ Request::SetPusher {
1686+ format: PusherFormat::Full,
1687+ ..
1688+ }
1689+ ));
1690+ let resolve: Request = serde_json::from_str(
1691+ r#"{"op":"resolve_push_notification","room_id":"!r:x","event_id":"$e"}"#,
1692+ )
1693+ .unwrap();
1694+ assert!(matches!(resolve, Request::ResolvePushNotification { .. }));
1695+ let capitalised: Request = serde_json::from_str(
1696+ r#"{"op":"set_pusher","app_id":"a","pushkey":"k","url":"https://x/","format":"Full"}"#,
1697+ )
1698+ .unwrap();
1699+ assert!(matches!(
1700+ capitalised,
1701+ Request::SetPusher {
1702+ format: PusherFormat::Full,
1703+ ..
1704+ }
1705+ ));
1706+ assert!(pusher_url("ftp://x".to_owned()).is_err());
1707+ assert!(pusher_text(String::new(), "app_id", 64).is_err());
1708+ }
1709+
1710+ #[test]
15811711 fn reaction_keys() {
15821712 assert!(valid_reaction_key("👍"));
15831713 assert!(!valid_reaction_key(""));
Added file: crates/malice-push/Cargo.toml+29 additions
@@ -0,0 +1,29 @@
1+[package]
2+name = "malice-push"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[lib]
7+name = "malice_push"
8+
9+[features]
10+default = ["send"]
11+send = ["dep:aws-lc-rs", "dep:base64", "dep:reqwest", "dep:tokio", "dep:tracing", "dep:url"]
12+
13+[dependencies]
14+serde = { workspace = true }
15+serde_json = { workspace = true }
16+aws-lc-rs = { version = "1", optional = true }
17+base64 = { version = "0.22", optional = true }
18+reqwest = { version = "0.13", default-features = false, features = ["rustls"], optional = true }
19+tokio = { workspace = true, features = ["time"], optional = true }
20+tracing = { workspace = true, optional = true }
21+url = { version = "2", optional = true }
22+
23+[dev-dependencies]
24+tempfile = "3"
25+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
26+wiremock = "0.6"
27+
28+[lints]
29+workspace = true
Added file: crates/malice-push/src/ece.rs+225 additions
@@ -0,0 +1,225 @@
1+//! RFC 8291 message encryption with the aes128gcm content coding of RFC 8188,
2+//! as a single record.
3+
4+use aws_lc_rs::{
5+ aead::{AES_128_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
6+ agreement::{self, ECDH_P256, PrivateKey, UnparsedPublicKey},
7+ hkdf::{HKDF_SHA256, KeyType, Salt},
8+};
9+
10+const RECORD_SIZE: u32 = 4096;
11+const TAG_LEN: usize = 16;
12+const HEADER_LEN: usize = 16 + 4 + 1 + 65;
13+/// Push services cap the encrypted body at 4096 bytes.
14+pub const MAX_PLAINTEXT: usize = 4096 - HEADER_LEN - TAG_LEN - 1;
15+
16+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17+pub enum EceError {
18+ BadKey,
19+ TooLong,
20+ Crypto,
21+}
22+
23+impl std::fmt::Display for EceError {
24+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25+ f.write_str(match self {
26+ Self::BadKey => "the subscription's keys are not valid",
27+ Self::TooLong => "the push payload is too long",
28+ Self::Crypto => "push encryption failed",
29+ })
30+ }
31+}
32+
33+impl std::error::Error for EceError {}
34+
35+struct Len(usize);
36+
37+impl KeyType for Len {
38+ fn len(&self) -> usize {
39+ self.0
40+ }
41+}
42+
43+fn expand(salt: &[u8], secret: &[u8], info: &[&[u8]], out: &mut [u8]) -> Result<(), EceError> {
44+ Salt::new(HKDF_SHA256, salt)
45+ .extract(secret)
46+ .expand(info, Len(out.len()))
47+ .and_then(|okm| okm.fill(out))
48+ .map_err(|_| EceError::Crypto)
49+}
50+
51+struct Keys {
52+ cek: [u8; 16],
53+ nonce: [u8; 12],
54+}
55+
56+fn derive(
57+ ecdh_secret: &[u8],
58+ auth: &[u8],
59+ ua_public: &[u8],
60+ as_public: &[u8],
61+ salt: &[u8],
62+) -> Result<Keys, EceError> {
63+ let mut ikm = [0u8; 32];
64+ expand(
65+ auth,
66+ ecdh_secret,
67+ &[b"WebPush: info\0", ua_public, as_public],
68+ &mut ikm,
69+ )?;
70+ let mut keys = Keys {
71+ cek: [0; 16],
72+ nonce: [0; 12],
73+ };
74+ expand(
75+ salt,
76+ &ikm,
77+ &[b"Content-Encoding: aes128gcm\0"],
78+ &mut keys.cek,
79+ )?;
80+ expand(salt, &ikm, &[b"Content-Encoding: nonce\0"], &mut keys.nonce)?;
81+ Ok(keys)
82+}
83+
84+fn ecdh(private: &PrivateKey, public: &[u8]) -> Result<Vec<u8>, EceError> {
85+ agreement::agree(
86+ private,
87+ UnparsedPublicKey::new(&ECDH_P256, public),
88+ EceError::BadKey,
89+ |secret| Ok(secret.to_vec()),
90+ )
91+}
92+
93+/// Encrypts `plaintext` for the subscription whose `p256dh` key is
94+/// `ua_public` (uncompressed, 65 bytes) and whose auth secret is `auth`.
95+pub fn encrypt(ua_public: &[u8], auth: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, EceError> {
96+ let as_private = PrivateKey::generate(&ECDH_P256).map_err(|_| EceError::Crypto)?;
97+ let mut salt = [0u8; 16];
98+ aws_lc_rs::rand::fill(&mut salt).map_err(|_| EceError::Crypto)?;
99+ encrypt_with(&as_private, salt, ua_public, auth, plaintext)
100+}
101+
102+fn encrypt_with(
103+ as_private: &PrivateKey,
104+ salt: [u8; 16],
105+ ua_public: &[u8],
106+ auth: &[u8],
107+ plaintext: &[u8],
108+) -> Result<Vec<u8>, EceError> {
109+ if ua_public.len() != 65 || auth.len() != 16 {
110+ return Err(EceError::BadKey);
111+ }
112+ if plaintext.len() > MAX_PLAINTEXT {
113+ return Err(EceError::TooLong);
114+ }
115+ let as_public = as_private
116+ .compute_public_key()
117+ .map_err(|_| EceError::Crypto)?;
118+ let as_public = as_public.as_ref();
119+ let secret = ecdh(as_private, ua_public)?;
120+ let keys = derive(&secret, auth, ua_public, as_public, &salt)?;
121+
122+ let mut out = Vec::with_capacity(HEADER_LEN + plaintext.len() + 1 + TAG_LEN);
123+ out.extend_from_slice(&salt);
124+ out.extend_from_slice(&RECORD_SIZE.to_be_bytes());
125+ out.push(as_public.len() as u8);
126+ out.extend_from_slice(as_public);
127+ let mut record = Vec::with_capacity(plaintext.len() + 1 + TAG_LEN);
128+ record.extend_from_slice(plaintext);
129+ record.push(2);
130+ LessSafeKey::new(UnboundKey::new(&AES_128_GCM, &keys.cek).map_err(|_| EceError::Crypto)?)
131+ .seal_in_place_append_tag(
132+ Nonce::assume_unique_for_key(keys.nonce),
133+ Aad::empty(),
134+ &mut record,
135+ )
136+ .map_err(|_| EceError::Crypto)?;
137+ out.extend_from_slice(&record);
138+ Ok(out)
139+}
140+
141+/// The user agent's side, for tests and the mock push service.
142+pub fn decrypt(ua_private: &PrivateKey, auth: &[u8], body: &[u8]) -> Result<Vec<u8>, EceError> {
143+ if body.len() < 21 {
144+ return Err(EceError::Crypto);
145+ }
146+ let salt = &body[..16];
147+ let id_len = body[20] as usize;
148+ let as_public = body.get(21..21 + id_len).ok_or(EceError::Crypto)?;
149+ let mut record = body[21 + id_len..].to_vec();
150+ let ua_public = ua_private
151+ .compute_public_key()
152+ .map_err(|_| EceError::Crypto)?;
153+ let secret = ecdh(ua_private, as_public)?;
154+ let keys = derive(&secret, auth, ua_public.as_ref(), as_public, salt)?;
155+ let plain =
156+ LessSafeKey::new(UnboundKey::new(&AES_128_GCM, &keys.cek).map_err(|_| EceError::Crypto)?)
157+ .open_in_place(
158+ Nonce::assume_unique_for_key(keys.nonce),
159+ Aad::empty(),
160+ &mut record,
161+ )
162+ .map_err(|_| EceError::Crypto)?;
163+ match plain.iter().rposition(|&byte| byte != 0) {
164+ Some(end) if plain[end] == 2 => Ok(plain[..end].to_vec()),
165+ _ => Err(EceError::Crypto),
166+ }
167+}
168+
169+#[cfg(test)]
170+mod tests {
171+ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
172+
173+ use super::*;
174+
175+ fn b64(value: &str) -> Vec<u8> {
176+ URL_SAFE_NO_PAD.decode(value).unwrap()
177+ }
178+
179+ // RFC 8291 section 5 and appendix A
180+ const PLAINTEXT: &str = "V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24";
181+ const AS_PRIVATE: &str = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw";
182+ const UA_PUBLIC: &str =
183+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
184+ const UA_PRIVATE: &str = "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94";
185+ const SALT: &str = "DGv6ra1nlYgDCS1FRnbzlw";
186+ const AUTH: &str = "BTBZMqHH6r4Tts7J_aSIgg";
187+ const MESSAGE: &str = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
188+
189+ #[test]
190+ fn rfc8291_vector_encrypts_exactly() {
191+ let as_private = PrivateKey::from_private_key(&ECDH_P256, &b64(AS_PRIVATE)).unwrap();
192+ let salt: [u8; 16] = b64(SALT).try_into().unwrap();
193+ let body = encrypt_with(
194+ &as_private,
195+ salt,
196+ &b64(UA_PUBLIC),
197+ &b64(AUTH),
198+ &b64(PLAINTEXT),
199+ )
200+ .unwrap();
201+ assert_eq!(URL_SAFE_NO_PAD.encode(body), MESSAGE);
202+ }
203+
204+ #[test]
205+ fn rfc8291_vector_decrypts() {
206+ let ua_private = PrivateKey::from_private_key(&ECDH_P256, &b64(UA_PRIVATE)).unwrap();
207+ let plain = decrypt(&ua_private, &b64(AUTH), &b64(MESSAGE)).unwrap();
208+ assert_eq!(plain, b64(PLAINTEXT));
209+ }
210+
211+ #[test]
212+ fn random_keys_round_trip_and_bad_input_is_refused() {
213+ let ua_private = PrivateKey::generate(&ECDH_P256).unwrap();
214+ let ua_public = ua_private.compute_public_key().unwrap();
215+ let auth = [9u8; 16];
216+ let body = encrypt(ua_public.as_ref(), &auth, b"hello").unwrap();
217+ assert_eq!(decrypt(&ua_private, &auth, &body).unwrap(), b"hello");
218+ assert!(decrypt(&ua_private, &[1u8; 16], &body).is_err());
219+ assert_eq!(encrypt(&[4u8; 65], &auth, b"x"), Err(EceError::BadKey));
220+ assert_eq!(
221+ encrypt(ua_public.as_ref(), &auth, &vec![0; MAX_PLAINTEXT + 1]),
222+ Err(EceError::TooLong)
223+ );
224+ }
225+}
Added file: crates/malice-push/src/env.rs+38 additions
@@ -0,0 +1,38 @@
1+use std::path::Path;
2+
3+use crate::{Allowlist, Sender, Vapid};
4+
5+/// `MALICE_PUBLIC_URL`, the origin browsers and the homeserver reach this server at.
6+pub fn public_url() -> Option<String> {
7+ let url = std::env::var("MALICE_PUBLIC_URL").ok()?;
8+ let url = url.trim().trim_end_matches('/');
9+ match url::Url::parse(url) {
10+ Ok(parsed) if matches!(parsed.scheme(), "http" | "https") && parsed.has_host() => {
11+ Some(url.to_owned())
12+ }
13+ _ => {
14+ tracing::warn!("ignoring MALICE_PUBLIC_URL, it is not an http(s) URL");
15+ None
16+ }
17+ }
18+}
19+
20+/// The VAPID `sub`: `MALICE_PUSH_CONTACT`, else the public URL.
21+pub fn contact(public_url: Option<&str>) -> String {
22+ std::env::var("MALICE_PUSH_CONTACT")
23+ .ok()
24+ .map(|contact| contact.trim().to_owned())
25+ .filter(|contact| contact.starts_with("mailto:") || contact.starts_with("https://"))
26+ .or_else(|| {
27+ public_url
28+ .filter(|url| url.starts_with("https://"))
29+ .map(str::to_owned)
30+ })
31+ .unwrap_or_else(|| "mailto:webpush@localhost".to_owned())
32+}
33+
34+/// A sender with the VAPID key kept in `dir`, configured from the environment.
35+pub fn sender(dir: &Path, public_url: Option<&str>) -> Result<Sender, String> {
36+ let vapid = Vapid::load_or_create(&dir.join("vapid.pk8"))?;
37+ Sender::new(vapid, contact(public_url), Allowlist::from_env())
38+}
Added file: crates/malice-push/src/lib.rs+19 additions
@@ -0,0 +1,19 @@
1+//! Web Push for malice: the notification payload both servers send, and with
2+//! the `send` feature the VAPID key, RFC 8291 encryption and the HTTP sender.
3+
4+pub mod payload;
5+
6+#[cfg(feature = "send")]
7+pub mod ece;
8+#[cfg(feature = "send")]
9+pub mod env;
10+#[cfg(feature = "send")]
11+pub mod send;
12+#[cfg(feature = "send")]
13+pub mod vapid;
14+
15+pub use payload::{Content, Notice};
16+#[cfg(feature = "send")]
17+pub use send::{Allowlist, Message, Outcome, Sender, Subscription, Urgency};
18+#[cfg(feature = "send")]
19+pub use vapid::Vapid;
Added file: crates/malice-push/src/payload.rs+203 additions
@@ -0,0 +1,203 @@
1+use serde_json::{Value, json};
2+
3+const TITLE_CHARS: usize = 80;
4+const BODY_CHARS: usize = 400;
5+
6+/// What a push says about its event, before it becomes notification text.
7+#[derive(Debug, Clone, PartialEq, Eq)]
8+pub enum Content {
9+ Text(String),
10+ Emote(String),
11+ /// e.g. "sent an image", shown after the sender's name
12+ Action(&'static str),
13+ Encrypted,
14+}
15+
16+impl Content {
17+ pub fn of(event_type: Option<&str>, content: Option<&Value>, user_is_target: bool) -> Self {
18+ let field = |name: &str| content.and_then(|content| content.get(name)?.as_str());
19+ match event_type.unwrap_or_default() {
20+ "m.room.encrypted" => Self::Encrypted,
21+ "m.room.message" => match (field("msgtype"), field("body")) {
22+ (Some("m.text" | "m.notice"), Some(body)) => Self::Text(strip_reply_fallback(body)),
23+ (Some("m.emote"), Some(body)) => Self::Emote(strip_reply_fallback(body)),
24+ (Some("m.image"), _) => Self::Action("sent an image"),
25+ (Some("m.video"), _) => Self::Action("sent a video"),
26+ (Some("m.audio"), _) => Self::Action("sent an audio clip"),
27+ (Some("m.file"), _) => Self::Action("sent a file"),
28+ (Some("m.location"), _) => Self::Action("shared a location"),
29+ (_, Some(body)) => Self::Text(strip_reply_fallback(body)),
30+ (_, None) => Self::Action("sent a message"),
31+ },
32+ "m.sticker" => Self::Action("sent a sticker"),
33+ "m.room.member" if user_is_target && field("membership") == Some("invite") => {
34+ Self::Action("invited you")
35+ }
36+ "m.call.invite" => Self::Action("is calling"),
37+ "m.poll.start" | "org.matrix.msc3381.poll.start" => Self::Action("started a poll"),
38+ _ => Self::Action("sent a message"),
39+ }
40+ }
41+
42+ pub fn body(&self, sender: &str, direct: bool) -> String {
43+ match (self, direct) {
44+ (Self::Text(text), true) => text.clone(),
45+ (Self::Text(text), false) => format!("{sender}: {text}"),
46+ (Self::Emote(text), _) => format!("* {sender} {text}"),
47+ (Self::Action(action), _) => format!("{sender} {action}"),
48+ (Self::Encrypted, true) => "Encrypted message".to_owned(),
49+ (Self::Encrypted, false) => format!("{sender}: Encrypted message"),
50+ }
51+ }
52+}
53+
54+/// Older clients quote the replied-to message as leading "> " lines and a
55+/// blank line, which would crowd out the reply itself.
56+fn strip_reply_fallback(body: &str) -> String {
57+ if !body.starts_with("> ") {
58+ return body.to_owned();
59+ }
60+ let mut lines = body.lines().skip_while(|line| line.starts_with('>'));
61+ let rest: Vec<&str> = match lines.next() {
62+ Some("") | None => lines.collect(),
63+ Some(first) => std::iter::once(first).chain(lines).collect(),
64+ };
65+ if rest.is_empty() {
66+ body.to_owned()
67+ } else {
68+ rest.join("\n")
69+ }
70+}
71+
72+/// The notification one push shows, in the Declarative Web Push shape so
73+/// Safari can display it without running the service worker.
74+#[derive(Debug, Clone, Default, PartialEq, Eq)]
75+pub struct Notice {
76+ pub title: String,
77+ pub body: String,
78+ pub room_id: String,
79+ pub event_id: String,
80+ /// the body is generic because the event could not be read
81+ pub encrypted: bool,
82+ pub mention: bool,
83+ pub silent: bool,
84+ pub unread: Option<u64>,
85+}
86+
87+impl Notice {
88+ /// `origin` without a trailing slash, or empty for a relative `navigate`.
89+ pub fn to_value(&self, origin: &str) -> Value {
90+ let room = &self.room_id;
91+ let mut notification = json!({
92+ "title": truncate(&self.title, TITLE_CHARS),
93+ "body": truncate(&self.body, BODY_CHARS),
94+ "navigate": format!("{origin}/?room={}", query_escape(room)),
95+ "tag": format!("room:{room}"),
96+ "silent": self.silent,
97+ });
98+ if let Some(unread) = self.unread {
99+ notification["app_badge"] = Value::String(unread.to_string());
100+ }
101+ json!({
102+ "web_push": 8030,
103+ "notification": notification,
104+ "malice": {
105+ "room_id": room,
106+ "event_id": self.event_id,
107+ "encrypted": self.encrypted,
108+ "mention": self.mention,
109+ "unread": self.unread,
110+ },
111+ })
112+ }
113+
114+ pub fn to_bytes(&self, origin: &str) -> Vec<u8> {
115+ serde_json::to_vec(&self.to_value(origin)).expect("a json value serialises")
116+ }
117+}
118+
119+fn truncate(text: &str, max: usize) -> String {
120+ match text.char_indices().nth(max) {
121+ Some((end, _)) => format!("{}…", text[..end].trim_end()),
122+ None => text.to_owned(),
123+ }
124+}
125+
126+fn query_escape(value: &str) -> String {
127+ let mut out = String::with_capacity(value.len());
128+ for byte in value.bytes() {
129+ match byte {
130+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'!' | b':' => {
131+ out.push(byte as char);
132+ }
133+ _ => out.push_str(&format!("%{byte:02X}")),
134+ }
135+ }
136+ out
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+
143+ #[test]
144+ fn content_reads_messages_and_hides_encrypted_ones() {
145+ let text = json!({"msgtype": "m.text", "body": "> <@a:x> old\n\nnew"});
146+ assert_eq!(
147+ Content::of(Some("m.room.message"), Some(&text), false),
148+ Content::Text("new".to_owned())
149+ );
150+ assert_eq!(
151+ Content::of(
152+ Some("m.room.encrypted"),
153+ Some(&json!({"ciphertext": "x"})),
154+ false
155+ ),
156+ Content::Encrypted
157+ );
158+ let image = json!({"msgtype": "m.image", "body": "cat.png"});
159+ assert_eq!(
160+ Content::of(Some("m.room.message"), Some(&image), false).body("Ann", false),
161+ "Ann sent an image"
162+ );
163+ assert_eq!(Content::Text("hi".to_owned()).body("Ann", false), "Ann: hi");
164+ assert_eq!(Content::Text("hi".to_owned()).body("Ann", true), "hi");
165+ let invite = json!({"membership": "invite"});
166+ assert_eq!(
167+ Content::of(Some("m.room.member"), Some(&invite), true),
168+ Content::Action("invited you")
169+ );
170+ }
171+
172+ #[test]
173+ fn notice_matches_the_wire_contract_and_stays_small() {
174+ let notice = Notice {
175+ title: "Room".to_owned(),
176+ body: "x".repeat(5000),
177+ room_id: "!r:example.org".to_owned(),
178+ event_id: "$e".to_owned(),
179+ encrypted: false,
180+ mention: true,
181+ silent: false,
182+ unread: Some(3),
183+ };
184+ let value = notice.to_value("https://chat.example");
185+ assert_eq!(value["web_push"], 8030);
186+ assert_eq!(
187+ value["notification"]["navigate"],
188+ "https://chat.example/?room=!r:example.org"
189+ );
190+ assert_eq!(value["notification"]["tag"], "room:!r:example.org");
191+ assert_eq!(value["notification"]["app_badge"], "3");
192+ assert_eq!(value["malice"]["unread"], 3);
193+ assert_eq!(value["malice"]["mention"], true);
194+ assert!(notice.to_bytes("https://chat.example").len() < 3 * 1024);
195+ let unknown = Notice {
196+ unread: None,
197+ ..notice
198+ }
199+ .to_value("");
200+ assert!(unknown["notification"].get("app_badge").is_none());
201+ assert_eq!(unknown["malice"]["unread"], Value::Null);
202+ }
203+}
Added file: crates/malice-push/src/send.rs+317 additions
@@ -0,0 +1,317 @@
1+use std::{sync::Arc, time::Duration};
2+
3+use aws_lc_rs::digest::{SHA256, digest};
4+use base64::{
5+ Engine,
6+ engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD},
7+};
8+use reqwest::{StatusCode, header};
9+use serde::{Deserialize, Serialize};
10+use url::Url;
11+
12+use crate::{
13+ ece::{self, EceError},
14+ vapid::Vapid,
15+};
16+
17+const PUSH_HOSTS: &[&str] = &[
18+ "*.push.apple.com",
19+ "fcm.googleapis.com",
20+ "updates.push.services.mozilla.com",
21+ "*.notify.windows.com",
22+];
23+const ALLOW_HOSTS_ENV: &str = "MALICE_PUSH_ALLOW_HOSTS";
24+const MAX_ENDPOINT_LEN: usize = 2048;
25+const TTL_SECS: u32 = 12 * 60 * 60;
26+
27+/// A browser's `PushSubscription`, keys as the browser's `toJSON` gives them.
28+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29+pub struct Subscription {
30+ pub endpoint: String,
31+ pub p256dh: String,
32+ pub auth: String,
33+}
34+
35+impl Subscription {
36+ /// Whether the keys decode to a P-256 point and a 16-byte secret.
37+ pub fn keys_valid(&self) -> bool {
38+ matches!(
39+ (decode_key(&self.p256dh), decode_key(&self.auth)),
40+ (Some(p256dh), Some(auth)) if p256dh.len() == 65 && p256dh[0] == 4 && auth.len() == 16
41+ )
42+ }
43+}
44+
45+fn decode_key(value: &str) -> Option<Vec<u8>> {
46+ let value = value.trim().trim_end_matches('=');
47+ URL_SAFE_NO_PAD
48+ .decode(value)
49+ .or_else(|_| STANDARD_NO_PAD.decode(value))
50+ .ok()
51+}
52+
53+/// Push service hosts this server will POST to, so a pusher can't aim it at
54+/// an arbitrary URL.
55+#[derive(Debug, Clone, Default)]
56+pub struct Allowlist {
57+ /// `host:port` authorities also allowed over plain http, for local testing
58+ extra: Vec<String>,
59+}
60+
61+impl Allowlist {
62+ /// The built-in push services, plus any `host:port` entries in
63+ /// `MALICE_PUSH_ALLOW_HOSTS`.
64+ pub fn from_env() -> Self {
65+ let extra = std::env::var(ALLOW_HOSTS_ENV)
66+ .unwrap_or_default()
67+ .split(',')
68+ .map(|host| host.trim().to_ascii_lowercase())
69+ .filter(|host| !host.is_empty())
70+ .collect::<Vec<_>>();
71+ if !extra.is_empty() {
72+ tracing::warn!(hosts = ?extra, "{ALLOW_HOSTS_ENV} allows extra push hosts, for testing only");
73+ }
74+ Self { extra }
75+ }
76+
77+ pub fn with_extra(extra: impl IntoIterator<Item = String>) -> Self {
78+ Self {
79+ extra: extra.into_iter().collect(),
80+ }
81+ }
82+
83+ pub fn check(&self, endpoint: &str) -> Option<Url> {
84+ if endpoint.len() > MAX_ENDPOINT_LEN {
85+ return None;
86+ }
87+ let url = Url::parse(endpoint).ok()?;
88+ if !url.username().is_empty() || url.password().is_some() {
89+ return None;
90+ }
91+ let port = url.port_or_known_default()?;
92+ let builtin = url.scheme() == "https"
93+ && port == 443
94+ && url.domain().is_some_and(|host| {
95+ PUSH_HOSTS
96+ .iter()
97+ .any(|pattern| match pattern.strip_prefix('*') {
98+ Some(suffix) => host.len() > suffix.len() && host.ends_with(suffix),
99+ None => host == *pattern,
100+ })
101+ });
102+ let extra = matches!(url.scheme(), "http" | "https")
103+ && self.extra.contains(&format!("{}:{port}", url.host_str()?));
104+ (builtin || extra).then_some(url)
105+ }
106+}
107+
108+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109+pub enum Urgency {
110+ Normal,
111+ High,
112+}
113+
114+pub struct Message<'a> {
115+ pub payload: &'a [u8],
116+ pub urgency: Urgency,
117+ /// pushes sharing a topic replace each other while still queued
118+ pub topic: &'a str,
119+}
120+
121+#[derive(Debug, Clone, PartialEq, Eq)]
122+pub enum Outcome {
123+ Delivered,
124+ /// the push service no longer knows the subscription (404 or 410)
125+ Gone,
126+ /// the endpoint is not an allowed push service, or the keys are unusable
127+ Refused,
128+ Retryable,
129+ Failed(String),
130+}
131+
132+impl Outcome {
133+ pub fn drops_subscription(&self) -> bool {
134+ matches!(self, Self::Gone | Self::Refused)
135+ }
136+}
137+
138+/// Encrypts and delivers pushes, signed with this server's VAPID key.
139+#[derive(Clone)]
140+pub struct Sender {
141+ http: reqwest::Client,
142+ vapid: Arc<Vapid>,
143+ contact: String,
144+ allow: Allowlist,
145+}
146+
147+impl Sender {
148+ /// `contact` is the VAPID `sub`, a `mailto:` or `https:` URL.
149+ pub fn new(vapid: Vapid, contact: String, allow: Allowlist) -> Result<Self, String> {
150+ let http = reqwest::Client::builder()
151+ .redirect(reqwest::redirect::Policy::none())
152+ .connect_timeout(Duration::from_secs(5))
153+ .timeout(Duration::from_secs(10))
154+ .build()
155+ .map_err(|error| format!("building the push client: {error}"))?;
156+ Ok(Self {
157+ http,
158+ vapid: Arc::new(vapid),
159+ contact,
160+ allow,
161+ })
162+ }
163+
164+ pub fn vapid_public_key(&self) -> &str {
165+ self.vapid.public_key()
166+ }
167+
168+ pub fn allows(&self, endpoint: &str) -> bool {
169+ self.allow.check(endpoint).is_some()
170+ }
171+
172+ /// Sends once, and once more after a second if the push service asks to retry.
173+ pub async fn send(&self, subscription: &Subscription, message: &Message<'_>) -> Outcome {
174+ let Some(url) = self.allow.check(&subscription.endpoint) else {
175+ return Outcome::Refused;
176+ };
177+ let (Some(p256dh), Some(auth)) = (
178+ decode_key(&subscription.p256dh),
179+ decode_key(&subscription.auth),
180+ ) else {
181+ return Outcome::Refused;
182+ };
183+ let body = match ece::encrypt(&p256dh, &auth, message.payload) {
184+ Ok(body) => body,
185+ Err(EceError::BadKey) => return Outcome::Refused,
186+ Err(error) => return Outcome::Failed(error.to_string()),
187+ };
188+ let authorization = match self
189+ .vapid
190+ .authorization(&url.origin().ascii_serialization(), &self.contact)
191+ {
192+ Ok(authorization) => authorization,
193+ Err(error) => return Outcome::Failed(error),
194+ };
195+ let topic = topic(message.topic);
196+ let urgency = match message.urgency {
197+ Urgency::Normal => "normal",
198+ Urgency::High => "high",
199+ };
200+ for attempt in 0..2 {
201+ if attempt > 0 {
202+ tokio::time::sleep(Duration::from_secs(1)).await;
203+ }
204+ let response = self
205+ .http
206+ .post(url.clone())
207+ .header(header::AUTHORIZATION, &authorization)
208+ .header(header::CONTENT_ENCODING, "aes128gcm")
209+ .header(header::CONTENT_TYPE, "application/octet-stream")
210+ .header("TTL", TTL_SECS)
211+ .header("Urgency", urgency)
212+ .header("Topic", &topic)
213+ .body(body.clone())
214+ .send()
215+ .await;
216+ let outcome = match response {
217+ Ok(response) => classify(response.status()),
218+ Err(error) => {
219+ tracing::debug!(%error, "push service unreachable");
220+ Outcome::Retryable
221+ }
222+ };
223+ if outcome != Outcome::Retryable {
224+ return outcome;
225+ }
226+ }
227+ Outcome::Retryable
228+ }
229+}
230+
231+fn classify(status: StatusCode) -> Outcome {
232+ match status {
233+ status if status.is_success() => Outcome::Delivered,
234+ StatusCode::NOT_FOUND | StatusCode::GONE => Outcome::Gone,
235+ StatusCode::TOO_MANY_REQUESTS => Outcome::Retryable,
236+ status if status.is_server_error() => Outcome::Retryable,
237+ status => Outcome::Failed(format!("push service answered {status}")),
238+ }
239+}
240+
241+/// RFC 8030 limits a topic to 32 characters of the base64url alphabet.
242+fn topic(raw: &str) -> String {
243+ let mut topic = URL_SAFE_NO_PAD.encode(digest(&SHA256, raw.as_bytes()));
244+ topic.truncate(32);
245+ topic
246+}
247+
248+#[cfg(test)]
249+mod tests {
250+ use super::*;
251+
252+ #[test]
253+ fn allowlist_admits_push_services_only() {
254+ let allow = Allowlist::default();
255+ assert!(
256+ allow
257+ .check("https://fcm.googleapis.com/fcm/send/abc")
258+ .is_some()
259+ );
260+ assert!(allow.check("https://web.push.apple.com/QGx").is_some());
261+ assert!(
262+ allow
263+ .check("https://wns2-par02p.notify.windows.com/w/?token=x")
264+ .is_some()
265+ );
266+ assert!(
267+ allow
268+ .check("https://updates.push.services.mozilla.com/wpush/v2/x")
269+ .is_some()
270+ );
271+ assert!(allow.check("http://fcm.googleapis.com/x").is_none());
272+ assert!(allow.check("https://fcm.googleapis.com:8443/x").is_none());
273+ assert!(allow.check("https://push.apple.com/x").is_none());
274+ assert!(allow.check("https://evilpush.apple.com/x").is_none());
275+ assert!(
276+ allow
277+ .check("https://fcm.googleapis.com.evil.org/x")
278+ .is_none()
279+ );
280+ assert!(allow.check("https://user@fcm.googleapis.com/x").is_none());
281+ assert!(allow.check("https://127.0.0.1/x").is_none());
282+ assert!(allow.check("http://127.0.0.1:9000/x").is_none());
283+ let allow = Allowlist::with_extra(["localhost:9000".to_owned()]);
284+ assert!(allow.check("http://localhost:9000/x").is_some());
285+ assert!(allow.check("http://localhost:9001/x").is_none());
286+ let allow = Allowlist::with_extra(["127.0.0.1:9000".to_owned()]);
287+ assert!(allow.check("http://127.0.0.1:9000/x").is_some());
288+ }
289+
290+ #[test]
291+ fn topics_fit_rfc8030() {
292+ let topic = topic("!room:example.org");
293+ assert_eq!(topic.len(), 32);
294+ assert!(
295+ topic
296+ .chars()
297+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
298+ );
299+ }
300+
301+ #[test]
302+ fn keys_validate() {
303+ let good = Subscription {
304+ endpoint: String::new(),
305+ p256dh: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4".to_owned(),
306+ auth: "BTBZMqHH6r4Tts7J_aSIgg==".to_owned(),
307+ };
308+ assert!(good.keys_valid());
309+ assert!(
310+ !Subscription {
311+ auth: "short".to_owned(),
312+ ..good
313+ }
314+ .keys_valid()
315+ );
316+ }
317+}
Added file: crates/malice-push/src/vapid.rs+147 additions
@@ -0,0 +1,147 @@
1+use std::{
2+ io::{ErrorKind, Write},
3+ path::Path,
4+ time::{SystemTime, UNIX_EPOCH},
5+};
6+
7+use aws_lc_rs::{
8+ rand::SystemRandom,
9+ signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair},
10+};
11+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
12+
13+const TOKEN_LIFETIME_SECS: u64 = 12 * 60 * 60;
14+
15+/// The server's VAPID identity (RFC 8292), an ES256 key kept on disk.
16+pub struct Vapid {
17+ key: EcdsaKeyPair,
18+ public: String,
19+}
20+
21+impl Vapid {
22+ /// Reads the PKCS#8 key at `path`, or creates it owner-only.
23+ pub fn load_or_create(path: &Path) -> Result<Self, String> {
24+ match std::fs::read(path) {
25+ Ok(bytes) => return Self::from_pkcs8(&bytes),
26+ Err(error) if error.kind() != ErrorKind::NotFound => {
27+ return Err(format!("reading the VAPID key: {error}"));
28+ }
29+ Err(_) => {}
30+ }
31+ let document =
32+ EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &SystemRandom::new())
33+ .map_err(|_| "generating the VAPID key failed".to_owned())?;
34+ let mut options = std::fs::OpenOptions::new();
35+ options.write(true).create_new(true);
36+ #[cfg(unix)]
37+ {
38+ use std::os::unix::fs::OpenOptionsExt;
39+ options.mode(0o600);
40+ }
41+ match options.open(path) {
42+ Ok(mut file) => {
43+ if let Err(error) = file
44+ .write_all(document.as_ref())
45+ .and_then(|_| file.sync_all())
46+ {
47+ let _ = std::fs::remove_file(path);
48+ return Err(format!("writing the VAPID key: {error}"));
49+ }
50+ Self::from_pkcs8(document.as_ref())
51+ }
52+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {
53+ let bytes = std::fs::read(path)
54+ .map_err(|error| format!("reading the VAPID key: {error}"))?;
55+ Self::from_pkcs8(&bytes)
56+ }
57+ Err(error) => Err(format!("creating the VAPID key: {error}")),
58+ }
59+ }
60+
61+ fn from_pkcs8(bytes: &[u8]) -> Result<Self, String> {
62+ let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, bytes)
63+ .map_err(|_| "the VAPID key file is not a P-256 PKCS#8 key".to_owned())?;
64+ let public = URL_SAFE_NO_PAD.encode(key.public_key().as_ref());
65+ Ok(Self { key, public })
66+ }
67+
68+ /// The uncompressed public point, base64url, as `applicationServerKey` takes it.
69+ pub fn public_key(&self) -> &str {
70+ &self.public
71+ }
72+
73+ /// The `Authorization` header for a push service at `audience` (its origin).
74+ pub fn authorization(&self, audience: &str, contact: &str) -> Result<String, String> {
75+ let now = SystemTime::now()
76+ .duration_since(UNIX_EPOCH)
77+ .map_err(|_| "the clock is before 1970".to_owned())?
78+ .as_secs();
79+ let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
80+ let claims = serde_json::json!({
81+ "aud": audience,
82+ "exp": now + TOKEN_LIFETIME_SECS,
83+ "sub": contact,
84+ });
85+ let claims = URL_SAFE_NO_PAD.encode(claims.to_string());
86+ let signing_input = format!("{header}.{claims}");
87+ let signature = self
88+ .key
89+ .sign(&SystemRandom::new(), signing_input.as_bytes())
90+ .map_err(|_| "signing the VAPID token failed".to_owned())?;
91+ Ok(format!(
92+ "vapid t={signing_input}.{}, k={}",
93+ URL_SAFE_NO_PAD.encode(signature.as_ref()),
94+ self.public
95+ ))
96+ }
97+}
98+
99+#[cfg(test)]
100+mod tests {
101+ use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED, UnparsedPublicKey};
102+
103+ use super::*;
104+
105+ #[test]
106+ fn key_persists_owner_only_and_signs_verifiable_tokens() {
107+ let dir = tempfile::tempdir().unwrap();
108+ let path = dir.path().join("vapid.pk8");
109+ let first = Vapid::load_or_create(&path).unwrap();
110+ let again = Vapid::load_or_create(&path).unwrap();
111+ assert_eq!(first.public_key(), again.public_key());
112+ let public = URL_SAFE_NO_PAD.decode(first.public_key()).unwrap();
113+ assert_eq!(public.len(), 65);
114+ assert_eq!(public[0], 4);
115+ #[cfg(unix)]
116+ {
117+ use std::os::unix::fs::PermissionsExt;
118+ let mode = std::fs::metadata(&path).unwrap().permissions().mode();
119+ assert_eq!(mode & 0o777, 0o600);
120+ }
121+
122+ let header = first
123+ .authorization("https://fcm.googleapis.com", "mailto:a@example.org")
124+ .unwrap();
125+ let (token, key) = header
126+ .strip_prefix("vapid t=")
127+ .unwrap()
128+ .split_once(", k=")
129+ .unwrap();
130+ assert_eq!(key, first.public_key());
131+ let (input, signature) = token.rsplit_once('.').unwrap();
132+ UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &public)
133+ .verify(
134+ input.as_bytes(),
135+ &URL_SAFE_NO_PAD.decode(signature).unwrap(),
136+ )
137+ .unwrap();
138+ let claims: serde_json::Value = serde_json::from_slice(
139+ &URL_SAFE_NO_PAD
140+ .decode(input.split_once('.').unwrap().1)
141+ .unwrap(),
142+ )
143+ .unwrap();
144+ assert_eq!(claims["aud"], "https://fcm.googleapis.com");
145+ assert_eq!(claims["sub"], "mailto:a@example.org");
146+ }
147+}
Added file: crates/malice-push/tests/send.rs+107 additions
@@ -0,0 +1,107 @@
1+use aws_lc_rs::agreement::{ECDH_P256, PrivateKey};
2+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3+use malice_push::{Allowlist, Message, Outcome, Sender, Subscription, Urgency, Vapid, ece};
4+use wiremock::{
5+ Mock, MockServer, ResponseTemplate,
6+ matchers::{header, method, path},
7+};
8+
9+struct Browser {
10+ private: PrivateKey,
11+ auth: [u8; 16],
12+}
13+
14+impl Browser {
15+ fn new() -> Self {
16+ Self {
17+ private: PrivateKey::generate(&ECDH_P256).unwrap(),
18+ auth: [3; 16],
19+ }
20+ }
21+
22+ fn subscription(&self, endpoint: String) -> Subscription {
23+ Subscription {
24+ endpoint,
25+ p256dh: URL_SAFE_NO_PAD.encode(self.private.compute_public_key().unwrap().as_ref()),
26+ auth: URL_SAFE_NO_PAD.encode(self.auth),
27+ }
28+ }
29+}
30+
31+async fn sender(server: &MockServer, dir: &tempfile::TempDir) -> Sender {
32+ let authority = server.address().to_string();
33+ Sender::new(
34+ Vapid::load_or_create(&dir.path().join("vapid.pk8")).unwrap(),
35+ "mailto:test@example.org".to_owned(),
36+ Allowlist::with_extra([authority]),
37+ )
38+ .unwrap()
39+}
40+
41+#[tokio::test]
42+async fn delivers_encrypted_and_reports_gone_retry_and_refusal() {
43+ let server = MockServer::start().await;
44+ Mock::given(method("POST"))
45+ .and(path("/push/ok"))
46+ .and(header("content-encoding", "aes128gcm"))
47+ .and(header("urgency", "high"))
48+ .and(header("ttl", "43200"))
49+ .respond_with(ResponseTemplate::new(201))
50+ .expect(1)
51+ .mount(&server)
52+ .await;
53+ Mock::given(method("POST"))
54+ .and(path("/push/gone"))
55+ .respond_with(ResponseTemplate::new(410))
56+ .mount(&server)
57+ .await;
58+ Mock::given(method("POST"))
59+ .and(path("/push/flaky"))
60+ .respond_with(ResponseTemplate::new(503))
61+ .up_to_n_times(1)
62+ .with_priority(1)
63+ .mount(&server)
64+ .await;
65+ Mock::given(method("POST"))
66+ .and(path("/push/flaky"))
67+ .respond_with(ResponseTemplate::new(201))
68+ .with_priority(2)
69+ .mount(&server)
70+ .await;
71+
72+ let dir = tempfile::tempdir().unwrap();
73+ let sender = sender(&server, &dir).await;
74+ let browser = Browser::new();
75+ let message = Message {
76+ payload: br#"{"web_push":8030}"#,
77+ urgency: Urgency::High,
78+ topic: "!room:example.org",
79+ };
80+
81+ let ok = browser.subscription(format!("{}/push/ok", server.uri()));
82+ assert_eq!(sender.send(&ok, &message).await, Outcome::Delivered);
83+ let requests = server.received_requests().await.unwrap();
84+ let request = &requests[0];
85+ let authorization = request.headers["authorization"].to_str().unwrap();
86+ assert!(authorization.starts_with("vapid t="));
87+ assert!(authorization.ends_with(&format!("k={}", sender.vapid_public_key())));
88+ assert_eq!(request.headers["topic"].to_str().unwrap().len(), 32);
89+ let plain = ece::decrypt(&browser.private, &browser.auth, &request.body).unwrap();
90+ assert_eq!(plain, message.payload);
91+
92+ let gone = browser.subscription(format!("{}/push/gone", server.uri()));
93+ let outcome = sender.send(&gone, &message).await;
94+ assert_eq!(outcome, Outcome::Gone);
95+ assert!(outcome.drops_subscription());
96+
97+ let flaky = browser.subscription(format!("{}/push/flaky", server.uri()));
98+ assert_eq!(sender.send(&flaky, &message).await, Outcome::Delivered);
99+
100+ let elsewhere = browser.subscription("https://example.org/push".to_owned());
101+ assert_eq!(sender.send(&elsewhere, &message).await, Outcome::Refused);
102+ let bad_keys = Subscription {
103+ p256dh: "AAAA".to_owned(),
104+ ..ok
105+ };
106+ assert_eq!(sender.send(&bad_keys, &message).await, Outcome::Refused);
107+}
Modified file: crates/malice-server/Cargo.toml+2 additions
@@ -14,6 +14,8 @@ path = "src/lib.rs"
1414 [dependencies]
1515 malice-core = { path = "../malice-core" }
1616 malice-static = { path = "../malice-static" }
17+malice-push = { path = "../malice-push" }
18+macltok-push-gateway = { workspace = true }
1719 macltok = { workspace = true, features = ["sqlite", "serde", "mirror"] }
1820 axum = { version = "0.8", features = ["macros", "multipart"] }
1921 futures-util = { version = "0.3", default-features = false, features = ["std"] }
Modified file: crates/malice-server/src/lib.rs+1 additions
@@ -1,4 +1,5 @@
11 pub mod csrf;
22 pub mod error;
3+pub mod push;
34 pub mod routes;
45 pub mod state;
Modified file: crates/malice-server/src/main.rs+4 additions−1 deletions
@@ -1,6 +1,7 @@
11 use std::path::PathBuf;
22
33 use malice::{
4+ push::HostedPush,
45 routes::router,
56 state::{AppState, ensure_dirs, load_or_create_master_key},
67 };
@@ -59,7 +60,9 @@ async fn main() {
5960 load_or_create_master_key(&data_dir).expect("loading the master key must succeed");
6061 let cookie_secure = env_flag("MALICE_SESSION_COOKIE_SECURE", true);
6162 let allow_http_homeserver = env_flag("MALICE_ALLOW_HTTP_HOMESERVER", false);
62- let state = AppState::new(data_dir, master_key, cookie_secure, allow_http_homeserver);
63+ let push = HostedPush::from_env(&data_dir).expect("setting up web push must succeed");
64+ let state = AppState::new(data_dir, master_key, cookie_secure, allow_http_homeserver)
65+ .with_push(push);
6366 let app = router(state.clone(), web_dir());
6467 let listener = bind_listener().await;
6568 serve(
Added file: crates/malice-server/src/push.rs+580 additions
@@ -0,0 +1,580 @@
1+use std::{
2+ collections::HashMap,
3+ path::{Path, PathBuf},
4+ sync::{Mutex, MutexGuard, PoisonError},
5+ time::Duration,
6+};
7+
8+use axum::{
9+ Router,
10+ body::Bytes,
11+ extract::{DefaultBodyLimit, State},
12+ http::{HeaderMap, StatusCode},
13+ response::{IntoResponse, Response},
14+ routing::{get, post},
15+};
16+use macltok::{HttpPusher, PushFormat, PushNotificationStatus, PusherKey};
17+use macltok_push_gateway::{GatewayDevice, GatewayNotification, notify_response, parse_notify};
18+use malice_core::push::{notice, unread_total};
19+use malice_push::{Message, Outcome, Sender, Subscription, Urgency};
20+use malice_static::push::{NOTIFY_PATH, json_response};
21+use rand_core::{OsRng, RngCore};
22+use serde::{Deserialize, Serialize};
23+use tokio::{sync::Semaphore, time::timeout};
24+
25+use crate::{
26+ error::{ApiError, json_ok, no_content},
27+ routes::auth,
28+ state::{AppState, RestoreError},
29+};
30+
31+pub const HOSTED_APP_ID: &str = "dev.malice.hosted";
32+const CONCURRENT_NOTIFIES: usize = 8;
33+const RESOLVE_TIMEOUT: Duration = Duration::from_secs(20);
34+
35+#[derive(Debug, Clone, Serialize, Deserialize)]
36+struct Entry {
37+ sid: String,
38+ subscription: Subscription,
39+}
40+
41+/// Browser subscriptions by the random pushkey each one's pusher carries,
42+/// kept in `push.json` beside the sessions.
43+pub struct HostedPush {
44+ sender: Sender,
45+ public_url: Option<String>,
46+ path: PathBuf,
47+ entries: Mutex<HashMap<String, Entry>>,
48+ permits: Semaphore,
49+}
50+
51+impl HostedPush {
52+ pub fn new(data_dir: &Path, public_url: Option<String>, sender: Sender) -> Self {
53+ let path = data_dir.join("push.json");
54+ let entries = match std::fs::read(&path) {
55+ Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
56+ tracing::warn!(%error, "push subscriptions are unreadable, starting empty");
57+ HashMap::new()
58+ }),
59+ Err(_) => HashMap::new(),
60+ };
61+ Self {
62+ sender,
63+ public_url,
64+ path,
65+ entries: Mutex::new(entries),
66+ permits: Semaphore::new(CONCURRENT_NOTIFIES),
67+ }
68+ }
69+
70+ /// The VAPID key lives in `data_dir`. Subscribing needs `MALICE_PUBLIC_URL`,
71+ /// since the homeserver must reach the notify endpoint.
72+ pub fn from_env(data_dir: &Path) -> Result<Self, String> {
73+ let public_url = malice_push::env::public_url();
74+ if public_url.is_none() {
75+ tracing::info!("web push is off until MALICE_PUBLIC_URL is set");
76+ }
77+ let sender = malice_push::env::sender(data_dir, public_url.as_deref())?;
78+ Ok(Self::new(data_dir, public_url, sender))
79+ }
80+
81+ fn entries(&self) -> MutexGuard<'_, HashMap<String, Entry>> {
82+ self.entries.lock().unwrap_or_else(PoisonError::into_inner)
83+ }
84+
85+ fn save(&self, entries: &HashMap<String, Entry>) {
86+ let result = serde_json::to_vec(entries)
87+ .map_err(std::io::Error::other)
88+ .and_then(|bytes| {
89+ let temp = self.path.with_extension("json.tmp");
90+ let mut options = std::fs::OpenOptions::new();
91+ options.write(true).create(true).truncate(true);
92+ #[cfg(unix)]
93+ {
94+ use std::os::unix::fs::OpenOptionsExt;
95+ options.mode(0o600);
96+ }
97+ let mut file = options.open(&temp)?;
98+ std::io::Write::write_all(&mut file, &bytes)?;
99+ file.sync_all()?;
100+ std::fs::rename(&temp, &self.path)
101+ });
102+ if let Err(error) = result {
103+ tracing::warn!(%error, "saving push subscriptions failed");
104+ }
105+ }
106+
107+ /// Files `subscription` under `sid`, keeping the pushkey if this session
108+ /// already had it. Returns the pushkey, and the session and pushkey that
109+ /// held the endpoint before when another session did.
110+ fn claim(&self, sid: &str, subscription: Subscription) -> (String, Option<(String, String)>) {
111+ let mut entries = self.entries();
112+ let held = entries
113+ .iter()
114+ .find(|(_, entry)| entry.subscription.endpoint == subscription.endpoint)
115+ .map(|(pushkey, entry)| (pushkey.clone(), entry.sid.clone()));
116+ let (pushkey, displaced) = match held {
117+ Some((pushkey, owner)) if owner == sid => (pushkey, None),
118+ Some((pushkey, owner)) => {
119+ entries.remove(&pushkey);
120+ (new_pushkey(), Some((owner, pushkey)))
121+ }
122+ None => (new_pushkey(), None),
123+ };
124+ entries.insert(
125+ pushkey.clone(),
126+ Entry {
127+ sid: sid.to_owned(),
128+ subscription,
129+ },
130+ );
131+ self.save(&entries);
132+ (pushkey, displaced)
133+ }
134+
135+ fn get(&self, pushkey: &str) -> Option<Entry> {
136+ self.entries().get(pushkey).cloned()
137+ }
138+
139+ fn find(&self, sid: &str, endpoint: &str) -> Option<String> {
140+ self.entries()
141+ .iter()
142+ .find(|(_, entry)| entry.sid == sid && entry.subscription.endpoint == endpoint)
143+ .map(|(pushkey, _)| pushkey.clone())
144+ }
145+
146+ fn forget(&self, pushkey: &str) {
147+ let mut entries = self.entries();
148+ if entries.remove(pushkey).is_some() {
149+ self.save(&entries);
150+ }
151+ }
152+
153+ /// The pushers a logout of `sid` should take down with it.
154+ pub fn pusher_keys(&self, sid: &str) -> Vec<PusherKey> {
155+ self.entries()
156+ .iter()
157+ .filter(|(_, entry)| entry.sid == sid)
158+ .map(|(pushkey, _)| PusherKey {
159+ app_id: HOSTED_APP_ID.to_owned(),
160+ pushkey: pushkey.clone(),
161+ })
162+ .collect()
163+ }
164+
165+ pub fn forget_session(&self, sid: &str) {
166+ let mut entries = self.entries();
167+ let before = entries.len();
168+ entries.retain(|_, entry| entry.sid != sid);
169+ if entries.len() != before {
170+ self.save(&entries);
171+ }
172+ }
173+}
174+
175+fn new_pushkey() -> String {
176+ let mut bytes = [0u8; 16];
177+ OsRng.fill_bytes(&mut bytes);
178+ hex::encode(bytes)
179+}
180+
181+#[derive(Debug, Deserialize)]
182+struct SubscribeKeys {
183+ p256dh: String,
184+ auth: String,
185+}
186+
187+#[derive(Debug, Deserialize)]
188+struct SubscribeBody {
189+ endpoint: String,
190+ keys: SubscribeKeys,
191+}
192+
193+#[derive(Debug, Deserialize)]
194+struct UnsubscribeBody {
195+ endpoint: String,
196+}
197+
198+pub fn api_routes() -> Router<AppState> {
199+ Router::new()
200+ .route("/push/vapid", get(vapid))
201+ .route("/push/subscribe", post(subscribe))
202+ .route("/push/unsubscribe", post(unsubscribe))
203+}
204+
205+pub fn notify_route() -> Router<AppState> {
206+ Router::new()
207+ .route(NOTIFY_PATH, post(notify))
208+ .layer(DefaultBodyLimit::max(64 * 1024))
209+}
210+
211+fn push_off() -> ApiError {
212+ ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "web push is not set up")
213+}
214+
215+/// The push service, the public URL to point pushers at, or why push is off.
216+fn configured(state: &AppState) -> Result<(&HostedPush, &str), ApiError> {
217+ let push = state.push().ok_or_else(push_off)?;
218+ let url = push.public_url.as_deref().ok_or_else(push_off)?;
219+ Ok((push, url))
220+}
221+
222+async fn vapid(State(state): State<AppState>) -> Response {
223+ match configured(&state) {
224+ Ok((push, _)) => json_ok(
225+ &serde_json::json!({ "public_key": push.sender.vapid_public_key() }),
226+ None,
227+ ),
228+ Err(error) => error.into_response(),
229+ }
230+}
231+
232+async fn subscribe(
233+ State(state): State<AppState>,
234+ headers: HeaderMap,
235+ body: axum::Json<SubscribeBody>,
236+) -> Response {
237+ let (push, public_url) = match configured(&state) {
238+ Ok(configured) => configured,
239+ Err(error) => return error.into_response(),
240+ };
241+ let (user, sid) = match auth(&state, &headers).await {
242+ Ok(pair) => pair,
243+ Err(error) => return error.into_response(),
244+ };
245+ let subscription = Subscription {
246+ endpoint: body.0.endpoint,
247+ p256dh: body.0.keys.p256dh,
248+ auth: body.0.keys.auth,
249+ };
250+ if !push.sender.allows(&subscription.endpoint) {
251+ return ApiError::bad_request("endpoint is not a known push service").into_response();
252+ }
253+ if !subscription.keys_valid() {
254+ return ApiError::bad_request("subscription keys are not valid").into_response();
255+ }
256+ let (pushkey, displaced) = push.claim(&sid, subscription);
257+ if let Some((owner, pushkey)) = displaced {
258+ let state = state.clone();
259+ tokio::spawn(async move {
260+ if let Ok(owner) = state.get_or_restore(&owner).await {
261+ let _ = owner
262+ .handle
263+ .remove_pusher(PusherKey {
264+ app_id: HOSTED_APP_ID.to_owned(),
265+ pushkey,
266+ })
267+ .await;
268+ }
269+ });
270+ }
271+ let pusher = HttpPusher {
272+ app_id: HOSTED_APP_ID.to_owned(),
273+ pushkey: pushkey.clone(),
274+ app_display_name: "malice".to_owned(),
275+ device_display_name: "Browser".to_owned(),
276+ lang: "en".to_owned(),
277+ url: format!("{public_url}{NOTIFY_PATH}"),
278+ format: PushFormat::EventIdOnly,
279+ data: serde_json::Map::new(),
280+ };
281+ match user.handle.set_pusher(pusher).await {
282+ Ok(()) => no_content(None),
283+ Err(error) => {
284+ push.forget(&pushkey);
285+ ApiError::from(malice_core::error::CoreError::from(error)).into_response()
286+ }
287+ }
288+}
289+
290+async fn unsubscribe(
291+ State(state): State<AppState>,
292+ headers: HeaderMap,
293+ body: axum::Json<UnsubscribeBody>,
294+) -> Response {
295+ let Some(push) = state.push() else {
296+ return push_off().into_response();
297+ };
298+ let (user, sid) = match auth(&state, &headers).await {
299+ Ok(pair) => pair,
300+ Err(error) => return error.into_response(),
301+ };
302+ let Some(pushkey) = push.find(&sid, &body.0.endpoint) else {
303+ return no_content(None);
304+ };
305+ let removed = user
306+ .handle
307+ .remove_pusher(PusherKey {
308+ app_id: HOSTED_APP_ID.to_owned(),
309+ pushkey: pushkey.clone(),
310+ })
311+ .await;
312+ match removed {
313+ Ok(()) => {
314+ push.forget(&pushkey);
315+ no_content(None)
316+ }
317+ Err(error) => ApiError::from(malice_core::error::CoreError::from(error)).into_response(),
318+ }
319+}
320+
321+async fn notify(State(state): State<AppState>, body: Bytes) -> Response {
322+ let Some(push) = state.push() else {
323+ return StatusCode::NOT_FOUND.into_response();
324+ };
325+ let notification = match parse_notify(&body) {
326+ Ok(notification) => notification,
327+ Err(error) => {
328+ return ApiError::bad_request(error.to_string()).into_response();
329+ }
330+ };
331+ if !notification.is_event() {
332+ return json_response(StatusCode::OK, notify_response(&[]));
333+ }
334+ let Ok(_permit) = push.permits.acquire().await else {
335+ return StatusCode::SERVICE_UNAVAILABLE.into_response();
336+ };
337+ let mut rejected = Vec::new();
338+ for device in &notification.devices {
339+ if deliver(&state, push, &notification, device).await {
340+ push.forget(&device.pushkey);
341+ rejected.push(device.pushkey.clone());
342+ }
343+ }
344+ json_response(StatusCode::OK, notify_response(&rejected))
345+}
346+
347+/// Resolves and sends one device's push. True when its pushkey should be rejected.
348+async fn deliver(
349+ state: &AppState,
350+ push: &HostedPush,
351+ notification: &GatewayNotification,
352+ device: &GatewayDevice,
353+) -> bool {
354+ let (Some(room_id), Some(event_id)) = (&notification.room_id, &notification.event_id) else {
355+ return false;
356+ };
357+ if device.app_id != HOSTED_APP_ID {
358+ return true;
359+ }
360+ let Some(entry) = push.get(&device.pushkey) else {
361+ return true;
362+ };
363+ let user = match state.get_or_restore(&entry.sid).await {
364+ Ok(user) => user,
365+ Err(RestoreError::Unavailable(error)) => {
366+ tracing::warn!(%error, "push for a session that would not restore");
367+ return false;
368+ }
369+ Err(
370+ RestoreError::NotFound | RestoreError::Stale(_) | RestoreError::SoftLoggedOut { .. },
371+ ) => return true,
372+ };
373+ let status = match timeout(
374+ RESOLVE_TIMEOUT,
375+ user.handle.resolve_push_notification(room_id, event_id),
376+ )
377+ .await
378+ {
379+ Ok(Ok(status)) => status,
380+ Ok(Err(error)) => {
381+ tracing::warn!(%error, "resolving a push failed");
382+ return false;
383+ }
384+ Err(_) => {
385+ tracing::warn!("resolving a push timed out");
386+ return false;
387+ }
388+ };
389+ let PushNotificationStatus::Event(resolved) = status else {
390+ return false;
391+ };
392+ let unread = unread_total(&user.mirror.snapshot(), &resolved.room);
393+ let notice = notice(&resolved, unread);
394+ let urgency = if notice.mention || resolved.room.direct {
395+ Urgency::High
396+ } else {
397+ Urgency::Normal
398+ };
399+ let outcome = push
400+ .sender
401+ .send(
402+ &entry.subscription,
403+ &Message {
404+ payload: &notice.to_bytes(push.public_url.as_deref().unwrap_or_default()),
405+ urgency,
406+ topic: &notice.room_id,
407+ },
408+ )
409+ .await;
410+ if matches!(outcome, Outcome::Failed(_) | Outcome::Retryable) {
411+ tracing::warn!(?outcome, "web push delivery failed");
412+ }
413+ if !outcome.drops_subscription() {
414+ return false;
415+ }
416+ // continuwuity 26.9 ignores a notify response's rejected pushkeys
417+ let _ = user
418+ .handle
419+ .remove_pusher(PusherKey {
420+ app_id: HOSTED_APP_ID.to_owned(),
421+ pushkey: device.pushkey.clone(),
422+ })
423+ .await;
424+ true
425+}
426+
427+#[cfg(test)]
428+mod tests {
429+ use axum::{body::Body, http::Request};
430+ use http_body_util::BodyExt;
431+ use malice_push::{Allowlist, Vapid};
432+ use serde_json::{Value, json};
433+ use tower::ServiceExt;
434+
435+ use super::*;
436+
437+ fn hosted(dir: &tempfile::TempDir) -> HostedPush {
438+ let sender = Sender::new(
439+ Vapid::load_or_create(&dir.path().join("vapid.pk8")).unwrap(),
440+ "mailto:test@example.org".to_owned(),
441+ Allowlist::default(),
442+ )
443+ .unwrap();
444+ HostedPush::new(
445+ dir.path(),
446+ Some("https://malice.example".to_owned()),
447+ sender,
448+ )
449+ }
450+
451+ fn subscription(endpoint: &str) -> Subscription {
452+ Subscription {
453+ endpoint: endpoint.to_owned(),
454+ p256dh: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4".to_owned(),
455+ auth: "BTBZMqHH6r4Tts7J_aSIgg".to_owned(),
456+ }
457+ }
458+
459+ #[test]
460+ fn registry_persists_and_moves_endpoints_between_sessions() {
461+ let dir = tempfile::tempdir().unwrap();
462+ let push = hosted(&dir);
463+ let (first, displaced) = push.claim("alpha", subscription("https://fcm.googleapis.com/a"));
464+ assert!(displaced.is_none());
465+ let (again, _) = push.claim("alpha", subscription("https://fcm.googleapis.com/a"));
466+ assert_eq!(first, again);
467+ let (moved, displaced) = push.claim("beta", subscription("https://fcm.googleapis.com/a"));
468+ assert_ne!(moved, first);
469+ assert_eq!(displaced, Some(("alpha".to_owned(), first.clone())));
470+ push.claim("beta", subscription("https://fcm.googleapis.com/b"));
471+
472+ let reloaded = hosted(&dir);
473+ assert_eq!(reloaded.pusher_keys("beta").len(), 2);
474+ assert!(reloaded.pusher_keys("alpha").is_empty());
475+ assert_eq!(
476+ reloaded.find("beta", "https://fcm.googleapis.com/a"),
477+ Some(moved)
478+ );
479+ reloaded.forget_session("beta");
480+ assert!(hosted(&dir).pusher_keys("beta").is_empty());
481+ #[cfg(unix)]
482+ {
483+ use std::os::unix::fs::PermissionsExt;
484+ let mode = std::fs::metadata(dir.path().join("push.json"))
485+ .unwrap()
486+ .permissions()
487+ .mode();
488+ assert_eq!(mode & 0o777, 0o600);
489+ }
490+ }
491+
492+ async fn notify(state: &AppState, notification: Value) -> (StatusCode, Value) {
493+ let response = crate::routes::router(state.clone(), None)
494+ .oneshot(
495+ Request::post(NOTIFY_PATH)
496+ .body(Body::from(
497+ json!({ "notification": notification }).to_string(),
498+ ))
499+ .unwrap(),
500+ )
501+ .await
502+ .unwrap();
503+ let status = response.status();
504+ let body = response.into_body().collect().await.unwrap().to_bytes();
505+ (status, serde_json::from_slice(&body).unwrap_or(Value::Null))
506+ }
507+
508+ #[tokio::test]
509+ async fn notify_drops_count_only_pushes_and_rejects_unknown_pushers() {
510+ let dir = tempfile::tempdir().unwrap();
511+ let push = hosted(&dir);
512+ let (orphan, _) = push.claim(
513+ "nosuchsession",
514+ subscription("https://fcm.googleapis.com/o"),
515+ );
516+ let state = AppState::new(dir.path().to_owned(), [7; 32], false, false).with_push(push);
517+ let device = |app_id: &str, pushkey: &str| json!({"app_id": app_id, "pushkey": pushkey});
518+
519+ let (status, body) = notify(
520+ &state,
521+ json!({"counts": {"unread": 1}, "devices": [device(HOSTED_APP_ID, &orphan)]}),
522+ )
523+ .await;
524+ assert_eq!(status, StatusCode::OK);
525+ assert_eq!(body, json!({"rejected": []}));
526+ assert_eq!(state.push().unwrap().pusher_keys("nosuchsession").len(), 1);
527+
528+ let (status, body) = notify(
529+ &state,
530+ json!({
531+ "event_id": "$e:x",
532+ "room_id": "!r:x",
533+ "devices": [
534+ device(HOSTED_APP_ID, "unknown"),
535+ device("dev.malice.device", "foreign"),
536+ device(HOSTED_APP_ID, &orphan),
537+ ],
538+ }),
539+ )
540+ .await;
541+ assert_eq!(status, StatusCode::OK);
542+ assert_eq!(body, json!({"rejected": ["unknown", "foreign", orphan]}));
543+ assert!(
544+ state
545+ .push()
546+ .unwrap()
547+ .pusher_keys("nosuchsession")
548+ .is_empty()
549+ );
550+
551+ let (status, _) = notify(&state, json!("not a notification")).await;
552+ assert_eq!(status, StatusCode::BAD_REQUEST);
553+ }
554+
555+ #[tokio::test]
556+ async fn vapid_needs_a_public_url() {
557+ let dir = tempfile::tempdir().unwrap();
558+ let mut push = hosted(&dir);
559+ let key = push.sender.vapid_public_key().to_owned();
560+ let state =
561+ AppState::new(dir.path().to_owned(), [7; 32], false, false).with_push(hosted(&dir));
562+ let get = |state: &AppState| {
563+ crate::routes::router(state.clone(), None)
564+ .oneshot(Request::get("/api/push/vapid").body(Body::empty()).unwrap())
565+ };
566+ let response = get(&state).await.unwrap();
567+ assert_eq!(response.status(), StatusCode::OK);
568+ let body = response.into_body().collect().await.unwrap().to_bytes();
569+ assert_eq!(
570+ serde_json::from_slice::<Value>(&body).unwrap()["public_key"],
571+ key
572+ );
573+ push.public_url = None;
574+ let state = AppState::new(dir.path().to_owned(), [7; 32], false, false).with_push(push);
575+ assert_eq!(
576+ get(&state).await.unwrap().status(),
577+ StatusCode::SERVICE_UNAVAILABLE
578+ );
579+ }
580+}
Modified file: crates/malice-server/src/routes.rs+5 additions−3 deletions
@@ -183,7 +183,7 @@ struct SessionResponse {
183183 soft_logout: Option<SoftLogoutInfo>,
184184 }
185185
186-async fn auth(
186+pub(crate) async fn auth(
187187 state: &AppState,
188188 headers: &HeaderMap,
189189 ) -> Result<(Arc<UserSession>, String), ApiError> {
@@ -1157,7 +1157,8 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
11571157 .route("/encryption", get(get_encryption))
11581158 .route("/encryption/recover", post(recover_encryption))
11591159 .route("/encryption/start", post(start_verification))
1160- .route("/encryption/verify", post(verify_encryption));
1160+ .route("/encryption/verify", post(verify_encryption))
1161+ .merge(crate::push::api_routes());
11611162 let compression = CompressionLayer::new().compress_when(
11621163 DefaultPredicate::new()
11631164 .and(NotForContentType::const_new("video/"))
@@ -1166,7 +1167,8 @@ pub fn router(state: AppState, web_dir: Option<String>) -> Router {
11661167 );
11671168 let api = Router::new()
11681169 .nest("/api", api.layer(compression))
1169- .layer(middleware::from_fn(csrf_guard));
1170+ .layer(middleware::from_fn(csrf_guard))
1171+ .merge(crate::push::notify_route());
11701172 let router = api.with_state(state).layer(TraceLayer::new_for_http());
11711173 match web_dir {
11721174 Some(dir) => {
Modified file: crates/malice-server/src/state.rs+24 additions−2 deletions
@@ -24,7 +24,7 @@ use malice_core::{
2424 login::login_error,
2525 };
2626
27-use crate::csrf::valid_sid;
27+use crate::{csrf::valid_sid, push::HostedPush};
2828
2929 const AUTH_TIMEOUT: Duration = Duration::from_secs(20);
3030
@@ -53,6 +53,7 @@ struct Inner {
5353 #[derive(Clone)]
5454 pub struct AppState {
5555 inner: Arc<Inner>,
56+ push: Option<Arc<HostedPush>>,
5657 }
5758
5859 #[derive(Debug)]
@@ -83,9 +84,21 @@ impl AppState {
8384 cookie_secure,
8485 allow_http_homeserver,
8586 }),
87+ push: None,
8688 }
8789 }
8890
91+ pub fn with_push(self, push: HostedPush) -> Self {
92+ Self {
93+ push: Some(Arc::new(push)),
94+ ..self
95+ }
96+ }
97+
98+ pub fn push(&self) -> Option<&HostedPush> {
99+ self.push.as_deref()
100+ }
101+
89102 pub fn allow_http_homeserver(&self) -> bool {
90103 self.inner.allow_http_homeserver
91104 }
@@ -291,8 +304,12 @@ impl AppState {
291304 pub async fn logout(&self, sid: &str) {
292305 let user = { self.inner.sessions.read().await.get(sid).cloned() };
293306 if let Some(user) = user {
307+ let pushers = self
308+ .push()
309+ .map(|push| push.pusher_keys(sid))
310+ .unwrap_or_default();
294311 // answers once the server has let go of the device, and the engine stops itself
295- let _ = timeout(Duration::from_secs(5), user.handle.logout(Vec::new())).await;
312+ let _ = timeout(Duration::from_secs(5), user.handle.logout(pushers)).await;
296313 }
297314 self.remove_session(sid, Retain::Nothing).await;
298315 }
@@ -321,6 +338,11 @@ impl AppState {
321338 }
322339 }
323340 self.apply_retention(&sessions, retain).await;
341+ if retain == Retain::Nothing
342+ && let Some(push) = self.push()
343+ {
344+ push.forget_session(sid);
345+ }
324346 }
325347
326348 /// Graceful shutdown: ask every engine to stop, keep persisted records so
Modified file: crates/malice-static/Cargo.toml+7 additions−1 deletions
@@ -14,14 +14,20 @@ path = "src/lib.rs"
1414 [dependencies]
1515 axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "tracing"] }
1616 listenfd = "1"
17-tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
17+macltok-push-gateway = { workspace = true }
18+malice-push = { path = "../malice-push" }
19+serde_json = { workspace = true }
20+tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
1821 tower-http = { version = "0.6", features = ["fs", "trace"] }
1922 tracing = { workspace = true }
2023 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
2124
2225 [dev-dependencies]
26+aws-lc-rs = "1"
27+base64 = "0.22"
2328 reqwest = { version = "0.13", default-features = false }
2429 tempfile = "3"
30+wiremock = "0.6"
2531
2632 [lints]
2733 workspace = true
Modified file: crates/malice-static/src/lib.rs+1 additions
@@ -1,4 +1,5 @@
11 pub mod idle;
2+pub mod push;
23
34 use std::{future::Future, net::SocketAddr, time::Duration};
45
Modified file: crates/malice-static/src/main.rs+11 additions−2 deletions
@@ -1,4 +1,10 @@
1-use malice_static::{bind_listener, serve, static_router};
1+use std::sync::Arc;
2+
3+use malice_static::{
4+ bind_listener,
5+ push::{Gateway, push_off_router, push_router},
6+ serve, static_router,
7+};
28
39 fn web_dir() -> Option<String> {
410 let dir = std::env::var("MALICE_WEB_DIR").ok()?;
@@ -20,6 +26,9 @@ async fn main() {
2026 .compact()
2127 .init();
2228
23- let app = static_router(web_dir());
29+ let app = match Gateway::from_env() {
30+ Some(gateway) => push_router(Arc::new(gateway)).merge(static_router(web_dir())),
31+ None => push_off_router().merge(static_router(web_dir())),
32+ };
2433 serve(bind_listener().await, app, async {}).await;
2534 }
Added file: crates/malice-static/src/push.rs+247 additions
@@ -0,0 +1,247 @@
1+use std::{path::PathBuf, sync::Arc};
2+
3+use axum::{
4+ Router,
5+ body::Bytes,
6+ extract::{DefaultBodyLimit, State},
7+ http::{HeaderMap, HeaderValue, StatusCode, header},
8+ response::{IntoResponse, Response},
9+ routing::{get, post},
10+};
11+use macltok_push_gateway::{GatewayDevice, GatewayNotification, notify_response, parse_notify};
12+use malice_push::{Content, Message, Notice, Outcome, Sender, Subscription, Urgency};
13+use serde_json::Value;
14+use tokio::sync::Semaphore;
15+
16+pub const DEVICE_APP_ID: &str = "dev.malice.device";
17+pub const NOTIFY_PATH: &str = "/_matrix/push/v1/notify";
18+const NOTIFY_BODY_LIMIT: usize = 64 * 1024;
19+const CONCURRENT_NOTIFIES: usize = 16;
20+
21+/// A push gateway that keeps nothing per user: each pusher's data carries its
22+/// browser subscription's endpoint and auth secret, and its pushkey is the
23+/// subscription's p256dh key, as Sygnal's webpush pushkin does it.
24+pub struct Gateway {
25+ sender: Sender,
26+ public_url: Option<String>,
27+ permits: Semaphore,
28+}
29+
30+impl Gateway {
31+ pub fn new(sender: Sender, public_url: Option<String>) -> Self {
32+ Self {
33+ sender,
34+ public_url,
35+ permits: Semaphore::new(CONCURRENT_NOTIFIES),
36+ }
37+ }
38+
39+ /// Keeps the VAPID key in `MALICE_DATA_DIR`, systemd's `STATE_DIRECTORY`
40+ /// or the XDG state dir. Push stays off when none of them is usable.
41+ pub fn from_env() -> Option<Self> {
42+ let Some(dir) = state_dir() else {
43+ tracing::warn!("no state directory for the VAPID key, push is off");
44+ return None;
45+ };
46+ if let Err(error) = create_private_dir(&dir) {
47+ tracing::warn!(%error, dir = %dir.display(), "creating the push state directory failed, push is off");
48+ return None;
49+ }
50+ let public_url = malice_push::env::public_url();
51+ match malice_push::env::sender(&dir, public_url.as_deref()) {
52+ Ok(sender) => Some(Self::new(sender, public_url)),
53+ Err(error) => {
54+ tracing::warn!(%error, "push is off");
55+ None
56+ }
57+ }
58+ }
59+
60+ fn origin(&self, headers: &HeaderMap) -> String {
61+ if let Some(url) = &self.public_url {
62+ return url.clone();
63+ }
64+ headers
65+ .get(header::HOST)
66+ .and_then(|host| host.to_str().ok())
67+ .map(|host| format!("https://{host}"))
68+ .unwrap_or_default()
69+ }
70+}
71+
72+fn state_dir() -> Option<PathBuf> {
73+ let var = |name| std::env::var_os(name).filter(|value| !value.is_empty());
74+ if let Some(dir) = var("MALICE_DATA_DIR") {
75+ return Some(dir.into());
76+ }
77+ if let Some(dirs) = var("STATE_DIRECTORY") {
78+ return std::env::split_paths(&dirs).next();
79+ }
80+ var("XDG_STATE_HOME")
81+ .map(PathBuf::from)
82+ .or_else(|| var("HOME").map(|home| PathBuf::from(home).join(".local/state")))
83+ .map(|dir| dir.join("malice"))
84+}
85+
86+fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> {
87+ let mut builder = std::fs::DirBuilder::new();
88+ builder.recursive(true);
89+ #[cfg(unix)]
90+ {
91+ use std::os::unix::fs::DirBuilderExt;
92+ builder.mode(0o700);
93+ }
94+ builder.create(path)
95+}
96+
97+pub fn push_router(gateway: Arc<Gateway>) -> Router {
98+ Router::new()
99+ .route("/push/vapid", get(vapid))
100+ .route(NOTIFY_PATH, post(notify))
101+ .layer(DefaultBodyLimit::max(NOTIFY_BODY_LIMIT))
102+ .with_state(gateway)
103+}
104+
105+/// Keeps the push paths from falling through to the app shell.
106+pub fn push_off_router() -> Router {
107+ let off = || async { StatusCode::NOT_FOUND };
108+ Router::new()
109+ .route("/push/vapid", get(off))
110+ .route(NOTIFY_PATH, post(off))
111+}
112+
113+async fn vapid(State(gateway): State<Arc<Gateway>>) -> Response {
114+ json_response(
115+ StatusCode::OK,
116+ serde_json::to_vec(&serde_json::json!({ "public_key": gateway.sender.vapid_public_key() }))
117+ .unwrap_or_default(),
118+ )
119+}
120+
121+pub fn json_response(status: StatusCode, body: Vec<u8>) -> Response {
122+ (
123+ status,
124+ [(
125+ header::CONTENT_TYPE,
126+ HeaderValue::from_static("application/json"),
127+ )],
128+ body,
129+ )
130+ .into_response()
131+}
132+
133+async fn notify(State(gateway): State<Arc<Gateway>>, headers: HeaderMap, body: Bytes) -> Response {
134+ let notification = match parse_notify(&body) {
135+ Ok(notification) => notification,
136+ Err(error) => {
137+ return json_response(
138+ StatusCode::BAD_REQUEST,
139+ serde_json::to_vec(&serde_json::json!({ "error": error.to_string() }))
140+ .unwrap_or_default(),
141+ );
142+ }
143+ };
144+ if !notification.is_event() {
145+ return json_response(StatusCode::OK, notify_response(&[]));
146+ }
147+ let Ok(_permit) = gateway.permits.acquire().await else {
148+ return StatusCode::SERVICE_UNAVAILABLE.into_response();
149+ };
150+ let origin = gateway.origin(&headers);
151+ let mut rejected = Vec::new();
152+ for device in &notification.devices {
153+ if deliver(&gateway.sender, &notification, device, &origin)
154+ .await
155+ .drops_subscription()
156+ {
157+ rejected.push(device.pushkey.clone());
158+ }
159+ }
160+ json_response(StatusCode::OK, notify_response(&rejected))
161+}
162+
163+async fn deliver(
164+ sender: &Sender,
165+ notification: &GatewayNotification,
166+ device: &GatewayDevice,
167+ origin: &str,
168+) -> Outcome {
169+ let data = |key: &str| device.data.get(key).and_then(Value::as_str);
170+ let (DEVICE_APP_ID, Some(endpoint), Some(auth)) =
171+ (device.app_id.as_str(), data("endpoint"), data("auth"))
172+ else {
173+ return Outcome::Refused;
174+ };
175+ let subscription = Subscription {
176+ endpoint: endpoint.to_owned(),
177+ p256dh: device.pushkey.clone(),
178+ auth: auth.to_owned(),
179+ };
180+ let notice = device_notice(notification, device);
181+ let urgency = if notice.mention || notification.room_name.is_none() {
182+ Urgency::High
183+ } else {
184+ Urgency::Normal
185+ };
186+ let outcome = sender
187+ .send(
188+ &subscription,
189+ &Message {
190+ payload: &notice.to_bytes(origin),
191+ urgency,
192+ topic: &notice.room_id,
193+ },
194+ )
195+ .await;
196+ if matches!(outcome, Outcome::Failed(_) | Outcome::Retryable) {
197+ tracing::warn!(?outcome, "web push delivery failed");
198+ }
199+ outcome
200+}
201+
202+/// Room and sender as the homeserver named them. A room without a name is
203+/// taken for a DM, so its title is the sender.
204+pub fn device_notice(notification: &GatewayNotification, device: &GatewayDevice) -> Notice {
205+ let sender = notification
206+ .sender_display_name
207+ .clone()
208+ .filter(|name| !name.trim().is_empty())
209+ .or_else(|| {
210+ notification
211+ .sender
212+ .as_ref()
213+ .map(|user| user.localpart().to_owned())
214+ })
215+ .unwrap_or_default();
216+ let content: Option<Value> = notification
217+ .content
218+ .as_ref()
219+ .and_then(|raw| serde_json::from_str(raw.get()).ok());
220+ let content = Content::of(
221+ notification.event_type.as_deref(),
222+ content.as_ref(),
223+ notification.user_is_target,
224+ );
225+ let direct = notification.room_name.is_none();
226+ Notice {
227+ title: notification
228+ .room_name
229+ .clone()
230+ .unwrap_or_else(|| sender.clone()),
231+ body: content.body(&sender, direct),
232+ room_id: notification
233+ .room_id
234+ .as_ref()
235+ .map(ToString::to_string)
236+ .unwrap_or_default(),
237+ event_id: notification
238+ .event_id
239+ .as_ref()
240+ .map(ToString::to_string)
241+ .unwrap_or_default(),
242+ encrypted: content == Content::Encrypted,
243+ mention: device.tweaks.get("highlight").and_then(Value::as_bool) == Some(true),
244+ silent: !device.tweaks.contains_key("sound"),
245+ unread: Some(notification.counts.unread),
246+ }
247+}
Added file: crates/malice-static/tests/push_gateway.rs+187 additions
@@ -0,0 +1,187 @@
1+use std::sync::Arc;
2+
3+use aws_lc_rs::agreement::{ECDH_P256, PrivateKey};
4+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
5+use malice_push::{Allowlist, Sender, Vapid, ece};
6+use malice_static::push::{Gateway, push_router};
7+use reqwest::{Client, StatusCode};
8+use serde_json::{Value, json};
9+use wiremock::{
10+ Mock, MockServer, ResponseTemplate,
11+ matchers::{method, path},
12+};
13+
14+async fn gateway(push_service: &MockServer, dir: &tempfile::TempDir) -> String {
15+ let sender = Sender::new(
16+ Vapid::load_or_create(&dir.path().join("vapid.pk8")).unwrap(),
17+ "mailto:test@example.org".to_owned(),
18+ Allowlist::with_extra([push_service.address().to_string()]),
19+ )
20+ .unwrap();
21+ let app = push_router(Arc::new(Gateway::new(
22+ sender,
23+ Some("https://malice.example".to_owned()),
24+ )));
25+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
26+ let base = format!("http://{}", listener.local_addr().unwrap());
27+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
28+ base
29+}
30+
31+fn device(browser: &PrivateKey, auth: &[u8], endpoint: String) -> Value {
32+ json!({
33+ "app_id": "dev.malice.device",
34+ "pushkey": URL_SAFE_NO_PAD.encode(browser.compute_public_key().unwrap().as_ref()),
35+ "data": {"format": "full", "endpoint": endpoint, "auth": URL_SAFE_NO_PAD.encode(auth)},
36+ "tweaks": {"sound": "default", "highlight": true},
37+ })
38+}
39+
40+async fn notify(client: &Client, base: &str, notification: Value) -> (StatusCode, Value) {
41+ let response = client
42+ .post(format!("{base}/_matrix/push/v1/notify"))
43+ .body(json!({ "notification": notification }).to_string())
44+ .send()
45+ .await
46+ .unwrap();
47+ let status = response.status();
48+ (
49+ status,
50+ serde_json::from_slice(&response.bytes().await.unwrap()).unwrap(),
51+ )
52+}
53+
54+#[tokio::test]
55+async fn gateway_encrypts_for_the_subscription_and_rejects_gone_pushkeys() {
56+ let push_service = MockServer::start().await;
57+ Mock::given(method("POST"))
58+ .and(path("/live"))
59+ .respond_with(ResponseTemplate::new(201))
60+ .expect(2)
61+ .mount(&push_service)
62+ .await;
63+ Mock::given(method("POST"))
64+ .and(path("/gone"))
65+ .respond_with(ResponseTemplate::new(410))
66+ .expect(1)
67+ .mount(&push_service)
68+ .await;
69+ let dir = tempfile::tempdir().unwrap();
70+ let base = gateway(&push_service, &dir).await;
71+ let client = Client::new();
72+
73+ let vapid = client
74+ .get(format!("{base}/push/vapid"))
75+ .send()
76+ .await
77+ .unwrap()
78+ .text()
79+ .await
80+ .unwrap();
81+ let vapid: Value = serde_json::from_str(&vapid).unwrap();
82+ assert_eq!(
83+ URL_SAFE_NO_PAD
84+ .decode(vapid["public_key"].as_str().unwrap())
85+ .unwrap()
86+ .len(),
87+ 65
88+ );
89+
90+ let browser = PrivateKey::generate(&ECDH_P256).unwrap();
91+ let auth = [5u8; 16];
92+ let live = device(&browser, &auth, format!("{}/live", push_service.uri()));
93+ let gone = device(&browser, &auth, format!("{}/gone", push_service.uri()));
94+ let event = |devices: Vec<Value>| {
95+ json!({
96+ "event_id": "$event:example.org",
97+ "room_id": "!room:example.org",
98+ "type": "m.room.message",
99+ "sender": "@ann:example.org",
100+ "sender_display_name": "Ann",
101+ "room_name": "Garden",
102+ "prio": "high",
103+ "content": {"msgtype": "m.text", "body": "the tomatoes are ripe"},
104+ "counts": {"unread": 4},
105+ "devices": devices,
106+ })
107+ };
108+
109+ let (status, body) = notify(&client, &base, event(vec![live.clone()])).await;
110+ assert_eq!(status, StatusCode::OK);
111+ assert_eq!(body, json!({"rejected": []}));
112+ let requests = push_service.received_requests().await.unwrap();
113+ let payload: Value =
114+ serde_json::from_slice(&ece::decrypt(&browser, &auth, &requests[0].body).unwrap()).unwrap();
115+ assert_eq!(
116+ payload,
117+ json!({
118+ "web_push": 8030,
119+ "notification": {
120+ "title": "Garden",
121+ "body": "Ann: the tomatoes are ripe",
122+ "navigate": "https://malice.example/?room=!room:example.org",
123+ "tag": "room:!room:example.org",
124+ "silent": false,
125+ "app_badge": "4",
126+ },
127+ "malice": {
128+ "room_id": "!room:example.org",
129+ "event_id": "$event:example.org",
130+ "encrypted": false,
131+ "mention": true,
132+ "unread": 4,
133+ },
134+ })
135+ );
136+ assert_eq!(requests[0].headers["urgency"], "high");
137+
138+ let gone_key = gone["pushkey"].clone();
139+ let (status, body) = notify(&client, &base, event(vec![gone, live])).await;
140+ assert_eq!(status, StatusCode::OK);
141+ assert_eq!(body, json!({ "rejected": [gone_key] }));
142+
143+ let mut encrypted = event(vec![]);
144+ encrypted["type"] = json!("m.room.encrypted");
145+ encrypted["content"] = json!({"algorithm": "m.megolm.v1.aes-sha2", "ciphertext": "x"});
146+ let foreign = json!({
147+ "app_id": "org.example.other",
148+ "pushkey": "elsewhere",
149+ "data": {"endpoint": format!("{}/live", push_service.uri()), "auth": "x"},
150+ });
151+ let unlisted = device(&browser, &auth, "https://example.org/push".to_owned());
152+ let unlisted_key = unlisted["pushkey"].clone();
153+ encrypted["devices"] = json!([foreign, unlisted]);
154+ let (_, body) = notify(&client, &base, encrypted).await;
155+ assert_eq!(body, json!({ "rejected": ["elsewhere", unlisted_key] }));
156+
157+ let count_only = json!({
158+ "counts": {"unread": 1},
159+ "devices": [device(&browser, &auth, format!("{}/gone", push_service.uri()))],
160+ });
161+ let (status, body) = notify(&client, &base, count_only).await;
162+ assert_eq!(status, StatusCode::OK);
163+ assert_eq!(body, json!({"rejected": []}));
164+}
165+
166+#[test]
167+fn device_notice_hides_encrypted_content_and_titles_dms_by_sender() {
168+ let notification = macltok_push_gateway::parse_notify(
169+ json!({"notification": {
170+ "event_id": "$e:x",
171+ "room_id": "!dm:x",
172+ "type": "m.room.encrypted",
173+ "sender": "@bob:x",
174+ "content": {"ciphertext": "x"},
175+ "devices": [{"app_id": "dev.malice.device", "pushkey": "k"}],
176+ }})
177+ .to_string()
178+ .as_bytes(),
179+ )
180+ .unwrap();
181+ let notice = malice_static::push::device_notice(&notification, &notification.devices[0]);
182+ assert_eq!(notice.title, "bob");
183+ assert_eq!(notice.body, "Encrypted message");
184+ assert!(notice.encrypted);
185+ assert!(notice.silent);
186+ assert!(!notice.mention);
187+}
Modified file: crates/malice-static/tests/static_only.rs+9 additions
@@ -43,6 +43,7 @@ async fn start(port: u16, web_dir: &TempDir, idle_secs: u64, client: &Client) ->
4343 let child = Command::new(server_binary())
4444 .env("MALICE_BIND", format!("127.0.0.1:{port}"))
4545 .env("MALICE_WEB_DIR", web_dir.path())
46+ .env("MALICE_DATA_DIR", web_dir.path().join("state"))
4647 .env("MALICE_IDLE_EXIT_SECS", idle_secs.to_string())
4748 .stdout(Stdio::null())
4849 .stderr(Stdio::inherit())
@@ -174,6 +175,14 @@ async fn static_only_serves_the_bundle_and_exits_when_idle() {
174175 StatusCode::NOT_FOUND,
175176 );
176177
178+ let vapid = client
179+ .get(format!("{base}/push/vapid"))
180+ .send()
181+ .await
182+ .unwrap();
183+ assert_eq!(vapid.status(), StatusCode::OK);
184+ assert!(vapid.text().await.unwrap().contains("\"public_key\""));
185+
177186 let status = tokio::time::timeout(Duration::from_secs(10), async {
178187 loop {
179188 if let Some(status) = server.0.try_wait().expect("poll backend") {
Modified file: crates/malice-worker/Cargo.lock+10 additions−1 deletions
@@ -1999,7 +1999,7 @@ checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
19991999 [[package]]
20002000 name = "macltok"
20012001 version = "0.1.0"
2002-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=419c65426a62191a26b47d07a0e0661e047317da#419c65426a62191a26b47d07a0e0661e047317da"
2002+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/macltok?rev=a5865326c81b53de3d590e28dd59f07f2de00a09#a5865326c81b53de3d590e28dd59f07f2de00a09"
20032003 dependencies = [
20042004 "futures-util",
20052005 "getrandom 0.2.17",
@@ -2136,6 +2136,15 @@ dependencies = [
21362136 "futures-util",
21372137 "image",
21382138 "macltok",
2139+ "malice-push",
2140+ "serde",
2141+ "serde_json",
2142+]
2143+
2144+[[package]]
2145+name = "malice-push"
2146+version = "0.1.0"
2147+dependencies = [
21392148 "serde",
21402149 "serde_json",
21412150 ]
Modified file: crates/malice-worker/Cargo.toml+13 additions−1 deletions
@@ -14,7 +14,7 @@ publish = false
1414 crate-type = ["cdylib", "rlib"]
1515
1616 [target.'cfg(target_family = "wasm")'.dependencies]
17-macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "419c65426a62191a26b47d07a0e0661e047317da", features = ["indexeddb", "serde", "mirror"] }
17+macltok = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/macltok", rev = "a5865326c81b53de3d590e28dd59f07f2de00a09", features = ["indexeddb", "serde", "mirror"] }
1818 malice-core = { path = "../malice-core" }
1919 ruma = { version = "0.16", default-features = false }
2020 futures-util = { version = "0.3", default-features = false, features = ["std"] }
@@ -48,3 +48,15 @@ lto = true
4848 codegen-units = 1
4949 panic = "abort"
5050 strip = true
51+
52+[profile.release.package.pbkdf2]
53+opt-level = 3
54+
55+[profile.release.package.sha2]
56+opt-level = 3
57+
58+[profile.release.package.hmac]
59+opt-level = 3
60+
61+[profile.release.package.chacha20poly1305]
62+opt-level = 3
Modified file: crates/malice-worker/src/lib.rs+15 additions−4 deletions
@@ -10,8 +10,8 @@ use std::rc::Rc;
1010 use std::sync::Arc;
1111
1212 use macltok::{
13- EncryptedSessionStore, MediaId, MediaLocation, MediaSize, Restore, Retain, Session,
14- SessionStore, StoreConfig,
13+ EncryptedSessionStore, MediaId, MediaLocation, MediaSize, PusherKey, Restore, Retain,
14+ Session, SessionStore, StoreConfig,
1515 };
1616 use malice_core::error::CoreError;
1717 use malice_core::feed::WireEvent;
@@ -144,7 +144,18 @@ impl Host {
144144 }
145145 }
146146
147- pub async fn logout(&self, account: String) -> Result<(), JsValue> {
147+ /// `remove_pushers` is a JSON array of `{app_id, pushkey}`, this
148+ /// browser's own pushers, since pushers belong to the account.
149+ pub async fn logout(
150+ &self,
151+ account: String,
152+ remove_pushers: Option<String>,
153+ ) -> Result<(), JsValue> {
154+ let remove_pushers: Vec<PusherKey> = match remove_pushers {
155+ Some(json) => serde_json::from_str(&json)
156+ .map_err(|error| error_value("bad_request", error.to_string()))?,
157+ None => Vec::new(),
158+ };
148159 let handle = self
149160 .0
150161 .accounts
@@ -152,7 +163,7 @@ impl Host {
152163 .get(&account)
153164 .map(|entry| entry.handle.clone());
154165 if let Some(handle) = handle {
155- let _ = handle.logout(Vec::new()).await;
166+ let _ = handle.logout(remove_pushers).await;
156167 }
157168 self.0.remove_account(&account, Retain::Nothing).await;
158169 Ok(())
Modified file: crates/malice-worker/tests/host.rs+2 additions−2 deletions
@@ -68,7 +68,7 @@ async fn logs_in_syncs_and_logs_out() {
6868 Some("bad_request")
6969 );
7070
71- host.logout(account.clone())
71+ host.logout(account.clone(), None)
7272 .await
7373 .expect("logout should discard the store cleanly");
7474 let restored = host
@@ -130,7 +130,7 @@ async fn restore_after_shutdown_frees_the_store() {
130130 Some("resumed")
131131 );
132132
133- host.logout(account)
133+ host.logout(account, None)
134134 .await
135135 .expect("logout should discard the store cleanly");
136136 }
Modified file: flake.nix+1 additions
@@ -166,6 +166,7 @@
166166 "-p"
167167 "malice-static"
168168 ];
169+ SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
169170 meta.mainProgram = "malice-static";
170171 };
171172 server = pkgs.symlinkJoin {