mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
213 lines
9.1 KiB
TypeScript
213 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 {
|
|
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';
|
|
|
|
import jwt from 'jsonwebtoken';
|
|
import { createDecipheriv, createECDH, createPublicKey, hkdfSync, type ECDH } from 'node:crypto';
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
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/,
|
|
);
|
|
});
|
|
});
|