SPB Git

Repository Public

alerts-webpush.test.ts 6.2 KB 83 lines typescript

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
Files main
Menu

Theme