feat(admin): replace the key backup step with a recovery kit

The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:

- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
  (innerKeyIds) or an off-host object inside the bucket's retention
  (OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
  named by the id their objects record;
- API_TOKEN_HMAC_KEY.

Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.

Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:32:29 +01:00
1 parent 953c64f7a4
commit d3c672cc3f
56 files changed
+3042 -1611

No files matched your search

+2 -1
View File
@@ -199,7 +199,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"encounter.reminder-conflict",
"encounter.reminder-not-found",
],
encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"],
encryption: ["encryption.key_mismatch"],
environment: [
"environment.backfill_pending",
"environment.backfill_rate_limited",
@@ -398,6 +398,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"profile_claim.taken",
],
record_write: ["record_write.rate_limited"],
recovery: ["recovery.kit.stale"],
"report-selection": [
"report-selection.body.invalid_json",
"report-selection.body.invalid_shape",