Skip to main content

hennery_kernel/
push.rs

1//! Web Push (kernel spec §6; plan 10a): the VAPID key pair in
2//! `<data>/vapid.key` and the tokens it signs, the owner's push
3//! subscriptions, and each hat's push policy. Delivery and its triggers are
4//! plan 10b's. The subscriptions and the policies share the host registry's
5//! connection and owner, as hats do, and every query names the owner
6//! (kernel spec §1).
7
8use 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
21/// The VAPID private key's file in the collector's data directory
22/// (kernel spec §1).
23pub const VAPID_KEY_FILE: &str = "vapid.key";
24
25/// How long a VAPID token is valid. RFC 8292 §2 allows at most 24 hours.
26pub const VAPID_TOKEN_SECS: i64 = 12 * 60 * 60;
27
28/// The subscriptions an owner may have at once (decision 5): each is a
29/// request per notification.
30pub const MAX_SUBSCRIPTIONS: usize = 32;
31
32/// The longest endpoint accepted (decision 3). The push services' own are
33/// a few hundred bytes.
34pub const MAX_ENDPOINT: usize = 2048;
35
36/// The longest contact address accepted (RFC 5321's path limit).
37pub const MAX_CONTACT: usize = 254;
38
39/// Names that never reach a public push service (decision 3; the review's
40/// A3): special-use and private-network suffixes. The address check at
41/// every send is the guard; this refuses the obvious early.
42const 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
56/// The collector's VAPID key pair (RFC 8292): a P-256 key generated on the
57/// first start and kept in `vapid.key` (kernel spec §6). Every push
58/// subscription is bound to its public key, so the file is never replaced:
59/// one that cannot be read stops the start (decision 1). The private key
60/// leaves this type only as signatures.
61pub struct VapidKey {
62    secret: p256::SecretKey,
63    /// The public key as `applicationServerKey` takes it: the uncompressed
64    /// point, base64url without padding.
65    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    /// A fresh key held only in memory: for tests, and for a collector
78    /// that has not loaded its file yet.
79    pub fn generate() -> Self {
80        Self::from_secret(fresh_secret())
81    }
82
83    /// The key in `dir/vapid.key`, created 0600 when there is none. A file
84    /// there that is not a private, regular, 32-byte P-256 key is an error,
85    /// never replaced (decision 1).
86    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    /// The public key, base64url: `GET /api/push/vapid`.
96    pub fn public_key(&self) -> &str {
97        &self.public
98    }
99
100    /// The `Authorization` header of a push to `endpoint` at `now` (RFC 8292
101    /// §3): `vapid t=<token>, k=<public key>`. The token names the
102    /// endpoint's origin (`audience`), `subject` (`subject`) when there is
103    /// one, and an expiry `VAPID_TOKEN_SECS` ahead.
104    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    /// A JWT (RFC 7519) signed with ES256 (RFC 7515, 7518 §3.4): header
110    /// `{"typ":"JWT","alg":"ES256"}`, claims `aud`, `exp` and, when there is
111    /// one, `sub`, every part base64url without padding (decision 8).
112    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    /// ECDSA over P-256 with SHA-256, deterministic (RFC 6979), in the form
125    /// JWS takes (RFC 7518 §3.4): `r ‖ s`, 32 bytes each, never DER.
126    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
140/// A VAPID token's `aud` (RFC 8292 §2): the endpoint's origin,
141/// `scheme://host`, with `:port` only when it is not the scheme's default.
142pub fn audience(endpoint: &url::Url) -> String {
143    endpoint.origin().ascii_serialization()
144}
145
146/// A VAPID token's `sub` (kernel spec §6; maintainer decision 6b): `mailto:`
147/// the owner's contact (`contact_problem` keeps it free of anything `mailto:`
148/// would need escaped), else the `public_url` origin when it is `https`.
149/// `None` for an `http` one (loopback): kernel §6 never sends a
150/// non-routable `sub`, and Apple refuses anything but `mailto:` or `https:`
151/// (the review's N1), so the token carries none.
152pub 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
159/// A random scalar from the OS: a draw out of range (zero, or the group
160/// order or more, about 2^-32 likely) is drawn again.
161pub(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
170/// The key at `path`, or `None` when there is no file. A symlink, a file
171/// its group or others can read, or anything but 32 bytes of a valid key
172/// is an error, which never quotes the file's bytes.
173fn load(path: &Path) -> Result<Option<VapidKey>> {
174    // Not followed if a symlink; and a FIFO put there does not block the
175    // open (the review's A5): it is refused as not a regular file.
176    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
206/// Write a fresh key to `path`, 0600, under a temporary name first, then
207/// hard-linked into place: a link never replaces a file, so of two starts
208/// racing, the first key stays and the other start loads it. The temporary
209/// file is removed whatever happens (the review's A6).
210fn 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
235/// `endpoint` as a push subscription's (decision 3), in the form it is
236/// stored, looked up and sent to (the review's A2), or why it cannot be one.
237/// The push services' endpoints are `https` URLs on their own domain names
238/// at the default port; anything else is refused before it is stored, and
239/// the address it resolves to is checked again on every send (kernel spec
240/// §7.1).
241pub 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
277/// The subscription's public key and secret (base64url, padded or not),
278/// in the form they are stored: base64url without padding, so delivery
279/// decodes them one way. `Err` unless they are a P-256 point, uncompressed,
280/// and 16 bytes (RFC 8291 §3.2).
281pub 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
293/// Why `contact` cannot be the owner's push contact, or `None`: a plain
294/// e-mail address, which the VAPID token names as `mailto:` (kernel spec
295/// §6; plan 10a decision 7). Letters, digits and `.+_-` before the `@`; a
296/// domain name with a dot after it.
297pub 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/// A subscription as the browser sent it (`PushSubscription.toJSON()`).
316#[derive(Clone)]
317pub struct NewSubscription<'a> {
318    pub endpoint: &'a str,
319    pub p256dh: &'a str,
320    pub auth: &'a str,
321    /// What Settings lists it as; the endpoint's host name when absent.
322    pub device_label: Option<&'a str>,
323    /// `expirationTime`, in seconds since the epoch.
324    pub expires_at: Option<i64>,
325}
326
327/// The endpoint is a capability URL and `auth` a secret: neither is
328/// printed (decision 3).
329impl 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/// One stored subscription.
346#[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    /// The signed-in session that subscribed it (`Authenticated::session_id`).
354    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    /// The endpoint's host name: what Settings shows of it.
373    pub fn endpoint_host(&self) -> String {
374        endpoint_host(&self.endpoint)
375    }
376}
377
378/// The outcome of `Hosts::subscribe` and `Hosts::rotate`.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub enum Subscribed {
381    Created(Subscription),
382    /// The endpoint was the owner's already: its keys, label, session and
383    /// expiry are replaced (a browser re-subscribing after a key change).
384    /// A rotation's answer.
385    Replaced(Subscription),
386    Invalid(String),
387    /// `MAX_SUBSCRIPTIONS` already.
388    TooMany,
389    /// Another owner has this endpoint (the review's A7).
390    Conflict,
391    /// A rotation whose old endpoint is not the owner's.
392    NotFound,
393    /// The subscribing session ended before the subscription was stored (a
394    /// sign-out racing it; Task 1's review): nothing is stored.
395    SignedOut,
396}
397
398/// A hat's push policy (kernel spec §6). The default, for a hat with no
399/// row: not muted, no details, the session title shown.
400#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
401pub struct PushPolicy {
402    /// No notification for the hat's sessions.
403    pub muted: bool,
404    /// Include the agent's question title.
405    pub details: bool,
406    /// "Session needs your answer", without the session title.
407    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
428/// A new subscription checked, in the form it is stored: its endpoint,
429/// its keys, and its label when it gave one.
430struct Checked {
431    endpoint: url::Url,
432    p256dh: String,
433    auth: String,
434    label: Option<String>,
435}
436
437/// `new` checked at `now`, or the reason it cannot be stored.
438fn 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
457/// Whether `err` is a uniqueness constraint failing: of the subscriptions'
458/// columns only `endpoint` is unique, and only another owner's row can hold
459/// it by then. Any other constraint is an error, not a conflict.
460fn 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    /// Store the subscription `new`, made by the signed-in session
474    /// `auth_session` at `now`. An endpoint the owner has already is
475    /// replaced (`Subscribed::Replaced`), not counted twice, and keeps its
476    /// label unless `new` names one. The session must still be live when
477    /// the row is written (`Subscribed::SignedOut`; Task 1's review): ending
478    /// it removes its subscriptions, so one stored after would outlive it.
479    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    /// Replace the subscription of `old_endpoint` with `new` (a push service
562    /// rotating a subscription, which the browser reports to its service
563    /// worker; the review's A1). It keeps its id, `created_at` and the
564    /// session that made it, and its label unless `new` names one. Only an
565    /// endpoint the owner has is rotated (`Subscribed::NotFound`): the API
566    /// never shows endpoints, so knowing one is holding that browser.
567    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        // A row the browser made for its new endpoint already gives way.
590        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    /// Every subscription of the owner's, oldest first.
619    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    /// Remove the subscription `id`. Whether there was one.
629    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    /// Remove the subscription of `endpoint`: a browser unsubscribing
638    /// itself, which knows its endpoint and not its id. Whether there was
639    /// one.
640    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    // Delivery's three outcomes name the endpoint the push went to, as
652    // well as the subscription: a browser rotating it meanwhile keeps the
653    // id, and the old endpoint's answer is not the new one's (plan 10b-ii).
654
655    /// Record that a push to `endpoint` reached its push service at `now`,
656    /// clearing the subscription `id`'s last error (plan 10b-ii).
657    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    /// Record why a push to the subscription `id` at `endpoint` failed: a
667    /// short reason of delivery's own, never the endpoint or the service's
668    /// answer (plan 10a's review, O2).
669    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    /// Remove the subscription `id` its push service says is gone, if it
678    /// still has the `endpoint` the push went to (plan 10b-ii).
679    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    /// Remove the subscriptions whose `expirationTime` has passed at `now`
688    /// (plan 10b-ii): how many.
689    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    /// Every hat's push policy, the hats oldest first: the default for a
697    /// hat that never had one set.
698    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    /// The push policy of `hat_id`: the default for a hat with none set,
719    /// and for a hat that does not exist (a session from before hats has
720    /// none).
721    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    /// Set the push policy of `hat_id`. `false`, and nothing stored, when
740    /// the owner has no such hat.
741    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            // The `WHERE` cannot fail after the owner's `EXISTS` above: it
759            // keeps the statement owner-scoped on its own (kernel spec §1).
760            params![hat_id, owner, policy.muted, policy.details, policy.generic_title],
761        )?;
762        tx.commit()?;
763        Ok(true)
764    }
765}
766
767/// How soon a push service should deliver a notification (RFC 8030 §5.3):
768/// `high` for "needs your answer", `normal` otherwise (kernel spec §6).
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub enum Urgency {
771    High,
772    Normal,
773}
774
775/// A notification for the owner's devices, before the hat's policy is
776/// applied (kernel spec §6; plan 10b decision 1). The modules that own a
777/// trigger fill it in (ACP core §10, gateway §7); delivery applies the
778/// policy, so no caller re-implements it:
779/// - a muted hat: nothing is sent;
780/// - `generic_title`: `generic_title` replaces `title`, and `body` is
781///   dropped;
782/// - `details`: `detail`, when there is one, replaces `body`.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct Notice {
785    /// The hat whose policy applies; `''` (a session from before hats) has
786    /// the default.
787    pub hat_id: String,
788    pub urgency: Urgency,
789    /// The session title, or the connection's label.
790    pub title: String,
791    /// The title with nothing of the session's own: "Session needs your
792    /// answer".
793    pub generic_title: String,
794    pub body: String,
795    /// More than the default shows (the agent's question title), only
796    /// under a hat with `details`.
797    pub detail: Option<String>,
798    /// The same-origin path the notification opens: `/sessions/<id>`, or
799    /// `/mcp` for the gateway's. Never a URL.
800    pub url: String,
801    /// One notification per tag on a device: a newer one replaces it.
802    pub tag: String,
803}
804
805/// The tags waiting to be delivered, at most (plan 10b-i decision 5). A
806/// tag holds one notice, its latest, so one session cannot fill the queue
807/// for the others (10b-i's review, A2).
808pub const NOTICE_QUEUE: usize = 256;
809
810#[derive(Default)]
811struct Queue {
812    /// Tags in the order they were first queued.
813    order: std::collections::VecDeque<String>,
814    /// Each queued tag's latest notice.
815    latest: std::collections::HashMap<String, Notice>,
816    /// Notices dropped because `NOTICE_QUEUE` tags were waiting.
817    dropped: u64,
818    /// Whether a reader is there; without one, every notice is dropped.
819    read: bool,
820}
821
822/// Modules may revoke queued notification attempts without changing delivery policy.
823pub 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/// Where notices go to be delivered (kernel spec §6): a queue that
832/// delivery (plan 10b-ii) drains. Cheap to clone; `notify` never waits and
833/// never fails, so a trigger can call it from any thread, async or not.
834#[derive(Clone)]
835pub struct Push {
836    shared: std::sync::Arc<Shared>,
837}
838
839/// The queue's reading end, for delivery. Dropping it makes `notify` drop
840/// everything.
841pub struct Notices {
842    shared: std::sync::Arc<Shared>,
843}
844
845impl Push {
846    /// A queue of `NOTICE_QUEUE` tags and its reading end.
847    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    /// A queue nobody reads: every notice is dropped. What a collector has
860    /// until delivery runs, and tests that do not look.
861    pub fn detached() -> Push {
862        Push::new().0
863    }
864
865    /// Install the owning module's final admission check. Delivery consults the
866    /// current callback before payload construction and each transport attempt.
867    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    /// Queue `notice` for delivery. Never waits. A notice for a tag already
876    /// waiting replaces it there, keeping its place: a device shows one
877    /// notification per tag anyway. A new tag when `NOTICE_QUEUE` are
878    /// waiting is dropped and counted, with a warning at the first drop and
879    /// every power of two after. The log names no notice's text.
880    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(&notice.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    /// Reads the current guard on every attempt, including when installed after run starts.
908    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    /// The next notice, oldest tag first; waits for one.
916    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    /// The next notice, if one is waiting.
928    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    /// Notices dropped so far because the queue was full.
939    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    /// 10b-i's review, A2: a tag holds its latest notice, in its first
981    /// place, so one session's burst is one notice.
982    #[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        // A waiting tag is still replaced.
1004        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    /// A real browser's keys (web-push-native's example subscription).
1029    const P256DH: &str = "BLn9b-VR0ca83knDNZ32dCHGyjJp-1riX9ZTN40MqV8K_LpQmLqxC_DoHvqvFXO_nGdAB4W9dogZb_sM-uV4JbY";
1030    const AUTH: &str = "_ordMnz7uTCmrpBTeUV4Bw";
1031
1032    /// RFC 8292 §2.4's example `Authorization` header, as printed there.
1033    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    /// Why a header failed the verifier: which check.
1041    #[derive(Debug, PartialEq)]
1042    enum Bad {
1043        Format(&'static str),
1044        Signature,
1045    }
1046
1047    /// Strict base64url without padding: anything else is not a JWS part.
1048    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    /// A verifier written from RFC 8292 §3 and RFC 7515 alone, not from
1059    /// the signing code: parse `vapid t=…, k=…`, split the compact JWS,
1060    /// check the header, take the signature as raw `r ‖ s` (64 bytes; DER
1061    /// is longer, and refused), and verify it with the key in `k`. The
1062    /// claims, parsed, or which check failed.
1063    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        // Another `aud`, still valid JSON: only the signature can tell.
1106        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    /// RFC 6979 A.2.5: P-256, SHA-256, message "sample". Deterministic, so
1117    /// the signature is known: `r ‖ s`, raw, as JWS takes it.
1118    #[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        // Another key's `k` does not verify this token.
1145        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    /// The review's N1: with no subject, the token carries no `sub` at all.
1175    #[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    /// The private key never leaves the type but as signatures: not in
1187    /// `Debug`, not in a token or header, in no encoding.
1188    #[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    /// The review's A2: what is stored is what was checked.
1225    #[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        // Padded keys are accepted, and given back unpadded.
1269        assert_eq!(parse_keys(&format!("{P256DH}="), &format!("{AUTH}==")), unpadded);
1270        // 15 bytes: `Auth::clone_from_slice` would panic on it (plan 10a
1271        // decision 3).
1272        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        // No temporary file is left behind.
1330        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            // The error names the file, never its bytes.
1353            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    /// The review's A5: a FIFO is refused, not waited on.
1369    #[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}