151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import webPush from 'web-push';
|
|
import { logger } from './logger.js';
|
|
|
|
/**
|
|
* VAPID key material for Web Push (RFC 8292).
|
|
* Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
|
|
*
|
|
* - `keyId` identifies which key generation a subscription was created under,
|
|
* so after rotation we can still push to old subscriptions until they fail
|
|
* with 410 and self-clean.
|
|
* - `subject` MUST be a `mailto:` or `https:` URL per RFC 8292; FCM has been
|
|
* observed to rate-limit generic `mailto:` values, so an operations URL is
|
|
* the recommended default.
|
|
*/
|
|
export interface VapidKeyMaterial {
|
|
keyId: string;
|
|
publicKey: string;
|
|
privateKey: string;
|
|
subject: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
const FILE_MODE = 0o600;
|
|
|
|
function newKeyId(): string {
|
|
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
const suffix = Math.random().toString(36).slice(2, 8);
|
|
return `v1-${date}-${suffix}`;
|
|
}
|
|
|
|
function readKeyFile(path: string): VapidKeyMaterial {
|
|
const raw = readFileSync(path, 'utf-8');
|
|
const parsed = JSON.parse(raw) as Partial<VapidKeyMaterial>;
|
|
if (!parsed.keyId || !parsed.publicKey || !parsed.privateKey || !parsed.subject) {
|
|
throw new Error(
|
|
`[vapid-store] malformed key file at ${path} (missing keyId/publicKey/privateKey/subject)`,
|
|
);
|
|
}
|
|
return {
|
|
keyId: parsed.keyId,
|
|
publicKey: parsed.publicKey,
|
|
privateKey: parsed.privateKey,
|
|
subject: parsed.subject,
|
|
createdAt: parsed.createdAt ?? new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function writeKeyFile(path: string, material: VapidKeyMaterial): void {
|
|
const dir = dirname(path);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
writeFileSync(path, JSON.stringify(material, null, 2), { mode: FILE_MODE });
|
|
}
|
|
|
|
/**
|
|
* Manages VAPID key lifecycle: load-or-generate, history retrieval, rotation.
|
|
*
|
|
* Operational model:
|
|
* - `currentPath` holds the active keypair used for new subscriptions.
|
|
* - `historyDir` accumulates retired keys so push to old subscriptions still
|
|
* works during the grace period.
|
|
* - Rotation is a manual CLI step (`scripts/vapid-rotate.ts`); subscriptions
|
|
* carry the `keyId` they were created under so we can pick the right key
|
|
* per-send.
|
|
*/
|
|
export class VapidKeyStore {
|
|
private current: VapidKeyMaterial | null = null;
|
|
private history = new Map<string, VapidKeyMaterial>();
|
|
|
|
constructor(
|
|
private readonly currentPath: string,
|
|
private readonly historyDir: string,
|
|
) {}
|
|
|
|
/**
|
|
* Load the current key from disk, generating a fresh one if missing.
|
|
* Idempotent — repeated calls return the in-memory copy.
|
|
*/
|
|
loadOrGenerate(subject: string): VapidKeyMaterial {
|
|
if (this.current) return this.current;
|
|
if (existsSync(this.currentPath)) {
|
|
this.current = readKeyFile(this.currentPath);
|
|
logger.info(`[vapid-store] loaded VAPID key keyId=${this.current.keyId}`);
|
|
return this.current;
|
|
}
|
|
const generated = webPush.generateVAPIDKeys();
|
|
this.current = {
|
|
keyId: newKeyId(),
|
|
publicKey: generated.publicKey,
|
|
privateKey: generated.privateKey,
|
|
subject,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
writeKeyFile(this.currentPath, this.current);
|
|
logger.info(
|
|
`[vapid-store] generated new VAPID key keyId=${this.current.keyId} path=${this.currentPath}`,
|
|
);
|
|
return this.current;
|
|
}
|
|
|
|
/** Current key (must call loadOrGenerate first). */
|
|
getCurrent(): VapidKeyMaterial {
|
|
if (!this.current) {
|
|
throw new Error('[vapid-store] getCurrent called before loadOrGenerate');
|
|
}
|
|
return this.current;
|
|
}
|
|
|
|
/**
|
|
* Look up a specific key by ID. Returns the current key if it matches,
|
|
* otherwise loads from history (cached after first lookup). Returns null
|
|
* when the key is unknown — caller should treat the subscription as
|
|
* needing re-subscribe.
|
|
*/
|
|
getKey(keyId: string): VapidKeyMaterial | null {
|
|
if (this.current && this.current.keyId === keyId) return this.current;
|
|
const cached = this.history.get(keyId);
|
|
if (cached) return cached;
|
|
const historyPath = join(this.historyDir, `${keyId}.json`);
|
|
if (!existsSync(historyPath)) return null;
|
|
const material = readKeyFile(historyPath);
|
|
this.history.set(keyId, material);
|
|
return material;
|
|
}
|
|
|
|
/**
|
|
* Rotate to a fresh keypair. The old current key is moved to history
|
|
* (filename = old keyId). Returns the new material.
|
|
*
|
|
* Subscriptions in the DB are *not* touched here — they continue to carry
|
|
* the old keyId, and the next push for each will be sent with the
|
|
* preserved old key (until 410 / re-subscribe clears them).
|
|
*/
|
|
rotate(subject: string): VapidKeyMaterial {
|
|
const old = this.loadOrGenerate(subject);
|
|
if (!existsSync(this.historyDir)) {
|
|
mkdirSync(this.historyDir, { recursive: true, mode: 0o700 });
|
|
}
|
|
const historyPath = join(this.historyDir, `${old.keyId}.json`);
|
|
renameSync(this.currentPath, historyPath);
|
|
this.history.set(old.keyId, old);
|
|
this.current = null;
|
|
const fresh = this.loadOrGenerate(subject);
|
|
logger.info(
|
|
`[vapid-store] rotated VAPID key oldKeyId=${old.keyId} newKeyId=${fresh.keyId}`,
|
|
);
|
|
return fresh;
|
|
}
|
|
}
|