feat(backup): encrypted on-site DB backup engine + local target

The SQLite DB is the signed append-only ledger, so a disk failure / stolen or
destroyed PC means total revenue-history loss (open-question #5). This is the first
slice of the backup-recovery design: the engine + a local/mounted target + a daily
timer + a manual route.

Engine (apps/server/src/backup.ts):
- Consistent online copy of the live WAL DB via better-sqlite3's native .backup()
  (not a raw file copy, which can capture a torn WAL) — the restored copy is a
  byte-identical, queryable DB.
- AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header
  (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file.
  Zero new dependencies (Node crypto).
- The plaintext intermediate is kept in scratch (not the removable/network target)
  and wiped in a finally, success or fail.
- Retention: keep-last-N + one-per-day within N days.

Wiring:
- BackupService (env config, single in-flight guard, last-success/last-error).
- routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run
  (backup:create), 409 when unconfigured. No restore route — restore is an
  out-of-band runbook action on a fresh appliance, not a console call.
- New  permission resource in @parking/shared.
- server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY
  are set, deliberately not run at startup (a just-power-cut booth shouldn't write
  to a possibly-unmounted disk).
- openRawDb() added to @parking/db/testing (open a file without migrating, for
  restore-verification tests).

BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation;
backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP +
admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical,
GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC +
409. build/lint/test green (212 server tests). Wiki + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-29 11:59:45 +02:00
parent 9e442586af
commit 0c218179c4
12 changed files with 750 additions and 7 deletions
+35 -5
View File
@@ -119,10 +119,40 @@ Therefore:
would. The backup is a **durability** control, not an integrity one — integrity stays with the
signed chain + [[reconciliation]].
## As-built (2026-06-29) — engine + local/mounted target
The first slice is **built and tested**: the backup **engine + a local/mounted target + the daily
timer + the manual route**. What landed:
- **`apps/server/src/backup.ts`** — the engine. Consistent online copy via better-sqlite3's native
`.backup()` (a transactionally-consistent snapshot of the live WAL DB — **not** a raw file copy),
then **AES-256-GCM** encryption with a **scrypt-derived** key from `BACKUP_KEY`. Self-describing
header (`magic | version | salt | iv | … | authTag`) so a restore tool needs only the key + the file
— **zero new dependencies** (Node `crypto`). The plaintext intermediate is written to **scratch**
(not the removable/network target) and **wiped in a `finally`**, success or fail. Retention =
**keep-last-N + one-per-day-within-N-days** (`pruneOldBackups`). Tested: round-trip decrypts to a
**byte-identical, queryable DB**; a flipped byte or wrong key **fails GCM auth**; short key rejected;
scratch plaintext always removed.
- **`backup-service.ts`** — resolves config from env (`BACKUP_TARGET_DIR`, `BACKUP_KEY`,
`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`), **serializes** concurrent runs (single in-flight
guard), records last-success / last-error for the UI.
- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`) + `POST /api/backup/run`
(`backup:create`); a clean **409 `backup_not_configured`** when unset. New `backup` permission
resource (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by
design.
- **`server.ts`** — an **unref'd daily timer** (`backupService.runScheduled`), a **no-op until
configured**, and **deliberately NOT run at startup** (a just-power-cut booth shouldn't write to a
possibly-unmounted disk; the daily cadence + the manual button cover it).
- Env documented in `apps/server/.env.example` (with the escrow + separate-key notes).
**SMB/NFS already work** — they're just a mounted path under `BACKUP_TARGET_DIR`. **Deferred to
follow-up slices:** an **SFTP** target, the **admin UI** (status panel + "Back up now" button + i18n),
and a **restore runbook / CLI**.
## Status
Design settled 2026-06-29; **not yet built**. Resolves the *design* half of [[open-questions]] #5
(implementation pending), and records the key-custody stance that bears on #6 (signing stays
decoupled from the TPM) and #10 (snapshots bloat backups → future exclude toggle). See
[[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
[[reconciliation]].
Design settled 2026-06-29; **engine + local/mounted target BUILT 2026-06-29** (SFTP + UI + restore
tooling pending). Resolves the *design* half of [[open-questions]] #5 and the first build slice; records
the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat
backups → future exclude toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]],
[[fleet-deployment-komodo]], [[reconciliation]].
+3 -2
View File
@@ -23,8 +23,9 @@ procurement. (See [[parking-system-architecture]] §10.)
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
control. See [[reconciliation]].
5. **Durability / backup.** _(Design SETTLED 2026-06-29 — see [[backup-recovery]]; implementation
pending.)_ A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**.
5. **Durability / backup.** _(Design SETTLED + first slice BUILT 2026-06-29 — see [[backup-recovery]];
engine + local/mounted target + daily timer + manual route done, SFTP/UI/restore-tooling pending.)_
A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**.
Settled design: an **admin-driven encrypted full-DB backup** (online-backup/`VACUUM INTO`, snapshots
included) to a **local/USB · SMB/NFS · SFTP** target, **manual button + in-process daily timer**,
**keep-last-N + dailies** retention, encrypted with a **dedicated `park_buzi_backup_key`** (separate
+13
View File
@@ -1921,3 +1921,16 @@ Keys are NEVER inside the backup they unlock — recovery = backup file + both e
Updated: [[open-questions]] #5 (design SETTLED) + #10 note (backup includes snapshot BLOBs by default, future
exclude toggle); [[disk-os-hardening]] deploy env runbook (EVENT_SIGNING_KEY-not-sealed rationale +
`park_buzi_backup_key`); index catalog + concept count 45→46. Design only — NOT yet built.
## [2026-06-29] feat | Backup engine + local target (first slice of backup-recovery)
Built the durability engine designed in [[backup-recovery]]. `apps/server/src/backup.ts`: consistent
online copy via better-sqlite3 `.backup()` (NOT a raw file copy of a live WAL DB) → AES-256-GCM with a
scrypt-derived key from BACKUP_KEY, self-describing header (magic|ver|salt|iv|…|tag), zero new deps;
plaintext intermediate kept in scratch + wiped in finally; keep-last-N + dailies retention. Tested:
round-trip → byte-identical queryable DB, GCM tamper/wrong-key fails, short-key rejected, scratch always
cleaned. `backup-service.ts` (env config, single in-flight guard, last-success/error) + `routes/backup.ts`
(GET /api/backup/status backup:read, POST /api/backup/run backup:create, 409 when unconfigured; NO restore
route — out-of-band by design). New `backup` permission resource in @parking/shared. server.ts: unref'd
daily timer, no-op until configured, NOT run at startup. `openRawDb()` added to @parking/db/testing.
SMB/NFS work as mount paths; SFTP + admin UI + restore runbook deferred. build/lint/test green (212 server
tests, 25 files). Updated [[open-questions]] #5 (first slice BUILT). NOT yet committed beyond this branch.