feat: nevermind, migrate push notification handling from UnifiedPush to FCM
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { subscriptionStore } from '@/lib/push/store';
|
import { subscriptionStore } from '@/lib/push/store';
|
||||||
import { sendUnifiedPush } from '@/lib/push/unified-push';
|
import { sendFcmPush } from '@/lib/push/fcm';
|
||||||
import { isValidSubscriptionId } from '@/lib/push/validation';
|
import { isValidSubscriptionId } from '@/lib/push/validation';
|
||||||
import type { JmapPushBody } from '@/lib/push/types';
|
import type { JmapPushBody } from '@/lib/push/types';
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ import type { JmapPushBody } from '@/lib/push/types';
|
|||||||
* { "@type": "StateChange", changed: { [accountId]: { [type]: state } } }
|
* { "@type": "StateChange", changed: { [accountId]: { [type]: state } } }
|
||||||
*
|
*
|
||||||
* PushVerification is terminated here — cached against the subscription so
|
* PushVerification is terminated here — cached against the subscription so
|
||||||
* the client can poll it. StateChange is fanned out as an Expo push so the
|
* the client can poll it. StateChange is fanned out via FCM so the mobile
|
||||||
* mobile app wakes and re-fetches with its own credentials.
|
* app wakes and re-fetches with its own credentials.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function getAllowedOrigin(): string | null {
|
function getAllowedOrigin(): string | null {
|
||||||
@@ -75,10 +75,15 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body['@type'] === 'StateChange') {
|
if (body['@type'] === 'StateChange') {
|
||||||
const ok = await sendUnifiedPush(record, body);
|
const result = await sendFcmPush(record, body);
|
||||||
record.lastPushAt = Date.now();
|
record.lastPushAt = Date.now();
|
||||||
await subscriptionStore.put(id, record);
|
await subscriptionStore.put(id, record);
|
||||||
return NextResponse.json({ ok });
|
if (result.unregistered) {
|
||||||
|
// FCM told us the token is no longer valid — drop the subscription
|
||||||
|
// so the device re-registers on next launch.
|
||||||
|
await subscriptionStore.delete(id);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: result.ok });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Unsupported JMAP push type' }, { status: 400 });
|
return NextResponse.json({ error: 'Unsupported JMAP push type' }, { status: 400 });
|
||||||
|
|||||||
@@ -1,45 +1,31 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { subscriptionStore } from '@/lib/push/store';
|
import { subscriptionStore } from '@/lib/push/store';
|
||||||
import {
|
import { isValidFcmToken, isValidSubscriptionId } from '@/lib/push/validation';
|
||||||
isValidAuthSecret,
|
|
||||||
isValidP256dh,
|
|
||||||
isValidPushEndpoint,
|
|
||||||
isValidSubscriptionId,
|
|
||||||
} from '@/lib/push/validation';
|
|
||||||
import type { SubscriptionRecord } from '@/lib/push/types';
|
import type { SubscriptionRecord } from '@/lib/push/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/push/register
|
* POST /api/push/register
|
||||||
* Body: { subscriptionId, endpoint, p256dh, auth, accountLabel? }
|
* Body: { subscriptionId, fcmToken, accountLabel? }
|
||||||
*
|
*
|
||||||
* Called by the mobile app once its UnifiedPush distributor has produced a
|
* Called by the mobile app once Firebase has issued an FCM registration
|
||||||
* push endpoint. We hold only the Web Push keys needed to encrypt payloads
|
* token. The relay stores only the opaque token — no user credentials.
|
||||||
* per RFC 8291 — no user credentials pass through the relay.
|
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json().catch(() => null)) as {
|
const body = (await request.json().catch(() => null)) as {
|
||||||
subscriptionId?: unknown;
|
subscriptionId?: unknown;
|
||||||
endpoint?: unknown;
|
fcmToken?: unknown;
|
||||||
p256dh?: unknown;
|
|
||||||
auth?: unknown;
|
|
||||||
accountLabel?: unknown;
|
accountLabel?: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
|
||||||
const { subscriptionId, endpoint, p256dh, auth, accountLabel } = body;
|
const { subscriptionId, fcmToken, accountLabel } = body;
|
||||||
if (!isValidSubscriptionId(subscriptionId)) {
|
if (!isValidSubscriptionId(subscriptionId)) {
|
||||||
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid subscriptionId' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!isValidPushEndpoint(endpoint)) {
|
if (!isValidFcmToken(fcmToken)) {
|
||||||
return NextResponse.json({ error: 'Invalid endpoint' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid fcmToken' }, { 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
|
// Preserve verificationCode if a previous record exists — the JMAP server
|
||||||
@@ -47,9 +33,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const existing = await subscriptionStore.get(subscriptionId);
|
const existing = await subscriptionStore.get(subscriptionId);
|
||||||
|
|
||||||
const record: SubscriptionRecord = {
|
const record: SubscriptionRecord = {
|
||||||
endpoint,
|
fcmToken,
|
||||||
p256dh,
|
|
||||||
auth,
|
|
||||||
verificationCode: existing?.verificationCode ?? null,
|
verificationCode: existing?.verificationCode ?? null,
|
||||||
createdAt: existing?.createdAt ?? Date.now(),
|
createdAt: existing?.createdAt ?? Date.now(),
|
||||||
lastPushAt: existing?.lastPushAt ?? null,
|
lastPushAt: existing?.lastPushAt ?? null,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { StateChange } from './types';
|
||||||
|
import type { SubscriptionRecord } from './types';
|
||||||
|
|
||||||
|
interface ServiceAccount {
|
||||||
|
type: string;
|
||||||
|
project_id: string;
|
||||||
|
private_key_id: string;
|
||||||
|
private_key: string;
|
||||||
|
client_email: string;
|
||||||
|
token_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedAccount: ServiceAccount | null = null;
|
||||||
|
let cachedAccessToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function loadServiceAccount(): Promise<ServiceAccount> {
|
||||||
|
if (cachedAccount) return cachedAccount;
|
||||||
|
|
||||||
|
const inline = process.env.FCM_SERVICE_ACCOUNT_JSON;
|
||||||
|
if (inline && inline.trim().startsWith('{')) {
|
||||||
|
cachedAccount = JSON.parse(inline) as ServiceAccount;
|
||||||
|
return cachedAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = inline && inline.trim().length > 0
|
||||||
|
? inline
|
||||||
|
: path.join(process.env.PUSH_DATA_DIR ?? './data/push', 'fcm-service-account.json');
|
||||||
|
const raw = await fs.readFile(filePath, 'utf8');
|
||||||
|
cachedAccount = JSON.parse(raw) as ServiceAccount;
|
||||||
|
return cachedAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(buf: Buffer | string): string {
|
||||||
|
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
||||||
|
return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintAccessToken(): Promise<string> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedAccessToken && cachedAccessToken.expiresAt - 60_000 > now) {
|
||||||
|
return cachedAccessToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await loadServiceAccount();
|
||||||
|
const iat = Math.floor(now / 1000);
|
||||||
|
const exp = iat + 3600;
|
||||||
|
const header = { alg: 'RS256', typ: 'JWT', kid: account.private_key_id };
|
||||||
|
const claim = {
|
||||||
|
iss: account.client_email,
|
||||||
|
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||||
|
aud: account.token_uri ?? 'https://oauth2.googleapis.com/token',
|
||||||
|
iat,
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(claim))}`;
|
||||||
|
const signature = crypto
|
||||||
|
.createSign('RSA-SHA256')
|
||||||
|
.update(signingInput)
|
||||||
|
.sign(account.private_key);
|
||||||
|
const jwt = `${signingInput}.${base64url(signature)}`;
|
||||||
|
|
||||||
|
const res = await fetch(account.token_uri ?? 'https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
assertion: jwt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`FCM oauth2 token failed: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { access_token: string; expires_in: number };
|
||||||
|
cachedAccessToken = {
|
||||||
|
token: body.access_token,
|
||||||
|
expiresAt: now + body.expires_in * 1000,
|
||||||
|
};
|
||||||
|
return body.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FcmSendResult {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
unregistered: boolean;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a data message via FCM HTTP v1. Returns `unregistered: true` if the
|
||||||
|
* token was rejected with UNREGISTERED / NOT_FOUND — callers should delete
|
||||||
|
* the subscription in that case.
|
||||||
|
*/
|
||||||
|
export async function sendFcmPush(
|
||||||
|
record: SubscriptionRecord,
|
||||||
|
change: StateChange,
|
||||||
|
): Promise<FcmSendResult> {
|
||||||
|
const account = await loadServiceAccount();
|
||||||
|
const accessToken = await mintAccessToken();
|
||||||
|
const hasEmail = Boolean(change.changed && Object.values(change.changed).some((types) => 'Email' in types));
|
||||||
|
|
||||||
|
const title = hasEmail ? 'New mail' : 'Mailbox updated';
|
||||||
|
const body = record.accountLabel ?? 'Tap to open Bulwark';
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
message: {
|
||||||
|
token: record.fcmToken,
|
||||||
|
android: {
|
||||||
|
priority: 'HIGH',
|
||||||
|
notification: {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
channel_id: 'bulwark_mail',
|
||||||
|
default_sound: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
kind: 'jmap-state-change',
|
||||||
|
changed: JSON.stringify(change.changed ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.project_id)}/messages:send`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawBody = await res.text();
|
||||||
|
let parsed: unknown = rawBody;
|
||||||
|
try {
|
||||||
|
parsed = rawBody.length > 0 ? JSON.parse(rawBody) : null;
|
||||||
|
} catch {
|
||||||
|
// keep raw text
|
||||||
|
}
|
||||||
|
|
||||||
|
const errStatus =
|
||||||
|
typeof parsed === 'object' && parsed && 'error' in parsed
|
||||||
|
? ((parsed as { error?: { status?: string } }).error?.status ?? '')
|
||||||
|
: '';
|
||||||
|
const unregistered = res.status === 404 || errStatus === 'UNREGISTERED' || errStatus === 'NOT_FOUND';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: res.ok,
|
||||||
|
status: res.status,
|
||||||
|
unregistered,
|
||||||
|
body: parsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-7
@@ -2,13 +2,7 @@
|
|||||||
// so both server code and admin UIs can import without pulling `node:fs`.
|
// so both server code and admin UIs can import without pulling `node:fs`.
|
||||||
|
|
||||||
export interface SubscriptionRecord {
|
export interface SubscriptionRecord {
|
||||||
// UnifiedPush / Web Push endpoint the distributor gave the app. The relay
|
fcmToken: string;
|
||||||
// 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;
|
verificationCode: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastPushAt: number | null;
|
lastPushAt: number | null;
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-26
@@ -2,30 +2,9 @@ export function isValidSubscriptionId(id: unknown): id is string {
|
|||||||
return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id);
|
return typeof id === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidPushEndpoint(url: unknown): url is string {
|
export function isValidFcmToken(value: unknown): value is string {
|
||||||
if (typeof url !== 'string' || url.length > 2048) return false;
|
// FCM registration tokens are opaque. In practice they're ~140–250 chars of
|
||||||
try {
|
// [A-Za-z0-9:_-]. Be permissive on length; strict on charset to block
|
||||||
const parsed = new URL(url);
|
// obvious garbage without rejecting future token formats.
|
||||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
return typeof value === 'string' && value.length >= 64 && value.length <= 4096 && /^[A-Za-z0-9:_\-]+$/.test(value);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user