Repository Public
1// Secrets of the alerts layer: the VAPID key pair (generated at first start) and the webhook's shared secret.2// The API keeps its other secrets in its environment, written at deploy; these two are created at run time, so they live in a3// file owned by the API user — directory 0700, file 0600, the same permissions the signer uses for its own material. They are4// never stored in the database (which is backed up every night), never logged, never returned by an endpoint (the VAPID public5// key is public by design; the webhook secret is shown once, when it is generated).6import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';7import { homedir } from 'node:os';8import { join } from 'node:path';9import { randomBytes } from 'node:crypto';10import { generateVapidKeys, type VapidKeys } from './webpush.ts';1112interface Secrets { vapid: VapidKeys; webhookSecret: string | null; createdAt: string }13export const alertsDataDir = (): string => process.env.SPW_DATA_DIR || join(homedir(), '.config', 'sp-wallet');14export const secretsPath = (): string => join(alertsDataDir(), 'alerts-secrets.json');1516let cached: { path: string; v: Secrets } | null = null;17function write(path: string, v: Secrets): void {18 mkdirSync(alertsDataDir(), { recursive: true, mode: 0o700 });19 const tmp = `${path}.${process.pid}.tmp`;20 writeFileSync(tmp, JSON.stringify(v, null, 2), { mode: 0o600 });21 chmodSync(tmp, 0o600);22 renameSync(tmp, path);23 cached = { path, v };24}25function load(): Secrets {26 const path = secretsPath();27 if (cached && cached.path === path) return cached.v;28 if (existsSync(path)) {29 if ((statSync(path).mode & 0o077) !== 0) { chmodSync(path, 0o600); console.warn('[alerts] the secrets file was readable by other users; its permissions were reset to 0600'); }30 const v = JSON.parse(readFileSync(path, 'utf8')) as Secrets;31 if (typeof v?.vapid?.publicKey !== 'string' || typeof v?.vapid?.privateKey !== 'string') throw new Error('alerts secrets file is malformed; move it aside to generate a new VAPID key (devices must subscribe again)');32 cached = { path, v: { vapid: v.vapid, webhookSecret: v.webhookSecret ?? null, createdAt: v.createdAt } };33 return cached.v;34 }35 const v: Secrets = { vapid: generateVapidKeys(), webhookSecret: null, createdAt: new Date().toISOString() };36 write(path, v);37 console.log(`[alerts] VAPID key pair generated (${path}, mode 0600)`);38 return v;39}4041export const vapidKeys = (): VapidKeys => load().vapid;42export const webhookSecret = (): string | null => load().webhookSecret;43/** a new random secret, returned once to the owner */44export function rotateWebhookSecret(): string {45 const s = load(), secret = randomBytes(32).toString('base64url');46 write(secretsPath(), { ...s, webhookSecret: secret });47 return secret;48}49/** contact given to the push services (RFC 8292 `sub`): the wallet's own origin unless SPW_VAPID_SUBJECT is set — no personal address by default */50export const vapidSubject = (origin: string): string => process.env.SPW_VAPID_SUBJECT || origin;51