Expand description
cratefield-adapter-webpush: the Push port over Web Push
(issue #180) — RFC 8030 delivery, RFC 8188/RFC 8291 aes128gcm
payload encryption, RFC 8292 VAPID authentication. Uses the runtime’s
HttpClient and Clock ports — no vendor SDK, no reqwest, no
OpenSSL — so the same adapter runs on Workers (worker::Fetch) and
natively.
One adapter, two audiences. A browser subscription and a
UnifiedPush endpoint are the same protocol: a UnifiedPush distributor
(ntfy, NextPush, Sunup) hands the app an endpoint that accepts exactly
the RFC 8030 request a browser push service accepts. So this adapter
serves Google-free Android as well as Chrome, Firefox, Edge and Safari,
and nothing in it knows which it is talking to (ADR 0015: the transport
is a fact about the recipient, not about the device).
Recipients. It serves Recipient::WebPush and returns
PushError::unsupported_recipient for APNs and FCM. A venture with
more than one transport wires
RoutingPush, which dispatches by
variant.
Encryption. Every message gets a fresh P-256 key pair and a fresh
16-byte salt, combined with the subscription’s p256dh by ECDH and with
its auth secret by HKDF, then one AES-128-GCM record (module
ece). The push service sees ciphertext; only the browser that
created the subscription holds the private half. The RFC 8291 Appendix A
and RFC 8188 §3.1 vectors are reproduced byte for byte in that module’s
tests, and tests/rfc8291.rs decrypts from the user agent’s side so a
regression cannot pass by matching a vector alone.
Authentication. VAPID (module vapid) is an ES256 JWT whose aud
is the push service’s origin, signed by cratefield_push_auth and
cached per origin — the audience differs per vendor, so one cached token
would be wrong for all but one of them.
Degraded mode. WebPush::not_configured reports
PushOutcome::NotConfigured without any network call, the same
contract the other adapters use, so a venture with no VAPID key still
builds and runs. It answers that only for the transport it serves: an
APNs or FCM recipient is still PushError::unsupported_recipient,
configured or not.
§Unregistered is a delete instruction
PushError::Unregistered does not mean “this send failed”. It is the
one error in the port that tells the caller to destroy the recipient,
and it is far too easy to over-apply — this adapter and its FCM sibling
each mapped an extra status onto it independently, before either was
reviewed.
It costs more here than anywhere else in the port. An APNs device token
or an FCM registration token is re-registered by the app on its next
launch, unattended; a Web Push subscription can be recreated only by
the browser calling pushManager.subscribe(), which needs the user back
on the site with notification permission still granted. Pruning a live
subscription is not a lost message, it is a lost subscriber.
So the bar is a status whose only meaning is “gone”: RFC 8030 §5
defines exactly one, 410 Gone, and that is the only one mapped. A
status that a misconfigured proxy, a stale ingress rule or a rate limiter
can also produce is retried, however often it happens to mean a dead
subscription in practice. When in doubt, retry: the cost of a wrong
Transient is some wasted sends, and the cost of a wrong Unregistered
cannot be undone from the server at all.
§cratefield-adapter-webpush
The Push port over Web Push, for the
Cratefield harness (issue #180): RFC 8030 delivery, RFC 8188 /
RFC 8291 aes128gcm payload encryption, RFC 8292 VAPID authentication.
It POSTs to the subscription’s endpoint through the runtime’s HttpClient
port, so the one adapter runs unchanged on Cloudflare Workers and on the
native runtime — no vendor SDK, no reqwest, no OpenSSL. All the crypto is
pure Rust (p256, hkdf, sha2, aes-gcm) and builds for
wasm32-unknown-unknown.
§One adapter, browsers and Google-free Android
A browser subscription and a UnifiedPush endpoint are the same protocol.
A UnifiedPush distributor on Android (ntfy, NextPush, Sunup) hands the app an
endpoint that accepts exactly the RFC 8030 request a browser push service
accepts, so this adapter serves de-Googled Android as well as Chrome,
Firefox, Edge and Safari 16+/iOS 16.4+ PWAs. Nothing in it knows which it is
talking to — the transport is a fact about the recipient, not about the
device (ADR 0015).
A note for anyone probing this by hand: publishing to a UnifiedPush topic on the public
ntfy.shanswers507 "cannot publish to UnifiedPush topic without previously active subscriber", because that instance runs withvisitor-subscriber-rate-limitingon. It defaults tofalseon a self-hosted server, which is what the CI leg in issue #181 uses. A 507 is a configuration signal from ntfy, not a “subscription gone”, and it is not mapped toUnregistered— nor toTransient, despite being a 5xx, because an operator state does not clear on its own and retrying it is retrying forever. It isRejected, withvisitor-subscriber-rate-limitingnamed in the message so the log line says what to change.
§Recipients
It serves Recipient::WebPush and answers PushError::Rejected("unsupported recipient…") for Apns and Fcm — including when it is not configured,
which is a fact about the credentials and not about the transports it
carries. A venture that speaks more than one transport puts
cratefield_core::RoutingPush in front, which dispatches by variant, so
venture code holds one Arc<dyn Push>.
Notification is transport-neutral, so some fields are mapped and some are
dropped, deliberately:
| Field | Web Push |
|---|---|
title / body | payload title / body |
icon | payload icon |
url | payload url — the click target a service worker opens |
thread_id | payload tag: notifications sharing one replace each other in the shade |
silent | payload silent (always present) |
data | payload data, nested — not merged at the top level as the APNs adapter does, because showNotification takes a data member of its own and nesting means a caller’s key can never collide with the adapter’s |
ttl | the TTL header, whole seconds; 24 hours when unset, because RFC 8030 §5.2 makes the header mandatory and there is no “unset” on the wire. 0 is passed through as the deliberate “deliver only if online now”; a sub-second TTL rounds up to one second, never down to 0 |
priority | the Urgency header: Immediate → high, Conserve → normal (not low, which can mean “hold until the screen is next on”) |
collapse_id | the Topic header. RFC 8030 §5.4 allows at most 32 URL-safe base64 characters, which the port’s field need not respect, so a value that does not fit is replaced by the first 32 base64url characters of its SHA-256. Equal ids still collapse, different ids still do not — and a topic that might carry a user identifier stops being readable by the push service |
badge | dropped — the port’s badge is the iOS app-icon count, the web Notification.badge is an icon URL; a number there would be silently wrong |
category, loc | dropped — neither has a counterpart in the web Notification API. There is no OS-side string catalogue to look a loc key up in, so localisation on the web happens before this point |
§Encryption
Every message gets a fresh P-256 key pair and a fresh 16-byte salt. The
ephemeral private key is combined with the subscription’s p256dh by ECDH,
mixed with its auth secret by HKDF-SHA256 (RFC 8291 §3.3), and the result
is the input keying material for one AES-128-GCM record (RFC 8188). The push
service sees ciphertext; only the browser that created the subscription holds
the key to open it.
Every secret in that chain — the ECDH secret, the IKM, the CEK, the nonce,
the subscription’s auth and the per-message private scalar — is cleared on
drop (zeroize, the workspace convention crates/kms, crates/secrets and
module-linkedin follow), and aes-gcm carries the same feature so the
cipher clears its expanded round keys too. p256’s SharedSecret already
zeroizes, so a copy taken out of it that did not would be the worst of both.
The subscription’s two keys are read in base64url or standard base64,
padded or not. Browsers hand them over as unpadded base64url, but a
subscription is routinely stored and shipped by something that is not the
browser that made it, and the standard alphabet (+ and /) comes back from
any encoder that was not asked for the URL-safe one. The alphabets do not
overlap, so accepting both is unambiguous — and refusing one meant a p256dh
containing a + could never be pushed to, permanently, since only the browser
can re-subscribe.
The payload limit is computed, not remembered. RFC 8188 §2 puts the
content at “any length up to rs-17” — one padding delimiter and one
16-octet tag — and the 86-octet header sits outside that budget. So the
default record size is derived from the 4096 octets a push service is
required to accept (RFC 8030 §7.2): 4096 - 86 = 4010, giving
4010 - 17 = 3993 octets of payload, which is exactly the number RFC 8291 §4
arrives at by the same arithmetic. An oversize payload is
PushError::Rejected("payload too large: …") before any request is made.
WebPush::max_payload() reports the limit and
WebPush::with_record_size(..) raises it, for a venture whose subscribers are
all on services that accept more than the minimum.
The folklore numbers — 4078, 4079, 3052 — come from setting rs to 4096 and
forgetting that the header is added to it, which produces a 4182-octet body
that a service capping at 4096 answers 413 to.
Verification: the RFC 8291 Appendix A example is reproduced byte for byte
(every published intermediate value, the 86-octet header, the ciphertext, and
the whole §5 body), the RFC 8188 §3.1 vector covers the content coding on its
own, and tests/rfc8291.rs runs an independent decryptor — the browser’s
half, written from the RFC text — over arbitrary payloads, so a regression
cannot pass by matching a vector alone.
§Authentication (VAPID)
The Authorization header is vapid t=<jwt>, k=<base64url public key>
(RFC 8292 §3). The JWT is ES256 with aud = the push service’s origin,
sub = the contact URI, exp = 12 hours out; signing and the mint-once
cache are cratefield-push-auth’s, shared with the APNs signer.
The token is cached per push-service origin, because aud is part of the
signed claims: Mozilla’s token is not Google’s. aud is the origin and never
the path — a push endpoint’s path is the subscription’s bearer capability, so
signing over it mints one token per subscriber and earns a 401 from every
service that checks strictly.
The legacy Crypto-Key: p256ecdsa= header is deliberately not sent. It
belongs to the pre-RFC draft; every current push service accepts RFC 8292 §3.
§Keys
Two secrets: VAPID_PRIVATE_KEY and VAPID_SUBJECT (a mailto: or https:
contact URI). The public key is derived from the private one and never
configured separately, so the pair cannot drift.
The private key is accepted in both forms that circulate: a PKCS#8 PEM, and the bare 32-byte P-256 scalar base64url-encoded (what the JavaScript tooling calls a VAPID private key).
fz push vapid keygen is the way to generate one (issue #184):
fz push vapid keygen --file vapid.key # prints the public key; writes the private one
wrangler secret put VAPID_PRIVATE_KEY < vapid.keyIt prints the public key in the form the browser wants and refuses to
overwrite an existing key without --force, because rotating one
invalidates every existing subscription. openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt produces the same thing
in the PKCS#8 form, which this adapter also accepts.
The matching applicationServerKey for the browser is
WebPush::public_key() — serve it to the client rather than writing it down
twice. fz push inspect-subscription reports the aud this adapter will
sign for a subscription, which is the first thing to check on a 401.
§Usage
use std::sync::Arc;
use cratefield_adapter_webpush::{WebPush, vapid::VapidKeys};
// On Workers: read the secrets from `env`, and use the runtime's ports.
let http = Arc::new(cratefield_runtime_cloudflare::FetchClient);
let clock = Arc::new(cratefield_runtime_cloudflare::WorkersClock);
let push: Arc<dyn cratefield_core::Push> = match env.secret("VAPID_PRIVATE_KEY").ok() {
Some(key) => Arc::new(WebPush::new(
http,
clock,
VapidKeys {
private_key: key.to_string(),
// "mailto:ops@example.test"
subject: env.secret("VAPID_SUBJECT")?.to_string(),
},
)?),
// No key set: reports NotConfigured, never touches the network.
None => Arc::new(WebPush::not_configured()),
};
// One transport: hand the adapter straight to the runtime. With more than
// one, wrap them: RoutingPush::new().web_push(push).apns(apns).
let runtime = cratefield_runtime_cloudflare::Cloudflare::new().push_arc(push);
// Sending names the transport, not a bare string. The three parts are what
// `PushSubscription.toJSON()` hands the client:
// push.send(
// &Recipient::web_push(endpoint, p256dh, auth),
// ¬ification,
// ).await?;The browser side — pushManager.subscribe({ applicationServerKey }) and the
service worker that reads this payload — is cf.push and /ui/sw-push.js
in cratefield-ui (issue #183); docs/UI.md has the client contract. The
applicationServerKey it subscribes with is this adapter’s
public_key(), served by cratefield-module-notifications at
GET /v1/notifications/vapid-public-key.
§Failure contract
send returns:
PushOutcome::Delivered { id }on any2xx. RFC 8030 specifies201 Createdwith aLocationnaming the push message resource, which becomes theid;200and202are accepted too, because ntfy answers200to a UnifiedPush publish and refusing that would fail a delivery that succeeded.PushError::Unregisteredon410— and on nothing else. See below.PushError::Transient { retry_after }on429,5xxother than507,3xx,404,401/403, and a transport error — retry, and not beforeretry_after. Both forms of that header are read: delta-seconds and the HTTP-date form, the latter resolved against theClockport.- On
401/403the cached token for that origin is dropped and the send stays retryable: a clock a few minutes out or a token that aged past itsexpin flight is exactly this case, and the re-sign the rejection just arranged is what fixes it. A genuinely wrongaudrepeats the error instead of being masked by a cache hit, bounded by the caller’s own attempt budget. 3xxis not followed. The VAPID token is signed over the original origin, so replaying the POST at theLocationearns a401from any service that checksaud; a distributor that moved is at worst temporary. TheLocationis never quoted back — it is a push endpoint.
- On
PushError::Rejected(..)on400(malformed) and413(body too large), on507(below), for a recipient this adapter does not serve, and — before any request is made — for a malformed subscription, an endpoint that is not an absolutehttp/httpsURL, and an oversize payload.
Error messages carry at most 200 characters of the service’s response body,
control characters dropped and everything URL- or path-shaped replaced with
[redacted]. A Web Push endpoint is a bearer capability — whoever holds it
can push to that browser — and ntfy, nginx and CDN error pages all echo the
request path, which for this protocol is the subscription.
§Unregistered is a delete instruction
PushError::Unregistered does not mean “this send failed”. It is the one
error in the port that tells the caller to destroy the recipient, and it
is easy to over-apply: this adapter and its FCM sibling each mapped an extra
status onto it independently, before either was reviewed.
It costs more here than anywhere else in the port. An APNs device token or an
FCM registration token is re-registered by the app on its next launch,
unattended; a Web Push subscription can be recreated only by the browser
calling pushManager.subscribe() again, which needs the user back on the site
with notification permission still granted. Pruning a live subscription is not
a lost message, it is a lost subscriber.
So the bar is a status whose only meaning is “gone”. RFC 8030 §5 defines
exactly one — 410 Gone — and that is the only one mapped. In particular
404 is not: a self-hosted UnifiedPush distributor behind a proxy that
came back without its routes, or an edited ingress rule, answers 404 for
every path, so pruning on it deletes a venture’s whole Web Push register in
one pass. It is retried instead. The trade is visible and deliberate: a
subscription that really is gone behind a service that only ever says 404
is retried until the caller’s attempt budget gives up, and lingers in the
register. Wasted sends against lost subscribers beats deleting live ones.
§Verification
The RFC vectors, the decrypt-side round trip, the VAPID header (verified with
p256’s own verifier under a fixed key and a fixed clock) and every status
mapping are unit-tested here. The three vendor-live browser proofs are
needs-human (issue #186).
§The interop leg: a real ntfy server (issue #181)
Everything above is this crate talking to itself, and two consistent
misreadings of one RFC paragraph agree with each other perfectly.
tests/ntfy_live.rs is the leg that cannot: it generates a subscription the
way a browser does, sends through the adapter over the native runtime’s
HttpClient to a real ntfy server, reads the stored body back out of
ntfy’s own JSON API, and opens it with the private key it generated. A
server that is not ours accepted the request, held ciphertext it could not
read, and what came back out is the notification.
CI runs it as the web push conformance against a real ntfy server job,
against a digest-pinned binwiederhier/ntfy container. Locally:
docker run --rm -p 8090:80 \
-e NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING=false \
binwiederhier/ntfy:v2.28.0 serve
NTFY_URL=http://127.0.0.1:8090 cargo test -p cratefield-adapter-webpush \
--test ntfy_live -- --nocaptureWithout NTFY_URL the test skips and prints that recipe, so
cargo test --workspace stays green with no Docker.
Three things that recipe is deliberate about:
127.0.0.1, neverlocalhost. The native runtime’sHttpClientrefuses loopback names outright, before any resolver is consulted, so that the answer can never depend on/etc/hosts. The address form is admitted byOutboundOptions::allow_loopback, which the test sets and a deployment does not.visitor-subscriber-rate-limitingpinned off. With it on, as the publicntfy.shruns it, a UnifiedPush publish answers the507quoted above even with a subscriber stream held open. It is already the self-hosted default; pinning it stops an upstream default change from turning the leg into a silent no-op, and the test fails loudly on a 507 rather than skipping.- An
up-prefixed 14-character topic, the shape a distributor’s own topics have — the shape ntfy’s subscriber rate limiting is eligible for. A differently-shaped topic would walk past that trap and prove less.
What the leg does not prove: Unregistered. ntfy’s “nobody is
listening” signal is that 507, not the 410 RFC 8030 defines as gone, and
no real server produces a 410 on demand — so that mapping stays a unit
test. And ntfy reads none of TTL, Urgency or Topic from the request
(only Content-Encoding, which alone marks a publish as UnifiedPush, and
its own X-* headers), so the leg proves a real distributor accepts the
full RFC 8030 header set, not that it acts on it. A browser push service
acts on all three.
MIT. Built in the open for Cratefield, a Factory Zero venture.
Modules§
- ece
- The payload encryption: the
aes128gcmHTTP content coding (RFC 8188) and the Web Push key schedule layered on top of it (RFC 8291). - vapid
- VAPID: Voluntary Application Server Identification (RFC 8292).
Structs§
Enums§
- WebPush
Config Error - Building a
WebPushadapter failed.
Constants§
- DEFAULT_
TTL - How long a push service holds an undelivered notification when the caller names no TTL.
- MAX_
TOPIC_ LEN - RFC 8030 §5.4: a
Topicis 1–32 characters from the URL-safe base64 alphabet.