Skip to content

feat!: 2.0, RFC 8291 aes128gcm and RFC 8292 vapid - #19

Merged
maxholman[bot] merged 38 commits into
masterfrom
v2
Sep 3, 2026
Merged

feat!: 2.0, RFC 8291 aes128gcm and RFC 8292 vapid#19
maxholman[bot] merged 38 commits into
masterfrom
v2

Conversation

@maxholman

@maxholman maxholman Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Supersedes #18, whose two remaining checklist items are done here.

The encoding

Messages now use aes128gcm from RFC 8291 and the vapid authorization scheme from RFC 8292, replacing the draft-04 aesgcm encoding and the WebPush scheme.

Apple accepts neither of the old ones, so Safari and iOS never worked with this library. That is the reason for the major.

Verified against the RFC 8291 section 5 worked example, which the implementation reproduces byte for byte, and by decrypting the payload the way a subscriber does.

Breaking

  • encryptNotification returns the complete RFC 8188 body rather than { ciphertext, salt, localPublicKeyBytes }, and takes an options argument
  • the crypto-key and encryption headers are gone
  • maximum payload is 3993 bytes
  • buildPushPayload is otherwise unchanged, so fetch(subscription.endpoint, payload) still works as documented

Anything that needs the aesgcm encoding should stay on 1.x.

Security

A Fable security review found nothing exploitable, and a second pass re-verified the crypto after the review's own recommendations were applied. Its HKDF check compared the implementation against Node's native hkdfSync and a hand written RFC 5869 loop across 417 input combinations with no mismatches.

Applied from it:

  • every message is padded to a constant 4096 octets, so ciphertext length does not disclose plaintext length. This also keeps the body inside the size a push service is obliged to accept under RFC 8030 section 7.2, which the previous 4182 byte maximum did not
  • p256dh is validated as a 65 byte uncompressed P-256 point, auth as 16 octets, and the endpoint as https
  • ephemeral and HMAC keys import non-extractable, and the private JWK is no longer materialised as a string

Tests

The tests that posted to real FCM and WNS endpoints are gone. They asserted a 201, which says the push service accepted the request and nothing about whether a subscriber can read it, and they relied on fake subscriptions that expire.

In their place:

  • the RFC 8291 section 5 vector
  • a decrypt round trip over four payload sizes with freshly generated keys
  • constant-size padding assertions, and the oversize, p256dh, auth and non-https rejections
  • a new e2e package that subscribes real Chrome and real Firefox to their real push services, sends a notification, and asserts the service worker decrypted it. It is not in CI and not part of make test

17 unit tests and 2 e2e tests.

Closes the #18 checklist

remove triple slash references, to avoid a repeat of #15 and #16. Done, and checked rather than assumed: a consumer with lib: ["es2022"], types: [] and skipLibCheck: false typechecks against the packed tarball. That took dropping type-fest, which reintroduced the #16 class through Float16Array. Jsonifiable and RequireAtLeastOne are now about ten lines in types.ts, and the package ships one dependency.

better integration tests that do not involve fake subscriptions. Done, see above.

Everything else

pnpm 11, typescript 7, vitest 4, node 26 for development on a node 22 floor. Biome replaced with oxlint and oxfmt. Shared versions move through a pnpm catalog. Tailwind 4, wrangler 4 with wrangler.jsonc, drizzle 1.0 rc in the examples. CI tests node 22, 24 and 26 and runs lint.

The nodejs example never created its table on a clean checkout: the journal indexing its migration was gitignored. Fixed.

maxholman and others added 30 commits December 15, 2024 14:19
The block65 lint and format standard is oxc. @block65/shared-config is not
published to npm, so the settings are inlined here rather than extended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pnpm 11, typescript 7, vitest 4, node 26 as the development version. The
peerDependencyRules block is dropped because pnpm no longer reads it, and
allowBuilds records that none of the native packages need their build scripts.

CI now tests node 22, 24 and 26, and runs oxlint and oxfmt as a lint job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The root tsconfig only pointed tsc at the single package, so the Makefile names
it directly instead. The browser example referenced two projects that are not
composite, which tsc rejects, and both wrote their build info to the same file
inherited from @tsconfig/vite-react. The test project no longer sets outDir
alongside noEmit.

clean now removes dist outright. tsc -b --clean leaves behind output whose
source has been deleted, and files is dist/lib/**/*, so those would ship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BREAKING CHANGE: messages now use the aes128gcm content encoding from RFC 8291
and the vapid authorization scheme from RFC 8292, replacing the draft-04 aesgcm
encoding and the WebPush scheme. Apple accepts neither of the old ones, so
Safari and iOS never worked. Consumers that need aesgcm should stay on 1.x.

encryptNotification now returns the complete RFC 8188 body rather than the
ciphertext, salt and key separately, and takes an options argument. The
crypto-key and encryption headers are gone, and buildPushPayload is otherwise
unchanged.

Every message is padded to a constant 4096 octets so the ciphertext length does
not disclose the plaintext length, which also keeps the body within the size a
push service is obliged to accept. The maximum payload is 3993 bytes.

Subscription keys are validated: p256dh as a 65 byte uncompressed P-256 point,
auth as 16 octets, and the endpoint as https. Ephemeral and HMAC keys are
imported non-extractable and the private JWK is no longer materialised.

hkdf expand now follows RFC 5869 for outputs longer than one block instead of
truncating, and type-fest is gone, so the only dependency is uint8array-extras.

Tested against the RFC 8291 section 5 vector, which the implementation matches
byte for byte, and by decrypting as a subscriber would. The tests that posted
to real push endpoints are gone; they proved transport, not decryptability.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tailwind 4 is CSS first, so the config file and the postcss pipeline are gone.
Wrangler 4 reads wrangler.jsonc, and drizzle-kit 1.0 writes each migration to
its own directory, which wrangler needs migrations_pattern to find.

The nodejs example had the cloudflare example's package name, imported dotenv
where node reads the env file itself, and never created its data directory. Its
migration was also never applied on a clean checkout: the journal indexing it
sits under migrations/meta, which was gitignored. The new layout has no meta
directory, so the ignore is dropped and the migration is tracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Subscribes a real browser to its real push service, sends a notification, and
asserts the service worker decrypted it. The tests that were removed posted to
push endpoints and checked for a 201, which says the request was accepted and
nothing about whether a subscriber can read it.

Chrome has to come from the system because playwright's chromium is built
without the credentials to register with FCM, and the push api is unavailable
in incognito, so the context has to be persistent. Firefox needs its push
connection turned on explicitly.

Not wired into make test, and not in CI, because it needs the network and a
subscription that expires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maxholman and others added 5 commits September 3, 2026 16:16
fetch rejects Uint8Array<ArrayBufferLike> as a BodyInit, so the call in the
readme did not typecheck in a project with the dom lib. The body is built with
new Uint8Array, so it always was ArrayBuffer backed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed server

node has a test runner and strips types itself at the version this repo
targets, so the e2e package needs neither vitest nor a transpiler. The static
server is hono, matching the examples.

The subscribe now happens inside page.evaluate against real DOM types, and the
decrypted message comes back through the document title, so nothing declares
page globals on Window from a node file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@types/node, @tsconfig/node22, hono, @hono/node-server and the drizzle pair are
used by more than one workspace, so they resolve through catalog: and move in
one place.

Everything is on the node 22 floor now that engines asks for it. The examples
and the e2e package were pulling node 26 types, which would let them use APIs
the published package does not support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It is a playwright test, so playwright runs it. That also replaces the manual
waitForFunction with toHaveTitle, which retries on its own.

The persistent context is still launched by hand: the built in page fixture
gives an incognito context, and chrome disables the push api there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The run was already logging a 404 for the favicon the browser asks for on its
own, which nothing noticed. The page now declares an empty one.

Listening on the context rather than the page picks up the service worker,
which is where the decryption happens, though only chrome reports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxholman maxholman Bot mentioned this pull request Sep 3, 2026
10 tasks
Comment thread .github/workflows/pr.yml Fixed
maxholman and others added 3 commits September 3, 2026 18:13
new Date(2000, 1, 1, 13) is local time, so the iat and exp signed into the
vapid token moved with the machine timezone and the snapshot only matched in
the zone it was recorded in. CI runs in UTC and failed on every node version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flagged by CodeQL on #19. Neither workflow uses the token for anything but
checkout, and npm publish authenticates with NODE_AUTH_TOKEN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in the repo uses yarn, so the install instructions and the leftover
yarn log patterns are gone. The root readme leads with the package name rather
than the repository name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maxholman
maxholman Bot merged commit e60d223 into master Sep 3, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants