// server/supabase/functions/_shared/webpush.ts // Standard Web Push (VAPID + RFC 8291 aes128gcm) transport. // // Firebase-free: the browser Push API delivers the message; we only sign with // VAPID and encrypt per RFC 8291. No Google account or Firebase project needed. import { PushContractError, type PushNotification } from './push-contract.ts' export interface WebPushConfig { /** base64url encoded uncompressed P-256 public key (65 bytes, 0x04 prefix). */ publicKey: string /** base64url encoded P-256 private scalar (32 bytes). */ privateKey: string /** VAPID subject: `mailto:` or `https:` URL. */ subject: string } export interface WebPushSubscription { endpoint: string p256dh: string auth: string } export interface WebPushSendOptions { fetchImpl?: typeof fetch config?: WebPushConfig now?: number randomBytes?: (length: number) => Uint8Array } export interface WebPushSendResult { status: number } const SUBJECT_PATTERN = /^(mailto:[^@\s]+@[^@\s]+|https:\/\/[^\s]+)$/ const RECORD_SIZE = 4096 const encoder = new TextEncoder() const REQUEST_TIMEOUT_MS = 10_000 function decodeBase64Url(value: string): Uint8Array { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) const binary = atob(padded) return Uint8Array.from(binary, (character) => character.charCodeAt(0)) } function encodeBase64Url(bytes: Uint8Array): string { let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') } function concat(...parts: Uint8Array[]): Uint8Array { const total = parts.reduce((sum, part) => sum + part.byteLength, 0) const output = new Uint8Array(total) let offset = 0 for (const part of parts) { output.set(part, offset) offset += part.byteLength } return output } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const buffer = new ArrayBuffer(bytes.byteLength) new Uint8Array(buffer).set(bytes) return buffer } async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { const imported = await crypto.subtle.importKey( 'raw', toArrayBuffer(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ) const signature = await crypto.subtle.sign('HMAC', imported, toArrayBuffer(data)) return new Uint8Array(signature) } async function hkdfExpand(prk: Uint8Array, info: Uint8Array, length: number): Promise { const blocks = Math.ceil(length / 32) const output = new Uint8Array(blocks * 32) let previous = new Uint8Array(0) for (let index = 0; index < blocks; index += 1) { const block = await hmacSha256(prk, concat(previous, info, Uint8Array.of(index + 1))) output.set(block, index * 32) previous = new Uint8Array(block) } return output.slice(0, length) } export function readWebPushConfig( readEnv: (name: string) => string | undefined = (name) => Deno.env.get(name), ): WebPushConfig { const publicKey = readEnv('WEBPUSH_VAPID_PUBLIC_KEY')?.trim() ?? '' const privateKey = readEnv('WEBPUSH_VAPID_PRIVATE_KEY')?.trim() ?? '' const subject = readEnv('WEBPUSH_SUBJECT')?.trim() ?? '' if (!publicKey || !privateKey || !subject) { throw new PushContractError('webpush_not_configured', 503) } try { const publicBytes = decodeBase64Url(publicKey) const privateBytes = decodeBase64Url(privateKey) if ( publicBytes.byteLength !== 65 || publicBytes[0] !== 0x04 || privateBytes.byteLength !== 32 || !SUBJECT_PATTERN.test(subject) ) { throw new Error('invalid web push credentials') } return { publicKey, privateKey, subject } } catch { throw new PushContractError('webpush_credentials_invalid', 503) } } export function parseWebPushSubscription(registrationId: string): WebPushSubscription { if (typeof registrationId !== 'string' || registrationId.length < 20 || registrationId.length > 8192) { throw new PushContractError('webpush_registration_invalid', 400, true) } try { const parsed = JSON.parse(registrationId) as Record const endpoint = typeof parsed.endpoint === 'string' ? parsed.endpoint : '' const keys = parsed.keys const p256dh = keys && typeof keys === 'object' && typeof (keys as Record).p256dh === 'string' ? (keys as Record).p256dh : '' const auth = keys && typeof keys === 'object' && typeof (keys as Record).auth === 'string' ? (keys as Record).auth : '' const endpointUrl = new URL(endpoint) const p256dhBytes = decodeBase64Url(p256dh) const authBytes = decodeBase64Url(auth) if ( endpointUrl.protocol !== 'https:' || p256dhBytes.byteLength !== 65 || p256dhBytes[0] !== 0x04 || authBytes.byteLength < 16 || authBytes.byteLength > 32 ) { throw new Error('invalid subscription') } return { endpoint: endpointUrl.toString(), p256dh, auth } } catch (error) { if (error instanceof PushContractError) throw error throw new PushContractError('webpush_registration_invalid', 400, true) } } async function buildVapidAuthorization( config: WebPushConfig, endpoint: string, nowSeconds: number, ): Promise { const publicBytes = decodeBase64Url(config.publicKey) const jwk: JsonWebKey = { kty: 'EC', crv: 'P-256', d: config.privateKey, x: encodeBase64Url(publicBytes.slice(1, 33)), y: encodeBase64Url(publicBytes.slice(33, 65)), } const key = await crypto.subtle.importKey( 'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'], ) const header = encodeBase64Url(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' }))) const payload = encodeBase64Url(encoder.encode(JSON.stringify({ aud: new URL(endpoint).origin, exp: nowSeconds + 12 * 3600, sub: config.subject, }))) const signingInput = `${header}.${payload}` const signature = await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256' }, key, encoder.encode(signingInput), ) const jwt = `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}` return `vapid t=${jwt}, k=${config.publicKey}` } export async function encryptWebPushPayload( subscription: WebPushSubscription, payload: Uint8Array, randomBytes: (length: number) => Uint8Array, ): Promise { const uaPublic = decodeBase64Url(subscription.p256dh) const authSecret = decodeBase64Url(subscription.auth) const salt = randomBytes(16) const applicationKeys = await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits'], ) const applicationPublic = new Uint8Array( await crypto.subtle.exportKey('raw', applicationKeys.publicKey), ) const userAgentKey = await crypto.subtle.importKey( 'raw', toArrayBuffer(uaPublic), { name: 'ECDH', namedCurve: 'P-256' }, false, [], ) const sharedSecret = new Uint8Array( await crypto.subtle.deriveBits( { name: 'ECDH', public: userAgentKey }, applicationKeys.privateKey, 256, ), ) const prkKey = await hmacSha256(authSecret, sharedSecret) const keyInfo = concat( encoder.encode('WebPush: info\x00'), uaPublic, applicationPublic, ) const ikm = await hkdfExpand(prkKey, keyInfo, 32) const prk = await hmacSha256(salt, ikm) const contentEncryptionKey = await hkdfExpand( prk, encoder.encode('Content-Encoding: aes128gcm\x00'), 16, ) const nonce = await hkdfExpand(prk, encoder.encode('Content-Encoding: nonce\x00'), 12) const plaintext = concat(payload, Uint8Array.of(0x02)) const aesKey = await crypto.subtle.importKey( 'raw', toArrayBuffer(contentEncryptionKey), 'AES-GCM', false, ['encrypt'], ) const ciphertext = new Uint8Array( await crypto.subtle.encrypt( { name: 'AES-GCM', iv: toArrayBuffer(nonce), tagLength: 128 }, aesKey, toArrayBuffer(plaintext), ), ) const header = new Uint8Array(16 + 4 + 1 + 65) header.set(salt, 0) new DataView(header.buffer).setUint32(16, RECORD_SIZE, false) header[20] = 65 header.set(applicationPublic, 21) return concat(header, ciphertext) } export async function sendWebPushMessage( registrationId: string, notification: PushNotification, options: WebPushSendOptions = {}, ): Promise { const subscription = parseWebPushSubscription(registrationId) const config = options.config ?? readWebPushConfig() const fetchImpl = options.fetchImpl ?? fetch const nowSeconds = Math.floor((options.now ?? Date.now()) / 1000) const randomBytes = options.randomBytes ?? ((length: number) => crypto.getRandomValues(new Uint8Array(length))) const payload = encoder.encode(JSON.stringify({ title: notification.title, body: notification.body, data: notification.data, })) const body = await encryptWebPushPayload(subscription, payload, randomBytes) const authorization = await buildVapidAuthorization(config, subscription.endpoint, nowSeconds) let response: Response try { response = await fetchImpl(subscription.endpoint, { method: 'POST', headers: { Authorization: authorization, 'Content-Encoding': 'aes128gcm', 'Content-Type': 'application/octet-stream', TTL: '86400', }, body, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) } catch { throw new PushContractError('webpush_send_timeout', 504) } if (response.status === 200 || response.status === 201 || response.status === 202) { return { status: response.status } } if (response.status === 404 || response.status === 410) { throw new PushContractError('webpush_registration_stale', 410, true) } if (response.status === 413) { throw new PushContractError('webpush_payload_too_large', 500) } throw new PushContractError('webpush_send_failed', 502) }