Commit aa3dce5114d0
tarball31 files changed+2521-23
@@ -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"@@ -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 = 320
+21
+[profile.dev.package.sha2]22
+opt-level = 323
+24
+[profile.dev.package.hmac]25
+opt-level = 326
+27
+[profile.dev.package.chacha20poly1305]28
+opt-level = 3@@ -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 }@@ -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;@@ -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 has51
+/// any rooms. The pushed room counts at least the pushed event, which neither52
+/// 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 state60
+ .rooms61
+ .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 = notification75
+ .sender76
+ .display_name77
+ .clone()78
+ .filter(|name| !name.trim().is_empty())79
+ .unwrap_or_else(|| notification.sender.user_id.localpart().to_owned());80
+ let (event, invite) = match ¬ification.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 = ¬ification.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, ¬ification.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
+}@@ -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
+ handle1521
+ .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_name1526
+ .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 = handle1550
+ .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_BYTES1573
+ || 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(""));@@ -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@@ -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.040
+ }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 is94
+/// `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_private116
+ .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_private151
+ .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 A180
+ 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
+}@@ -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
+ None16
+ }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_url28
+ .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
+}@@ -0,0 +1,19 @@
1
+//! Web Push for malice: the notification payload both servers send, and with2
+//! 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;@@ -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 name12
+ 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 a55
+/// 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 so73
+/// 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 read81
+ 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
+ out137
+}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
+ false155
+ ),156
+ Content::Encrypted157
+ );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
+ ..notice198
+ }199
+ .to_value("");200
+ assert!(unknown["notification"].get("app_badge").is_none());201
+ assert_eq!(unknown["malice"]["unread"], Value::Null);202
+ }203
+}@@ -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() == 1641
+ )42
+ }43
+}44
+45
+fn decode_key(value: &str) -> Option<Vec<u8>> {46
+ let value = value.trim().trim_end_matches('=');47
+ URL_SAFE_NO_PAD48
+ .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 at54
+/// an arbitrary URL.55
+#[derive(Debug, Clone, Default)]56
+pub struct Allowlist {57
+ /// `host:port` authorities also allowed over plain http, for local testing58
+ extra: Vec<String>,59
+}60
+61
+impl Allowlist {62
+ /// The built-in push services, plus any `host:port` entries in63
+ /// `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 == 44394
+ && url.domain().is_some_and(|host| {95
+ PUSH_HOSTS96
+ .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 queued118
+ 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 unusable127
+ 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 self189
+ .vapid190
+ .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 = self205
+ .http206
+ .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::Retryable221
+ }222
+ };223
+ if outcome != Outcome::Retryable {224
+ return outcome;225
+ }226
+ }227
+ Outcome::Retryable228
+ }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
+ topic246
+}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
+ allow257
+ .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
+ allow263
+ .check("https://wns2-par02p.notify.windows.com/w/?token=x")264
+ .is_some()265
+ );266
+ assert!(267
+ allow268
+ .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
+ allow277
+ .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
+ topic296
+ .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
+ ..good313
+ }314
+ .keys_valid()315
+ );316
+ }317
+}@@ -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) = file44
+ .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.public71
+ }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 = self88
+ .key89
+ .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.public95
+ ))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 = first123
+ .authorization("https://fcm.googleapis.com", "mailto:a@example.org")124
+ .unwrap();125
+ let (token, key) = header126
+ .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_PAD140
+ .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
+}@@ -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
+ ..ok105
+ };106
+ assert_eq!(sender.send(&bad_keys, &message).await, Outcome::Refused);107
+}@@ -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"] }@@ -1,4 +1,5 @@
11
pub mod csrf;22
pub mod error;3
+pub mod push;34
pub mod routes;45
pub mod state;@@ -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(@@ -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 session108
+ /// already had it. Returns the pushkey, and the session and pushkey that109
+ /// 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 = entries113
+ .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 _ = owner262
+ .handle263
+ .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 = user306
+ .handle307
+ .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 ¬ification.devices {339
+ if deliver(&state, push, ¬ification, 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)) = (¬ification.room_id, ¬ification.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
+ .await378
+ {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::High396
+ } else {397
+ Urgency::Normal398
+ };399
+ let outcome = push400
+ .sender401
+ .send(402
+ &entry.subscription,403
+ &Message {404
+ payload: ¬ice.to_bytes(push.public_url.as_deref().unwrap_or_default()),405
+ urgency,406
+ topic: ¬ice.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 pushkeys417
+ let _ = user418
+ .handle419
+ .remove_pusher(PusherKey {420
+ app_id: HOSTED_APP_ID.to_owned(),421
+ pushkey: device.pushkey.clone(),422
+ })423
+ .await;424
+ true425
+}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
+ .await502
+ .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
+ state545
+ .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
+ key572
+ );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_UNAVAILABLE578
+ );579
+ }580
+}@@ -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) => {@@ -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
+ ..self95
+ }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_homeserver91104
}@@ -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 = self308
+ .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 itself295
- 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::Nothing342
+ && 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@@ -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@@ -1,4 +1,5 @@
11
pub mod idle;2
+pub mod push;23
34
use std::{future::Future, net::SocketAddr, time::Duration};45
@@ -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
}@@ -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 its22
+/// browser subscription's endpoint and auth secret, and its pushkey is the23
+/// 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
+ None56
+ }57
+ }58
+ }59
+60
+ fn origin(&self, headers: &HeaderMap) -> String {61
+ if let Some(url) = &self.public_url {62
+ return url.clone();63
+ }64
+ headers65
+ .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 ¬ification.devices {153
+ if deliver(&gateway.sender, ¬ification, device, &origin)154
+ .await155
+ .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::High183
+ } else {184
+ Urgency::Normal185
+ };186
+ let outcome = sender187
+ .send(188
+ &subscription,189
+ &Message {190
+ payload: ¬ice.to_bytes(origin),191
+ urgency,192
+ topic: ¬ice.room_id,193
+ },194
+ )195
+ .await;196
+ if matches!(outcome, Outcome::Failed(_) | Outcome::Retryable) {197
+ tracing::warn!(?outcome, "web push delivery failed");198
+ }199
+ outcome200
+}201
+202
+/// Room and sender as the homeserver named them. A room without a name is203
+/// taken for a DM, so its title is the sender.204
+pub fn device_notice(notification: &GatewayNotification, device: &GatewayDevice) -> Notice {205
+ let sender = notification206
+ .sender_display_name207
+ .clone()208
+ .filter(|name| !name.trim().is_empty())209
+ .or_else(|| {210
+ notification211
+ .sender212
+ .as_ref()213
+ .map(|user| user.localpart().to_owned())214
+ })215
+ .unwrap_or_default();216
+ let content: Option<Value> = notification217
+ .content218
+ .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: notification228
+ .room_name229
+ .clone()230
+ .unwrap_or_else(|| sender.clone()),231
+ body: content.body(&sender, direct),232
+ room_id: notification233
+ .room_id234
+ .as_ref()235
+ .map(ToString::to_string)236
+ .unwrap_or_default(),237
+ event_id: notification238
+ .event_id239
+ .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
+}@@ -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
+ base29
+}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 = client42
+ .post(format!("{base}/_matrix/push/v1/notify"))43
+ .body(json!({ "notification": notification }).to_string())44
+ .send()45
+ .await46
+ .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 = client74
+ .get(format!("{base}/push/vapid"))75
+ .send()76
+ .await77
+ .unwrap()78
+ .text()79
+ .await80
+ .unwrap();81
+ let vapid: Value = serde_json::from_str(&vapid).unwrap();82
+ assert_eq!(83
+ URL_SAFE_NO_PAD84
+ .decode(vapid["public_key"].as_str().unwrap())85
+ .unwrap()86
+ .len(),87
+ 6588
+ );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(¬ification, ¬ification.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
+}@@ -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 = client179
+ .get(format!("{base}/push/vapid"))180
+ .send()181
+ .await182
+ .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") {@@ -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
]@@ -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 = 14949
panic = "abort"5050
strip = true51
+52
+[profile.release.package.pbkdf2]53
+opt-level = 354
+55
+[profile.release.package.sha2]56
+opt-level = 357
+58
+[profile.release.package.hmac]59
+opt-level = 360
+61
+[profile.release.package.chacha20poly1305]62
+opt-level = 3@@ -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}`, this148
+ /// 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 = self149160
.0150161
.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(())@@ -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
.await7373
.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
.await135135
.expect("logout should discard the store cleanly");136136
}@@ -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 {