feat: migrate to UnifiedPush

This commit is contained in:
Linus Rath
2026-04-20 10:54:26 +02:00
parent 15006086d2
commit 8b21851353
10 changed files with 396 additions and 84 deletions
+2 -2
View File
@@ -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 });
+29 -13
View File
@@ -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,
};
+22
View File
@@ -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 });
}
}
+14
View File
@@ -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');
}
-61
View File
@@ -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<boolean> {
// 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<string>();
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;
}
}
+7 -1
View File
@@ -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;
+57
View File
@@ -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<string, Record<string, string>>;
}
function buildPayload(record: SubscriptionRecord, change: StateChange): UnifiedPushPayload {
const types = new Set<string>();
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<boolean> {
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;
}
}
+26 -7
View File
@@ -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);
}
+90
View File
@@ -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<VapidKeys> | null = null;
async function loadOrCreate(): Promise<VapidKeys> {
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<VapidKeys> {
if (cached) return cached;
if (!loading) {
loading = loadOrCreate().then((keys) => {
cached = keys;
return keys;
});
}
return loading;
}
+149
View File
@@ -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<WebPushResult> {
const body = encryptAes128gcm({
p256dh: b64uDecode(params.p256dh),
auth: b64uDecode(params.auth),
payload: params.payload,
});
const jwt = buildVapidJwt(params.endpoint, params.vapid);
const headers: Record<string, string> = {
'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),
};
}