web-push VAPID keys and aes128gcm payload encryption, verified offline: working versions (tested 2026-10-15)
Generate VAPID keys and the exact push-service request with web-push (no send), then decrypt the aes128gcm payload as the browser would with http_ece and verify the ES256 VAPID JWT with node:crypto.
passed5 passed · 0 failed2026-10-15 20:10 UTC- Status
- passed
- Last passed
- 2026-10-15 20:10 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions4
web-push@3.6.7@types/node@26.6.5tsx@4.23.15http_ece@1.2.1
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("web-push-vapid-payload-encryption") (install), or see them on GitHub.
package.json
{
"name": "web-push-vapid-payload-encryption",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"web-push": "3.6.7"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"http_ece": "1.2.1"
}
}
src/push.ts
import webpush, { type PushSubscription } from "web-push";
export type Vapid = { publicKey: string; privateKey: string };
export const newVapidKeys = (): Vapid => webpush.generateVAPIDKeys();
/**
* Build the exact HTTP request web-push would send to the browser's push service
* (encrypted aes128gcm body, VAPID Authorization, TTL/Urgency/Topic headers) without sending it.
*/
export function buildPushRequest(sub: PushSubscription, payload: object, vapid: Vapid, opts: { ttl?: number; urgency?: webpush.Urgency; topic?: string } = {}) {
return webpush.generateRequestDetails(sub, JSON.stringify(payload), {
vapidDetails: { subject: "mailto:[email protected]", publicKey: vapid.publicKey, privateKey: vapid.privateKey },
TTL: opts.ttl ?? 3600,
urgency: opts.urgency ?? "normal",
topic: opts.topic,
contentEncoding: "aes128gcm",
});
}
export const vapidHeaders = (audience: string, vapid: Vapid, expSeconds: number) =>
webpush.getVapidHeaders(audience, "mailto:[email protected]", vapid.publicKey, vapid.privateKey, "aes128gcm", expSeconds);
test/push.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import ece from "http_ece";
import { newVapidKeys, buildPushRequest, vapidHeaders } from "../src/push.ts";
const b64u = (b: Buffer) => b.toString("base64url");
/** A fake browser subscription: a real P-256 key pair and auth secret, like PushManager.subscribe() makes. */
function fakeBrowser() {
const ecdh = crypto.createECDH("prime256v1");
ecdh.generateKeys();
const auth = crypto.randomBytes(16);
return {
ecdh, auth,
sub: { endpoint: "https://fcm.googleapis.com/fcm/send/abc123", keys: { p256dh: b64u(ecdh.getPublicKey()), auth: b64u(auth) } },
};
}
test("VAPID keys are P-256 base64url", () => {
const k = newVapidKeys();
assert.equal(Buffer.from(k.publicKey, "base64url").length, 65);
assert.equal(Buffer.from(k.privateKey, "base64url").length, 32);
});
test("request details: endpoint, headers, encrypted body", () => {
const vapid = newVapidKeys();
const { sub } = fakeBrowser();
const req = buildPushRequest(sub, { title: "Class moved", body: "Nov 1st" }, vapid, { ttl: 60, urgency: "high", topic: "schedule" });
assert.equal(req.method, "POST");
assert.equal(req.endpoint, sub.endpoint);
assert.equal(req.headers.TTL, 60);
assert.equal(req.headers.Urgency, "high");
assert.equal(req.headers.Topic, "schedule");
assert.equal(req.headers["Content-Encoding"], "aes128gcm");
assert.equal(req.headers["Content-Type"], "application/octet-stream");
assert.match(String(req.headers.Authorization), /^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/);
assert.ok(!Buffer.from(req.body!).toString("latin1").includes("Class moved"), "payload must be encrypted");
});
test("browser side can decrypt the payload", () => {
const vapid = newVapidKeys();
const b = fakeBrowser();
const req = buildPushRequest(b.sub, { title: "Class moved", body: "Nov 1st" }, vapid);
const plain = ece.decrypt(Buffer.from(req.body!), { version: "aes128gcm", privateKey: b.ecdh, authSecret: b64u(b.auth) });
assert.deepEqual(JSON.parse(plain.toString("utf8")), { title: "Class moved", body: "Nov 1st" });
});
test("a different subscriber cannot decrypt", () => {
const vapid = newVapidKeys();
const a = fakeBrowser(), other = fakeBrowser();
const req = buildPushRequest(a.sub, { x: 1 }, vapid);
assert.throws(() => ece.decrypt(Buffer.from(req.body!), { version: "aes128gcm", privateKey: other.ecdh, authSecret: b64u(other.auth) }));
});
test("VAPID JWT verifies with the public key and carries aud/exp/sub", () => {
const vapid = newVapidKeys();
const exp = Math.floor(Date.now() / 1000) + 3600;
const { Authorization } = vapidHeaders("https://fcm.googleapis.com", vapid, exp);
const [, jwt, k] = /^vapid t=([^,]+), k=(.+)$/.exec(Authorization)!;
assert.equal(k, vapid.publicKey);
const [h, p, s] = jwt.split(".");
assert.deepEqual(JSON.parse(Buffer.from(h, "base64url").toString()), { typ: "JWT", alg: "ES256" });
assert.deepEqual(JSON.parse(Buffer.from(p, "base64url").toString()), { aud: "https://fcm.googleapis.com", exp, sub: "mailto:[email protected]" });
const raw = Buffer.from(vapid.publicKey, "base64url");
const key = crypto.createPublicKey({ format: "jwk", key: { kty: "EC", crv: "P-256", x: b64u(raw.subarray(1, 33)), y: b64u(raw.subarray(33)) } });
const ok = crypto.verify("sha256", Buffer.from(`${h}.${p}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(s, "base64url"));
assert.equal(ok, true);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.