# 0085 — Content-free Web Push wakes approvers; `inspect --search` closes the bstr proof hole - Status: accepted (implemented in the PoC; amends ADR 0020 and ADR 0025, replaces N15's "PWA open only"; §2–§4 amended by ADR 0116: JWT cache, conditional 404/410 delete, subscribe/revoke under one lock, serialised endpoint length) - Date: 2026-10-23 - Area: relay, approver PWA, proof tooling ## Context Personas rejected "remote approval works only while the PWA is open (N15)": a locked phone in a pocket never learns that an agent is waiting, which undercuts the core pitch (approve from anywhere, overnight). ADR 0020 already chose "opaque Web Push for wakeup" as the product target; ADR 0025 deferred it. This ADR decides the exact form and ships it. Facts that constrain the design: 1. **Web Push is RFC 8030 + RFC 8292 + RFC 8291.** The application server POSTs to the subscription's *endpoint* URL at the browser vendor's push service with `TTL`, optional `Urgency` and `Topic`; RFC 8291 encryption applies only to a **payload** (src 1, 2, 3). A push message may be empty: the Push API fires `push` with `event.data === null` (src 4, 7). Apple: "If the payload is empty, you may omit [Content-Encoding]" (src 5). 2. **VAPID (RFC 8292):** ES256 JWT with `aud` = origin of the push resource, `exp` ≤ 24 h, optional `sub` contact; header `Authorization: vapid t=, k=`; a restricted subscription (created with `applicationServerKey`) MUST be refused without valid VAPID by that key (src 2 §2, §4.2). Apple additionally rejects a `sub` that is not a `mailto:` or URL (`BadJwtToken`), asks servers not to refresh a JWT more than once per hour, and caps payloads at 4 KB (src 5, 6). 3. **iOS/iPadOS 16.4+:** Web Push only for Home Screen web apps, permission only from a user gesture, delivered over APNs; "Safari doesn't support invisible push notifications … If you don't [show one], Safari revokes the push notification permission" — subscriptions must use `userVisibleOnly: true` (src 5, 8, 9). Safari ignores notification `tag` replacement; elsewhere a constant tag replaces silently unless `renotify` (src 7). 4. **What the push service learns** (RFC 8030 §8.1–8.2): TLS hides content from the network, not from the push service; push URIs must not correlate a user agent across subscriptions, but the service sees *who sends* (the relay's IP and VAPID key), *to which subscription*, *when*, and the `Topic`/`TTL`/`Urgency` metadata ("The Topic header field … might be used to aid traffic analysis") (src 1). Apple/Google/Mozilla also know which device (and so which Apple/Google account) holds a subscription — that is how they deliver. 5. **Endpoint hosts in practice:** Apple `*.push.apple.com` (Apple's documented allow-list, src 5, 8), Chrome `fcm.googleapis.com` (src 10), Firefox `updates.push.services.mozilla.com` (src 11), Edge `*.notify.windows.com` (WNS; src 12, a forum report — the host suffix is **unverified** against Microsoft documentation, so it is only a default that an operator can replace with `--push-host`). 6. **Playwright Chromium cannot subscribe for real:** measured 2026-10-23 with Playwright 1.63 — the headless shell reports `Notification.permission = "denied"`; the full Chromium build in new headless mode grants permission but `pushManager.subscribe` fails with `AbortError: Registration failed - permission denied` (no push-service connection). CDP `ServiceWorker.deliverPushMessage` does deliver a push (with empty data) to the worker, which then shows a notification (src 13; measurement in this ADR's test). ## Decision 1. **Content-free ping.** On every appended `request` entry (access or drop) the relay sends each **active approver** of that account an RFC 8030 push with an **empty body** — no request id, handle, host, reason, size class or account id. Headers: `TTL: 600` (a request lives ≤ 600 s, §5.4), `Urgency: high`, `Topic: custos-pending` (a newer ping replaces an undelivered one), `Authorization: vapid …`. No payload ⇒ no RFC 8291 encryption, no new AES-GCM or ECDH code (fact 1). The service worker never reads `event.data`; it shows the fixed text "An approval request is waiting. Open Custos to review it." with a per-push tag (so it alerts, fact 3), and a tap focuses or opens the app **at its own scope** — never a URL from the push. The app then long-polls and verifies the log exactly as before; the push grants nothing and carries nothing to trust. 2. **VAPID with the existing `p256`.** ES256 via `p256 =0.14.0` (already pinned for WebAuthn, ADR 0027). The VAPID key is derived from the relay seed: the first valid scalar of `KDF("", seed, "custos/C1/relay-vapid", [i])` (new label in the §3.2 registry), so the relay keeps one key file. JWT: `{aud, exp = now + 12 h, sub}` (≤ 24 h, fact 2), cached per audience and reused until < 1 h remains (Apple's hourly guidance). `sub` is the operator's `--push-subject` (`mailto:` or `https:`, checked at start); push is **off** without it. 3. **Subscription at the relay.** `POST /v1/accounts/:acct/push {endpoint | null}`, device-authenticated (§10); only an **active approver** may subscribe (`403 not_approver`); one endpoint per device (replace), `null`/absent removes it. Stored as `push(account, device, endpoint)`; `inspect` shows it. The endpoint must be `https`, ≤ 2048 bytes, without user info or fragment, on an **allow-listed host** (fact 5; `--push-host` replaces the list) — the relay never POSTs to an arbitrary URL (SSRF), never follows redirects, times out after 10 s. `GET /v1/relay` adds `vapid_pub` (the `applicationServerKey`, `null` when off). 4. **Revocation.** A `device.revoke` entry deletes that device's subscription in the same append; independently, every send re-reads the log state and pings only devices that are active approvers *now*. A `404`/`410` from the push service deletes the subscription (RFC 8030 §7.3); other failures keep it. 5. **Rate limiting.** One worker task coalesces per account: at most one ping per 30 s; a request inside the interval schedules one trailing ping at its end (never dropped), so a flooding host causes ≤ 2 pings/min per approver whatever its request rate. Combined with `Topic`, an offline phone receives one ping, not a queue. 6. **PWA.** Settings → Notifications (tap → permission → `navigator.serviceWorker.register('/sw.js')` → `pushManager.subscribe({userVisibleOnly: true, applicationServerKey})` → signed `POST …/push`); off removes it at the browser and the relay; at every start the current endpoint is re-sent (rotation) or removed. `serviceWorker.register` is a Trusted Types script-URL sink, so the CSP changes from `trusted-types 'none'` to `trusted-types custos-sw`: one named policy whose `createScriptURL` returns exactly `/sw.js` and throws for anything else (ADR 0036 stays: no HTML sink is opened). The worker is its own build entry at the fixed URL `/sw.js` (listed in `manifest.sha256`). 7. **No new crate.** `reqwest =0.13.5` (+ the `rustls` `prefer-post-quantum` feature line) is already pinned for the daemon (ADR 0029); the relay now uses it. `p256` is already a dependency of core. `Cargo.lock` gains edges, no packages (dependency-age rule unaffected). 8. **`inspect --search` (proof hole).** `inspect` prints a CBOR byte string as `h'…'`, so "grep the dump for the secret/name/reason → 0 hits" misses plaintext inside any `bstr`. `custos-relay inspect --db … --search NEEDLE` (repeatable) or `--search-stdin` (one per line; use it for secrets — argv is visible to other local users) searches every column of every row (raw bytes), the rendered dump and the raw database files (`-wal`, `-journal`, free pages: deleted rows linger), in raw, lower/upper hex, base64 and base64url in all three alignments, percent and JSON-escaped forms (encoded forms < 8 bytes skipped as chance matches). It prints `HIT needle #n (len) as
in ` — never the needle — and exits 1 on any hit. The e2e canary rule (`assert_no_leaks`) now runs it for every plaintext needle and every key/value; a positive control plants a name inside a stored `bstr` and proves the plain dump misses it while `--search` finds it (raw and hex). ## Alternatives considered | Option | Why not | | --- | --- | | Encrypted payload with the request id (ADR 0020's first form) | Needs RFC 8291 (ECDH P-256 + HKDF + AES-128-GCM, i.e. an AES crate or hand-rolled content coding) for no user benefit: the app must sync and verify anyway. A fixed-size ciphertext still reveals nothing more than an empty push, but adds code, keys (`p256dh`, `auth`) stored at the relay and a parser in the worker | | Request details in the notification ("devbox wants github-token") | Puts names, hosts and reasons through Apple/Google/Mozilla and onto the lock screen; contradicts P1 and the zero-knowledge relay (it cannot read them anyway) | | Actionable notification buttons (Approve/Deny) | A decision must be a UV-signed log entry made in the app (ADR 0027); iOS PWA buttons are unreliable (ADR 0020) | | Silent push + background sync | Not allowed on Safari (fact 3); revoked subscriptions | | Separate VAPID key file | A second secret file to back up and protect; derivation from the relay seed is one HKDF call under a registered label | | `web-push` / `a2` crates | New dependencies for ~60 lines of JWT and headers (BRIEF: few dependencies) | | Push to every endpoint a client names | SSRF from the relay's network position (cloud metadata, internal hosts); hence the host allow-list | | Native APNs/FCM apps | Product work (ADR 0020, 0028) | ## Evidence 1. [RFC 8030 — Generic Event Delivery Using HTTP Push](https://www.rfc-editor.org/rfc/rfc8030.txt) — §5.2 TTL, §5.3 Urgency, §5.4 Topic replacement, §7.3 404 for expired subscriptions, §8.1–8.2 confidentiality and privacy (Topic aids traffic analysis) — accessed 2026-10-23 2. [RFC 8292 — VAPID for Web Push](https://www.rfc-editor.org/rfc/rfc8292.txt) — §2 claims (aud = origin, exp ≤ 24 h, sub), §2.4 example (used as a known-answer test: the example JWT verifies under the example key), §3 `vapid t=, k=`, §4.2 restricted subscriptions — accessed 2026-10-23 3. [RFC 8291 — Message Encryption for Web Push](https://www.rfc-editor.org/rfc/rfc8291.txt) — `aes128gcm` content coding applies to a payload — accessed 2026-10-23 4. [W3C Push API](https://www.w3.org/TR/push-api/) — `PushManager.subscribe(userVisibleOnly, applicationServerKey)`, `PushEvent.data` null without payload — accessed 2026-10-23 5. [Apple: Sending web push notifications in web apps and browsers](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers) — iOS 16.4+ Home Screen apps, no invisible push, `*.push.apple.com`, empty payload may omit Content-Encoding, JWT refresh ≤ hourly, `BadJwtToken` (sub must be URL or mailto, exp ≤ 1 day), 4 KB limit, 410 for expired tokens — accessed 2026-10-23 6. [web-push-libs/web-push#947](https://github.com/web-push-libs/web-push/issues/947) — Apple returns 403 BadJwtToken for an invalid `sub` domain — accessed 2026-10-23 7. [Dr. Lex: The sorry state of implementing Web Push (2024–2026)](https://www.dr-lex.be/info-stuff/web-push.html) — payload-less pushes give `event.data === null`; always show a notification first or iOS revokes; the tag trap (silent replacement), Safari ignores tags — accessed 2026-10-23 8. [WebKit: Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) — Home Screen only, user-gesture permission, APNs, `*.push.apple.com` — accessed 2026-10-23 9. [WebKit: Meet Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) — `userVisibleOnly` required; a push handler that shows nothing gets the subscription revoked — accessed 2026-10-23 10. [Chrome for Developers: Web Push interoperability wins](https://developer.chrome.com/blog/web-push-interop-wins) — Chrome endpoints at `https://fcm.googleapis.com/fcm/send/…` — accessed 2026-10-23 11. [Mozilla Hacks: Debugging Web Push in Firefox](https://hacks.mozilla.org/2017/05/debugging-web-push-in-mozilla-firefox/) — endpoints at `https://updates.push.services.mozilla.com/wpush/…` — accessed 2026-10-23 12. [Stack Overflow 75514875](https://stackoverflow.com/questions/75514875/push-notifications-in-edge-never-arrive-is-it-supported) — Edge endpoint `https://wns2-ch1p.notify.windows.com/w/…` (forum report; **unverified** against Microsoft docs) — accessed 2026-10-23 13. [Chrome DevTools Protocol: ServiceWorker.deliverPushMessage](https://chromedevtools.github.io/devtools-protocol/tot/ServiceWorker/) — delivers a push to a registration (used by the Playwright test) — accessed 2026-10-23 14. Measurement 2026-10-23 (this repository): Playwright 1.63 Chromium headless shell → notification permission `denied`; `channel: 'chromium'` → permission granted, `subscribe` → `AbortError: Registration failed - permission denied`; CDP delivery of an empty push → notification shown ## Consequences - **N15 changes:** a locked or backgrounded phone with notifications on is told that *something* is waiting; it still needs the app opened to see and decide. Without push (not a Home Screen app on iOS, permission denied, relay without `--push-subject`) N15 holds as before. - **N10 grows** (new metadata, documented): the **relay** learns which approver devices enabled push and their push vendor (endpoint host: Apple ⇒ iPhone/Mac, FCM ⇒ Chrome/Android, …), and the endpoint is a capability to send that device empty pushes (useless without the VAPID key for a restricted subscription). The **push service** (Apple/Google/Mozilla/Microsoft) learns that this relay (IP, VAPID key, contact `sub`) pings this device/account, **when** — i.e. the timing of the user's approval requests — and the constant `Topic`; never content. A malicious relay can withhold pings (N3/N15 unchanged) or send empty pings (annoyance only: the app shows only verified log state). - `inspect --search` gives operators and the e2e suite a sound check: the plaintext-in-a-bstr blind spot of `grep inspect` is closed and tested. - Tests: `crates/relay/tests/push.rs` (mock RFC 8030 push service that verifies VAPID per RFC 8292 §4.2 and asserts an empty body; RFC 8292 §2.4 KAT; subscription rules; every active approver pinged, nothing in any header; coalescing; revocation; 404/410 cleanup; broken endpoints), `crates/relay/tests/cli.rs` (`--search` finds a planted `bstr` needle), `tests-e2e/tests/canary_rule.rs` (positive control), `approver/tests/unit/push.test.ts` (100 %), `approver/tests/e2e/push.spec.ts` (Chromium: worker under the real CSP, signed subscription, CDP push → generic notification; subscribe stubbed per fact 6). - Residual: Firefox ignores `renotify`, Safari ignores tags (fact 3) — a unique tag per push keeps both alerting; notifications may pile up (≤ 2/min). Real push delivery through Apple/Google is not exercised in CI (fact 6); a manual phone test is listed in STATUS. ## Amendment (2026-10-23, review round 6): mute, quarantine and a lost subscription **Context.** The PWA's fatigue controls (mute one host, "Mute requests from all hosts for 8 h", offered in Settings and right after **Revoke everything**, and the quarantine storm card, ARCHITECTURE §7.5.5) were local only, and §6 did not mention them. The relay pinged for every request, so a compromised host that is still enrolled could keep an alerting notification coming about twice a minute (§5) for the whole mute window, and opening the app showed nothing (SECURITY-C6-1). Separately, when the browser lost the subscription, the start-up refresh removed the endpoint at the relay but left the local "push on" flag set, so the Inbox kept saying "Notifications are on" (SPEC-CONFORMANCE-C6-1). **Decision.** 9. **"Mute all" pauses push at the relay.** While all hosts are muted, the PWA sends `{endpoint: null}` (§3), so the relay holds no address and pings nothing. The browser subscription and the local "on" setting stay, so no new permission prompt or user gesture is needed afterwards. When the mute is cleared ("Unmute all") or expires, the endpoint is sent again. The PWA reconciles at start, on each mute change and in every sync round, and posts only when the wanted value differs from what it last sent in that session. A failed post is retried in the next round. 10. **A per-host mute or a quarantine does not silence notifications, and the PWA says so.** An empty push cannot say which host asked (§1, fact 4), and the worker cannot drop a push: WebKit revokes a subscription whose push shows no notification (facts 7, 9). The storm card and the "hidden from muted hosts" line show: "Notifications continue for this host / muted hosts; Mute all hosts in Settings or turn notifications off to silence them". Settings → Quiet hours states the same. While "Mute all" is active, the Inbox shows "Notifications are paused while all hosts are muted (until HH:MM)". 11. **A lost subscription turns push off locally.** If the browser has no subscription at refresh, the PWA removes the endpoint at the relay **and** clears its local flag. The Inbox then says that requests arrive only while the app is open, and Settings offers "Turn on notifications". **Alternatives considered.** - *Encrypted payload naming the host, filtered in the worker:* rejected. It needs RFC 8291 encryption and per-device keys (against §1 and §7), it puts a host identifier through the push service, and it still cannot suppress a notification on iOS (facts 7, 9). - *Per-host mute at the relay (a signed `host.mute` entry):* rejected for the PoC (it is deferred in ARCHITECTURE §13.2). It would also tell the relay and every device which host the user muted. - *Unsubscribe in the browser on "Mute all":* rejected. Turning push back on would need a new subscribe from a tap (fact 8), so the mute could not end by itself. **Consequences.** N10: the relay also learns when the user muted all hosts (the row disappears and later comes back). N15: a per-host mute and a quarantine leave notifications on (≤ 2/min, §5), and the PWA says so. Tests in `approver/tests/unit/push.test.ts` ("review round 6": mute-all posts null, per-host mute does not, unmute and expiry re-send, enabling during a mute sends null, retry after a failed post, the Inbox texts, a lost subscription turns push off and is persisted, `applicationServerKey` equals the relay's `vapid_pub`). `approver/tests/e2e/push.spec.ts`: the stubbed `subscribe` records its key, which must equal the `/v1/relay` `vapid_pub` bytes, and "Mute all" empties the relay's endpoints.