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

+45 -19
View File
@@ -49,8 +49,8 @@
* tomorrow morning
* - An AI-configured Coach with five sample conversations spread from
* today back over a few weeks
* - App settings (registration disabled, English locale, the key-backup
* step confirmed for the key the demo runs with)
* - App settings (registration disabled, English locale) and the recovery
* kit confirmed for the keys the demo runs with
* - A multi-year history before the 90-day window (about four and a half
* years) for the timeline and the day view: blood pressure in bursts with
* real gaps, weekly weight, phone steps, watch resting heart rate / HRV /
@@ -90,8 +90,14 @@ import {
encrypt,
encryptBytes,
getActiveKeyId,
getConfiguredKeyIds,
getKeyFingerprint,
} from "../src/lib/crypto";
import {
applicationKitKeys,
backupKitKeys,
tokenKitKeys,
} from "../src/lib/recovery/kit-rules";
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
import { withBriefingGeneratedAt } from "../src/lib/insights/briefing-generated-at";
import { dailyStatsExternalId } from "../src/lib/measurements/apple-health-mapping";
@@ -3949,24 +3955,44 @@ export async function seed() {
// ── App Settings ─────────────────────────
console.log("Creating app settings...");
// The key-backup step is confirmed for the key the demo runs with, the
// same fields `POST /api/admin/encryption/key-backup/confirm` writes (key
// id + its public fingerprint, never the key). The demo account is an
// admin, so an unconfirmed step put a "Back up your encryption key"
// banner above the dashboard for every visitor, about a key no visitor
// can reach.
const activeKeyId = getActiveKeyId();
await client.query(
`INSERT INTO app_settings (id, registration_enabled, default_locale,
encryption_key_backup_confirmed_at, encryption_key_backup_confirmed_key_id,
encryption_key_backup_confirmed_fingerprint, encryption_key_backup_confirmed_by_user_id)
VALUES ('singleton', false, 'en', NOW(), $1, $2, $3)
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en',
encryption_key_backup_confirmed_at = EXCLUDED.encryption_key_backup_confirmed_at,
encryption_key_backup_confirmed_key_id = EXCLUDED.encryption_key_backup_confirmed_key_id,
encryption_key_backup_confirmed_fingerprint = EXCLUDED.encryption_key_backup_confirmed_fingerprint,
encryption_key_backup_confirmed_by_user_id = EXCLUDED.encryption_key_backup_confirmed_by_user_id`,
[activeKeyId, getKeyFingerprint(activeKeyId), userId],
`INSERT INTO app_settings (id, registration_enabled, default_locale)
VALUES ('singleton', false, 'en')
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`,
);
// The recovery kit is confirmed for the keys the demo runs with, the same
// entries `POST /api/admin/recovery-kit/confirm` writes (key ids and
// public fingerprints, never a key). The demo account is an admin, so an
// unconfirmed kit put a "Save your recovery kit" banner above the
// dashboard for every visitor, about keys no visitor can reach. The seed
// writes no backup copies, so no retired key is needed.
const kitEntries = [
...applicationKitKeys({
activeKeyId: getActiveKeyId(),
configuredKeyIds: getConfiguredKeyIds(),
fingerprintOf: getKeyFingerprint,
needs: {
stored: [],
unrecorded: { copies: 0, oldestAt: null },
offhost: [],
offhostExpirationDays: null,
retiredKeysStillNeeded: [],
},
now: new Date(),
}),
...backupKitKeys(
process.env.BACKUP_ENCRYPTION_KEY,
process.env.BACKUP_ENCRYPTION_PREVIOUS_KEYS,
),
...tokenKitKeys(process.env.API_TOKEN_HMAC_KEY),
].map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint }));
await client.query(
`INSERT INTO recovery_kit_confirmations (id, confirmed_at, confirmed_by_user_id, entries)
VALUES ('singleton', NOW(), $1, $2::jsonb)
ON CONFLICT (id) DO UPDATE SET confirmed_at = EXCLUDED.confirmed_at,
confirmed_by_user_id = EXCLUDED.confirmed_by_user_id,
entries = EXCLUDED.entries`,
[userId, JSON.stringify(kitEntries)],
);
// ── Audit Log (some login entries) ──────