Repository Public
1// Web Push crypto against the worked example of RFC 8291 (section 5 and appendix A), VAPID (RFC 8292), endpoint allow-list.2import { createPublicKey, verify } from 'node:crypto';3import { describe, expect, it } from 'vitest';4import { decryptPush, encryptPush, endpointAllowed, generateSubscriptionKeys, generateVapidKeys, MAX_PLAINTEXT, sendPush, vapidAuthorization } from '../src/alerts/webpush.ts';56// RFC 8291, appendix A7const V = {8 plaintext: 'When I grow up, I want to be a watermelon',9 asPrivate: 'yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw',10 uaPublic: 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4',11 uaPrivate: 'q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94',12 auth: 'BTBZMqHH6r4Tts7J_aSIgg',13 salt: 'DGv6ra1nlYgDCS1FRnbzlw',14 message: 'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN',15};1617describe('web push encryption (RFC 8291 / RFC 8188)', () => {18 it('produces the exact message of the RFC 8291 example', () => {19 const out = encryptPush(Buffer.from(V.plaintext), { p256dh: V.uaPublic, auth: V.auth }, { ephemeralPrivateKey: Buffer.from(V.asPrivate, 'base64url'), salt: Buffer.from(V.salt, 'base64url') });20 expect(out.toString('base64url')).toBe(V.message);21 });22 it('the user agent of the example decrypts the message', () => {23 expect(decryptPush(Buffer.from(V.message, 'base64url'), { privateKey: V.uaPrivate, auth: V.auth }).toString()).toBe(V.plaintext);24 });25 it('round-trips with random keys; a wrong auth secret or a tampered byte fails', () => {26 const k = generateSubscriptionKeys(), text = JSON.stringify({ v: 1, kind: 'system.test', é: 'accentué' });27 const a = encryptPush(Buffer.from(text), k), b = encryptPush(Buffer.from(text), k);28 expect(a.equals(b)).toBe(false); // fresh salt and ephemeral key every time29 expect(decryptPush(a, k).toString()).toBe(text);30 expect(() => decryptPush(a, { privateKey: k.privateKey, auth: generateSubscriptionKeys().auth })).toThrow();31 const t = Buffer.from(a); t[t.length - 1] = t[t.length - 1]! ^ 1;32 expect(() => decryptPush(t, k)).toThrow();33 });34 it('refuses malformed keys and oversized payloads', () => {35 const k = generateSubscriptionKeys();36 expect(() => encryptPush(Buffer.from('x'), { p256dh: 'AAAA', auth: k.auth })).toThrow(/P-256/);37 expect(() => encryptPush(Buffer.from('x'), { p256dh: k.p256dh, auth: 'AAAA' })).toThrow(/16 bytes/);38 expect(() => encryptPush(Buffer.alloc(MAX_PLAINTEXT + 1), k)).toThrow(/too large/);39 });40});4142describe('VAPID (RFC 8292)', () => {43 it('signs an ES256 token for the push service origin that verifies with the public key', () => {44 const vapid = generateVapidKeys(), now = Date.parse('2026-11-11T12:00:00Z');45 const h = vapidAuthorization('https://web.push.apple.com/QGx-abc', vapid, 'mailto:owner@wallet.test', now);46 const m = /^vapid t=([\w-]+)\.([\w-]+)\.([\w-]+), k=([\w-]+)$/.exec(h)!;47 expect(m).not.toBeNull();48 expect(m[4]).toBe(vapid.publicKey);49 expect(JSON.parse(Buffer.from(m[1]!, 'base64url').toString())).toEqual({ typ: 'JWT', alg: 'ES256' });50 const claims = JSON.parse(Buffer.from(m[2]!, 'base64url').toString());51 expect(claims).toEqual({ aud: 'https://web.push.apple.com', exp: Math.floor(now / 1000) + 12 * 3600, sub: 'mailto:owner@wallet.test' });52 const pub = Buffer.from(vapid.publicKey, 'base64url');53 const key = createPublicKey({ format: 'jwk', key: { kty: 'EC', crv: 'P-256', x: pub.subarray(1, 33).toString('base64url'), y: pub.subarray(33).toString('base64url') } });54 expect(verify('sha256', Buffer.from(`${m[1]}.${m[2]}`), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(m[3]!, 'base64url'))).toBe(true);55 expect(Buffer.from(m[3]!, 'base64url').length).toBe(64);56 });57});5859describe('subscription endpoints', () => {60 it('only the browsers’ push services are accepted', () => {61 for (const ok of ['https://web.push.apple.com/abc', 'https://fcm.googleapis.com/fcm/send/x', 'https://updates.push.services.mozilla.com/wpush/v2/x', 'https://wns2-by3p.notify.windows.com/w/?token=x'])62 expect(endpointAllowed(ok, [])).toBe(true);63 for (const no of ['http://web.push.apple.com/abc', 'https://evil.example/push', 'https://127.0.0.1/x', 'https://push.apple.com.evil.example/x', 'https://user:pw@fcm.googleapis.com/x', 'not a url', 'file:///etc/passwd'])64 expect(endpointAllowed(no, [])).toBe(false);65 expect(endpointAllowed('http://127.0.0.1:9999/push/1', ['127.0.0.1:9999'])).toBe(true); // tests: a local fake push service66 });67 it('sendPush never throws and reports a gone subscription', async () => {68 const k = generateSubscriptionKeys(), vapid = generateVapidKeys(), seen: { headers: Headers; body: Buffer }[] = [];69 const fake = (status: number) => (async (_u: unknown, init: RequestInit) => { seen.push({ headers: new Headers(init.headers), body: Buffer.from(init.body as Uint8Array) }); return new Response(null, { status }); }) as typeof fetch;70 const sub = { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', ...k };71 expect(await sendPush(sub, Buffer.from('hello'), vapid, { subject: 'mailto:a@b.c', urgency: 'high', fetch: fake(201) })).toEqual({ ok: true, status: 201 });72 expect(seen[0]!.headers.get('content-encoding')).toBe('aes128gcm');73 expect(seen[0]!.headers.get('ttl')).toBe('3600');74 expect(seen[0]!.headers.get('urgency')).toBe('high');75 expect(seen[0]!.headers.get('authorization')).toMatch(/^vapid t=/);76 expect(decryptPush(seen[0]!.body, k).toString()).toBe('hello');77 expect(await sendPush(sub, Buffer.from('x'), vapid, { subject: 's', fetch: fake(410) })).toMatchObject({ ok: false, gone: true, status: 410 });78 expect(await sendPush(sub, Buffer.from('x'), vapid, { subject: 's', fetch: fake(404) })).toMatchObject({ ok: false, gone: true });79 expect(await sendPush(sub, Buffer.from('x'), vapid, { subject: 's', fetch: fake(500) })).toMatchObject({ ok: false, gone: false });80 expect(await sendPush(sub, Buffer.from('x'), vapid, { subject: 's', fetch: (async () => { throw new Error('network down'); }) as typeof fetch })).toMatchObject({ ok: false, gone: false, error: 'network down' });81 });82});83