import crypto from 'node:crypto'; export interface VapidKeys { publicKey: string; // base64url uncompressed P-256 (65 bytes) privateKey: string; // base64url scalar D (32 bytes) or PEM subject: string; } export interface PushSubscriptionKeys { p256dh: string; // base64url auth: string; // base64url } export interface PushSubscriptionTarget { endpoint: string; keys: PushSubscriptionKeys; } export interface WebPushSendResult { statusCode: number; successful: boolean; shouldRemoveSubscription: boolean; // 404 Not Found or 410 Gone error?: string | undefined; } export interface WebPushOptions { fetchFn?: typeof fetch | undefined; ttl?: number | undefined; urgency?: ('very-low' | 'low' | 'normal' | 'high') | undefined; } let cachedEphemeralKeys: VapidKeys | null = null; /** * Generate a fresh ECDSA P-256 key pair suitable for WebPush VAPID. */ export function generateVapidKeys(subject = 'mailto:admin@krona.local'): VapidKeys { const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1', }); const pubJwk = publicKey.export({ format: 'jwk' }); const privJwk = privateKey.export({ format: 'jwk' }); if (!pubJwk.x || !pubJwk.y || !privJwk.d) { throw new Error('Failed to generate EC P-256 key pair'); } const rawPub = Buffer.concat([ Buffer.from([0x04]), Buffer.from(pubJwk.x, 'base64url'), Buffer.from(pubJwk.y, 'base64url'), ]); return { publicKey: rawPub.toString('base64url'), privateKey: privJwk.d, subject, }; } /** * Load VAPID keys from config or environment, or fallback to an in-memory ephemeral key pair. */ export function resolveVapidKeys(config: { VAPID_PUBLIC_KEY?: string | undefined; VAPID_PRIVATE_KEY?: string | undefined; VAPID_SUBJECT?: string | undefined; }): VapidKeys { const subject = config.VAPID_SUBJECT || 'mailto:admin@krona.local'; if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY) { return { publicKey: config.VAPID_PUBLIC_KEY, privateKey: config.VAPID_PRIVATE_KEY, subject, }; } if (Boolean(config.VAPID_PUBLIC_KEY) !== Boolean(config.VAPID_PRIVATE_KEY)) { throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together'); } // Safe ephemeral fallback in memory for dev/test if (!cachedEphemeralKeys) { cachedEphemeralKeys = generateVapidKeys(subject); } return cachedEphemeralKeys; } /** * Convert VAPID private key string to a KeyObject. */ function createVapidPrivateKeyObject(vapidKeys: VapidKeys): crypto.KeyObject { const priv = vapidKeys.privateKey.trim(); if (priv.startsWith('-----BEGIN')) { return crypto.createPrivateKey({ key: priv, format: 'pem' }); } // Raw 32-byte base64url scalar D const pubBuf = Buffer.from(vapidKeys.publicKey, 'base64url'); if (pubBuf.length !== 65 || pubBuf[0] !== 0x04) { throw new Error('Invalid VAPID public key length or format'); } const x = pubBuf.subarray(1, 33).toString('base64url'); const y = pubBuf.subarray(33, 65).toString('base64url'); return crypto.createPrivateKey({ key: { kty: 'EC', crv: 'P-256', x, y, d: vapidKeys.privateKey, }, format: 'jwk', }); } /** * Create a VAPID Authorization header (RFC 8292). */ export function createVapidAuthorizationHeader( endpoint: string, vapidKeys: VapidKeys, expirationSeconds = 12 * 3600, ): string { const url = new URL(endpoint); const audience = `${url.protocol}//${url.host}`; const now = Math.floor(Date.now() / 1000); const exp = now + expirationSeconds; const header = { typ: 'JWT', alg: 'ES256' }; const payload = { aud: audience, exp, sub: vapidKeys.subject }; const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); const signingInput = `${headerB64}.${payloadB64}`; const privateKey = createVapidPrivateKeyObject(vapidKeys); const signature = crypto.sign('SHA256', Buffer.from(signingInput, 'utf8'), { key: privateKey, dsaEncoding: 'ieee-p1363', }); const jwt = `${signingInput}.${signature.toString('base64url')}`; return `vapid t=${jwt}, k=${vapidKeys.publicKey}`; } /** * Encrypt a payload using AES-128-GCM content encoding (RFC 8291 / RFC 8188). */ export function encryptPushPayload(payload: string | Buffer, keys: PushSubscriptionKeys): Buffer { const plaintext = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8'); const recipientP256dh = Buffer.from(keys.p256dh, 'base64url'); const recipientAuth = Buffer.from(keys.auth, 'base64url'); if (recipientP256dh.length !== 65 || recipientP256dh[0] !== 0x04) { throw new Error('Invalid recipient p256dh key: expected 65-byte uncompressed point'); } if (recipientAuth.length !== 16) { throw new Error('Invalid recipient auth secret: expected 16 bytes'); } // 1. Generate ephemeral sender key pair const sender = crypto.createECDH('prime256v1'); sender.generateKeys(); const senderPublicKey = sender.getPublicKey(); // 65 bytes const sharedSecret = sender.computeSecret(recipientP256dh); // 2. Derive PRK via HKDF-Extract & Expand (RFC 8291 Section 3.2) const keyInfo = Buffer.concat([ Buffer.from('WebPush: info\0', 'utf8'), recipientP256dh, senderPublicKey, ]); const prk = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, recipientAuth, keyInfo, 32)); // 3. Generate 16-byte random salt const salt = crypto.randomBytes(16); // 4. Derive CEK and NONCE (RFC 8291 Section 3.3) const cekInfo = Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'); const cek = Buffer.from(crypto.hkdfSync('sha256', prk, salt, cekInfo, 16)); const nonceInfo = Buffer.from('Content-Encoding: nonce\0', 'utf8'); const nonce = Buffer.from(crypto.hkdfSync('sha256', prk, salt, nonceInfo, 12)); // 5. Append padding delimiter (0x02 for the final/only record in RFC 8188) const record = Buffer.concat([plaintext, Buffer.from([0x02])]); // 6. Encrypt with AES-128-GCM const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce); const ciphertext = Buffer.concat([cipher.update(record), cipher.final()]); const tag = cipher.getAuthTag(); // 7. Build RFC 8188 header: // salt (16) + rs (4, uint32 BE = 4096) + idlen (1, uint8 = 65) + keyid (65, senderPublicKey) const rs = 4096; const rsBuf = Buffer.alloc(4); rsBuf.writeUInt32BE(rs, 0); const header = Buffer.concat([ salt, rsBuf, Buffer.from([senderPublicKey.length]), senderPublicKey, ]); return Buffer.concat([header, ciphertext, tag]); } /** * Send an encrypted WebPush notification over HTTPS. */ export function isPrivateOrLoopbackHost(host: string): boolean { const h = host.toLowerCase().replace(/^\[|\]$/g, ''); if (h === 'localhost' || h.endsWith('.localhost')) return true; const ipv4Match = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (ipv4Match) { const parts = ipv4Match.slice(1).map(Number); const [o1, o2] = parts; if (o1 === 127 || o1 === 10 || o1 === 0) return true; if (o1 === 172 && o2! >= 16 && o2! <= 31) return true; if (o1 === 192 && o2! === 168) return true; if (o1 === 169 && o2! === 254) return true; } if ( h === '::1' || h === '::' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe80') || h.includes('::ffff:127.') || h.includes('::ffff:10.') || h.includes('::ffff:192.168.') ) { return true; } return false; } export async function sendWebPush( target: PushSubscriptionTarget, payload: string, vapidKeys: VapidKeys, options?: WebPushOptions, ): Promise { if (!target.endpoint.startsWith('https://')) { throw new Error('Subscription endpoint must use HTTPS'); } let targetUrl: URL; try { targetUrl = new URL(target.endpoint); } catch { throw new Error('Invalid endpoint URL'); } if (isPrivateOrLoopbackHost(targetUrl.hostname)) { return { statusCode: 400, successful: false, shouldRemoveSubscription: true, error: 'Cannot dispatch push to internal or private IP address', }; } const encryptedBody = encryptPushPayload(payload, target.keys); const authHeader = createVapidAuthorizationHeader(target.endpoint, vapidKeys); const fetchImpl = options?.fetchFn ?? fetch; try { const response = await fetchImpl(target.endpoint, { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': 'application/octet-stream', 'Content-Encoding': 'aes128gcm', TTL: String(options?.ttl ?? 86400), Urgency: options?.urgency ?? 'normal', }, body: new Uint8Array(encryptedBody), redirect: 'manual', }); const statusCode = response.status; if (statusCode >= 200 && statusCode < 300) { return { statusCode, successful: true, shouldRemoveSubscription: false }; } const shouldRemoveSubscription = statusCode === 404 || statusCode === 410; const bodyText = await response.text().catch(() => ''); return { statusCode, successful: false, shouldRemoveSubscription, error: `Push service rejected notification: HTTP ${statusCode}${bodyText ? ` (${bodyText.slice(0, 200)})` : ''}`, }; } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); return { statusCode: 0, successful: false, shouldRemoveSubscription: false, error: `Network error delivering push: ${errorMsg}`, }; } }