import { createCipheriv, randomBytes, scryptSync } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import { mkdir, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; import type { Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; // On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite // DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means // total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh- // appliance copy. See wiki/concepts/backup-recovery.md. // // Two load-bearing properties: // 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a // raw file copy, which can capture a torn WAL). The result must still verifyChain. // 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from // EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain, // and a backup target never exposes the signing key. The key is NEVER written into the // backup it unlocks. // // This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/ // mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on // top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box. /** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only * the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */ const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup const FORMAT_VERSION = 1; const SALT_LEN = 16; const IV_LEN = 12; const TAG_LEN = 16; const SCRYPT_KEYLEN = 32; // AES-256 export interface BackupRetention { /** Keep at least this many most-recent backups regardless of age. */ readonly keepLast: number; /** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */ readonly keepDailyDays: number; } // Code defaults — the fallback when the admin hasn't set a value in site_config (the source of // truth). NOT env-driven: retention is operational policy tuned from the Backup screen. export const DEFAULT_BACKUP_RETENTION: BackupRetention = { keepLast: 7, keepDailyDays: 30, }; export interface BackupOptions { /** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */ readonly targetDir: string; /** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */ readonly key: string; readonly retention?: BackupRetention; /** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */ readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise; /** Override "now" for deterministic filenames/retention in tests. */ readonly now?: () => Date; /** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */ readonly scratchDir?: string; } export interface BackupResult { /** Absolute path of the encrypted backup written. */ readonly path: string; /** Size of the encrypted file in bytes. */ readonly bytes: number; /** Backups pruned by the retention policy this run. */ readonly prunedFiles: number; } /** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */ const FILE_PREFIX = "parking-backup-"; const FILE_SUFFIX = ".sqlite.enc"; function stampFor(d: Date): string { return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); } /** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */ export function parseBackupStamp(name: string): Date | null { const base = basename(name); if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null; const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length); // 20260629T141503Z → 2026-06-29T14:15:03Z const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp); if (!m) return null; const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`; const dt = new Date(iso); return Number.isNaN(dt.getTime()) ? null : dt; } /** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */ async function defaultConsistentCopy(db: Db, destPath: string): Promise { // db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a // transactionally-consistent snapshot even while the source is being written. const client = db.$client as { backup: (dest: string) => Promise }; await client.backup(destPath); } /** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */ async function encryptFile(srcPath: string, destPath: string, key: string): Promise { const salt = randomBytes(SALT_LEN); const iv = randomBytes(IV_LEN); const derived = scryptSync(key, salt, SCRYPT_KEYLEN); const cipher = createCipheriv("aes-256-gcm", derived, iv); const out = createWriteStream(destPath); const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]); out.write(header); await pipeline(createReadStream(srcPath), cipher, out, { end: false }); // GCM auth tag is available only after the cipher has flushed; append it, then close. const tag = cipher.getAuthTag(); await new Promise((res, rej) => { out.end(tag, () => res()); out.on("error", rej); }); } /** * Run one backup: consistent copy → encrypt → prune old backups by retention. * Best-effort caller-facing: throws on real failure (so a manual run surfaces the error), * but the scheduled timer wraps it and logs. */ export async function runBackup( db: Db, opts: BackupOptions, logger?: FastifyBaseLogger, ): Promise { if (!opts.key || opts.key.length < 16) { throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)"); } const now = opts.now ?? (() => new Date()); const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION; const targetDir = resolve(opts.targetDir); await mkdir(targetDir, { recursive: true }); const stamp = stampFor(now()); const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`); // Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network // share / removable disk; keep the plaintext local and short-lived, then wipe it). const scratch = opts.scratchDir ?? tmpdir(); await mkdir(scratch, { recursive: true }); const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`); try { const copy = opts.makeConsistentCopy ?? defaultConsistentCopy; await copy(db, plainPath); await encryptFile(plainPath, finalPath, opts.key); } finally { // Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger. await rm(plainPath, { force: true }).catch((err) => logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`), ); } const { size } = await stat(finalPath); const prunedFiles = await pruneOldBackups(targetDir, retention, now()); logger?.info( `backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` + (prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""), ); return { path: finalPath, bytes: size, prunedFiles }; } /** * Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one * backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day * duplicates outside the keepLast window. Returns the count deleted. */ export async function pruneOldBackups( targetDir: string, retention: BackupRetention, now: Date, ): Promise { let names: string[]; try { names = await readdir(targetDir); } catch { return 0; // target gone/unmounted — nothing to prune (the write would have failed first) } const backups = names .map((n) => ({ name: n, at: parseBackupStamp(n) })) .filter((b): b is { name: string; at: Date } => b.at !== null) .sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first const keep = new Set(); // 1. Always keep the keepLast newest. for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name); // 2. Beyond that, keep the newest per UTC day within the keepDailyDays window. const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000; const seenDays = new Set(); for (const b of backups) { if (keep.has(b.name)) { seenDays.add(b.at.toISOString().slice(0, 10)); continue; } if (b.at.getTime() < cutoff) continue; // too old → not kept const day = b.at.toISOString().slice(0, 10); if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra seenDays.add(day); keep.add(b.name); } let pruned = 0; for (const b of backups) { if (keep.has(b.name)) continue; await rm(join(targetDir, b.name), { force: true }); pruned += 1; } return pruned; }