From 8b2185135318d150ef27ca5d16ebd6228fe29582 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:54:26 +0200 Subject: [PATCH] feat: migrate to UnifiedPush --- app/api/push/jmap/[id]/route.ts | 4 +- app/api/push/register/route.ts | 42 ++++++--- app/api/push/vapid/route.ts | 22 +++++ lib/push/base64url.ts | 14 +++ lib/push/expo.ts | 61 ------------- lib/push/types.ts | 8 +- lib/push/unified-push.ts | 57 ++++++++++++ lib/push/validation.ts | 33 +++++-- lib/push/vapid.ts | 90 +++++++++++++++++++ lib/push/web-push.ts | 149 ++++++++++++++++++++++++++++++++ 10 files changed, 396 insertions(+), 84 deletions(-) create mode 100644 app/api/push/vapid/route.ts create mode 100644 lib/push/base64url.ts delete mode 100644 lib/push/expo.ts create mode 100644 lib/push/unified-push.ts create mode 100644 lib/push/vapid.ts create mode 100644 lib/push/web-push.ts diff --git a/app/api/push/jmap/[id]/route.ts b/app/api/push/jmap/[id]/route.ts index a83e2aac..641e7a1f 100644 --- a/app/api/push/jmap/[id]/route.ts +++ b/app/api/push/jmap/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { subscriptionStore } from '@/lib/push/store'; -import { sendExpoPush } from '@/lib/push/expo'; +import { sendUnifiedPush } from '@/lib/push/unified-push'; import { isValidSubscriptionId } from '@/lib/push/validation'; import type { JmapPushBody } from '@/lib/push/types'; @@ -75,7 +75,7 @@ export async function POST( } if (body['@type'] === 'StateChange') { - const ok = await sendExpoPush(record, body); + const ok = await sendUnifiedPush(record, body); record.lastPushAt = Date.now(); await subscriptionStore.put(id, record); return NextResponse.json({ ok }); diff --git a/app/api/push/register/route.ts b/app/api/push/register/route.ts index c5e459b0..415a14ad 100644 --- a/app/api/push/register/route.ts +++ b/app/api/push/register/route.ts @@ -2,41 +2,57 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { subscriptionStore } from '@/lib/push/store'; import { - isValidExpoPushToken, + isValidAuthSecret, + isValidP256dh, + isValidPushEndpoint, isValidSubscriptionId, } from '@/lib/push/validation'; import type { SubscriptionRecord } from '@/lib/push/types'; /** * POST /api/push/register - * Body: { subscriptionId, expoPushToken, accountLabel? } + * Body: { subscriptionId, endpoint, p256dh, auth, accountLabel? } * - * Called by the mobile app once it has created a JMAP PushSubscription and - * received the subscriptionId it plans to use. We hold only the device's - * Expo push token + that id — no user credentials pass through the relay. + * Called by the mobile app once its UnifiedPush distributor has produced a + * push endpoint. We hold only the Web Push keys needed to encrypt payloads + * per RFC 8291 — no user credentials pass through the relay. */ export async function POST(request: NextRequest) { try { const body = (await request.json().catch(() => null)) as { subscriptionId?: unknown; - expoPushToken?: unknown; + endpoint?: unknown; + p256dh?: unknown; + auth?: unknown; accountLabel?: unknown; } | null; if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); - const { subscriptionId, expoPushToken, accountLabel } = body; + const { subscriptionId, endpoint, p256dh, auth, accountLabel } = body; if (!isValidSubscriptionId(subscriptionId)) { return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 }); } - if (!isValidExpoPushToken(expoPushToken)) { - return NextResponse.json({ error: 'Invalid expoPushToken' }, { status: 400 }); + if (!isValidPushEndpoint(endpoint)) { + return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 }); + } + if (!isValidP256dh(p256dh)) { + return NextResponse.json({ error: 'Invalid p256dh' }, { status: 400 }); + } + if (!isValidAuthSecret(auth)) { + return NextResponse.json({ error: 'Invalid auth' }, { status: 400 }); } + // Preserve verificationCode if a previous record exists — the JMAP server + // may have already POSTed PushVerification before the app re-registers. + const existing = await subscriptionStore.get(subscriptionId); + const record: SubscriptionRecord = { - expoPushToken, - verificationCode: null, - createdAt: Date.now(), - lastPushAt: null, + endpoint, + p256dh, + auth, + verificationCode: existing?.verificationCode ?? null, + createdAt: existing?.createdAt ?? Date.now(), + lastPushAt: existing?.lastPushAt ?? null, accountLabel: typeof accountLabel === 'string' ? accountLabel.slice(0, 120) : undefined, }; diff --git a/app/api/push/vapid/route.ts b/app/api/push/vapid/route.ts new file mode 100644 index 00000000..7e7650f1 --- /dev/null +++ b/app/api/push/vapid/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getVapidKeys } from '@/lib/push/vapid'; + +/** + * GET /api/push/vapid + * + * Returns the relay's VAPID public key. The mobile app passes this to the + * UnifiedPush distributor so the distributor can lock the endpoint to pushes + * signed by us. Generated on first call; persisted under `data/push/vapid.json`. + */ +export async function GET() { + try { + const keys = await getVapidKeys(); + return NextResponse.json({ publicKey: keys.publicKey }); + } catch (error) { + logger.error('push: vapid key fetch failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/lib/push/base64url.ts b/lib/push/base64url.ts new file mode 100644 index 00000000..ac168166 --- /dev/null +++ b/lib/push/base64url.ts @@ -0,0 +1,14 @@ +export function b64uEncode(buf: Buffer | Uint8Array): string { + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + return b + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function b64uDecode(value: string): Buffer { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const padLen = (4 - (padded.length % 4)) % 4; + return Buffer.from(padded + '='.repeat(padLen), 'base64'); +} diff --git a/lib/push/expo.ts b/lib/push/expo.ts deleted file mode 100644 index 5dafc137..00000000 --- a/lib/push/expo.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { logger } from '@/lib/logger'; -import type { StateChange, SubscriptionRecord } from './types'; - -// Expo's push API. Swap via env when self-hosting. -function getExpoPushUrl(): string { - return process.env.EXPO_PUSH_URL ?? 'https://exp.host/--/api/v2/push/send'; -} - -export async function sendExpoPush( - record: SubscriptionRecord, - change: StateChange, -): Promise { - // Summarize the JMAP StateChange into a hint the user can see on lock - // screen. The app wakes on receipt, re-fetches via JMAP with its own creds, - // and can replace the notification via `presentNotificationAsync` if - // subject/sender should be shown. - const types = new Set(); - for (const perAccount of Object.values(change.changed)) { - for (const type of Object.keys(perAccount)) types.add(type); - } - const hasEmail = types.has('Email') || types.has('EmailDelivery'); - - const message = { - to: record.expoPushToken, - title: hasEmail ? 'New mail' : 'Mailbox updated', - body: record.accountLabel ?? '', - sound: 'default', - priority: 'high' as const, - channelId: 'mail', - data: { - kind: 'jmap-state-change', - changed: change.changed, - }, - _contentAvailable: true, - }; - - try { - const res = await fetch(getExpoPushUrl(), { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - 'accept-encoding': 'gzip, deflate', - }, - body: JSON.stringify(message), - }); - if (!res.ok) { - logger.warn('push: expo delivery failed', { - status: res.status, - body: await res.text().catch(() => ''), - }); - return false; - } - return true; - } catch (error) { - logger.warn('push: expo delivery error', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return false; - } -} diff --git a/lib/push/types.ts b/lib/push/types.ts index 1cbea419..442f1a48 100644 --- a/lib/push/types.ts +++ b/lib/push/types.ts @@ -2,7 +2,13 @@ // so both server code and admin UIs can import without pulling `node:fs`. export interface SubscriptionRecord { - expoPushToken: string; + // UnifiedPush / Web Push endpoint the distributor gave the app. The relay + // POSTs RFC 8291 aes128gcm-encrypted bodies here. + endpoint: string; + // P-256 public key (UA), uncompressed 65-byte point, base64url without padding. + p256dh: string; + // Auth secret, 16 bytes, base64url without padding. + auth: string; verificationCode: string | null; createdAt: number; lastPushAt: number | null; diff --git a/lib/push/unified-push.ts b/lib/push/unified-push.ts new file mode 100644 index 00000000..83712f03 --- /dev/null +++ b/lib/push/unified-push.ts @@ -0,0 +1,57 @@ +import { logger } from '@/lib/logger'; +import type { StateChange, SubscriptionRecord } from './types'; +import { sendWebPush } from './web-push'; +import { getVapidKeys } from './vapid'; + +interface UnifiedPushPayload { + kind: 'jmap-state-change'; + title: string; + body: string; + changed: Record>; +} + +function buildPayload(record: SubscriptionRecord, change: StateChange): UnifiedPushPayload { + const types = new Set(); + for (const perAccount of Object.values(change.changed)) { + for (const type of Object.keys(perAccount)) types.add(type); + } + const hasEmail = types.has('Email') || types.has('EmailDelivery'); + return { + kind: 'jmap-state-change', + title: hasEmail ? 'New mail' : 'Mailbox updated', + body: record.accountLabel ?? '', + changed: change.changed, + }; +} + +export async function sendUnifiedPush( + record: SubscriptionRecord, + change: StateChange, +): Promise { + try { + const vapid = await getVapidKeys(); + const payload = Buffer.from(JSON.stringify(buildPayload(record, change)), 'utf-8'); + const result = await sendWebPush({ + endpoint: record.endpoint, + p256dh: record.p256dh, + auth: record.auth, + payload, + vapid, + ttlSeconds: 60, + urgency: 'high', + }); + if (!result.ok) { + logger.warn('push: unified-push delivery failed', { + status: result.status, + body: result.body, + }); + return false; + } + return true; + } catch (error) { + logger.warn('push: unified-push delivery error', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return false; + } +} diff --git a/lib/push/validation.ts b/lib/push/validation.ts index 11785de9..3e7dc3e8 100644 --- a/lib/push/validation.ts +++ b/lib/push/validation.ts @@ -2,11 +2,30 @@ export function isValidSubscriptionId(id: unknown): id is string { return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id); } -export function isValidExpoPushToken(token: unknown): token is string { - if (typeof token !== 'string') return false; - // Expo managed tokens, or raw FCM/APNs tokens for bare workflows. - return ( - /^ExponentPushToken\[[A-Za-z0-9_-]+\]$/.test(token) || - /^[A-Za-z0-9:_-]{40,250}$/.test(token) - ); +export function isValidPushEndpoint(url: unknown): url is string { + if (typeof url !== 'string' || url.length > 2048) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } +} + +export function isBase64Url(value: unknown, minBytes: number, maxBytes: number): value is string { + if (typeof value !== 'string') return false; + if (!/^[A-Za-z0-9_-]+$/.test(value)) return false; + // base64url decoded length: floor(n * 3 / 4) where n is input length (no padding). + const decodedLen = Math.floor((value.length * 3) / 4); + return decodedLen >= minBytes && decodedLen <= maxBytes; +} + +export function isValidP256dh(value: unknown): value is string { + // Uncompressed P-256 point is 65 bytes (0x04 || X || Y). base64url no padding. + return isBase64Url(value, 64, 66); +} + +export function isValidAuthSecret(value: unknown): value is string { + // RFC 8291 requires 16 bytes. + return isBase64Url(value, 16, 16); } diff --git a/lib/push/vapid.ts b/lib/push/vapid.ts new file mode 100644 index 00000000..0909d557 --- /dev/null +++ b/lib/push/vapid.ts @@ -0,0 +1,90 @@ +import crypto from 'node:crypto'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { b64uDecode, b64uEncode } from './base64url'; + +interface StoredVapid { + publicKey: string; + privateKeyPem: string; + subject: string; +} + +export interface VapidKeys { + publicKey: string; + publicKeyBuf: Buffer; + privateKeyPem: string; + subject: string; +} + +function getVapidPath(): string { + const dir = process.env.PUSH_DATA_DIR || path.join(process.cwd(), 'data', 'push'); + return path.join(dir, 'vapid.json'); +} + +function getSubject(): string { + return process.env.VAPID_SUBJECT?.trim() || 'mailto:admin@localhost'; +} + +// P-256 SPKI DER wraps a 26-byte algorithm identifier followed by a BIT STRING +// containing the uncompressed point (0x04 || X || Y). Strip the header to get +// the raw 65-byte public key that VAPID requires. +function extractUncompressedPoint(spkiDer: Buffer): Buffer { + if (spkiDer.length < 65) throw new Error('SPKI DER too short for P-256'); + return spkiDer.subarray(spkiDer.length - 65); +} + +let cached: VapidKeys | null = null; +let loading: Promise | null = null; + +async function loadOrCreate(): Promise { + const file = getVapidPath(); + try { + const raw = await readFile(file, 'utf-8'); + const parsed = JSON.parse(raw) as StoredVapid; + return { + publicKey: parsed.publicKey, + publicKeyBuf: b64uDecode(parsed.publicKey), + privateKeyPem: parsed.privateKeyPem, + subject: parsed.subject, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const spki = publicKey.export({ format: 'der', type: 'spki' }); + const uncompressed = extractUncompressedPoint(spki); + const privateKeyPem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string; + const stored: StoredVapid = { + publicKey: b64uEncode(uncompressed), + privateKeyPem, + subject: getSubject(), + }; + + const dir = path.dirname(file); + if (!existsSync(dir)) await mkdir(dir, { recursive: true }); + const tmp = file + '.tmp'; + await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8'); + await import('node:fs/promises').then((fs) => fs.rename(tmp, file)); + + return { + publicKey: stored.publicKey, + publicKeyBuf: uncompressed, + privateKeyPem: stored.privateKeyPem, + subject: stored.subject, + }; +} + +export async function getVapidKeys(): Promise { + if (cached) return cached; + if (!loading) { + loading = loadOrCreate().then((keys) => { + cached = keys; + return keys; + }); + } + return loading; +} diff --git a/lib/push/web-push.ts b/lib/push/web-push.ts new file mode 100644 index 00000000..2bc32bf4 --- /dev/null +++ b/lib/push/web-push.ts @@ -0,0 +1,149 @@ +import crypto from 'node:crypto'; +import { b64uDecode, b64uEncode } from './base64url'; +import type { VapidKeys } from './vapid'; + +// RFC 5869 HKDF-Extract + HKDF-Expand using SHA-256. +function hkdf(salt: Buffer, ikm: Buffer, info: Buffer, length: number): Buffer { + const prk = crypto.createHmac('sha256', salt).update(ikm).digest(); + const out = Buffer.alloc(length); + let previous = Buffer.alloc(0); + let offset = 0; + let counter = 1; + while (offset < length) { + const hmac = crypto.createHmac('sha256', prk); + hmac.update(previous); + hmac.update(info); + hmac.update(Buffer.from([counter])); + previous = hmac.digest(); + const copyLen = Math.min(previous.length, length - offset); + previous.subarray(0, copyLen).copy(out, offset); + offset += copyLen; + counter++; + } + return out; +} + +// RFC 8291 aes128gcm Web Push content encoding (single record). +export function encryptAes128gcm(params: { + p256dh: Buffer; + auth: Buffer; + payload: Buffer; +}): Buffer { + const ecdh = crypto.createECDH('prime256v1'); + const serverPublic = ecdh.generateKeys(); + const sharedSecret = ecdh.computeSecret(params.p256dh); + const salt = crypto.randomBytes(16); + + // RFC 8291 §3.3: PRK_key = HMAC-SHA-256(auth_secret, IKM) + // IKM = ECDH shared secret + // info = "WebPush: info\0" || ua_public || as_public + const keyInfo = Buffer.concat([ + Buffer.from('WebPush: info\0', 'utf-8'), + params.p256dh, + serverPublic, + ]); + const prkKey = hkdf(params.auth, sharedSecret, keyInfo, 32); + + // RFC 8188 §2.2: derive CEK and nonce from PRK_key with salt. + const cek = hkdf(salt, prkKey, Buffer.from('Content-Encoding: aes128gcm\0', 'utf-8'), 16); + const nonce = hkdf(salt, prkKey, Buffer.from('Content-Encoding: nonce\0', 'utf-8'), 12); + + // Single-record padding: plaintext || 0x02 (end-of-last-record delimiter). + const padded = Buffer.concat([params.payload, Buffer.from([0x02])]); + + const cipher = crypto.createCipheriv('aes-128-gcm', cek, nonce); + const ciphertext = Buffer.concat([cipher.update(padded), cipher.final()]); + const tag = cipher.getAuthTag(); + + // Per RFC 8188 §2.1: salt (16) || rs (4 BE) || idlen (1) || keyid || ciphertext+tag. + const rsBuf = Buffer.alloc(4); + rsBuf.writeUInt32BE(4096, 0); + const idlenBuf = Buffer.from([serverPublic.length]); + + return Buffer.concat([salt, rsBuf, idlenBuf, serverPublic, ciphertext, tag]); +} + +// ECDSA DER → raw r||s (64 bytes) for ES256 signature encoding used by JWT. +function derEs256ToRaw(der: Buffer): Buffer { + if (der[0] !== 0x30) throw new Error('Invalid DER signature'); + let offset = 2; + if (der[1] & 0x80) offset += der[1] & 0x7f; + if (der[offset] !== 0x02) throw new Error('Invalid DER r marker'); + const rLen = der[offset + 1]; + let r = der.subarray(offset + 2, offset + 2 + rLen); + offset += 2 + rLen; + if (der[offset] !== 0x02) throw new Error('Invalid DER s marker'); + const sLen = der[offset + 1]; + let s = der.subarray(offset + 2, offset + 2 + sLen); + const pad = (buf: Buffer) => { + if (buf.length === 32) return buf; + if (buf.length === 33 && buf[0] === 0x00) return buf.subarray(1); + if (buf.length > 32) throw new Error('ECDSA component > 32 bytes'); + const out = Buffer.alloc(32); + buf.copy(out, 32 - buf.length); + return out; + }; + return Buffer.concat([pad(r), pad(s)]); +} + +function buildVapidJwt(endpoint: string, vapid: VapidKeys): string { + const audience = new URL(endpoint).origin; + const header = b64uEncode(Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' }))); + const claims = b64uEncode( + Buffer.from( + JSON.stringify({ + aud: audience, + exp: Math.floor(Date.now() / 1000) + 12 * 3600, + sub: vapid.subject, + }), + ), + ); + const signingInput = `${header}.${claims}`; + const der = crypto.createSign('SHA256').update(signingInput).sign(vapid.privateKeyPem); + return `${signingInput}.${b64uEncode(derEs256ToRaw(der))}`; +} + +export interface WebPushResult { + ok: boolean; + status: number; + body?: string; +} + +export async function sendWebPush(params: { + endpoint: string; + p256dh: string; + auth: string; + payload: Buffer; + vapid: VapidKeys; + ttlSeconds?: number; + urgency?: 'very-low' | 'low' | 'normal' | 'high'; + topic?: string; +}): Promise { + const body = encryptAes128gcm({ + p256dh: b64uDecode(params.p256dh), + auth: b64uDecode(params.auth), + payload: params.payload, + }); + const jwt = buildVapidJwt(params.endpoint, params.vapid); + const headers: Record = { + 'content-type': 'application/octet-stream', + 'content-encoding': 'aes128gcm', + 'content-length': String(body.length), + ttl: String(params.ttlSeconds ?? 60), + urgency: params.urgency ?? 'high', + authorization: `vapid t=${jwt}, k=${params.vapid.publicKey}`, + }; + if (params.topic) headers.topic = params.topic; + + const res = await fetch(params.endpoint, { + method: 'POST', + headers, + body: new Uint8Array(body), + }); + const ok = res.status >= 200 && res.status < 300; + return { + ok, + status: res.status, + body: ok ? undefined : await res.text().catch(() => undefined), + }; +}