1use crate::hosts::{Hosts, is_valid_display_field};
9use crate::secret::random_bytes;
10use anyhow::{Context, Result, bail, ensure};
11use base64::Engine;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use p256::ecdsa::signature::Signer;
14use p256::elliptic_curve::sec1::ToSec1Point;
15use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
16use std::io::{Read, Write};
17use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
18use std::path::Path;
19use zeroize::Zeroizing;
20
21pub const VAPID_KEY_FILE: &str = "vapid.key";
24
25pub const VAPID_TOKEN_SECS: i64 = 12 * 60 * 60;
27
28pub const MAX_SUBSCRIPTIONS: usize = 32;
31
32pub const MAX_ENDPOINT: usize = 2048;
35
36pub const MAX_CONTACT: usize = 254;
38
39const PRIVATE_SUFFIXES: &[&str] = &[
43 "localhost",
44 "local",
45 "localdomain",
46 "lan",
47 "internal",
48 "home.arpa",
49 "onion",
50 "test",
51 "invalid",
52 "example",
53 "alt",
54];
55
56pub struct VapidKey {
62 secret: p256::SecretKey,
63 public: String,
66}
67
68impl std::fmt::Debug for VapidKey {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("VapidKey")
71 .field("public", &self.public)
72 .finish_non_exhaustive()
73 }
74}
75
76impl VapidKey {
77 pub fn generate() -> Self {
80 Self::from_secret(fresh_secret())
81 }
82
83 pub fn load_or_create(dir: &Path) -> Result<Self> {
87 let path = dir.join(VAPID_KEY_FILE);
88 if let Some(key) = load(&path)? {
89 return Ok(key);
90 }
91 create(dir, &path)?;
92 load(&path)?.with_context(|| format!("{} vanished as it was created", path.display()))
93 }
94
95 pub fn public_key(&self) -> &str {
97 &self.public
98 }
99
100 pub fn authorization(&self, endpoint: &url::Url, subject: Option<&str>, now: i64) -> String {
105 let token = self.token(&audience(endpoint), subject, now + VAPID_TOKEN_SECS);
106 format!("vapid t={token}, k={}", self.public)
107 }
108
109 pub fn token(&self, audience: &str, subject: Option<&str>, expires_at: i64) -> String {
113 let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#);
114 let mut claims = serde_json::json!({ "aud": audience, "exp": expires_at });
115 if let Some(subject) = subject {
116 claims["sub"] = subject.into();
117 }
118 let claims = URL_SAFE_NO_PAD.encode(claims.to_string());
119 let input = format!("{header}.{claims}");
120 let signature = URL_SAFE_NO_PAD.encode(self.sign(input.as_bytes()));
121 format!("{input}.{signature}")
122 }
123
124 fn sign(&self, message: &[u8]) -> [u8; 64] {
127 let key = p256::ecdsa::SigningKey::from(&self.secret);
128 let signature: p256::ecdsa::Signature = key.sign(message);
129 let mut out = [0u8; 64];
130 out.copy_from_slice(&signature.to_bytes());
131 out
132 }
133
134 fn from_secret(secret: p256::SecretKey) -> Self {
135 let public = URL_SAFE_NO_PAD.encode(secret.public_key().to_sec1_point(false).as_bytes());
136 Self { secret, public }
137 }
138}
139
140pub fn audience(endpoint: &url::Url) -> String {
143 endpoint.origin().ascii_serialization()
144}
145
146pub fn subject(contact: Option<&str>, public_url: &crate::operator::PublicUrl) -> Option<String> {
153 match contact {
154 Some(contact) => Some(format!("mailto:{contact}")),
155 None => public_url.is_https().then(|| public_url.origin().to_string()),
156 }
157}
158
159pub(crate) fn fresh_secret() -> p256::SecretKey {
162 loop {
163 let bytes = Zeroizing::new(random_bytes::<32>());
164 if let Ok(secret) = p256::SecretKey::from_slice(&bytes[..]) {
165 return secret;
166 }
167 }
168}
169
170fn load(path: &Path) -> Result<Option<VapidKey>> {
174 let file = match std::fs::OpenOptions::new()
177 .read(true)
178 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
179 .open(path)
180 {
181 Ok(file) => file,
182 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
183 Err(err) => return Err(err).with_context(|| format!("open {} (a symlink is refused)", path.display())),
184 };
185 let meta = file.metadata()?;
186 ensure!(meta.is_file(), "{} is not a regular file", path.display());
187 ensure!(
188 meta.permissions().mode() & 0o077 == 0,
189 "{} must be readable by its owner only (chmod 600 it)",
190 path.display()
191 );
192 let mut bytes = Zeroizing::new(Vec::with_capacity(33));
193 file.take(33).read_to_end(&mut bytes)?;
194 if bytes.len() != 32 {
195 bail!(
196 "{} is not a VAPID key (32 bytes). Every push subscription depends on it, so it is never \
197 replaced: restore it from a backup, or remove it and subscribe every device again",
198 path.display()
199 );
200 }
201 let secret = p256::SecretKey::from_slice(&bytes[..])
202 .map_err(|_| anyhow::anyhow!("{} does not hold a valid P-256 key", path.display()))?;
203 Ok(Some(VapidKey::from_secret(secret)))
204}
205
206fn create(dir: &Path, path: &Path) -> Result<()> {
211 let secret = fresh_secret();
212 let bytes = Zeroizing::new(secret.to_bytes());
213 let temp = dir.join(format!(".{VAPID_KEY_FILE}.{}.tmp", hex::encode(random_bytes::<8>())));
214 let mut out = std::fs::OpenOptions::new()
215 .write(true)
216 .create_new(true)
217 .mode(0o600)
218 .custom_flags(libc::O_NOFOLLOW)
219 .open(&temp)
220 .with_context(|| format!("create {}", temp.display()))?;
221 let written = out.write_all(&bytes[..]).and_then(|()| out.sync_all());
222 drop(out);
223 let linked = written.and_then(|()| std::fs::hard_link(&temp, path));
224 let _ = std::fs::remove_file(&temp);
225 match linked {
226 Ok(()) => {}
227 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()),
228 Err(err) => return Err(err).with_context(|| format!("create {}", path.display())),
229 }
230 std::fs::File::open(dir)
231 .and_then(|d| d.sync_all())
232 .with_context(|| format!("sync {}", dir.display()))
233}
234
235pub fn parse_endpoint(endpoint: &str) -> Result<url::Url, String> {
242 let too_long = || format!("an endpoint is at most {MAX_ENDPOINT} bytes");
243 if endpoint.len() > MAX_ENDPOINT {
244 return Err(too_long());
245 }
246 let Ok(url) = url::Url::parse(endpoint) else {
247 return Err("an endpoint must be a URL".into());
248 };
249 if url.as_str().len() > MAX_ENDPOINT {
250 return Err(too_long());
251 }
252 if url.scheme() != "https" {
253 return Err("an endpoint must be https".into());
254 }
255 if !url.username().is_empty() || url.password().is_some() {
256 return Err("an endpoint must not carry credentials".into());
257 }
258 if url.port().is_some() {
259 return Err("an endpoint must use the default port".into());
260 }
261 if url.fragment().is_some() {
262 return Err("an endpoint must not have a fragment".into());
263 }
264 let Some(url::Host::Domain(host)) = url.host() else {
265 return Err("an endpoint must name its host by a domain name".into());
266 };
267 let host = host.trim_end_matches('.');
268 let private = PRIVATE_SUFFIXES
269 .iter()
270 .any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}")));
271 if !host.contains('.') || private {
272 return Err("an endpoint must name a public host".into());
273 }
274 Ok(url)
275}
276
277pub fn parse_keys(p256dh: &str, auth: &str) -> Result<(String, String), String> {
282 let decode = |text: &str| URL_SAFE_NO_PAD.decode(text.trim_end_matches('='));
283 let point = match decode(p256dh) {
284 Ok(point) if point.len() == 65 && p256::PublicKey::from_sec1_bytes(&point).is_ok() => point,
285 _ => return Err("keys.p256dh must be an uncompressed P-256 public key, base64url".into()),
286 };
287 match decode(auth) {
288 Ok(secret) if secret.len() == 16 => Ok((URL_SAFE_NO_PAD.encode(point), URL_SAFE_NO_PAD.encode(secret))),
289 _ => Err("keys.auth must be 16 bytes, base64url".into()),
290 }
291}
292
293pub fn contact_problem(contact: &str) -> Option<String> {
298 let valid = contact.len() <= MAX_CONTACT
299 && contact.split_once('@').is_some_and(|(local, domain)| {
300 !local.is_empty()
301 && local
302 .chars()
303 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '_' | '-'))
304 && domain.contains('.')
305 && domain.split('.').all(|label| {
306 !label.is_empty()
307 && !label.starts_with('-')
308 && !label.ends_with('-')
309 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
310 })
311 });
312 (!valid).then(|| format!("a contact must be an e-mail address of at most {MAX_CONTACT} characters"))
313}
314
315#[derive(Clone)]
317pub struct NewSubscription<'a> {
318 pub endpoint: &'a str,
319 pub p256dh: &'a str,
320 pub auth: &'a str,
321 pub device_label: Option<&'a str>,
323 pub expires_at: Option<i64>,
325}
326
327impl std::fmt::Debug for NewSubscription<'_> {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 f.debug_struct("NewSubscription")
332 .field("endpoint_host", &endpoint_host(self.endpoint))
333 .field("device_label", &self.device_label)
334 .finish_non_exhaustive()
335 }
336}
337
338fn endpoint_host(endpoint: &str) -> String {
339 url::Url::parse(endpoint)
340 .ok()
341 .and_then(|url| url.host_str().map(str::to_string))
342 .unwrap_or_default()
343}
344
345#[derive(Clone, PartialEq, Eq)]
347pub struct Subscription {
348 pub id: String,
349 pub endpoint: String,
350 pub p256dh: String,
351 pub auth: String,
352 pub device_label: String,
353 pub auth_session: String,
355 pub created_at: i64,
356 pub expires_at: Option<i64>,
357 pub last_success_at: Option<i64>,
358 pub last_error: Option<String>,
359}
360
361impl std::fmt::Debug for Subscription {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 f.debug_struct("Subscription")
364 .field("id", &self.id)
365 .field("endpoint_host", &self.endpoint_host())
366 .field("device_label", &self.device_label)
367 .finish_non_exhaustive()
368 }
369}
370
371impl Subscription {
372 pub fn endpoint_host(&self) -> String {
374 endpoint_host(&self.endpoint)
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
380pub enum Subscribed {
381 Created(Subscription),
382 Replaced(Subscription),
386 Invalid(String),
387 TooMany,
389 Conflict,
391 NotFound,
393 SignedOut,
396}
397
398#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
401pub struct PushPolicy {
402 pub muted: bool,
404 pub details: bool,
406 pub generic_title: bool,
408}
409
410const SUBSCRIPTION_COLUMNS: &str = "id, endpoint, p256dh, auth, device_label, auth_session, created_at, \
411 expires_at, last_success_at, last_error";
412
413fn read_subscription(r: &rusqlite::Row<'_>) -> rusqlite::Result<Subscription> {
414 Ok(Subscription {
415 id: r.get(0)?,
416 endpoint: r.get(1)?,
417 p256dh: r.get(2)?,
418 auth: r.get(3)?,
419 device_label: r.get(4)?,
420 auth_session: r.get(5)?,
421 created_at: r.get(6)?,
422 expires_at: r.get(7)?,
423 last_success_at: r.get(8)?,
424 last_error: r.get(9)?,
425 })
426}
427
428struct Checked {
431 endpoint: url::Url,
432 p256dh: String,
433 auth: String,
434 label: Option<String>,
435}
436
437fn checked(new: &NewSubscription<'_>, now: i64) -> Result<Checked, String> {
439 let endpoint = parse_endpoint(new.endpoint)?;
440 let (p256dh, auth) = parse_keys(new.p256dh, new.auth)?;
441 let label = match new.device_label {
442 Some(label) if is_valid_display_field(label) => Some(label.trim().to_string()),
443 Some(_) => return Err("a device label must be 1 to 64 printable characters".into()),
444 None => None,
445 };
446 if new.expires_at.is_some_and(|at| at <= now) {
447 return Err("the subscription has expired already".into());
448 }
449 Ok(Checked {
450 endpoint,
451 p256dh,
452 auth,
453 label,
454 })
455}
456
457fn is_taken(err: &rusqlite::Error) -> bool {
461 matches!(err, rusqlite::Error::SqliteFailure(e, _) if e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE)
462}
463
464fn stored(tx: &Transaction<'_>, owner: &str, id: &str) -> Result<Subscription> {
465 Ok(tx.query_row(
466 &format!("SELECT {SUBSCRIPTION_COLUMNS} FROM push_subscriptions WHERE id = ?1 AND owner_id = ?2"),
467 params![id, owner],
468 read_subscription,
469 )?)
470}
471
472impl Hosts {
473 pub fn subscribe(&self, new: &NewSubscription<'_>, auth_session: &str, now: i64) -> Result<Subscribed> {
480 let new_one = match checked(new, now) {
481 Ok(checked) => checked,
482 Err(why) => return Ok(Subscribed::Invalid(why)),
483 };
484 let endpoint = new_one.endpoint.as_str();
485 let owner = self.owner_id();
486 let mut conn = self.conn();
487 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
488 let live: bool = tx.query_row(
489 "SELECT EXISTS(SELECT 1 FROM auth_sessions WHERE id_hash = ?1 AND owner_id = ?2 AND expires_at > ?3)",
490 params![auth_session, owner, now],
491 |r| r.get(0),
492 )?;
493 if !live {
494 return Ok(Subscribed::SignedOut);
495 }
496 let existing: Option<String> = tx
497 .query_row(
498 "SELECT id FROM push_subscriptions WHERE endpoint = ?1 AND owner_id = ?2",
499 params![endpoint, owner],
500 |r| r.get(0),
501 )
502 .optional()?;
503 if let Some(id) = existing {
504 tx.execute(
505 "UPDATE push_subscriptions SET p256dh = ?3, auth = ?4, device_label = coalesce(?5, device_label),
506 auth_session = ?6, expires_at = ?7, last_error = NULL
507 WHERE id = ?1 AND owner_id = ?2",
508 params![
509 id,
510 owner,
511 new_one.p256dh,
512 new_one.auth,
513 new_one.label,
514 auth_session,
515 new.expires_at
516 ],
517 )?;
518 let sub = stored(&tx, owner, &id)?;
519 tx.commit()?;
520 return Ok(Subscribed::Replaced(sub));
521 }
522 let count: i64 = tx.query_row(
523 "SELECT COUNT(*) FROM push_subscriptions WHERE owner_id = ?1",
524 [owner],
525 |r| r.get(0),
526 )?;
527 if count as usize >= MAX_SUBSCRIPTIONS {
528 return Ok(Subscribed::TooMany);
529 }
530 let id = format!("push-{}", hex::encode(random_bytes::<8>()));
531 let label = new_one
532 .label
533 .or_else(|| new_one.endpoint.host_str().map(str::to_string))
534 .unwrap_or_default();
535 let inserted = tx.execute(
536 "INSERT INTO push_subscriptions(id, owner_id, endpoint, p256dh, auth, device_label, auth_session,
537 created_at, expires_at)
538 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
539 params![
540 id,
541 owner,
542 endpoint,
543 new_one.p256dh,
544 new_one.auth,
545 label,
546 auth_session,
547 now,
548 new.expires_at
549 ],
550 );
551 match inserted {
552 Ok(_) => {}
553 Err(err) if is_taken(&err) => return Ok(Subscribed::Conflict),
554 Err(err) => return Err(err.into()),
555 }
556 let sub = stored(&tx, owner, &id)?;
557 tx.commit()?;
558 Ok(Subscribed::Created(sub))
559 }
560
561 pub fn rotate(&self, old_endpoint: &str, new: &NewSubscription<'_>, now: i64) -> Result<Subscribed> {
568 let Ok(old_endpoint) = parse_endpoint(old_endpoint) else {
569 return Ok(Subscribed::NotFound);
570 };
571 let new_one = match checked(new, now) {
572 Ok(checked) => checked,
573 Err(why) => return Ok(Subscribed::Invalid(why)),
574 };
575 let endpoint = new_one.endpoint.as_str();
576 let owner = self.owner_id();
577 let mut conn = self.conn();
578 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
579 let Some(id) = tx
580 .query_row(
581 "SELECT id FROM push_subscriptions WHERE endpoint = ?1 AND owner_id = ?2",
582 params![old_endpoint.as_str(), owner],
583 |r| r.get::<_, String>(0),
584 )
585 .optional()?
586 else {
587 return Ok(Subscribed::NotFound);
588 };
589 tx.execute(
591 "DELETE FROM push_subscriptions WHERE endpoint = ?1 AND id <> ?2 AND owner_id = ?3",
592 params![endpoint, id, owner],
593 )?;
594 let updated = tx.execute(
595 "UPDATE push_subscriptions SET endpoint = ?3, p256dh = ?4, auth = ?5,
596 device_label = coalesce(?6, device_label), expires_at = ?7, last_error = NULL
597 WHERE id = ?1 AND owner_id = ?2",
598 params![
599 id,
600 owner,
601 endpoint,
602 new_one.p256dh,
603 new_one.auth,
604 new_one.label,
605 new.expires_at
606 ],
607 );
608 match updated {
609 Ok(_) => {}
610 Err(err) if is_taken(&err) => return Ok(Subscribed::Conflict),
611 Err(err) => return Err(err.into()),
612 }
613 let sub = stored(&tx, owner, &id)?;
614 tx.commit()?;
615 Ok(Subscribed::Replaced(sub))
616 }
617
618 pub fn subscriptions(&self) -> Result<Vec<Subscription>> {
620 let conn = self.conn();
621 let mut stmt = conn.prepare(&format!(
622 "SELECT {SUBSCRIPTION_COLUMNS} FROM push_subscriptions WHERE owner_id = ?1 ORDER BY created_at, id"
623 ))?;
624 let rows = stmt.query_map([self.owner_id()], read_subscription)?;
625 Ok(rows.collect::<rusqlite::Result<_>>()?)
626 }
627
628 pub fn unsubscribe(&self, id: &str) -> Result<bool> {
630 let removed = self.conn().execute(
631 "DELETE FROM push_subscriptions WHERE id = ?1 AND owner_id = ?2",
632 params![id, self.owner_id()],
633 )?;
634 Ok(removed > 0)
635 }
636
637 pub fn unsubscribe_endpoint(&self, endpoint: &str) -> Result<bool> {
641 let Ok(endpoint) = parse_endpoint(endpoint) else {
642 return Ok(false);
643 };
644 let removed = self.conn().execute(
645 "DELETE FROM push_subscriptions WHERE endpoint = ?1 AND owner_id = ?2",
646 params![endpoint.as_str(), self.owner_id()],
647 )?;
648 Ok(removed > 0)
649 }
650
651 pub fn record_push_success(&self, id: &str, endpoint: &str, now: i64) -> Result<()> {
658 self.conn().execute(
659 "UPDATE push_subscriptions SET last_success_at = ?2, last_error = NULL
660 WHERE id = ?1 AND endpoint = ?4 AND owner_id = ?3",
661 params![id, now, self.owner_id(), endpoint],
662 )?;
663 Ok(())
664 }
665
666 pub fn record_push_error(&self, id: &str, endpoint: &str, error: &str) -> Result<()> {
670 self.conn().execute(
671 "UPDATE push_subscriptions SET last_error = ?2 WHERE id = ?1 AND endpoint = ?4 AND owner_id = ?3",
672 params![id, error, self.owner_id(), endpoint],
673 )?;
674 Ok(())
675 }
676
677 pub fn remove_gone_subscription(&self, id: &str, endpoint: &str) -> Result<bool> {
680 let removed = self.conn().execute(
681 "DELETE FROM push_subscriptions WHERE id = ?1 AND endpoint = ?2 AND owner_id = ?3",
682 params![id, endpoint, self.owner_id()],
683 )?;
684 Ok(removed > 0)
685 }
686
687 pub fn prune_expired_subscriptions(&self, now: i64) -> Result<usize> {
690 Ok(self.conn().execute(
691 "DELETE FROM push_subscriptions WHERE expires_at IS NOT NULL AND expires_at <= ?1 AND owner_id = ?2",
692 params![now, self.owner_id()],
693 )?)
694 }
695
696 pub fn push_policies(&self) -> Result<Vec<(String, PushPolicy)>> {
699 let conn = self.conn();
700 let mut stmt = conn.prepare(
701 "SELECT h.id, coalesce(p.muted, 0), coalesce(p.details, 0), coalesce(p.generic_title, 0)
702 FROM hats h LEFT JOIN hat_push_policies p ON p.hat_id = h.id AND p.owner_id = h.owner_id
703 WHERE h.owner_id = ?1 ORDER BY h.created_at, h.id",
704 )?;
705 let rows = stmt.query_map([self.owner_id()], |r| {
706 Ok((
707 r.get(0)?,
708 PushPolicy {
709 muted: r.get(1)?,
710 details: r.get(2)?,
711 generic_title: r.get(3)?,
712 },
713 ))
714 })?;
715 Ok(rows.collect::<rusqlite::Result<_>>()?)
716 }
717
718 pub fn push_policy(&self, hat_id: &str) -> Result<PushPolicy> {
722 Ok(self
723 .conn()
724 .query_row(
725 "SELECT muted, details, generic_title FROM hat_push_policies WHERE hat_id = ?1 AND owner_id = ?2",
726 params![hat_id, self.owner_id()],
727 |r| {
728 Ok(PushPolicy {
729 muted: r.get(0)?,
730 details: r.get(1)?,
731 generic_title: r.get(2)?,
732 })
733 },
734 )
735 .optional()?
736 .unwrap_or_default())
737 }
738
739 pub fn set_push_policy(&self, hat_id: &str, policy: PushPolicy) -> Result<bool> {
742 let owner = self.owner_id();
743 let mut conn = self.conn();
744 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
745 let exists: bool = tx.query_row(
746 "SELECT EXISTS(SELECT 1 FROM hats WHERE id = ?1 AND owner_id = ?2)",
747 params![hat_id, owner],
748 |r| r.get(0),
749 )?;
750 if !exists {
751 return Ok(false);
752 }
753 tx.execute(
754 "INSERT INTO hat_push_policies(hat_id, owner_id, muted, details, generic_title) VALUES (?1, ?2, ?3, ?4, ?5)
755 ON CONFLICT(hat_id) DO UPDATE SET muted = excluded.muted, details = excluded.details,
756 generic_title = excluded.generic_title
757 WHERE hat_push_policies.owner_id = ?2",
758 params![hat_id, owner, policy.muted, policy.details, policy.generic_title],
761 )?;
762 tx.commit()?;
763 Ok(true)
764 }
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub enum Urgency {
771 High,
772 Normal,
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct Notice {
785 pub hat_id: String,
788 pub urgency: Urgency,
789 pub title: String,
791 pub generic_title: String,
794 pub body: String,
795 pub detail: Option<String>,
798 pub url: String,
801 pub tag: String,
803}
804
805pub const NOTICE_QUEUE: usize = 256;
809
810#[derive(Default)]
811struct Queue {
812 order: std::collections::VecDeque<String>,
814 latest: std::collections::HashMap<String, Notice>,
816 dropped: u64,
818 read: bool,
820}
821
822pub type NoticeGuard = std::sync::Arc<dyn Fn(&Notice) -> bool + Send + Sync>;
824
825struct Shared {
826 guard: std::sync::RwLock<NoticeGuard>,
827 queue: std::sync::Mutex<Queue>,
828 ready: tokio::sync::Notify,
829}
830
831#[derive(Clone)]
835pub struct Push {
836 shared: std::sync::Arc<Shared>,
837}
838
839pub struct Notices {
842 shared: std::sync::Arc<Shared>,
843}
844
845impl Push {
846 pub fn new() -> (Push, Notices) {
848 let shared = std::sync::Arc::new(Shared {
849 queue: std::sync::Mutex::new(Queue {
850 read: true,
851 ..Queue::default()
852 }),
853 ready: tokio::sync::Notify::new(),
854 guard: std::sync::RwLock::new(std::sync::Arc::new(|_| true)),
855 });
856 (Push { shared: shared.clone() }, Notices { shared })
857 }
858
859 pub fn detached() -> Push {
862 Push::new().0
863 }
864
865 pub fn set_guard(&self, guard: NoticeGuard) {
868 *self
869 .shared
870 .guard
871 .write()
872 .unwrap_or_else(std::sync::PoisonError::into_inner) = guard;
873 }
874
875 pub fn notify(&self, notice: Notice) {
881 let mut queue = self
882 .shared
883 .queue
884 .lock()
885 .unwrap_or_else(std::sync::PoisonError::into_inner);
886 if !queue.read {
887 return;
888 }
889 if let Some(waiting) = queue.latest.get_mut(¬ice.tag) {
890 *waiting = notice;
891 } else if queue.order.len() >= NOTICE_QUEUE {
892 queue.dropped += 1;
893 if queue.dropped.is_power_of_two() {
894 tracing::warn!(dropped = queue.dropped, "push queue full; notifications dropped");
895 }
896 return;
897 } else {
898 queue.order.push_back(notice.tag.clone());
899 queue.latest.insert(notice.tag.clone(), notice);
900 }
901 drop(queue);
902 self.shared.ready.notify_one();
903 }
904}
905
906impl Notices {
907 pub fn guard(&self) -> NoticeGuard {
909 let shared = self.shared.clone();
910 std::sync::Arc::new(move |notice| {
911 (shared.guard.read().unwrap_or_else(std::sync::PoisonError::into_inner))(notice)
912 })
913 }
914
915 pub async fn recv(&mut self) -> Notice {
917 let shared = self.shared.clone();
918 loop {
919 let ready = shared.ready.notified();
920 if let Some(notice) = self.try_recv() {
921 return notice;
922 }
923 ready.await;
924 }
925 }
926
927 pub fn try_recv(&mut self) -> Option<Notice> {
929 let mut queue = self
930 .shared
931 .queue
932 .lock()
933 .unwrap_or_else(std::sync::PoisonError::into_inner);
934 let tag = queue.order.pop_front()?;
935 queue.latest.remove(&tag)
936 }
937
938 pub fn dropped(&self) -> u64 {
940 self.shared
941 .queue
942 .lock()
943 .unwrap_or_else(std::sync::PoisonError::into_inner)
944 .dropped
945 }
946}
947
948impl Drop for Notices {
949 fn drop(&mut self) {
950 let mut queue = self
951 .shared
952 .queue
953 .lock()
954 .unwrap_or_else(std::sync::PoisonError::into_inner);
955 queue.read = false;
956 queue.order.clear();
957 queue.latest.clear();
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use p256::ecdsa::signature::Verifier;
965 use serde_json::{Value, json};
966
967 fn notice(tag: &str, body: &str) -> Notice {
968 Notice {
969 hat_id: "hat-1".into(),
970 urgency: Urgency::Normal,
971 title: "t".into(),
972 generic_title: "g".into(),
973 body: body.into(),
974 detail: None,
975 url: format!("/sessions/{tag}"),
976 tag: tag.into(),
977 }
978 }
979
980 #[test]
983 fn a_tag_waiting_keeps_its_place_and_takes_the_latest_notice() {
984 let (push, mut notices) = Push::new();
985 push.notify(notice("s1", "needs your answer"));
986 push.notify(notice("s2", "finished"));
987 push.notify(notice("s1", "failed"));
988 assert_eq!(
989 notices.try_recv().map(|n| (n.tag, n.body)),
990 Some(("s1".into(), "failed".into()))
991 );
992 assert_eq!(notices.try_recv().map(|n| n.tag), Some("s2".into()));
993 assert_eq!(notices.try_recv(), None);
994 }
995
996 #[test]
997 fn a_full_queue_drops_new_tags_and_counts_them() {
998 let (push, mut notices) = Push::new();
999 for n in 0..NOTICE_QUEUE + 3 {
1000 push.notify(notice(&format!("s{n}"), "finished"));
1001 }
1002 assert_eq!(notices.dropped(), 3);
1003 push.notify(notice("s0", "failed"));
1005 assert_eq!(notices.try_recv().map(|n| n.body), Some("failed".into()));
1006 assert_eq!(std::iter::from_fn(|| notices.try_recv()).count(), NOTICE_QUEUE - 1);
1007 }
1008
1009 #[test]
1010 fn without_a_reader_every_notice_is_dropped() {
1011 let push = Push::detached();
1012 push.notify(notice("s1", "finished"));
1013 let (push, notices) = Push::new();
1014 drop(notices);
1015 push.notify(notice("s1", "finished"));
1016 assert!(push.shared.queue.lock().unwrap().latest.is_empty());
1017 }
1018
1019 #[tokio::test]
1020 async fn a_reader_waits_for_the_next_notice() {
1021 let (push, mut notices) = Push::new();
1022 let reader = tokio::spawn(async move { notices.recv().await });
1023 tokio::task::yield_now().await;
1024 push.notify(notice("s1", "finished"));
1025 assert_eq!(reader.await.unwrap().tag, "s1");
1026 }
1027
1028 const P256DH: &str = "BLn9b-VR0ca83knDNZ32dCHGyjJp-1riX9ZTN40MqV8K_LpQmLqxC_DoHvqvFXO_nGdAB4W9dogZb_sM-uV4JbY";
1030 const AUTH: &str = "_ordMnz7uTCmrpBTeUV4Bw";
1031
1032 const RFC8292_EXAMPLE: &str = "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3\
1034 B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1ha\
1035 Wx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_H\
1036 LGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA, \
1037 k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dR\
1038 uU_RCPCfA5aq9ojSwk5Y2EmClBPs";
1039
1040 #[derive(Debug, PartialEq)]
1042 enum Bad {
1043 Format(&'static str),
1044 Signature,
1045 }
1046
1047 fn part(text: &str) -> Result<Vec<u8>, Bad> {
1049 if !text
1050 .bytes()
1051 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1052 {
1053 return Err(Bad::Format("not unpadded base64url"));
1054 }
1055 URL_SAFE_NO_PAD.decode(text).map_err(|_| Bad::Format("not base64url"))
1056 }
1057
1058 fn verify(authorization: &str) -> Result<Value, Bad> {
1064 let rest = authorization
1065 .strip_prefix("vapid ")
1066 .ok_or(Bad::Format("not the vapid scheme"))?;
1067 let (t, k) = rest.split_once(", ").ok_or(Bad::Format("not t and k"))?;
1068 let token = t.strip_prefix("t=").ok_or(Bad::Format("no t="))?;
1069 let key = part(k.strip_prefix("k=").ok_or(Bad::Format("no k="))?)?;
1070 let parts: Vec<&str> = token.split('.').collect();
1071 let [header, claims, signature] = parts[..] else {
1072 return Err(Bad::Format("not a compact JWS"));
1073 };
1074 let header: Value = serde_json::from_slice(&part(header)?).map_err(|_| Bad::Format("header JSON"))?;
1075 if header != json!({ "typ": "JWT", "alg": "ES256" }) {
1076 return Err(Bad::Format("not the ES256 JWT header"));
1077 }
1078 let raw = part(signature)?;
1079 if raw.len() != 64 {
1080 return Err(Bad::Format("ES256 is r ‖ s, 64 bytes (RFC 7518 §3.4)"));
1081 }
1082 let signature = p256::ecdsa::Signature::from_slice(&raw).map_err(|_| Bad::Format("r or s"))?;
1083 let verifying = p256::ecdsa::VerifyingKey::from_sec1_bytes(&key).map_err(|_| Bad::Format("k"))?;
1084 let parsed: Value = serde_json::from_slice(&part(claims)?).map_err(|_| Bad::Format("claims JSON"))?;
1085 verifying
1086 .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature)
1087 .map_err(|_| Bad::Signature)?;
1088 Ok(parsed)
1089 }
1090
1091 fn verified(authorization: &str) -> Value {
1092 verify(authorization).unwrap()
1093 }
1094
1095 #[test]
1096 fn the_verifier_accepts_rfc_8292s_example() {
1097 assert_eq!(
1098 verified(RFC8292_EXAMPLE),
1099 json!({ "aud": "https://push.example.net", "exp": 1453523768, "sub": "mailto:push@example.com" })
1100 );
1101 }
1102
1103 #[test]
1104 fn the_verifier_refuses_a_tampered_token() {
1105 let (t, k) = RFC8292_EXAMPLE.split_once(", ").unwrap();
1107 let [header, _, signature]: [&str; 3] = t.strip_prefix("vapid t=").unwrap().split('.').collect::<Vec<_>>()[..]
1108 .try_into()
1109 .unwrap();
1110 let claims = URL_SAFE_NO_PAD
1111 .encode(r#"{"aud":"https://push.example.org","exp":1453523768,"sub":"mailto:push@example.com"}"#);
1112 let tampered = format!("vapid t={header}.{claims}.{signature}, {k}");
1113 assert_eq!(verify(&tampered), Err(Bad::Signature));
1114 }
1115
1116 #[test]
1119 fn signatures_are_rfc_6979_and_raw_r_s() {
1120 let x = hex::decode("C9AFA9D845BA75166B5C215767B1D6934E50C3DB36E89B127B8A622B120F6721").unwrap();
1121 let key = VapidKey::from_secret(p256::SecretKey::from_slice(&x).unwrap());
1122 let expected = hex::decode(
1123 "EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716\
1124 F7CB1C942D657C41D436C7A1B6E29F65F3E900DBB9AFF4064DC4AB2F843ACDA8",
1125 )
1126 .unwrap();
1127 assert_eq!(key.sign(b"sample").to_vec(), expected);
1128 }
1129
1130 #[test]
1131 fn a_push_is_authorised_for_the_endpoints_origin_for_twelve_hours() {
1132 let key = VapidKey::generate();
1133 let endpoint = url::Url::parse("https://fcm.googleapis.com/fcm/send/abc:def?x=1").unwrap();
1134 let now = 1_800_000_000;
1135 let header = key.authorization(&endpoint, Some("mailto:me@example.com"), now);
1136 assert!(header.ends_with(&format!(", k={}", key.public_key())), "{header}");
1137 let claims = verified(&header);
1138 assert_eq!(
1139 claims,
1140 json!({ "aud": "https://fcm.googleapis.com", "exp": now + VAPID_TOKEN_SECS, "sub": "mailto:me@example.com" })
1141 );
1142 let lifetime = claims["exp"].as_i64().unwrap() - now;
1143 assert!(lifetime > 0 && lifetime <= 24 * 60 * 60);
1144 let other = header.replace(key.public_key(), VapidKey::generate().public_key());
1146 assert_eq!(verify(&other), Err(Bad::Signature));
1147 }
1148
1149 #[test]
1150 fn the_audience_is_the_origin_and_nothing_else() {
1151 for (endpoint, aud) in [
1152 ("https://fcm.googleapis.com/fcm/send/abc", "https://fcm.googleapis.com"),
1153 ("https://Web.Push.Apple.com/QAbc?x#y", "https://web.push.apple.com"),
1154 ("https://push.example.net:443/p", "https://push.example.net"),
1155 ("https://push.example.net:8443/p", "https://push.example.net:8443"),
1156 ("http://127.0.0.1:4000/push/1", "http://127.0.0.1:4000"),
1157 ] {
1158 assert_eq!(audience(&url::Url::parse(endpoint).unwrap()), aud, "{endpoint}");
1159 }
1160 }
1161
1162 #[test]
1163 fn the_subject_is_the_contact_else_an_https_public_url() {
1164 let https = crate::operator::PublicUrl::parse("https://hennery.example").unwrap();
1165 let loopback = crate::operator::PublicUrl::parse("http://localhost:7117").unwrap();
1166 assert_eq!(
1167 subject(Some("me@example.com"), &loopback).as_deref(),
1168 Some("mailto:me@example.com")
1169 );
1170 assert_eq!(subject(None, &https).as_deref(), Some("https://hennery.example"));
1171 assert_eq!(subject(None, &loopback), None);
1172 }
1173
1174 #[test]
1176 fn a_token_without_a_subject_has_no_sub_claim() {
1177 let key = VapidKey::generate();
1178 let endpoint = url::Url::parse("https://fcm.googleapis.com/fcm/send/abc").unwrap();
1179 let claims = verified(&key.authorization(&endpoint, None, 1_800_000_000));
1180 assert_eq!(
1181 claims,
1182 json!({ "aud": "https://fcm.googleapis.com", "exp": 1_800_000_000 + VAPID_TOKEN_SECS })
1183 );
1184 }
1185
1186 #[test]
1189 fn the_private_key_is_never_shown() {
1190 let key = VapidKey::generate();
1191 let secret = key.secret.to_bytes();
1192 let shown = [
1193 format!("{key:?}"),
1194 key.authorization(
1195 &url::Url::parse("https://fcm.googleapis.com/x").unwrap(),
1196 Some("mailto:a@b.co"),
1197 0,
1198 ),
1199 ];
1200 for encoded in [
1201 hex::encode(secret),
1202 hex::encode_upper(secret),
1203 URL_SAFE_NO_PAD.encode(secret),
1204 base64::engine::general_purpose::STANDARD.encode(secret),
1205 ] {
1206 for text in &shown {
1207 assert!(!text.contains(&encoded), "{text}");
1208 }
1209 }
1210 }
1211
1212 #[test]
1213 fn the_push_services_endpoints_are_accepted_as_they_are() {
1214 for endpoint in [
1215 "https://fcm.googleapis.com/fcm/send/abc:def",
1216 "https://updates.push.services.mozilla.com/wpush/v2/gAAAA",
1217 "https://web.push.apple.com/QAbc",
1218 "https://wns2-par02p.notify.windows.com/w/?token=BQYAAA",
1219 ] {
1220 assert_eq!(parse_endpoint(endpoint).map(String::from), Ok(endpoint.to_string()));
1221 }
1222 }
1223
1224 #[test]
1226 fn an_endpoint_is_stored_as_parsed() {
1227 assert_eq!(
1228 parse_endpoint(" https://FCM.googleapis.com/fcm/send/a\n").map(String::from),
1229 Ok("https://fcm.googleapis.com/fcm/send/a".to_string())
1230 );
1231 }
1232
1233 #[test]
1234 fn an_endpoint_that_could_reach_inside_is_refused() {
1235 for endpoint in [
1236 "http://fcm.googleapis.com/fcm/send/abc",
1237 "https://127.0.0.1/push",
1238 "https://[::1]/push",
1239 "https://10.0.0.1/push",
1240 "https://0x7f000001/push",
1241 "https://2130706433/push",
1242 "https://localhost/push",
1243 "https://localhost./push",
1244 "https://push.localhost/push",
1245 "https://printer.local/push",
1246 "https://nas.lan/push",
1247 "https://metadata.google.internal/push",
1248 "https://router.home.arpa/push",
1249 "https://push.example/push",
1250 "https://abc.onion/push",
1251 "https://intranet/push",
1252 "https://fcm.googleapis.com:8443/push",
1253 "https://user:pw@fcm.googleapis.com/push",
1254 "https://fcm.googleapis.com/push#x",
1255 "file:///etc/passwd",
1256 "not a url",
1257 ] {
1258 assert!(parse_endpoint(endpoint).is_err(), "{endpoint}");
1259 }
1260 let long = format!("https://fcm.googleapis.com/{}", "a".repeat(MAX_ENDPOINT));
1261 assert!(parse_endpoint(&long).is_err());
1262 }
1263
1264 #[test]
1265 fn keys_must_be_a_point_and_sixteen_bytes() {
1266 let unpadded = Ok((P256DH.to_string(), AUTH.to_string()));
1267 assert_eq!(parse_keys(P256DH, AUTH), unpadded);
1268 assert_eq!(parse_keys(&format!("{P256DH}="), &format!("{AUTH}==")), unpadded);
1270 assert!(parse_keys(P256DH, &URL_SAFE_NO_PAD.encode([0u8; 15])).is_err());
1273 assert!(parse_keys(P256DH, &URL_SAFE_NO_PAD.encode([0u8; 17])).is_err());
1274 assert!(parse_keys(&URL_SAFE_NO_PAD.encode([4u8; 65]), AUTH).is_err());
1275 let compressed = URL_SAFE_NO_PAD.encode(
1276 p256::PublicKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(P256DH).unwrap())
1277 .unwrap()
1278 .to_sec1_point(true)
1279 .as_bytes(),
1280 );
1281 assert!(parse_keys(&compressed, AUTH).is_err());
1282 assert!(parse_keys("not base64!", AUTH).is_err());
1283 }
1284
1285 #[test]
1286 fn a_contact_is_a_plain_address() {
1287 for contact in ["me@example.com", "first.last+push@mail.example.org", "a_b-c@x-y.io"] {
1288 assert_eq!(contact_problem(contact), None, "{contact}");
1289 }
1290 for contact in [
1291 "",
1292 "me",
1293 "@example.com",
1294 "me@",
1295 "me@localhost",
1296 "me@example..com",
1297 "me@-example.com",
1298 "me@example.com?subject=x",
1299 "me@example.com,you@example.com",
1300 "me @example.com",
1301 "mailto:me@example.com",
1302 "m\u{e9}@example.com",
1303 ] {
1304 assert!(contact_problem(contact).is_some(), "{contact:?}");
1305 }
1306 let long = format!("{}@example.com", "a".repeat(MAX_CONTACT));
1307 assert!(contact_problem(&long).is_some());
1308 }
1309
1310 #[test]
1311 fn a_generated_key_has_a_65_byte_public_key() {
1312 let key = VapidKey::generate();
1313 let public = URL_SAFE_NO_PAD.decode(key.public_key()).unwrap();
1314 assert_eq!(public.len(), 65);
1315 assert_eq!(public[0], 4);
1316 assert_ne!(VapidKey::generate().public_key(), key.public_key());
1317 }
1318
1319 #[test]
1320 fn the_key_file_is_created_private_and_then_loaded() {
1321 let dir = tempfile::tempdir().unwrap();
1322 let first = VapidKey::load_or_create(dir.path()).unwrap();
1323 let path = dir.path().join(VAPID_KEY_FILE);
1324 let meta = std::fs::metadata(&path).unwrap();
1325 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
1326 assert_eq!(meta.len(), 32);
1327 let again = VapidKey::load_or_create(dir.path()).unwrap();
1328 assert_eq!(again.public_key(), first.public_key());
1329 let names: Vec<_> = std::fs::read_dir(dir.path())
1331 .unwrap()
1332 .map(|e| e.unwrap().file_name())
1333 .collect();
1334 assert_eq!(names, [VAPID_KEY_FILE]);
1335 }
1336
1337 #[test]
1338 fn a_bad_key_file_stops_the_start_and_is_kept() {
1339 let dir = tempfile::tempdir().unwrap();
1340 let path = dir.path().join(VAPID_KEY_FILE);
1341 for (contents, mode) in [
1342 (vec![7u8; 31], 0o600),
1343 (vec![7u8; 33], 0o600),
1344 (vec![0u8; 32], 0o600),
1345 (vec![0xffu8; 32], 0o600),
1346 (vec![7u8; 32], 0o640),
1347 (vec![7u8; 32], 0o604),
1348 ] {
1349 std::fs::write(&path, &contents).unwrap();
1350 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
1351 let err = VapidKey::load_or_create(dir.path()).unwrap_err();
1352 assert!(!format!("{err:#}").contains(&hex::encode(&contents)));
1354 assert_eq!(std::fs::read(&path).unwrap(), contents);
1355 }
1356 }
1357
1358 #[test]
1359 fn a_symlinked_key_file_is_refused() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let target = dir.path().join("elsewhere");
1362 std::fs::write(&target, [7u8; 32]).unwrap();
1363 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
1364 std::os::unix::fs::symlink(&target, dir.path().join(VAPID_KEY_FILE)).unwrap();
1365 assert!(VapidKey::load_or_create(dir.path()).is_err());
1366 }
1367
1368 #[test]
1370 fn a_fifo_for_a_key_file_is_refused_at_once() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let path = std::ffi::CString::new(dir.path().join(VAPID_KEY_FILE).as_os_str().as_encoded_bytes()).unwrap();
1373 assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
1374 let err = VapidKey::load_or_create(dir.path()).unwrap_err();
1375 assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
1376 }
1377}