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

@@ -1,38 +1,39 @@
"use client";
/**
* Dashboard banner for an admin while the encryption key backup step is due.
* Not dismissable by design: it goes away when the step is done, and comes
* back when the key changes. Renders nothing for anyone who is not an admin,
* and asks the server nothing for them either.
* Dashboard banner for an admin while the recovery kit is due: never
* confirmed, or listing a key the confirmation does not cover (a rotation, a
* new backup key). Not dismissable by design: it goes away when the kit is
* confirmed, and comes back when a key changes. Renders nothing for anyone
* who is not an admin, and asks the server nothing for them either.
*/
import Link from "next/link";
import { KeyRound } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
import { useRecoveryKit } from "@/components/admin/use-recovery-kit";
import { InlineAction } from "@/components/ui/inline-action";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
export function RecoveryKitBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
const { data } = useKeyBackupStatus(isAdmin);
const { data } = useRecoveryKit(isAdmin);
if (!isAdmin || !data?.due) return null;
return (
<div
role="status"
data-testid="key-backup-banner"
data-testid="recovery-kit-banner"
className="border-warning/40 bg-warning/10 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center"
>
<KeyRound className="text-warning h-5 w-5 shrink-0" aria-hidden="true" />
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold">
{t("dashboard.keyBackupBanner.title")}
{t("dashboard.recoveryKitBanner.title")}
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
<p className="text-sm">{t("dashboard.recoveryKitBanner.body")}</p>
</div>
<InlineAction asChild size="sm" className="min-h-11 font-medium">
<Link href="/admin/encryption">
{t("dashboard.keyBackupBanner.action")}
{t("dashboard.recoveryKitBanner.action")}
</Link>
</InlineAction>
</div>