| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { b64url, decrypt, encrypt, fromB64url, generateVapidKeys, sendPush, vapidAuthorization, vapidJwt } from "./webpush.ts"; |
| 5 | |
| 6 | // RFC 8291, Appendix A: the worked example, every input and the body. |
| 7 | const RFC = { |
| 8 | plaintext: "When I grow up, I want to be a watermelon", |
| 9 | asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw", |
| 10 | asPublic: "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", |
| 11 | uaPrivate: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94", |
| 12 | uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", |
| 13 | auth: "BTBZMqHH6r4Tts7J_aSIgg", |
| 14 | salt: "DGv6ra1nlYgDCS1FRnbzlw", |
| 15 | body: |
| 16 | "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN", |
| 17 | }; |
| 18 | |
| 19 | test("encryption reproduces RFC 8291's example byte for byte", async () => { |
| 20 | const body = await encrypt(new TextEncoder().encode(RFC.plaintext), { p256dh: RFC.uaPublic, auth: RFC.auth }, { |
| 21 | asPrivate: RFC.asPrivate, |
| 22 | asPublic: RFC.asPublic, |
| 23 | salt: fromB64url(RFC.salt), |
| 24 | }); |
| 25 | assert.equal(b64url(body), RFC.body); |
| 26 | }); |
| 27 | |
| 28 | test("the example's body decrypts with the subscriber's key", async () => { |
| 29 | const plain = await decrypt(fromB64url(RFC.body), { privateKey: RFC.uaPrivate, publicKey: RFC.uaPublic, auth: RFC.auth }); |
| 30 | assert.equal(new TextDecoder().decode(plain), RFC.plaintext); |
| 31 | }); |
| 32 | |
| 33 | test("a fresh key and salt each time, and it still round-trips", async () => { |
| 34 | const message = new TextEncoder().encode(JSON.stringify({ title: "Ana", body: "ship it? ✨" })); |
| 35 | const a = await encrypt(message, { p256dh: RFC.uaPublic, auth: RFC.auth }); |
| 36 | const b = await encrypt(message, { p256dh: RFC.uaPublic, auth: RFC.auth }); |
| 37 | assert.notEqual(b64url(a), b64url(b)); |
| 38 | // Header: 16 salt, record size 4096, a 65-byte key id. |
| 39 | assert.equal(new DataView(a.buffer, a.byteOffset).getUint32(16), 4096); |
| 40 | assert.equal(a[20], 65); |
| 41 | assert.equal(a[21], 4); |
| 42 | const plain = await decrypt(a, { privateKey: RFC.uaPrivate, publicKey: RFC.uaPublic, auth: RFC.auth }); |
| 43 | assert.deepEqual(plain, message); |
| 44 | }); |
| 45 | |
| 46 | test("too long a message for one record is refused", async () => { |
| 47 | await assert.rejects(encrypt(new Uint8Array(4000), { p256dh: RFC.uaPublic, auth: RFC.auth })); |
| 48 | }); |
| 49 | |
| 50 | test("the VAPID JWT: ES256, the push service's origin, a 12-hour expiry, a subject, and a signature that verifies", async () => { |
| 51 | const keys = await generateVapidKeys(); |
| 52 | assert.equal(fromB64url(keys.publicKey).length, 65); |
| 53 | assert.equal(fromB64url(keys.privateKey).length, 32); |
| 54 | const now = Date.UTC(2026, 9, 8, 12); |
| 55 | const jwt = await vapidJwt("https://fcm.googleapis.com/fcm/send/abc:def", { ...keys, subject: "https://g1t.sh" }, now); |
| 56 | const [header, claims, signature] = jwt.split("."); |
| 57 | assert.deepEqual(JSON.parse(new TextDecoder().decode(fromB64url(header))), { typ: "JWT", alg: "ES256" }); |
| 58 | assert.deepEqual(JSON.parse(new TextDecoder().decode(fromB64url(claims))), { |
| 59 | aud: "https://fcm.googleapis.com", |
| 60 | exp: now / 1000 + 12 * 3600, |
| 61 | sub: "https://g1t.sh", |
| 62 | }); |
| 63 | // Raw r‖s, as JWS wants, not DER. |
| 64 | assert.equal(fromB64url(signature).length, 64); |
| 65 | const point = fromB64url(keys.publicKey); |
| 66 | const verifier = await crypto.subtle.importKey("raw", point.slice().buffer as ArrayBuffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]); |
| 67 | const ok = await crypto.subtle.verify( |
| 68 | { name: "ECDSA", hash: "SHA-256" }, |
| 69 | verifier, |
| 70 | fromB64url(signature).slice().buffer as ArrayBuffer, |
| 71 | new TextEncoder().encode(`${header}.${claims}`), |
| 72 | ); |
| 73 | assert.ok(ok); |
| 74 | const authorization = await vapidAuthorization("https://push.example/x", { ...keys, subject: "https://g1t.sh" }, now); |
| 75 | assert.match(authorization, /^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/); |
| 76 | }); |
| 77 | |
| 78 | test("a push is posted encrypted with its headers; 404 and 410 mean gone", async () => { |
| 79 | const keys = await generateVapidKeys(); |
| 80 | const seen: { url: string; init: RequestInit }[] = []; |
| 81 | const status = { value: 201 }; |
| 82 | const fetcher = (async (url: string, init: RequestInit) => { |
| 83 | seen.push({ url, init }); |
| 84 | return new Response(null, { status: status.value }); |
| 85 | }) as unknown as typeof fetch; |
| 86 | const subscription = { endpoint: "https://push.example/sub/1", p256dh: RFC.uaPublic, auth: RFC.auth }; |
| 87 | const sent = await sendPush(subscription, { title: "hi" }, { vapid: { ...keys, subject: "https://g1t.sh" }, urgency: "high", topic: "chat:chn_1/x" }, fetcher); |
| 88 | assert.deepEqual(sent, { endpoint: subscription.endpoint, status: 201, gone: false }); |
| 89 | const headers = seen[0].init.headers as Record<string, string>; |
| 90 | assert.equal(headers["content-encoding"], "aes128gcm"); |
| 91 | assert.equal(headers.urgency, "high"); |
| 92 | assert.equal(headers.topic, "chatchn_1x"); |
| 93 | assert.equal(headers.ttl, "86400"); |
| 94 | const body = new Uint8Array(seen[0].init.body as ArrayBuffer); |
| 95 | const plain = await decrypt(body, { privateKey: RFC.uaPrivate, publicKey: RFC.uaPublic, auth: RFC.auth }); |
| 96 | assert.deepEqual(JSON.parse(new TextDecoder().decode(plain)), { title: "hi" }); |
| 97 | for (const code of [404, 410]) { |
| 98 | status.value = code; |
| 99 | assert.equal((await sendPush(subscription, {}, { vapid: { ...keys, subject: "https://g1t.sh" } }, fetcher)).gone, true); |
| 100 | } |
| 101 | status.value = 429; |
| 102 | assert.equal((await sendPush(subscription, {}, { vapid: { ...keys, subject: "https://g1t.sh" } }, fetcher)).gone, false); |
| 103 | }); |