import { lookup } from "node:dns/promises"; import type { PrismaClient } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { Agent } from "undici"; import webPush from "web-push"; import { combineSignals } from "./connector-safety.js"; import type { ResolveHostname } from "./network-address.js"; import { createAddressCheckedLookup } from "./network-address.js"; import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; import type { EncryptedSecretStore } from "./secrets.js"; import { dispatcherFetch } from "./undici-fetch.js"; import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js"; /** Generated keys live encrypted in the deployment-wide settings table under this id. */ const VAPID_RECORD_ID = "web-push-vapid"; /** RFC 8292 wants a contact; deployments set VAPID_SUBJECT or serve the web app over https. */ const FALLBACK_SUBJECT = "mailto:admin@localhost"; const WEB_PUSH_TIMEOUT_MS = 15_000; /** Undelivered pushes expire after a day; older news belongs in the inbox. */ const WEB_PUSH_TTL_SECONDS = 86_400; export interface VapidDetails { subject: string; publicKey: string; privateKey: string; } export interface VapidEnv { publicKey?: string; privateKey?: string; subject?: string; } /** VAPID settings from the environment; the web origin is the contact when it is https. */ export function vapidEnvFromProcess(source: Record): VapidEnv { const value = (raw: string | undefined) => raw?.trim() || undefined; const origin = value(source.WEB_ORIGIN); return { publicKey: value(source.VAPID_PUBLIC_KEY), privateKey: value(source.VAPID_PRIVATE_KEY), subject: value(source.VAPID_SUBJECT) ?? (origin?.startsWith("https://") ? origin : undefined), }; } function assertVapidDetails(details: VapidDetails): VapidDetails { // Validates subject and both keys; the error messages never contain the private key. webPush.getVapidHeaders( "https://push.invalid", details.subject, details.publicKey, details.privateKey, "aes128gcm", ); return details; } /** * The deployment's VAPID key pair: the configured one, or one generated on first use and stored * encrypted, so the API and every worker sign with the same keys. */ export class VapidKeys { private pending: Promise | undefined; constructor( private readonly prisma: PrismaClient, private readonly secrets: EncryptedSecretStore, private readonly env: VapidEnv = {}, ) {} details(): Promise { this.pending ??= this.resolve().catch((error: unknown) => { this.pending = undefined; throw error; }); return this.pending; } /** The public key browsers subscribe with, or null when no usable key pair exists. */ async publicKey(): Promise { try { return (await this.details()).publicKey; } catch (error) { getLogger().error("web push keys unavailable", error); return null; } } private async resolve(): Promise { const subject = this.env.subject ?? FALLBACK_SUBJECT; if (this.env.publicKey && this.env.privateKey) { return assertVapidDetails({ subject, publicKey: this.env.publicKey, privateKey: this.env.privateKey, }); } if (this.env.publicKey || this.env.privateKey) { getLogger().warn("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be set together; ignoring"); } const stored = await this.load(); if (stored) return assertVapidDetails({ subject, ...stored }); const generated = webPush.generateVAPIDKeys(); const sealed = await this.secrets.put( JSON.stringify({ publicKey: generated.publicKey, privateKey: generated.privateKey }), { operationId: "web-push.keys", traceId: "web-push.keys", spaceId: "", userId: "", signal: AbortSignal.timeout(WEB_PUSH_TIMEOUT_MS), }, VAPID_RECORD_ID, ); // When the API and a worker generate at once, the first insert wins and both read it back. await this.prisma.integrationProviderConfig.createMany({ data: [{ id: VAPID_RECORD_ID, ciphertext: sealed.ciphertext }], skipDuplicates: true, }); const winner = await this.load(); if (!winner) throw new Error("Web push keys could not be stored"); return assertVapidDetails({ subject, ...winner }); } private async load(): Promise<{ publicKey: string; privateKey: string } | null> { const row = await this.prisma.integrationProviderConfig.findUnique({ where: { id: VAPID_RECORD_ID }, select: { ciphertext: true }, }); if (!row) return null; const parsed = JSON.parse(this.secrets.load(row.ciphertext, VAPID_RECORD_ID)) as { publicKey?: unknown; privateKey?: unknown; }; if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") { throw new Error("Stored web push keys are malformed"); } return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }; } } export interface WebPushSenderOptions { fetch?: typeof globalThis.fetch; resolveHostname?: ResolveHostname; timeoutMs?: number; } const defaultResolveHostname: ResolveHostname = (hostname) => lookup(hostname, { all: true, verbatim: true }); /** * Sends encrypted web push messages (RFC 8030/8291/8292). The endpoint comes from a browser, so * the request only ever connects to public addresses, pinned at connect time. */ export class WebPushSender { constructor( private readonly vapid: Pick, private readonly options: WebPushSenderOptions = {}, ) {} async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { if (!target.p256dh || !target.auth) return "gone"; const request = webPush.generateRequestDetails( { endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, JSON.stringify({ title: payload.title, body: payload.body, url: payload.url, tag: payload.threadId, kind: payload.kind, }), { vapidDetails: await this.vapid.details(), TTL: WEB_PUSH_TTL_SECONDS }, ); const resolve = this.options.resolveHostname ?? defaultResolveHostname; const deadline = combineSignals( signal, AbortSignal.timeout(this.options.timeoutMs ?? WEB_PUSH_TIMEOUT_MS), ); const url = await assertSafeWebUrl(request.endpoint, resolve, deadline); if (url.protocol !== "https:") throw new Error("Web push endpoint must use https"); const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { if (name.toLowerCase() !== "content-length") headers.set(name, String(value)); } const dispatcher = new Agent({ connect: { lookup: createAddressCheckedLookup(resolve, assertPublicAddresses) }, }); try { const response = await (this.options.fetch ?? dispatcherFetch)(url.href, { method: "POST", headers, body: request.body, redirect: "manual", signal: deadline, dispatcher, } as RequestInit & { dispatcher: Agent }); await response.body?.cancel().catch(() => undefined); if (response.status === 404 || response.status === 410) return "gone"; if (response.status < 200 || response.status >= 300) { // Status only: push service bodies and endpoints identify the device. throw new Error(`Web push failed (${response.status})`); } return "sent"; } finally { await dispatcher.close().catch(() => undefined); } } }