import * as plugins from '../plugins.js'; export interface IWebPushKeyRing { currentKeyId: string; keys: ReadonlyMap; } export interface IWebPushEncryptedEnvelope { version: 1; algorithm: 'aes-256-gcm'; keyId: string; nonce: string; ciphertext: string; tag: string; } export interface IWebPushAadContext { gatewayClientId: string; appInstanceId: string; bindingId: string; documentType: string; documentId: string; } export type TWebPushHmacDomain = | 'credential' | 'idempotency-key' | 'request' | 'endpoint' | 'topic'; const KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; const BASE64URL_KEY_PATTERN = /^[A-Za-z0-9_-]{43}$/; const BASE64URL_VALUE_PATTERN = /^[A-Za-z0-9_-]+$/; const KEY_RING_LIMIT = 8; const AAD_SCHEMA_VERSION = 1; function requirePlainObject( valueArg: unknown, labelArg: string, ): Record { if ( valueArg === null || typeof valueArg !== 'object' || Array.isArray(valueArg) || Object.getPrototypeOf(valueArg) !== Object.prototype ) { throw new Error(`${labelArg} must be a plain object`); } return valueArg as Record; } function decodeBase64UrlExact( valueArg: unknown, lengthArg: number, labelArg: string, ): Buffer { if (typeof valueArg !== 'string' || !BASE64URL_VALUE_PATTERN.test(valueArg)) { throw new Error(`${labelArg} must be unpadded base64url`); } const decoded = Buffer.from(valueArg, 'base64url'); if (decoded.length !== lengthArg || decoded.toString('base64url') !== valueArg) { throw new Error(`${labelArg} has an invalid encoded length`); } return decoded; } function parseKeyRingSource(rawValueArg: unknown, labelArg: string): Record { if (typeof rawValueArg === 'string') { const trimmed = rawValueArg.trim(); if (!trimmed) { throw new Error(`${labelArg} is empty`); } const jsonText = trimmed.startsWith('base64Object:') ? Buffer.from(trimmed.slice('base64Object:'.length), 'base64').toString('utf8') : trimmed; try { return requirePlainObject(JSON.parse(jsonText), labelArg); } catch (error: unknown) { if ((error as Error).message.startsWith(`${labelArg} `)) throw error; throw new Error(`${labelArg} must be valid JSON`, { cause: error }); } } return requirePlainObject(rawValueArg, labelArg); } export function parseWebPushKeyRing( rawValueArg: unknown, labelArg: string, ): IWebPushKeyRing { const source = parseKeyRingSource(rawValueArg, labelArg); const currentKeyId = source.currentKeyId; if (typeof currentKeyId !== 'string' || !KEY_ID_PATTERN.test(currentKeyId)) { throw new Error(`${labelArg}.currentKeyId is invalid`); } const rawKeys = requirePlainObject(source.keys, `${labelArg}.keys`); const entries = Object.entries(rawKeys); if (entries.length === 0 || entries.length > KEY_RING_LIMIT) { throw new Error(`${labelArg}.keys must contain 1 to ${KEY_RING_LIMIT} keys`); } const keys = new Map(); const fingerprints = new Set(); for (const [keyId, encodedKey] of entries) { if (!KEY_ID_PATTERN.test(keyId)) { throw new Error(`${labelArg}.keys contains an invalid key id`); } if (typeof encodedKey !== 'string' || !BASE64URL_KEY_PATTERN.test(encodedKey)) { throw new Error(`${labelArg}.keys.${keyId} must be a 32-byte unpadded base64url key`); } const key = decodeBase64UrlExact(encodedKey, 32, `${labelArg}.keys.${keyId}`); const fingerprint = key.toString('hex'); if (fingerprints.has(fingerprint)) { throw new Error(`${labelArg}.keys must not contain duplicate key material`); } fingerprints.add(fingerprint); keys.set(keyId, key); } if (!keys.has(currentKeyId)) { throw new Error(`${labelArg}.currentKeyId does not name a configured key`); } return { currentKeyId, keys, }; } export function assertDistinctWebPushKeyRings( encryptionRingArg: IWebPushKeyRing, hmacRingArg: IWebPushKeyRing, ): void { const encryptionFingerprints = new Set( [...encryptionRingArg.keys.values()].map((keyArg) => keyArg.toString('hex')), ); if ( [...hmacRingArg.keys.values()] .some((keyArg) => encryptionFingerprints.has(keyArg.toString('hex'))) ) { throw new Error('Web Push encryption and HMAC key rings must use distinct key material'); } } export function buildWebPushAad(contextArg: IWebPushAadContext): Buffer { for (const [name, value] of Object.entries(contextArg)) { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Web Push AAD ${name} is missing`); } } return Buffer.from(canonicalJson({ schemaVersion: AAD_SCHEMA_VERSION, gatewayClientId: contextArg.gatewayClientId, appInstanceId: contextArg.appInstanceId, bindingId: contextArg.bindingId, documentType: contextArg.documentType, documentId: contextArg.documentId, }), 'utf8'); } function canonicalize(valueArg: unknown): unknown { if ( valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'boolean' ) { return valueArg; } if (typeof valueArg === 'number') { if (!Number.isFinite(valueArg)) throw new Error('Canonical JSON does not support non-finite numbers'); return valueArg; } if (Array.isArray(valueArg)) { return valueArg.map((entryArg) => canonicalize(entryArg)); } const value = requirePlainObject(valueArg, 'Canonical JSON value'); const result: Record = {}; for (const key of Object.keys(value).sort()) { if (value[key] === undefined) { throw new Error('Canonical JSON does not support undefined values'); } result[key] = canonicalize(value[key]); } return result; } export function canonicalJson(valueArg: unknown): string { return JSON.stringify(canonicalize(valueArg)); } export class WebPushCrypto { public constructor( private readonly encryptionRing: IWebPushKeyRing, private readonly hmacRing: IWebPushKeyRing, ) { assertDistinctWebPushKeyRings(encryptionRing, hmacRing); } public get currentEncryptionKeyId(): string { return this.encryptionRing.currentKeyId; } public get currentHmacKeyId(): string { return this.hmacRing.currentKeyId; } public encryptJson( valueArg: unknown, aadContextArg: IWebPushAadContext, ): IWebPushEncryptedEnvelope { const keyId = this.encryptionRing.currentKeyId; const key = this.encryptionRing.keys.get(keyId); if (!key) throw new Error('Current Web Push encryption key is unavailable'); const nonce = plugins.crypto.randomBytes(12); const cipher = plugins.crypto.createCipheriv('aes-256-gcm', key, nonce); cipher.setAAD(buildWebPushAad(aadContextArg)); const ciphertext = Buffer.concat([ cipher.update(canonicalJson(valueArg), 'utf8'), cipher.final(), ]); const tag = cipher.getAuthTag(); return { version: 1, algorithm: 'aes-256-gcm', keyId, nonce: nonce.toString('base64url'), ciphertext: ciphertext.toString('base64url'), tag: tag.toString('base64url'), }; } public decryptJson( envelopeArg: IWebPushEncryptedEnvelope, aadContextArg: IWebPushAadContext, ): T { if ( envelopeArg?.version !== 1 || envelopeArg.algorithm !== 'aes-256-gcm' || typeof envelopeArg.keyId !== 'string' || !KEY_ID_PATTERN.test(envelopeArg.keyId) ) { throw new Error('Web Push encrypted envelope is malformed'); } const key = this.encryptionRing.keys.get(envelopeArg.keyId); if (!key) throw new Error('Web Push encrypted envelope references an unavailable key'); const nonce = decodeBase64UrlExact(envelopeArg.nonce, 12, 'Web Push envelope nonce'); const tag = decodeBase64UrlExact(envelopeArg.tag, 16, 'Web Push envelope tag'); if ( typeof envelopeArg.ciphertext !== 'string' || !envelopeArg.ciphertext || !BASE64URL_VALUE_PATTERN.test(envelopeArg.ciphertext) ) { throw new Error('Web Push envelope ciphertext is malformed'); } const ciphertext = Buffer.from(envelopeArg.ciphertext, 'base64url'); if (ciphertext.toString('base64url') !== envelopeArg.ciphertext) { throw new Error('Web Push envelope ciphertext is malformed'); } try { const decipher = plugins.crypto.createDecipheriv('aes-256-gcm', key, nonce); decipher.setAAD(buildWebPushAad(aadContextArg)); decipher.setAuthTag(tag); const plaintext = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]).toString('utf8'); return JSON.parse(plaintext) as T; } catch (error: unknown) { throw new Error('Web Push encrypted envelope authentication failed', { cause: error }); } } public hmac( domainArg: TWebPushHmacDomain, valueArg: string, keyIdArg = this.hmacRing.currentKeyId, ): { keyId: string; digest: string } { const key = this.hmacRing.keys.get(keyIdArg); if (!key) throw new Error('Web Push HMAC key is unavailable'); const digest = plugins.crypto .createHmac('sha256', key) .update(`dcrouter:webpush:v1:${domainArg}\0`, 'utf8') .update(valueArg, 'utf8') .digest('base64url'); return { keyId: keyIdArg, digest, }; } public verifyHmac( domainArg: TWebPushHmacDomain, valueArg: string, keyIdArg: string, expectedDigestArg: string, ): boolean { let actualDigest: string; try { actualDigest = this.hmac(domainArg, valueArg, keyIdArg).digest; } catch { return false; } const actual = Buffer.from(actualDigest, 'base64url'); const expected = Buffer.from(expectedDigestArg, 'base64url'); return actual.length === expected.length && actual.length > 0 && plugins.crypto.timingSafeEqual(actual, expected); } }