diff --git a/echoircd.conf.example b/echoircd.conf.example index 10278fe..c2ee543 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -183,6 +183,15 @@ limits { # chathistory_maxage 604800; # max age (secs) a client may request (7 days) # event_playback yes; # record JOIN/PART/QUIT/NICK/MODE/TOPIC/KICK into # # history for draft/event-playback clients (no = off) + # --- draft/webpush: encrypted Web Push (RFC 8291/8292) for a PM/highlight while a + # subscriber's tab is away. VAPID keypair auto-generated + persisted on first + # start; advertised via ISUPPORT VAPID= and the draft/webpush cap. --- + # webpush yes; # master switch (no = off) + # webpush_away_only yes; # only push to users marked away (no = always) + # webpush_ttl 259200; # push TTL in seconds (3 days) + # webpush_sub "mailto:admin@example.net"; # VAPID contact (default mailto:webpush@) + # webpush_vapid_file ""; # VAPID private key path (default .webpush-vapid) + # webpush_database ""; # subscription store path (default .webpush) # dccallow_maxentries 20; # /DCCALLOW list entries per user # http_max_concurrent 32; # in-flight outbound HTTP requests (API modules) } diff --git a/src/http.rs b/src/http.rs index e8f6b4d..14cfcff 100644 --- a/src/http.rs +++ b/src/http.rs @@ -94,6 +94,71 @@ pub fn post( Ok((status, out)) } +/// Like [`post`] but with a **binary** body (e.g. a Web Push `aes128gcm` payload), +/// so the request bytes are never forced through UTF-8. Returns `(status, "")` — the +/// response body is discarded (push endpoints just need the status). +pub fn post_bytes( + url: &str, + content_type: &str, + body: &[u8], + headers: &[(String, String)], + timeout: Duration, + verify: bool, +) -> Result<(u16, String), String> { + let (scheme, rest) = url.split_once("://").ok_or("bad url (no scheme)")?; + let (hostport, path) = match rest.split_once('/') { + Some((hp, p)) => (hp, format!("/{p}")), + None => (rest, "/".to_string()), + }; + let https = scheme.eq_ignore_ascii_case("https"); + let (host, port): (&str, u16) = match hostport.rsplit_once(':') { + Some((h, p)) => (h, p.parse().unwrap_or(if https { 443 } else { 80 })), + None => (hostport, if https { 443 } else { 80 }), + }; + let mut head = format!( + "POST {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: echoIRCd\r\nAccept: */*\r\n\ + Content-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + for (k, v) in headers { + head.push_str(&format!("{k}: {v}\r\n")); + } + head.push_str("\r\n"); + let mut req = head.into_bytes(); + req.extend_from_slice(body); + + let stream = TcpStream::connect((host, port)).map_err(|e| e.to_string())?; + stream.set_read_timeout(Some(timeout)).ok(); + stream.set_write_timeout(Some(timeout)).ok(); + let raw = if https { + let mut b = SslConnector::builder(SslMethod::tls()).map_err(|e| e.to_string())?; + if !verify { + b.set_verify(SslVerifyMode::NONE); + } + let connector = b.build(); + let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?; + tls.write_all(&req).map_err(|e| e.to_string())?; + let mut buf = Vec::new(); + let _ = tls.take(MAX_RESPONSE).read_to_end(&mut buf); + buf + } else { + let mut s = stream; + s.write_all(&req).map_err(|e| e.to_string())?; + let mut buf = Vec::new(); + let _ = s.take(MAX_RESPONSE).read_to_end(&mut buf); + buf + }; + let resp = String::from_utf8_lossy(&raw).into_owned(); + let head = resp.split_once("\r\n\r\n").map(|(h, _)| h).unwrap_or(&resp); + let status = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|c| c.parse::().ok()) + .unwrap_or(0); + Ok((status, String::new())) +} + /// Decode an HTTP/1.1 chunked body (best effort). Used for both outbound response /// bodies here and inbound request bodies in the RPC httpd. pub fn dechunk(body: &str) -> String { diff --git a/src/ircd.rs b/src/ircd.rs index 227f684..753e5ae 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -138,6 +138,7 @@ impl Ircd { crate::modules::reputation::load(&mut server); // restore per-IP reputation crate::modules::permchannels::load(&mut server); // recreate +P channels (pre-link) crate::modules::markread::load(&mut server); // restore account-keyed read markers + crate::modules::webpush::load(&mut server); // VAPID keypair + push subscriptions crate::modules::geoip::init(&mut server); // load the GeoIP database crate::modules::customprefix::init(&server); // load prefix config crate::mode::init_custom_prefixes(); // register any config-defined prefix modes diff --git a/src/modules/jwt.rs b/src/modules/jwt.rs index 51e19b8..881eb0b 100644 --- a/src/modules/jwt.rs +++ b/src/modules/jwt.rs @@ -11,7 +11,7 @@ use openssl::pkey::PKey; use openssl::sign::Signer; /// base64url (no padding) of arbitrary bytes. -fn b64url(data: &[u8]) -> String { +pub(crate) fn b64url(data: &[u8]) -> String { let std = openssl::base64::encode_block(data); std.trim_end_matches('=') .replace('+', "-") @@ -20,7 +20,7 @@ fn b64url(data: &[u8]) -> String { /// Decode base64url (no padding) back to bytes. #[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable on our MSRV -fn unb64url(s: &str) -> Option> { +pub(crate) fn unb64url(s: &str) -> Option> { let mut std = s.replace('-', "+").replace('_', "/"); while std.len() % 4 != 0 { std.push('='); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index d1599d3..7277f53 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -25,6 +25,7 @@ pub mod customprefix; pub mod customtitle; pub mod dccallow; pub mod event_playback; +pub mod webpush; pub mod denychans; pub mod disable; pub mod dnsbl; @@ -119,6 +120,7 @@ pub fn default_modules() -> Vec> { Box::new(permchannels::PermChannels::default()), Box::new(chathistory::ChatHistoryGc), Box::new(event_playback::EventPlayback), + Box::new(webpush::WebPush), Box::new(account_registration::AcctRegGc), ] } @@ -158,5 +160,6 @@ pub fn module_commands() -> Vec> { .chain(relaymsg::commands()) .chain(ojoin::commands()) .chain(namedmodes::commands()) + .chain(webpush::commands()) .collect() } diff --git a/src/modules/webpush.rs b/src/modules/webpush.rs new file mode 100644 index 0000000..8e8a1a3 --- /dev/null +++ b/src/modules/webpush.rs @@ -0,0 +1,558 @@ +//! draft/webpush — Web Push notifications (RFC 8291 payload encryption + RFC 8292 +//! VAPID) so a client (e.g. Orbit) is notified of a PM / highlight while its tab is +//! backgrounded (away) or gone. +//! +//! Wire contract (matches the IRCv3 draft + Orbit's client): +//! * ISUPPORT `VAPID=` and the `draft/webpush` cap tell +//! the client this server's application-server key. +//! * `WEBPUSH REGISTER p256dh=;auth=` subscribes; +//! `WEBPUSH UNREGISTER ` removes it. +//! * On a PM or nick-highlight to an away subscriber, the server encrypts a small +//! JSON copy (RFC 8291 `aes128gcm`) and POSTs it — off the core thread — to the +//! push endpoint with a VAPID `Authorization` header. +//! +//! All crypto is OpenSSL (already a dependency); no `unsafe`, no extra crate. Subscriptions +//! are keyed by account (persisted, like markread) so they survive a reconnect. Off when +//! `webpush = no`; the VAPID keypair is generated + persisted on first start. + +use crate::command::{CmdResult, Command}; +use crate::map::HashMap; +use crate::module::{ModResult, Module}; +use crate::modules::jwt::b64url; +use crate::server::{now, Server}; +use crate::Uid; + +use openssl::bn::BigNumContext; +use openssl::derive::Deriver; +use openssl::ec::{EcGroup, EcKey, EcPoint, PointConversionForm}; +use openssl::ecdsa::EcdsaSig; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkey::PKey; +use openssl::sign::Signer; +use openssl::symm::{encrypt_aead, Cipher}; + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +// ─────────────────────────── VAPID key + subscriptions ─────────────────────────── + +/// The persistent server VAPID keypair and its advertised public key. +pub struct Vapid { + pem: Vec, // the P-256 private key, PEM (for signing the VAPID JWT) + pub pub_b64: String, // uncompressed public point, base64url — the VAPID public key +} + +/// One push subscription: the endpoint URL and the client's key material. +#[derive(Clone)] +pub struct Sub { + pub endpoint: String, + pub p256dh: Vec, // client public key, 65-byte uncompressed point + pub auth: Vec, // 16-byte auth secret +} + +/// identity (account, else nick) -> its subscriptions. +#[derive(Default)] +pub struct Subs(pub HashMap>); + +fn group() -> EcGroup { + EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("P-256 available") +} + +fn vapid_path(s: &Server) -> String { + match s.conf("webpush_vapid_file") { + Some(p) if !p.is_empty() => p.to_string(), + _ => format!("{}.webpush-vapid", s.conf_path), + } +} + +fn subs_path(s: &Server) -> String { + match s.conf("webpush_database") { + Some(p) if !p.is_empty() => p.to_string(), + _ => format!("{}.webpush", s.conf_path), + } +} + +/// The push identity for `uid`: the account when logged in (so it survives a reconnect), +/// else the current nick lowercased (session-scoped). +fn identity(s: &Server, uid: Uid) -> String { + s.users + .get(&uid) + .map(|u| { + u.account + .clone() + .map(|a| a.to_ascii_lowercase()) + .unwrap_or_else(|| u.nick.to_ascii_lowercase()) + }) + .unwrap_or_default() +} + +/// Load (or first-time generate + persist) the VAPID keypair, and restore stored +/// subscriptions. Called once from `Ircd::new`. +pub fn load(s: &mut Server) { + let g = group(); + let path = vapid_path(s); + let ec = std::fs::read(&path) + .ok() + .and_then(|pem| EcKey::private_key_from_pem(&pem).ok()) + .unwrap_or_else(|| { + let k = EcKey::generate(&g).expect("EC keygen"); + if let Ok(pem) = k.private_key_to_pem() { + let _ = std::fs::write(&path, &pem); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + } + k + }); + let mut ctx = BigNumContext::new().expect("bn ctx"); + let pubpt = ec + .public_key() + .to_bytes(&g, PointConversionForm::UNCOMPRESSED, &mut ctx) + .expect("pub point"); + let pem = ec.private_key_to_pem().unwrap_or_default(); + let vapid = Vapid { + pem, + pub_b64: b64url(&pubpt), + }; + let _ = s.ext.get_or_insert_with::(move || vapid); + + // subscriptions + if let Ok(text) = std::fs::read_to_string(subs_path(s)) { + let store = s.ext.get_or_insert_with::(Subs::default); + for line in text.lines() { + let f: Vec<&str> = line.split(' ').collect(); + if f.len() != 4 || line.starts_with('#') { + continue; + } + if let (Some(p), Some(a)) = ( + crate::modules::jwt::unb64url(f[2]), + crate::modules::jwt::unb64url(f[3]), + ) { + store.0.entry(f[0].to_string()).or_default().push(Sub { + endpoint: f[1].to_string(), + p256dh: p, + auth: a, + }); + } + } + } +} + +/// Persist the subscription store (durable across restart). Off-core via `disk_write`. +fn save(s: &Server) { + let Some(store) = s.ext.get::() else { + return; + }; + let mut out = String::from("# echoircd web-push subscriptions — auto-generated\n"); + let mut ids: Vec<&String> = store.0.keys().collect(); + ids.sort(); + for id in ids { + for sub in &store.0[id] { + out.push_str(&format!( + "{id} {} {} {}\n", + sub.endpoint, + b64url(&sub.p256dh), + b64url(&sub.auth) + )); + } + } + s.disk_write(subs_path(s), out); +} + +/// The ISUPPORT `VAPID=` token, advertised in the welcome burst. `None` when +/// web-push is disabled or the key failed to load. +pub fn isupport(s: &Server) -> Option { + if !s.conf_bool("webpush", true) { + return None; + } + s.ext.get::().map(|v| format!("VAPID={}", v.pub_b64)) +} + +// ─────────────────────────────── crypto (RFC 8291/8292) ────────────────────────── + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Option> { + let k = PKey::hmac(key).ok()?; + let mut si = Signer::new(MessageDigest::sha256(), &k).ok()?; + si.update(data).ok()?; + si.sign_to_vec().ok() +} + +/// HKDF-SHA256 (extract + expand) → `len` bytes. +fn hkdf(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Option> { + let prk = hmac_sha256(salt, ikm)?; // Extract + let mut okm = Vec::new(); + let mut t: Vec = Vec::new(); + let mut ctr: u8 = 1; + while okm.len() < len { + let mut input = t.clone(); + input.extend_from_slice(info); + input.push(ctr); + t = hmac_sha256(&prk, &input)?; + okm.extend_from_slice(&t); + ctr = ctr.checked_add(1)?; + } + okm.truncate(len); + Some(okm) +} + +/// Encrypt `payload` for a subscription's `ua_public` (65-byte point) + `auth` secret, +/// producing an RFC 8188 `aes128gcm` body (RFC 8291). Fresh ephemeral key per call. +fn encrypt_payload(payload: &[u8], ua_public: &[u8], auth: &[u8]) -> Option> { + let g = group(); + let mut ctx = BigNumContext::new().ok()?; + let ua_point = EcPoint::from_bytes(&g, ua_public, &mut ctx).ok()?; + let ua_pkey = PKey::from_ec_key(EcKey::from_public_key(&g, &ua_point).ok()?).ok()?; + let as_ec = EcKey::generate(&g).ok()?; + let as_public = as_ec + .public_key() + .to_bytes(&g, PointConversionForm::UNCOMPRESSED, &mut ctx) + .ok()?; + let as_pkey = PKey::from_ec_key(as_ec).ok()?; + let mut deriver = Deriver::new(&as_pkey).ok()?; + deriver.set_peer(&ua_pkey).ok()?; + let ecdh = deriver.derive_to_vec().ok()?; + + let mut key_info = b"WebPush: info\x00".to_vec(); + key_info.extend_from_slice(ua_public); + key_info.extend_from_slice(&as_public); + let ikm = hkdf(auth, &ecdh, &key_info, 32)?; + + let mut salt = [0u8; 16]; + openssl::rand::rand_bytes(&mut salt).ok()?; + let cek = hkdf(&salt, &ikm, b"Content-Encoding: aes128gcm\x00", 16)?; + let nonce = hkdf(&salt, &ikm, b"Content-Encoding: nonce\x00", 12)?; + + let mut record = payload.to_vec(); + record.push(0x02); // single-record padding delimiter + let mut tag = [0u8; 16]; + let ct = encrypt_aead(Cipher::aes_128_gcm(), &cek, Some(&nonce), &[], &record, &mut tag).ok()?; + + // header: salt(16) | rs(4, BE) | idlen(1) | keyid(as_public) ; then ciphertext|tag + let mut body = Vec::with_capacity(21 + as_public.len() + ct.len() + 16); + body.extend_from_slice(&salt); + body.extend_from_slice(&4096u32.to_be_bytes()); + body.push(as_public.len() as u8); + body.extend_from_slice(&as_public); + body.extend_from_slice(&ct); + body.extend_from_slice(&tag); + Some(body) +} + +/// The `Authorization: vapid t=,k=` header for a push to `aud` (the endpoint +/// origin), signing an ES256 JWT with the VAPID private key. +fn vapid_auth(vapid: &Vapid, aud: &str, sub: &str) -> Option<(String, String)> { + let ec = EcKey::private_key_from_pem(&vapid.pem).ok()?; + let pkey = PKey::from_ec_key(ec).ok()?; + let header = b64url(br#"{"typ":"JWT","alg":"ES256"}"#); + let exp = now() as i64 + 12 * 3600; + let claims = format!(r#"{{"aud":"{aud}","exp":{exp},"sub":"{sub}"}}"#); + let signing_input = format!("{header}.{}", b64url(claims.as_bytes())); + let mut signer = Signer::new(MessageDigest::sha256(), &pkey).ok()?; + signer.update(signing_input.as_bytes()).ok()?; + let der = signer.sign_to_vec().ok()?; + let sig = EcdsaSig::from_der(&der).ok()?; + let mut raw = sig.r().to_vec_padded(32).ok()?; + raw.extend_from_slice(&sig.s().to_vec_padded(32).ok()?); + let jwt = format!("{signing_input}.{}", b64url(&raw)); + Some(( + "Authorization".to_string(), + format!("vapid t={jwt},k={}", vapid.pub_b64), + )) +} + +/// scheme://host of a URL (the VAPID audience), dropping the path. +fn origin(url: &str) -> String { + match url.split_once("://") { + Some((sch, rest)) => format!("{sch}://{}", rest.split('/').next().unwrap_or(rest)), + None => url.to_string(), + } +} + +/// POST an encrypted push off the core thread (bounded concurrency; fire-and-forget). +fn deliver(sub: Sub, body: Vec, auth: (String, String), ttl: u64, verify: bool) { + static ACTIVE: AtomicUsize = AtomicUsize::new(0); + if ACTIVE.fetch_add(1, Ordering::Relaxed) >= 64 { + ACTIVE.fetch_sub(1, Ordering::Relaxed); + return; + } + std::thread::spawn(move || { + struct Guard; + impl Drop for Guard { + fn drop(&mut self) { + ACTIVE.fetch_sub(1, Ordering::Relaxed); + } + } + let _g = Guard; + let headers = vec![ + auth, + ("Content-Encoding".to_string(), "aes128gcm".to_string()), + ("TTL".to_string(), ttl.to_string()), + ]; + let _ = crate::http::post_bytes( + &sub.endpoint, + "application/octet-stream", + &body, + &headers, + Duration::from_secs(15), + verify, + ); + }); +} + +/// JSON-escape a string into `out`. +fn esc(out: &mut String, s: &str) { + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } +} + +fn payload_json(from: &str, target: &str, text: &str) -> String { + let mut j = String::from("{\"from\":\""); + esc(&mut j, from); + j.push_str("\",\"target\":\""); + esc(&mut j, target); + j.push_str("\",\"body\":\""); + esc(&mut j, text); + j.push_str("\"}"); + j +} + +/// Whether `nick` appears as a whole word in `text` (a highlight). +fn mentions(text: &str, nick: &str) -> bool { + let n = nick.to_ascii_lowercase(); + text.to_ascii_lowercase() + .split(|c: char| c.is_whitespace() || matches!(c, ',' | ':' | ';' | '.' | '!' | '?' | '<' | '>' | '(' | ')' | '"')) + .any(|w| w == n) +} + +// ─────────────────────────────── module + command ─────────────────────────────── + +pub struct WebPush; + +impl Module for WebPush { + fn name(&self) -> &'static str { + "webpush" + } + + fn on_pre_message(&mut self, s: &mut Server, uid: Uid, target: &str, text: &str) -> ModResult { + // Fire pushes as a side effect; never affect delivery of the message itself. + maybe_push(s, uid, target, text); + ModResult::Passthru + } +} + +/// Encrypt + queue a push to every away subscriber the message would notify. +fn maybe_push(s: &Server, from_uid: Uid, target: &str, text: &str) { + if !s.conf_bool("webpush", true) || s.ext.get::().is_none() { + return; + } + let sender = s.users.get(&from_uid).map(|u| u.nick.clone()).unwrap_or_default(); + // who to notify: a PM's target, or channel members whose nick is highlighted + let mut recips: Vec = Vec::new(); + if target.starts_with('#') { + if let Some(ch) = s.channels.get(&target.to_ascii_lowercase()) { + for &m in ch.members.keys() { + if m != from_uid { + if let Some(u) = s.users.get(&m) { + if mentions(text, &u.nick) { + recips.push(m); + } + } + } + } + } + } else if let Some(tu) = s.find_nick(target) { + if tu != from_uid { + recips.push(tu); + } + } + if recips.is_empty() { + return; + } + let away_only = s.conf_bool("webpush_away_only", true); + let ttl = s.conf_num("webpush_ttl", 259200u64); + let contact = s + .conf("webpush_sub") + .map(|c| c.to_string()) + .unwrap_or_else(|| format!("mailto:webpush@{}", s.name)); + let verify = s.conf_bool("http_tls_verify", true); + let Some(vapid) = s.ext.get::() else { + return; + }; + let Some(store) = s.ext.get::() else { + return; + }; + for r in recips { + let Some(u) = s.users.get(&r) else { continue }; + if away_only && u.flags.away.is_none() { + continue; // client marks itself away when backgrounded + } + let Some(subs) = store.0.get(&identity(s, r)) else { + continue; + }; + let payload = payload_json(&sender, target, text); + for sub in subs { + if let Some(body) = encrypt_payload(payload.as_bytes(), &sub.p256dh, &sub.auth) { + if let Some(auth) = vapid_auth(vapid, &origin(&sub.endpoint), &contact) { + deliver(sub.clone(), body, auth, ttl, verify); + } + } + } + } +} + +pub fn commands() -> Vec> { + vec![Box::new(WebPushCmd)] +} + +struct WebPushCmd; +impl Command for WebPushCmd { + fn name(&self) -> &'static str { + "WEBPUSH" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.conf_bool("webpush", true) || s.ext.get::().is_none() { + s.fail(uid, "WEBPUSH", "TEMPORARILY_UNAVAILABLE", "Web push is not available"); + return CmdResult::Fail; + } + match params[0].to_ascii_uppercase().as_str() { + "REGISTER" => { + if params.len() < 3 { + s.fail(uid, "WEBPUSH", "NEED_MORE_PARAMS", "WEBPUSH REGISTER "); + return CmdResult::Fail; + } + let endpoint = params[1].clone(); + if !endpoint.starts_with("https://") { + s.fail(uid, "WEBPUSH", "INVALID_PARAMS", "endpoint must be https"); + return CmdResult::Fail; + } + let (mut p256dh, mut auth) = (None, None); + for kv in params[2].split(';') { + match kv.split_once('=') { + Some(("p256dh", v)) => p256dh = crate::modules::jwt::unb64url(v), + Some(("auth", v)) => auth = crate::modules::jwt::unb64url(v), + _ => {} + } + } + let (Some(p256dh), Some(auth)) = (p256dh, auth) else { + s.fail(uid, "WEBPUSH", "INVALID_PARAMS", "keys must be p256dh=;auth="); + return CmdResult::Fail; + }; + if p256dh.len() != 65 || auth.len() != 16 { + s.fail(uid, "WEBPUSH", "INVALID_PARAMS", "bad key length"); + return CmdResult::Fail; + } + let id = identity(s, uid); + { + let store = s.ext.get_or_insert_with::(Subs::default); + let list = store.0.entry(id).or_default(); + list.retain(|x| x.endpoint != endpoint); // replace an existing sub + list.push(Sub { endpoint: endpoint.clone(), p256dh, auth }); + } + save(s); + s.send(uid, format!(":{} WEBPUSH REGISTER {endpoint}", s.name)); + CmdResult::Ok + } + "UNREGISTER" => { + let Some(endpoint) = params.get(1) else { + s.fail(uid, "WEBPUSH", "NEED_MORE_PARAMS", "WEBPUSH UNREGISTER "); + return CmdResult::Fail; + }; + let id = identity(s, uid); + if let Some(store) = s.ext.get_mut::() { + if let Some(list) = store.0.get_mut(&id) { + list.retain(|x| &x.endpoint != endpoint); + if list.is_empty() { + store.0.remove(&id); + } + } + } + save(s); + s.send(uid, format!(":{} WEBPUSH UNREGISTER {endpoint}", s.name)); + CmdResult::Ok + } + other => { + s.fail(uid, "WEBPUSH", "INVALID_PARAMS", &format!("unknown WEBPUSH subcommand {other}")); + CmdResult::Fail + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // RFC 5869 Test Case 1 — validates HKDF-SHA256 (the trickiest primitive). + #[test] + fn hkdf_matches_rfc5869() { + let ikm = [0x0bu8; 22]; + let salt: Vec = (0..=0x0c).collect(); + let info: Vec = (0xf0..=0xf9).collect(); + let okm = hkdf(&salt, &ikm, &info, 42).unwrap(); + let want = "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"; + let got: String = okm.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(got, want); + } + + // Encrypt for a fresh UA keypair, then decrypt as the UA would (RFC 8291 reverse): + // recovering the plaintext proves ECDH + HKDF + AES-GCM + the aes128gcm framing. + #[test] + fn encrypt_roundtrips_like_a_browser() { + let g = group(); + let ua = EcKey::generate(&g).unwrap(); + let mut ctx = BigNumContext::new().unwrap(); + let ua_pub = ua + .public_key() + .to_bytes(&g, PointConversionForm::UNCOMPRESSED, &mut ctx) + .unwrap(); + let mut auth = [0u8; 16]; + openssl::rand::rand_bytes(&mut auth).unwrap(); + let plain = b"hi \xf0\x9f\x8d\x89 push"; + let body = encrypt_payload(plain, &ua_pub, &auth).unwrap(); + + // --- decrypt (what the browser/service-worker does) --- + let salt = &body[0..16]; + let idlen = body[20] as usize; + let as_public = &body[21..21 + idlen]; + let ciphertext = &body[21 + idlen..]; + let as_pkey = + PKey::from_ec_key(EcKey::from_public_key(&g, &EcPoint::from_bytes(&g, as_public, &mut ctx).unwrap()).unwrap()) + .unwrap(); + let ua_pkey = PKey::from_ec_key(ua.clone()).unwrap(); + let mut d = Deriver::new(&ua_pkey).unwrap(); + d.set_peer(&as_pkey).unwrap(); + let ecdh = d.derive_to_vec().unwrap(); + let mut key_info = b"WebPush: info\x00".to_vec(); + key_info.extend_from_slice(&ua_pub); + key_info.extend_from_slice(as_public); + let ikm = hkdf(&auth, &ecdh, &key_info, 32).unwrap(); + let cek = hkdf(salt, &ikm, b"Content-Encoding: aes128gcm\x00", 16).unwrap(); + let nonce = hkdf(salt, &ikm, b"Content-Encoding: nonce\x00", 12).unwrap(); + let (ct, tag) = ciphertext.split_at(ciphertext.len() - 16); + let rec = openssl::symm::decrypt_aead(Cipher::aes_128_gcm(), &cek, Some(&nonce), &[], ct, tag).unwrap(); + let end = rec.iter().rposition(|&b| b == 0x02).unwrap(); + assert_eq!(&rec[..end], plain); + } + + #[test] + fn mentions_word_boundary() { + assert!(mentions("hey bob, look", "bob")); + assert!(mentions("BOB!", "bob")); + assert!(!mentions("bobby is here", "bob")); + } +} diff --git a/src/server.rs b/src/server.rs index 99edeac..cd30d33 100644 --- a/src/server.rs +++ b/src/server.rs @@ -953,6 +953,9 @@ impl Server { if let Some(tok) = crate::modules::filehost::isupport(self) { tokens.push(tok); } + if let Some(tok) = crate::modules::webpush::isupport(self) { + tokens.push(tok); + } // at most 13 tokens per 005 line (the RFC-suggested cap) so strict clients // don't truncate trailing tokens tokens.chunks(13).map(|c| c.join(" ")).collect() diff --git a/src/users.rs b/src/users.rs index b4aacc9..18e18f9 100644 --- a/src/users.rs +++ b/src/users.rs @@ -169,6 +169,7 @@ define_caps! { "draft/relaymsg" => relaymsg, "draft/channel-rename" => channel_rename, "draft/read-marker" => read_marker, + "draft/webpush" => webpush, "no-implicit-names" => no_implicit_names, "cap-notify" => cap_notify, }