Files
TREK/server/tests/unit/nest/notifications/web-push-crypto.test.ts
T
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00

212 lines
9.1 KiB
TypeScript

/**
* RFC 8291 message encryption and RFC 8292 VAPID headers (WPCRYPTO-*).
*
* The encryption is pinned twice: byte for byte against the worked example in
* RFC 8291 Appendix A, and by a decrypt written here from the receiving side,
* the way a browser unwraps the message, for fresh keys and salts.
*/
import { describe, it, expect } from 'vitest';
import { createDecipheriv, createECDH, createPublicKey, hkdfSync, type ECDH } from 'node:crypto';
import jwt from 'jsonwebtoken';
import {
MAX_PUSH_PLAINTEXT_BYTES,
PUSH_RECORD_SIZE,
VAPID_TOKEN_TTL_SECONDS,
createVapidSigner,
derivePublicKey,
encryptPushMessage,
generateVapidKeyPair,
isP256Point,
isVapidKeyPair,
} from '../../../../src/nest/notifications/push/web-push-crypto';
const b64 = (value: string) => Buffer.from(value, 'base64url');
// RFC 8291 Appendix A.
const RFC = {
plaintext: b64('V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24'),
asPublic: 'BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8',
asPrivate: b64('yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw'),
uaPublic: b64('BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'),
uaPrivate: b64('q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94'),
salt: b64('DGv6ra1nlYgDCS1FRnbzlw'),
auth: b64('BTBZMqHH6r4Tts7J_aSIgg'),
// Section 5, the message body as sent (the three lines of the RFC joined).
body:
'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' +
'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' +
'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN',
};
/** The receiving side of RFC 8291, independent of the module under test. */
function decrypt(body: Buffer, ua: ECDH, auth: Buffer): { plaintext: Buffer; rs: number; keyId: Buffer } {
const salt = body.subarray(0, 16);
const rs = body.readUInt32BE(16);
const idlen = body[20];
const keyId = body.subarray(21, 21 + idlen);
const record = body.subarray(21 + idlen);
const secret = ua.computeSecret(keyId);
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0'), ua.getPublicKey(), keyId]);
const ikm = Buffer.from(hkdfSync('sha256', secret, auth, keyInfo, 32));
const cek = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0'), 16));
const nonce = Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0'), 12));
const decipher = createDecipheriv('aes-128-gcm', cek, nonce);
decipher.setAuthTag(record.subarray(record.length - 16));
const padded = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
let end = padded.length - 1;
while (end >= 0 && padded[end] === 0) end--;
expect(padded[end]).toBe(0x02); // the last-record delimiter
return { plaintext: padded.subarray(0, end), rs, keyId };
}
function browser() {
const ua = createECDH('prime256v1');
ua.generateKeys();
const auth = Buffer.from('0123456789abcdef');
return { ua, keys: { p256dh: ua.getPublicKey(), auth } };
}
describe('encryptPushMessage', () => {
it('WPCRYPTO-001: reproduces the RFC 8291 Appendix A message byte for byte', () => {
const body = encryptPushMessage(
RFC.plaintext,
{ p256dh: RFC.uaPublic, auth: RFC.auth },
{ salt: RFC.salt, senderPrivateKey: RFC.asPrivate },
);
expect(body.toString('base64url')).toBe(RFC.body);
});
it('WPCRYPTO-002: the RFC message decrypts with the user agent key, which checks the reference decrypt too', () => {
const ua = createECDH('prime256v1');
ua.setPrivateKey(RFC.uaPrivate);
const { plaintext, rs, keyId } = decrypt(b64(RFC.body), ua, RFC.auth);
expect(plaintext.toString('utf8')).toBe('When I grow up, I want to be a watermelon');
expect(rs).toBe(4096);
expect(keyId.toString('base64url')).toBe(RFC.asPublic);
});
it('WPCRYPTO-003: a real message round-trips, with a fresh salt and sender key every time', () => {
const { ua, keys } = browser();
const message = Buffer.from(JSON.stringify({ title: 'Trip reminder: Kyōto', body: 'Tomorrow 🚄' }));
const first = encryptPushMessage(message, keys);
const second = encryptPushMessage(message, keys);
expect(decrypt(first, ua, keys.auth).plaintext.equals(message)).toBe(true);
expect(decrypt(second, ua, keys.auth).plaintext.equals(message)).toBe(true);
expect(first.subarray(0, 16).equals(second.subarray(0, 16))).toBe(false);
expect(first.subarray(21, 86).equals(second.subarray(21, 86))).toBe(false);
});
it('WPCRYPTO-004: the largest allowed plaintext still fits one 4096-byte record', () => {
const { ua, keys } = browser();
const message = Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES, 'a');
const body = encryptPushMessage(message, keys);
expect(body.length).toBe(PUSH_RECORD_SIZE);
expect(decrypt(body, ua, keys.auth).plaintext.length).toBe(MAX_PUSH_PLAINTEXT_BYTES);
expect(() => encryptPushMessage(Buffer.alloc(MAX_PUSH_PLAINTEXT_BYTES + 1), keys)).toThrow(
/does not fit one record/,
);
});
it('WPCRYPTO-005: refuses keys it cannot encrypt for', () => {
const { keys } = browser();
const offCurve = Buffer.from(keys.p256dh);
offCurve[64] ^= 0x01;
expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, p256dh: offCurve })).toThrow(/p256dh/);
expect(() => encryptPushMessage(Buffer.from('x'), { ...keys, auth: Buffer.alloc(8) })).toThrow(/auth secret/);
expect(() => encryptPushMessage(Buffer.from('x'), keys, { salt: Buffer.alloc(4) })).toThrow(/salt/);
});
});
describe('key helpers', () => {
it('WPCRYPTO-006: generates a full-length pair whose halves belong together', () => {
for (let i = 0; i < 20; i++) {
const pair = generateVapidKeyPair();
const pub = b64(pair.publicKey);
expect(pub.length).toBe(65);
expect(pub[0]).toBe(0x04);
expect(b64(pair.privateKey).length).toBe(32);
expect(isVapidKeyPair(pair.publicKey, pair.privateKey)).toBe(true);
}
});
it('WPCRYPTO-007: tells a pair from two unrelated halves, and rejects what is not a key', () => {
const a = generateVapidKeyPair();
const b = generateVapidKeyPair();
expect(isVapidKeyPair(a.publicKey, b.privateKey)).toBe(false);
expect(isVapidKeyPair('not base64url!', a.privateKey)).toBe(false);
expect(derivePublicKey('short')).toBeNull();
expect(derivePublicKey(Buffer.alloc(32).toString('base64url'))).toBeNull(); // zero is no scalar
expect(derivePublicKey(a.privateKey)?.toString('base64url')).toBe(a.publicKey);
});
it('WPCRYPTO-008: isP256Point wants an uncompressed point that is on the curve', () => {
const { keys } = browser();
expect(isP256Point(keys.p256dh)).toBe(true);
const offCurve = Buffer.from(keys.p256dh);
offCurve[40] ^= 0xff;
expect(isP256Point(offCurve)).toBe(false);
expect(isP256Point(Buffer.concat([Buffer.from([0x03]), keys.p256dh.subarray(1)]))).toBe(false);
expect(isP256Point(keys.p256dh.subarray(0, 33))).toBe(false);
});
});
describe('createVapidSigner', () => {
const pair = generateVapidKeyPair();
const pub = b64(pair.publicKey);
const verifyKey = createPublicKey({
key: {
kty: 'EC',
crv: 'P-256',
x: pub.subarray(1, 33).toString('base64url'),
y: pub.subarray(33).toString('base64url'),
},
format: 'jwk',
});
function parse(header: string) {
const m = /^vapid t=([^,]+), k=(.+)$/.exec(header);
expect(m).not.toBeNull();
return { token: m![1], k: m![2] };
}
it('WPCRYPTO-009: builds `vapid t=<ES256 JWT>, k=<public key>` for the endpoint origin', () => {
const now = Date.UTC(2026, 8, 24, 12, 0, 0);
const sign = createVapidSigner(pair, 'mailto:ops@example.test', () => now);
const { token, k } = parse(sign('https://fcm.googleapis.com/fcm/send/abc:def'));
expect(k).toBe(pair.publicKey);
const claims = jwt.verify(token, verifyKey, {
algorithms: ['ES256'],
clockTimestamp: now / 1000,
}) as jwt.JwtPayload;
expect(claims).toEqual({
aud: 'https://fcm.googleapis.com',
exp: now / 1000 + VAPID_TOKEN_TTL_SECONDS,
sub: 'mailto:ops@example.test',
});
expect(VAPID_TOKEN_TTL_SECONDS).toBeLessThanOrEqual(24 * 60 * 60);
const [header, , signature] = token.split('.');
expect(JSON.parse(b64(header).toString('utf8'))).toEqual({ alg: 'ES256', typ: 'JWT' });
// JOSE r||s, not DER: the form push services verify.
expect(b64(signature).length).toBe(64);
});
it('WPCRYPTO-010: signs once per origin and separately for another push service', () => {
const sign = createVapidSigner(pair, 'https://trek.example.test');
const a = sign('https://updates.push.services.mozilla.com/wpush/v2/one');
const b = sign('https://updates.push.services.mozilla.com/wpush/v2/two');
const c = sign('https://web.push.apple.com/QGx');
expect(a).toBe(b);
expect(c).not.toBe(a);
const claims = jwt.decode(parse(c).token) as jwt.JwtPayload;
expect(claims.aud).toBe('https://web.push.apple.com');
});
it('WPCRYPTO-011: refuses a malformed key pair up front', () => {
expect(() => createVapidSigner({ publicKey: 'abc', privateKey: pair.privateKey }, 'mailto:a@b.test')).toThrow(
/malformed/,
);
});
});