mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
953c64f7a4
commit
d3c672cc3f
56 files changed
+3042
-1611
No files matched your search
@@ -5,8 +5,8 @@
|
||||
"entries": {
|
||||
"app/api/admin/ai-settings/route.ts": 1,
|
||||
"app/api/admin/drain-per-sample-cumulative/route.ts": 1,
|
||||
"app/api/admin/encryption/key-backup/verify/route.ts": 1,
|
||||
"app/api/admin/notifications/reminder-check/route.ts": 1,
|
||||
"app/api/admin/recovery-kit/verify/route.ts": 1,
|
||||
"app/api/admin/rollups/recompute/route.ts": 1,
|
||||
"app/api/admin/users/[id]/reset-password/route.ts": 1,
|
||||
"app/api/ai/test/route.ts": 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 86,
|
||||
"total": 96631,
|
||||
"total": 96611,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -67,7 +67,7 @@
|
||||
"lib/jobs/coach-nudge.ts": 1084,
|
||||
"lib/jobs/insight-pregenerate.ts": 1189,
|
||||
"lib/jobs/medication-intake-import.ts": 819,
|
||||
"lib/jobs/offhost-backup.ts": 1147,
|
||||
"lib/jobs/offhost-backup.ts": 1127,
|
||||
"lib/jobs/reaction-line.ts": 824,
|
||||
"lib/jobs/reminder/medication-reminder-check.ts": 816,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 348,
|
||||
"total": 112838,
|
||||
"count": 347,
|
||||
"total": 112600,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -59,7 +59,6 @@
|
||||
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
|
||||
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
|
||||
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
|
||||
"components/admin/key-backup-card.tsx :: KeyBackupCard": 238,
|
||||
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
|
||||
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
|
||||
"components/admin/reminders-section.tsx :: RemindersSection": 309,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2897,
|
||||
"total": 9249,
|
||||
"total": 9248,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -2703,7 +2703,7 @@
|
||||
"lib/queries/use-medication-compliance-summary.ts": 3,
|
||||
"lib/queries/use-medication-list-layout.ts": 3,
|
||||
"lib/queries/use-profile-claim.ts": 4,
|
||||
"lib/query-keys/admin.ts": 13,
|
||||
"lib/query-keys/admin.ts": 12,
|
||||
"lib/query-keys/ai-runs.ts": 1,
|
||||
"lib/query-keys/auth.ts": 9,
|
||||
"lib/query-keys/coach.ts": 13,
|
||||
|
||||
@@ -242,18 +242,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup/confirm": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup/verify": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
|
||||
@@ -276,6 +264,19 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/recovery-kit": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/recovery-kit/confirm": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/recovery-kit/verify": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/recovery-readiness": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
|
||||
"/api/admin/settings/assistant-flags": {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
|
||||
*
|
||||
* The admin states that the active encryption key is backed up. The body
|
||||
* names the key the admin was shown; when that is no longer the active key
|
||||
* (rotated, or the server restarted with another key since the page loaded)
|
||||
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
|
||||
* No step-up: confirming is a low-risk write that reveals nothing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
confirmKeyBackup,
|
||||
KEY_BACKUP_STALE_CODE,
|
||||
} from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const confirmSchema = z
|
||||
.object({
|
||||
keyId: z.string().min(1).max(32),
|
||||
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
|
||||
|
||||
const parsed = await parseJsonBody(request, confirmSchema, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const result = await confirmKeyBackup({
|
||||
keyId: parsed.data.keyId,
|
||||
fingerprint: parsed.data.fingerprint,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
annotate({ meta: { key_backup_stale: true } });
|
||||
return apiError(
|
||||
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
|
||||
409,
|
||||
{
|
||||
errorCode: KEY_BACKUP_STALE_CODE,
|
||||
activeKeyId: result.activeKeyId,
|
||||
fingerprint: result.fingerprint,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("encryption.keyBackup.confirmed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
keyId: result.status.activeKeyId,
|
||||
fingerprint: result.status.fingerprint,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result.status);
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/key-backup
|
||||
*
|
||||
* Whether the "Back up your encryption key" step is due, for the admin step,
|
||||
* the overview tile and the dashboard banner. A light read (one settings row),
|
||||
* separate from `/status`, which walks the whole encrypted corpus. Key id and
|
||||
* fingerprint only, never key material. Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
|
||||
const status = await readKeyBackupStatus();
|
||||
annotate({ meta: { key_backup_due: status.due } });
|
||||
return apiSuccess(status);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
|
||||
*
|
||||
* "Check my copy": the admin pastes the backed-up key and learns whether it
|
||||
* is the active key. The value is compared in memory (constant time on the
|
||||
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
|
||||
* the Zod issue list is not returned for this body (a validation failure is a
|
||||
* plain 422), and the field name is on the observability denylist.
|
||||
*
|
||||
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
|
||||
* per admin is there to keep the route from being anyone's busy loop. Over
|
||||
* plain HTTP the value crosses the network in clear, as the admin's password
|
||||
* does; the client says so before sending.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KEY_BACKUP_VERIFY_LIMIT = 5;
|
||||
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const verifySchema = z
|
||||
.object({ encryptionKey: z.string().min(1).max(256) })
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
|
||||
|
||||
const limit = await checkRateLimit(
|
||||
`key-backup-verify:${user.id}`,
|
||||
KEY_BACKUP_VERIFY_LIMIT,
|
||||
KEY_BACKUP_VERIFY_WINDOW_MS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
return apiError("Too many checks. Try again in a minute.", 429);
|
||||
}
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = verifySchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
// Deliberately not `returnAllZodIssues`: nothing derived from this body
|
||||
// goes back over the wire.
|
||||
return apiError("Paste the key as 64 hex characters.", 422);
|
||||
}
|
||||
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
|
||||
annotate({ meta: { key_backup_verify_matches: matches } });
|
||||
await auditLog("encryption.keyBackup.verified", {
|
||||
userId: user.id,
|
||||
details: { keyId: activeKeyId, matches },
|
||||
});
|
||||
return apiSuccess({ matches, keyId: activeKeyId });
|
||||
});
|
||||
@@ -17,13 +17,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import { readBackupKeyNeeds } from "@/lib/recovery/backup-key-needs";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
@@ -105,100 +101,6 @@ async function readRotationState(): Promise<RotationState> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which keys the backups still need.
|
||||
*
|
||||
* Rotation re-encrypts every row in the database and re-seals every stored
|
||||
* backup's envelope, but the content INSIDE a backup keeps the key it was
|
||||
* written under: a restore writes it back verbatim. So "every row is on the
|
||||
* active key" is not the same as "the old key can go". This answers the second
|
||||
* question per key id, from what each copy recorded as it was written: the
|
||||
* stored copies that need it and the oldest of them, and for the off-host
|
||||
* bucket the last night an object needing it went in, plus how long the
|
||||
* bucket's lifecycle rule keeps objects after that.
|
||||
*/
|
||||
export interface BackupKeyNeeds {
|
||||
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
|
||||
/** Copies written before v1.39.3, which recorded no key ids. */
|
||||
unrecorded: { copies: number; oldestAt: string | null };
|
||||
offhost: Array<{
|
||||
keyId: string;
|
||||
firstWrittenAt: string;
|
||||
lastWrittenAt: string;
|
||||
/** When the last object needing this key expires; null when unknown. */
|
||||
neededUntil: string | null;
|
||||
}>;
|
||||
/** The bucket's expiry, when it has one the credential may read. */
|
||||
offhostExpirationDays: number | null;
|
||||
/** Retired key ids some backup still needs (never the active one). */
|
||||
retiredKeysStillNeeded: string[];
|
||||
}
|
||||
|
||||
async function readBackupKeyNeeds(
|
||||
activeKeyId: string,
|
||||
now: Date,
|
||||
): Promise<BackupKeyNeeds> {
|
||||
const [stored, unrecorded, offhostRows] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
Array<{ key_id: string; copies: number; oldest_at: Date }>
|
||||
>`
|
||||
SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at
|
||||
FROM data_backups, unnest(inner_key_ids) AS k
|
||||
WHERE inner_key_ids_recorded
|
||||
GROUP BY k
|
||||
ORDER BY k
|
||||
`,
|
||||
prisma.dataBackup.aggregate({
|
||||
where: { innerKeyIdsRecorded: false },
|
||||
_count: { _all: true },
|
||||
_min: { createdAt: true },
|
||||
}),
|
||||
prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }),
|
||||
]);
|
||||
const lifecycle =
|
||||
offhostRows.length > 0 && offhostBackupConfigured()
|
||||
? await probeOffhostLifecycle()
|
||||
: null;
|
||||
const expirationDays =
|
||||
lifecycle?.state === "configured" ? lifecycle.expirationDays : null;
|
||||
|
||||
const offhost = offhostRows.map((row) => ({
|
||||
keyId: row.keyId,
|
||||
firstWrittenAt: row.firstWrittenAt.toISOString(),
|
||||
lastWrittenAt: row.lastWrittenAt.toISOString(),
|
||||
neededUntil:
|
||||
expirationDays === null
|
||||
? null
|
||||
: new Date(
|
||||
row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
const needed = new Set<string>();
|
||||
for (const row of stored) needed.add(row.key_id);
|
||||
for (const row of offhost) {
|
||||
if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) {
|
||||
needed.add(row.keyId);
|
||||
}
|
||||
}
|
||||
needed.delete(activeKeyId);
|
||||
|
||||
return {
|
||||
stored: stored.map((row) => ({
|
||||
keyId: row.key_id,
|
||||
copies: row.copies,
|
||||
oldestAt: row.oldest_at.toISOString(),
|
||||
})),
|
||||
unrecorded: {
|
||||
copies: unrecorded._count._all,
|
||||
oldestAt: unrecorded._min.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
offhost,
|
||||
offhostExpirationDays: expirationDays,
|
||||
retiredKeysStillNeeded: [...needed].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
@@ -214,7 +116,6 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const rotation = await readRotationState();
|
||||
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
||||
const keyBackup = await readKeyBackupStatus();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -235,8 +136,6 @@ export const GET = apiHandler(async () => {
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
backups,
|
||||
// The "Back up your encryption key" step, bound to the active key.
|
||||
keyBackup,
|
||||
// Every row rotated AND no backup still needs a retired key: the one
|
||||
// state in which dropping an old key loses nothing. Copies that recorded
|
||||
// no key ids count against it, because they may hold any key that
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /api/admin/recovery-kit/confirm { entries: [{ kind, id, fingerprint }] }
|
||||
*
|
||||
* The admin states that a copy of every key in the kit is kept outside the
|
||||
* server. The body is the kit as the admin was shown it; when that is no
|
||||
* longer the kit (a rotation, a new backup key, a restart with other keys
|
||||
* since the page loaded) the answer is 409 `recovery.kit.stale` with the
|
||||
* current kit, and nothing is recorded. No step-up: confirming is a low-risk
|
||||
* write that reveals nothing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { confirmRecoveryKit } from "@/lib/recovery/kit";
|
||||
import {
|
||||
kitConfirmationSchema,
|
||||
RECOVERY_KIT_STALE_CODE,
|
||||
} from "@/lib/recovery/kit-rules";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryKit.confirm" } });
|
||||
|
||||
const parsed = await parseJsonBody(request, kitConfirmationSchema, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const result = await confirmRecoveryKit({
|
||||
shown: parsed.data.entries,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
annotate({ meta: { recovery_kit_stale: true } });
|
||||
return apiError(
|
||||
"The recovery kit changed since this page was loaded. Reload, check the keys it lists now, and confirm again.",
|
||||
409,
|
||||
{ errorCode: RECOVERY_KIT_STALE_CODE },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("recovery.kit.confirmed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
keys: result.kit.keys.map(({ kind, id, fingerprint }) => ({
|
||||
kind,
|
||||
id,
|
||||
fingerprint,
|
||||
})),
|
||||
},
|
||||
});
|
||||
return apiSuccess(result.kit);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GET /api/admin/recovery-kit
|
||||
*
|
||||
* The recovery kit: every key a restore of this instance needs (the
|
||||
* application keys, including retired ones a backup still lists, the off-host
|
||||
* backup keys and the token key), each with its fingerprint and why it is
|
||||
* needed, and whether the admin's confirmation covers it. Ids and
|
||||
* fingerprints only, never key material. Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readRecoveryKit } from "@/lib/recovery/kit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryKit.status" } });
|
||||
const kit = await readRecoveryKit();
|
||||
annotate({
|
||||
meta: {
|
||||
recovery_kit_due: kit.due,
|
||||
recovery_kit_keys: kit.keys.length,
|
||||
recovery_kit_unconfirmed: kit.unconfirmed.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(kit);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* POST /api/admin/recovery-kit/verify { kind, id, keyCopy }
|
||||
*
|
||||
* "Check my copy": the admin pastes the copy of one key the kit lists and
|
||||
* learns whether it is that key. The value is compared in memory (constant
|
||||
* time on the compared bytes) and dropped. It is never stored, never logged,
|
||||
* never echoed: the Zod issue list is not returned for this body (a
|
||||
* validation failure is a plain 422), and the field name is on the
|
||||
* observability denylist.
|
||||
*
|
||||
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
|
||||
* per admin is there to keep the route from being anyone's busy loop. Over
|
||||
* plain HTTP the value crosses the network in clear, as the admin's password
|
||||
* does; the client says so before sending.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyKitKey } from "@/lib/recovery/kit";
|
||||
import { KIT_KEY_KINDS } from "@/lib/recovery/kit-rules";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KIT_VERIFY_LIMIT = 5;
|
||||
const KIT_VERIFY_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const verifySchema = z
|
||||
.object({
|
||||
kind: z.enum(KIT_KEY_KINDS),
|
||||
id: z.string().min(1).max(64),
|
||||
keyCopy: z.string().min(1).max(512),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryKit.verify" } });
|
||||
|
||||
const limit = await checkRateLimit(
|
||||
`recovery-kit-verify:${user.id}`,
|
||||
KIT_VERIFY_LIMIT,
|
||||
KIT_VERIFY_WINDOW_MS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
return apiError("Too many checks. Try again in a minute.", 429);
|
||||
}
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 2048 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = verifySchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
// Deliberately not `returnAllZodIssues`: nothing derived from this body
|
||||
// goes back over the wire.
|
||||
return apiError("Paste the key exactly as it is set.", 422);
|
||||
}
|
||||
|
||||
const { kind, id } = parsed.data;
|
||||
const matches = verifyKitKey({ kind, id, value: parsed.data.keyCopy });
|
||||
annotate({ meta: { recovery_kit_verify_matches: matches } });
|
||||
await auditLog("recovery.kit.verified", {
|
||||
userId: user.id,
|
||||
details: { kind, keyId: id, matches },
|
||||
});
|
||||
return apiSuccess({ matches, kind, id });
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useDashboardChartReveal } from "@/components/dashboard/chart-reveal";
|
||||
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
|
||||
import { EnvironmentChip } from "@/components/dashboard/environment-chip";
|
||||
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
|
||||
import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner";
|
||||
import {
|
||||
QuickEntrySheets,
|
||||
type QuickEntryDialog,
|
||||
@@ -117,7 +117,7 @@ export default function DashboardPageClient({
|
||||
>
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
|
||||
<KeyBackupBanner isAdmin={user?.role === "ADMIN"} />
|
||||
<RecoveryKitBanner isAdmin={user?.role === "ADMIN"} />
|
||||
|
||||
{/* Records other people share with you are reached from the account
|
||||
switcher in the top bar and the sidebar, not from a card here. The
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null }));
|
||||
vi.mock("../recovery-kit-card", () => ({ RecoveryKitCard: () => null }));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ColumnCoverage, EncryptionSection } from "../encryption-section";
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* The key backup step and its dashboard banner.
|
||||
*
|
||||
* 1. the card shows the key id and fingerprint and never anything shaped
|
||||
* like key material; the platform hint picks the open tab;
|
||||
* 2. a due step offers the confirmation, a done step does not;
|
||||
* 3. the banner renders only for an admin and only while the step is due;
|
||||
* 4. the copy resolves in German too.
|
||||
*
|
||||
* Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red;
|
||||
* render the confirm button unconditionally and case 2 goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { KeyBackupCard } from "@/components/admin/key-backup-card";
|
||||
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
|
||||
import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/encryption",
|
||||
}));
|
||||
|
||||
const DUE: KeyBackupStatus = {
|
||||
due: true,
|
||||
activeKeyId: "v1",
|
||||
fingerprint: "a1b2c3d4e5f6",
|
||||
confirmedAt: null,
|
||||
confirmedKeyId: null,
|
||||
confirmedFingerprint: null,
|
||||
confirmedBy: null,
|
||||
platformHint: "truenas",
|
||||
};
|
||||
|
||||
const DONE: KeyBackupStatus = {
|
||||
...DUE,
|
||||
due: false,
|
||||
confirmedAt: "2026-10-03T10:00:00.000Z",
|
||||
confirmedKeyId: "v1",
|
||||
confirmedFingerprint: "a1b2c3d4e5f6",
|
||||
confirmedBy: { id: "u1", email: "admin@example.test" },
|
||||
};
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
status: KeyBackupStatus | null,
|
||||
locale: "en" | "de" = "en",
|
||||
): string {
|
||||
const client = new QueryClient();
|
||||
if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("KeyBackupCard", () => {
|
||||
it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => {
|
||||
const html = render(<KeyBackupCard />, DUE);
|
||||
expect(html).toContain("a1b2c3d4e5f6");
|
||||
expect(html).toContain(">v1<");
|
||||
expect(html).toContain("Apps, select HealthLog");
|
||||
expect(html).not.toMatch(/[0-9a-f]{64}/);
|
||||
});
|
||||
|
||||
it("offers the confirmation only while the step is due", () => {
|
||||
expect(render(<KeyBackupCard />, DUE)).toContain(
|
||||
"I have backed up this key",
|
||||
);
|
||||
const done = render(<KeyBackupCard />, DONE);
|
||||
expect(done).not.toContain("I have backed up this key");
|
||||
expect(done).toContain("admin@example.test");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render(<KeyBackupCard />, DUE, "de")).toContain(
|
||||
"Sichere deinen Verschlüsselungsschlüssel",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyBackupBanner", () => {
|
||||
it("renders for an admin while the step is due", () => {
|
||||
expect(render(<KeyBackupBanner isAdmin />, DUE)).toContain(
|
||||
'data-testid="key-backup-banner"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a non-admin, or once the step is done", () => {
|
||||
expect(render(<KeyBackupBanner isAdmin={false} />, DUE)).toBe("");
|
||||
expect(render(<KeyBackupBanner isAdmin />, DONE)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* The recovery kit card and its dashboard banner.
|
||||
*
|
||||
* 1. the card lists every key with its id and fingerprint and never
|
||||
* anything shaped like key material; the platform hint picks the open
|
||||
* tab; the restore guide is linked;
|
||||
* 2. a retired key a backup still needs is listed with why, and a key this
|
||||
* server does not hold says so;
|
||||
* 3. a due kit offers the confirmation and names what the confirmation
|
||||
* does not cover, a confirmed one does not;
|
||||
* 4. the banner renders only for an admin and only while the kit is due;
|
||||
* 5. the copy resolves in German too.
|
||||
*
|
||||
* Mutation check: drop the `isAdmin` guard in the banner and case 4 goes red;
|
||||
* render the confirm button unconditionally and case 3 goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DISASTER_RECOVERY_GUIDE_URL,
|
||||
RecoveryKitCard,
|
||||
} from "@/components/admin/recovery-kit-card";
|
||||
import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner";
|
||||
import type { KitKey, RecoveryKit } from "@/components/admin/use-recovery-kit";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/encryption",
|
||||
}));
|
||||
|
||||
const need = (over: Partial<KitKey["need"]> = {}): KitKey["need"] => ({
|
||||
active: false,
|
||||
configured: true,
|
||||
storedCopies: 0,
|
||||
offhostUntil: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const KEYS: KitKey[] = [
|
||||
{
|
||||
kind: "application",
|
||||
id: "v1",
|
||||
fingerprint: null,
|
||||
source: "ENCRYPTION_KEYS",
|
||||
need: need({ configured: false, storedCopies: 2 }),
|
||||
confirmed: false,
|
||||
problem: "not_configured",
|
||||
},
|
||||
{
|
||||
kind: "application",
|
||||
id: "v2",
|
||||
fingerprint: "a1b2c3d4e5f6",
|
||||
source: "ENCRYPTION_KEYS",
|
||||
need: need({ active: true }),
|
||||
confirmed: false,
|
||||
problem: null,
|
||||
},
|
||||
{
|
||||
kind: "backup",
|
||||
id: "0f0e0d0c0b0a",
|
||||
fingerprint: "0f0e0d0c0b0a",
|
||||
source: "BACKUP_ENCRYPTION_KEY",
|
||||
need: need({ active: true }),
|
||||
confirmed: true,
|
||||
problem: null,
|
||||
},
|
||||
{
|
||||
kind: "token",
|
||||
id: "API_TOKEN_HMAC_KEY",
|
||||
fingerprint: "112233445566",
|
||||
source: "API_TOKEN_HMAC_KEY",
|
||||
need: need({ active: true }),
|
||||
confirmed: true,
|
||||
problem: null,
|
||||
},
|
||||
];
|
||||
|
||||
const DUE: RecoveryKit = {
|
||||
keys: KEYS,
|
||||
due: true,
|
||||
unconfirmed: [
|
||||
{ kind: "application", id: "v1" },
|
||||
{ kind: "application", id: "v2" },
|
||||
],
|
||||
confirmedAt: "2026-10-03T10:00:00.000Z",
|
||||
confirmedBy: null,
|
||||
platformHint: "truenas",
|
||||
};
|
||||
|
||||
const DONE: RecoveryKit = {
|
||||
...DUE,
|
||||
keys: KEYS.map((k) => ({ ...k, confirmed: true })),
|
||||
due: false,
|
||||
unconfirmed: [],
|
||||
confirmedBy: { id: "u1", email: "admin@example.test" },
|
||||
};
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
kit: RecoveryKit | null,
|
||||
locale: "en" | "de" = "en",
|
||||
): string {
|
||||
const client = new QueryClient();
|
||||
if (kit) client.setQueryData(queryKeys.adminRecoveryKit(), kit);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RecoveryKitCard", () => {
|
||||
it("lists every key by id and fingerprint, opens the hinted platform, links the guide, and shows no key material", () => {
|
||||
const html = render(<RecoveryKitCard />, DUE);
|
||||
for (const k of KEYS) {
|
||||
expect(html).toContain(
|
||||
`data-testid="recovery-kit-key-${k.kind}-${k.id}"`,
|
||||
);
|
||||
}
|
||||
expect(html).toContain("a1b2c3d4e5f6");
|
||||
expect(html).toContain("112233445566");
|
||||
expect(html).toContain("Apps, select HealthLog");
|
||||
expect(html).toContain(DISASTER_RECOVERY_GUIDE_URL);
|
||||
expect(html).not.toMatch(/[0-9a-f]{64}/);
|
||||
});
|
||||
|
||||
it("says why a retired key is listed and that this server does not hold it", () => {
|
||||
const html = render(<RecoveryKitCard />, DUE);
|
||||
expect(html).toContain("Needed by 2 stored copies.");
|
||||
expect(html).toContain("Not on this server");
|
||||
expect(html).toContain("Application key v1");
|
||||
});
|
||||
|
||||
it("offers the confirmation only while the kit is due, naming what is not covered", () => {
|
||||
const due = render(<RecoveryKitCard />, DUE);
|
||||
expect(due).toContain("I have a copy of every key");
|
||||
expect(due).toContain("v1, v2");
|
||||
const done = render(<RecoveryKitCard />, DONE);
|
||||
expect(done).not.toContain("I have a copy of every key");
|
||||
expect(done).toContain("admin@example.test");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render(<RecoveryKitCard />, DUE, "de")).toContain(
|
||||
"Wiederherstellungs-Kit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RecoveryKitBanner", () => {
|
||||
it("renders for an admin while the kit is due", () => {
|
||||
expect(render(<RecoveryKitBanner isAdmin />, DUE)).toContain(
|
||||
'data-testid="recovery-kit-banner"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a non-admin, or once the kit is confirmed", () => {
|
||||
expect(render(<RecoveryKitBanner isAdmin={false} />, DUE)).toBe("");
|
||||
expect(render(<RecoveryKitBanner isAdmin />, DONE)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -50,8 +50,8 @@ import {
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { KeyBackupCard } from "./key-backup-card";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
import { RecoveryKitCard } from "./recovery-kit-card";
|
||||
import { useRecoveryKit } from "./use-recovery-kit";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
@@ -101,7 +101,7 @@ export function EncryptionSection() {
|
||||
|
||||
// Same query as the card's own (deduplicated by key): read here only to
|
||||
// know when the page can paint the cards below it in one step.
|
||||
const keyBackup = useKeyBackupStatus();
|
||||
const recoveryKit = useRecoveryKit();
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
queryFn: async (): Promise<EncryptionStatus> => {
|
||||
@@ -158,14 +158,14 @@ export function EncryptionSection() {
|
||||
// downward the moment the status resolved. Same shape as
|
||||
// `coach-feedback-section.tsx`, which fixed this for itself and never had
|
||||
// the fix carried across.
|
||||
// Until both reads land, only the key-backup card paints. It is the first
|
||||
// Until both reads land, only the recovery kit card paints. It is the first
|
||||
// card and grows into its loaded shape with nothing under it yet; painting
|
||||
// the coverage card below it while the backup card was still a one-line
|
||||
// loader moved that card down the page when the backup status arrived.
|
||||
if (statusQuery.isLoading || keyBackup.isLoading) {
|
||||
// the coverage card below it while the kit was still a one-line loader
|
||||
// moved that card down the page when the kit arrived.
|
||||
if (statusQuery.isLoading || recoveryKit.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
<RecoveryKitCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function EncryptionSection() {
|
||||
if (statusQuery.isError || !statusQuery.data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
<RecoveryKitCard />
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
@@ -208,7 +208,7 @@ export function EncryptionSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{recentProof.dialog}
|
||||
<KeyBackupCard />
|
||||
<RecoveryKitCard />
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<KeyBackupCard>` — the "Back up your encryption key" step on the admin
|
||||
* Encryption page.
|
||||
*
|
||||
* Shows the active key id and its fingerprint (never the key), where the key
|
||||
* lives on each platform, what is lost without it, a "Check my copy" field
|
||||
* and the confirmation. The confirmation is bound to the key id and
|
||||
* fingerprint shown here: if the server's key changed since the page loaded,
|
||||
* the server answers 409 and the card reloads.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
useKeyBackupStatus,
|
||||
type KeyBackupPlatform,
|
||||
type KeyBackupStatus,
|
||||
} from "./use-key-backup-status";
|
||||
|
||||
const PLATFORMS: KeyBackupPlatform[] = [
|
||||
"compose",
|
||||
"truenas",
|
||||
"coolify",
|
||||
"unraid",
|
||||
"portainer",
|
||||
];
|
||||
|
||||
/** Literal keys per platform, so the i18n coverage guard sees every one. */
|
||||
function platformCopy(
|
||||
t: (key: string) => string,
|
||||
p: KeyBackupPlatform,
|
||||
): { label: string; where: string } {
|
||||
switch (p) {
|
||||
case "truenas":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.truenasLabel"),
|
||||
where: t("admin.keyBackup.platform.truenasWhere"),
|
||||
};
|
||||
case "coolify":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.coolifyLabel"),
|
||||
where: t("admin.keyBackup.platform.coolifyWhere"),
|
||||
};
|
||||
case "unraid":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.unraidLabel"),
|
||||
where: t("admin.keyBackup.platform.unraidWhere"),
|
||||
};
|
||||
case "portainer":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.portainerLabel"),
|
||||
where: t("admin.keyBackup.platform.portainerWhere"),
|
||||
};
|
||||
case "compose":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.composeLabel"),
|
||||
where: t("admin.keyBackup.platform.composeWhere"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function KeyBackupCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const status = useKeyBackupStatus();
|
||||
const [candidate, setCandidate] = useState("");
|
||||
const [checkResult, setCheckResult] = useState<boolean | null>(null);
|
||||
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminEncryptionKeyBackup(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: (s: KeyBackupStatus) =>
|
||||
apiPost<KeyBackupStatus>(
|
||||
"/api/admin/encryption/key-backup/confirm",
|
||||
{ keyId: s.activeKeyId, fingerprint: s.fingerprint },
|
||||
{ credentials: "include" },
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast(t("admin.keyBackup.confirmed"));
|
||||
await invalidate();
|
||||
},
|
||||
onError: async (err: Error) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
showErrorToast(t("admin.keyBackup.stale"), { cause: err });
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
// Not `apiPost`: no idempotency key, so no cached response row exists
|
||||
// for a request whose body was a key.
|
||||
mutationFn: (encryptionKey: string) =>
|
||||
apiFetch<{ matches: boolean }>(
|
||||
"/api/admin/encryption/key-backup/verify",
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ encryptionKey }),
|
||||
},
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setCheckResult(data.matches);
|
||||
setCandidate("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCheckResult(null);
|
||||
showErrorToast(err.message || t("admin.keyBackup.checkFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const insecure =
|
||||
typeof window !== "undefined" && window.isSecureContext === false;
|
||||
|
||||
if (!status.data) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.keyBackup.title")}
|
||||
description={t("admin.keyBackup.description")}
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorRow
|
||||
inset
|
||||
error={status.error}
|
||||
message={t("admin.keyBackup.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.keyBackup.loading")}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
const s = status.data;
|
||||
return (
|
||||
<SettingsCard data-testid="key-backup-card">
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.keyBackup.title")}
|
||||
description={t("admin.keyBackup.description")}
|
||||
status={
|
||||
s.due ? (
|
||||
<Badge variant="outline" className="border-warning/40 text-warning">
|
||||
{t("admin.keyBackup.badgeDue")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("admin.keyBackup.badgeDone")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.keyBackup.keyId")}
|
||||
</dt>
|
||||
<dd className="font-mono">{s.activeKeyId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.keyBackup.fingerprint")}
|
||||
</dt>
|
||||
<dd className="font-mono" data-testid="key-backup-fingerprint">
|
||||
{s.fingerprint}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="text-sm">{t("admin.keyBackup.whatIsLost")}</p>
|
||||
|
||||
<Tabs defaultValue={s.platformHint}>
|
||||
<TabsList className="flex-wrap">
|
||||
{PLATFORMS.map((p) => (
|
||||
<TabsTrigger key={p} value={p}>
|
||||
{platformCopy(t, p).label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{PLATFORMS.map((p) => (
|
||||
<TabsContent key={p} value={p} className="text-sm">
|
||||
{platformCopy(t, p).where}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.keyBackup.fingerprintHint")}
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (candidate.trim()) verify.mutate(candidate);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="key-backup-candidate">
|
||||
{t("admin.keyBackup.checkLabel")}
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="key-backup-candidate"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={candidate}
|
||||
onChange={(e) => {
|
||||
setCandidate(e.target.value);
|
||||
setCheckResult(null);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!candidate.trim() || verify.isPending}
|
||||
>
|
||||
{verify.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.keyBackup.checkButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{insecure
|
||||
? t("admin.keyBackup.checkInsecureNote")
|
||||
: t("admin.keyBackup.checkNote")}
|
||||
</p>
|
||||
{checkResult !== null && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="key-backup-check-result"
|
||||
className={
|
||||
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{checkResult
|
||||
? t("admin.keyBackup.checkMatches")
|
||||
: t("admin.keyBackup.checkNoMatch")}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{!s.due && s.confirmedAt && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{s.confirmedBy?.email
|
||||
? t("admin.keyBackup.confirmedAtBy", {
|
||||
date: fmt.dateTime(new Date(s.confirmedAt)),
|
||||
who: s.confirmedBy.email,
|
||||
})
|
||||
: t("admin.keyBackup.confirmedAt", {
|
||||
date: fmt.dateTime(new Date(s.confirmedAt)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{s.due && s.confirmedKeyId && (
|
||||
<p className="text-sm">
|
||||
{t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{s.due && (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
onClick={() => confirm.mutate(s)}
|
||||
disabled={confirm.isPending}
|
||||
>
|
||||
{confirm.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.keyBackup.confirmButton")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<RecoveryKitCard>` — the recovery kit on the admin Encryption page.
|
||||
*
|
||||
* Lists every key a restore of this server needs: the application keys
|
||||
* (including a retired one a backup still lists), the off-host backup keys and
|
||||
* the token key. Each row shows the key's id and fingerprint (never the key),
|
||||
* why the kit lists it and whether the confirmation covers it. Below them:
|
||||
* where the keys are set on each platform, the short restore path with a link
|
||||
* to the full guide, a "Check my copy" field and the confirmation.
|
||||
*
|
||||
* The confirmation sends the kit exactly as this card showed it. When the
|
||||
* server's kit changed since the page loaded (a rotation, a new backup key),
|
||||
* the server answers 409 and the card reloads.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
useRecoveryKit,
|
||||
type KitKey,
|
||||
type KitPlatform,
|
||||
type RecoveryKit,
|
||||
} from "./use-recovery-kit";
|
||||
|
||||
const PLATFORMS: KitPlatform[] = [
|
||||
"compose",
|
||||
"truenas",
|
||||
"coolify",
|
||||
"unraid",
|
||||
"portainer",
|
||||
];
|
||||
|
||||
/** The disaster recovery guide in the repository, the same on every host. */
|
||||
export const DISASTER_RECOVERY_GUIDE_URL =
|
||||
"https://github.com/MBombeck/HealthLog/blob/main/docs/ops/disaster-recovery.md";
|
||||
|
||||
/** Literal keys per platform, so the i18n coverage guard sees every one. */
|
||||
function platformCopy(
|
||||
t: (key: string) => string,
|
||||
p: KitPlatform,
|
||||
): { label: string; where: string } {
|
||||
switch (p) {
|
||||
case "truenas":
|
||||
return {
|
||||
label: t("admin.recoveryKit.platform.truenasLabel"),
|
||||
where: t("admin.recoveryKit.platform.truenasWhere"),
|
||||
};
|
||||
case "coolify":
|
||||
return {
|
||||
label: t("admin.recoveryKit.platform.coolifyLabel"),
|
||||
where: t("admin.recoveryKit.platform.coolifyWhere"),
|
||||
};
|
||||
case "unraid":
|
||||
return {
|
||||
label: t("admin.recoveryKit.platform.unraidLabel"),
|
||||
where: t("admin.recoveryKit.platform.unraidWhere"),
|
||||
};
|
||||
case "portainer":
|
||||
return {
|
||||
label: t("admin.recoveryKit.platform.portainerLabel"),
|
||||
where: t("admin.recoveryKit.platform.portainerWhere"),
|
||||
};
|
||||
case "compose":
|
||||
return {
|
||||
label: t("admin.recoveryKit.platform.composeLabel"),
|
||||
where: t("admin.recoveryKit.platform.composeWhere"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function keyLabel(
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
key: KitKey,
|
||||
): string {
|
||||
switch (key.kind) {
|
||||
case "application":
|
||||
return t("admin.recoveryKit.kind.application", { id: key.id });
|
||||
case "backup":
|
||||
return key.need.active
|
||||
? t("admin.recoveryKit.kind.backup")
|
||||
: t("admin.recoveryKit.kind.backupPrevious");
|
||||
case "token":
|
||||
return t("admin.recoveryKit.kind.token");
|
||||
}
|
||||
}
|
||||
|
||||
/** Why the kit lists a key, as short sentences. */
|
||||
function KeyNeeds({ k }: { k: KitKey }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const lines: string[] = [];
|
||||
if (k.kind === "token") {
|
||||
lines.push(t("admin.recoveryKit.need.token"));
|
||||
} else if (k.kind === "backup") {
|
||||
lines.push(
|
||||
k.need.active
|
||||
? t("admin.recoveryKit.need.backupActive")
|
||||
: t("admin.recoveryKit.need.backupPrevious"),
|
||||
);
|
||||
} else {
|
||||
if (k.need.active) lines.push(t("admin.recoveryKit.need.active"));
|
||||
else if (k.need.configured)
|
||||
lines.push(t("admin.recoveryKit.need.configured"));
|
||||
if (k.need.storedCopies > 0) {
|
||||
lines.push(
|
||||
t("admin.recoveryKit.need.storedCopies", {
|
||||
count: k.need.storedCopies,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (k.need.offhostUntil === "unknown") {
|
||||
lines.push(t("admin.recoveryKit.need.offhostUnknown"));
|
||||
} else if (k.need.offhostUntil) {
|
||||
lines.push(
|
||||
t("admin.recoveryKit.need.offhostUntil", {
|
||||
date: fmt.date(new Date(k.need.offhostUntil)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push(t("admin.recoveryKit.source", { variable: k.source }));
|
||||
return <p className="text-muted-foreground text-xs">{lines.join(" ")}</p>;
|
||||
}
|
||||
|
||||
function KeyStatus({ k }: { k: KitKey }) {
|
||||
const { t } = useTranslations();
|
||||
if (k.problem === "unreadable") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive"
|
||||
>
|
||||
{t("admin.recoveryKit.status.unreadable")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (k.confirmed) {
|
||||
return (
|
||||
<Badge variant="outline">{t("admin.recoveryKit.status.confirmed")}</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-warning/40 text-warning">
|
||||
{k.problem === "not_configured"
|
||||
? t("admin.recoveryKit.status.notConfigured")
|
||||
: t("admin.recoveryKit.status.new")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row per key: what it is, its id and fingerprint, why it is listed. */
|
||||
export function RecoveryKitKeyList({ kit }: { kit: RecoveryKit }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<ul
|
||||
className="divide-border divide-y rounded-lg border"
|
||||
data-testid="recovery-kit-keys"
|
||||
>
|
||||
{kit.keys.map((k) => (
|
||||
<li
|
||||
key={`${k.kind}:${k.id}`}
|
||||
className="space-y-1.5 p-3"
|
||||
data-testid={`recovery-kit-key-${k.kind}-${k.id}`}
|
||||
data-confirmed={k.confirmed ? "true" : "false"}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 text-sm font-medium">{keyLabel(t, k)}</p>
|
||||
<div className="shrink-0">
|
||||
<KeyStatus k={k} />
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid gap-x-3 gap-y-1 text-sm sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("admin.recoveryKit.keyId")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{k.id}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground text-xs">
|
||||
{t("admin.recoveryKit.fingerprint")}
|
||||
</dt>
|
||||
<dd className="font-mono" data-testid="recovery-kit-fingerprint">
|
||||
{k.fingerprint ?? t("admin.recoveryKit.noFingerprint")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<KeyNeeds k={k} />
|
||||
{k.problem === "not_configured" && k.kind !== "token" ? (
|
||||
<p className="text-sm">
|
||||
{t("admin.recoveryKit.notConfiguredHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{k.problem === "unreadable" ? (
|
||||
<p className="text-sm">{t("admin.recoveryKit.unreadableHint")}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckMyCopy({ kit }: { kit: RecoveryKit }) {
|
||||
const { t } = useTranslations();
|
||||
const checkable = kit.keys.filter((k) => k.fingerprint !== null);
|
||||
const [selected, setSelected] = useState(
|
||||
checkable[0] ? `${checkable[0].kind}:${checkable[0].id}` : "",
|
||||
);
|
||||
const [candidate, setCandidate] = useState("");
|
||||
const [checkResult, setCheckResult] = useState<boolean | null>(null);
|
||||
|
||||
const verify = useMutation({
|
||||
// Not `apiPost`: no idempotency key, so no cached response row exists
|
||||
// for a request whose body was a key.
|
||||
mutationFn: (keyCopy: string) => {
|
||||
const target = checkable.find((k) => `${k.kind}:${k.id}` === selected);
|
||||
return apiFetch<{ matches: boolean }>("/api/admin/recovery-kit/verify", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind: target?.kind,
|
||||
id: target?.id,
|
||||
keyCopy,
|
||||
}),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCheckResult(data.matches);
|
||||
setCandidate("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCheckResult(null);
|
||||
showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (checkable.length === 0) return null;
|
||||
const insecure =
|
||||
typeof window !== "undefined" && window.isSecureContext === false;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (candidate.trim() && selected) verify.mutate(candidate);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium">{t("admin.recoveryKit.checkLabel")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="recovery-kit-check-key" className="sr-only">
|
||||
{t("admin.recoveryKit.checkKeyLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="recovery-kit-check-key"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
setSelected(e.target.value);
|
||||
setCheckResult(null);
|
||||
}}
|
||||
>
|
||||
{checkable.map((k) => (
|
||||
<option key={`${k.kind}:${k.id}`} value={`${k.kind}:${k.id}`}>
|
||||
{keyLabel(t, k)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="recovery-kit-candidate" className="sr-only">
|
||||
{t("admin.recoveryKit.checkValueLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="recovery-kit-candidate"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={candidate}
|
||||
onChange={(e) => {
|
||||
setCandidate(e.target.value);
|
||||
setCheckResult(null);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!candidate.trim() || verify.isPending}
|
||||
className="min-h-11 sm:min-h-10"
|
||||
>
|
||||
{verify.isPending && (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.recoveryKit.checkButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{insecure
|
||||
? t("admin.recoveryKit.checkInsecureNote")
|
||||
: t("admin.recoveryKit.checkNote")}
|
||||
</p>
|
||||
{checkResult !== null && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="recovery-kit-check-result"
|
||||
className={
|
||||
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{checkResult
|
||||
? t("admin.recoveryKit.checkMatches")
|
||||
: t("admin.recoveryKit.checkNoMatch")}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecoveryKitCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const status = useRecoveryKit();
|
||||
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminRecoveryKit(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminRecoveryReadiness(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: (kit: RecoveryKit) =>
|
||||
apiPost<RecoveryKit>(
|
||||
"/api/admin/recovery-kit/confirm",
|
||||
{
|
||||
entries: kit.keys.map(({ kind, id, fingerprint }) => ({
|
||||
kind,
|
||||
id,
|
||||
fingerprint,
|
||||
})),
|
||||
},
|
||||
{ credentials: "include" },
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast(t("admin.recoveryKit.confirmed"));
|
||||
await invalidate();
|
||||
},
|
||||
onError: async (err: Error) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
showErrorToast(t("admin.recoveryKit.stale"), { cause: err });
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), {
|
||||
cause: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!status.data) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.recoveryKit.title")}
|
||||
description={t("admin.recoveryKit.description")}
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorRow
|
||||
inset
|
||||
error={status.error}
|
||||
message={t("admin.recoveryKit.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.recoveryKit.loading")}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
const kit = status.data;
|
||||
const unconfirmedIds = kit.unconfirmed.map((k) => k.id).join(", ");
|
||||
return (
|
||||
<SettingsCard data-testid="recovery-kit-card">
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.recoveryKit.title")}
|
||||
description={t("admin.recoveryKit.description")}
|
||||
status={
|
||||
kit.due ? (
|
||||
<Badge variant="outline" className="border-warning/40 text-warning">
|
||||
{t("admin.recoveryKit.badgeDue")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("admin.recoveryKit.badgeDone")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("admin.recoveryKit.intro")}</p>
|
||||
|
||||
<RecoveryKitKeyList kit={kit} />
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.recoveryKit.fingerprintHint")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.recoveryKit.whereTitle")}
|
||||
</p>
|
||||
<Tabs defaultValue={kit.platformHint}>
|
||||
<TabsList className="flex-wrap">
|
||||
{PLATFORMS.map((p) => (
|
||||
<TabsTrigger key={p} value={p}>
|
||||
{platformCopy(t, p).label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{PLATFORMS.map((p) => (
|
||||
<TabsContent key={p} value={p} className="text-sm">
|
||||
{platformCopy(t, p).where}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.recoveryKit.restoreTitle")}
|
||||
</p>
|
||||
<p className="text-sm">{t("admin.recoveryKit.restoreBody")}</p>
|
||||
<InlineAction asChild size="sm">
|
||||
<a
|
||||
href={DISASTER_RECOVERY_GUIDE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<BookOpen aria-hidden="true" />
|
||||
{t("admin.recoveryKit.restoreLink")}
|
||||
</a>
|
||||
</InlineAction>
|
||||
</div>
|
||||
|
||||
<CheckMyCopy kit={kit} />
|
||||
|
||||
{!kit.due && kit.confirmedAt && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{kit.confirmedBy?.email
|
||||
? t("admin.recoveryKit.confirmedAtBy", {
|
||||
date: fmt.dateTime(new Date(kit.confirmedAt)),
|
||||
who: kit.confirmedBy.email,
|
||||
})
|
||||
: t("admin.recoveryKit.confirmedAt", {
|
||||
date: fmt.dateTime(new Date(kit.confirmedAt)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{kit.due && kit.confirmedAt && (
|
||||
<p className="text-sm" data-testid="recovery-kit-due-again">
|
||||
{t("admin.recoveryKit.dueAgain", {
|
||||
date: fmt.dateTime(new Date(kit.confirmedAt)),
|
||||
keys: unconfirmedIds,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{kit.due && (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() => confirm.mutate(kit)}
|
||||
disabled={confirm.isPending}
|
||||
>
|
||||
{confirm.isPending && (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.recoveryKit.confirmButton")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -12,27 +12,17 @@
|
||||
* don't re-fetch.
|
||||
*/
|
||||
|
||||
import {
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { geoLookupState, StatusItem, useSystemStatus } from "./_shared";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError, error, refetch } = useSystemStatus();
|
||||
const { data: keyBackup } = useKeyBackupStatus();
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
@@ -72,19 +62,6 @@ export function SystemStatusSummary() {
|
||||
label={t("admin.overview.snapshotStarted")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
{/* v1.40.0 — the encryption key backup step, warning while due. */}
|
||||
{keyBackup && (
|
||||
<StatusItem
|
||||
icon={KeyRound}
|
||||
label={t("admin.keyBackup.tileLabel")}
|
||||
value={
|
||||
keyBackup.due
|
||||
? t("admin.keyBackup.tileDue")
|
||||
: t("admin.keyBackup.tileDone")
|
||||
}
|
||||
tone={keyBackup.due ? "warning" : "success"}
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 — surface the offline-geo state so the maintainer
|
||||
spots the missing GeoLite2 databases without crawling logs.
|
||||
Read from the admin status, not the public /api/version. */}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export type KeyBackupPlatform =
|
||||
"compose" | "truenas" | "coolify" | "unraid" | "portainer";
|
||||
|
||||
/** `GET /api/admin/encryption/key-backup`; key id and fingerprint only. */
|
||||
export interface KeyBackupStatus {
|
||||
due: boolean;
|
||||
activeKeyId: string;
|
||||
fingerprint: string;
|
||||
confirmedAt: string | null;
|
||||
confirmedKeyId: string | null;
|
||||
confirmedFingerprint: string | null;
|
||||
confirmedBy: { id: string; email: string | null } | null;
|
||||
platformHint: KeyBackupPlatform;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key backup step's state. `enabled` lets the dashboard banner ask only
|
||||
* for an admin; the route itself is admin-only and cookie-only.
|
||||
*/
|
||||
export function useKeyBackupStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminEncryptionKeyBackup(),
|
||||
queryFn: () =>
|
||||
apiGet<KeyBackupStatus>("/api/admin/encryption/key-backup", {
|
||||
credentials: "include",
|
||||
}),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { RecoveryKit } from "@/lib/recovery/kit-rules";
|
||||
|
||||
export type {
|
||||
KitKey,
|
||||
KitKeyKind,
|
||||
KitPlatform,
|
||||
RecoveryKit,
|
||||
} from "@/lib/recovery/kit-rules";
|
||||
|
||||
/**
|
||||
* `GET /api/admin/recovery-kit`: key ids and fingerprints only. `enabled` lets
|
||||
* the dashboard banner ask only for an admin; the route itself is admin-only
|
||||
* and cookie-only.
|
||||
*/
|
||||
export function useRecoveryKit(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminRecoveryKit(),
|
||||
queryFn: () =>
|
||||
apiGet<RecoveryKit>("/api/admin/recovery-kit", {
|
||||
credentials: "include",
|
||||
}),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
+12
-11
@@ -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>
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isKeyBackupDue, resolvePlatformHint } from "../key-backup";
|
||||
|
||||
const confirmed = (keyId: string, fingerprint: string) => ({
|
||||
encryptionKeyBackupConfirmedAt: new Date("2026-10-03T10:00:00Z"),
|
||||
encryptionKeyBackupConfirmedKeyId: keyId,
|
||||
encryptionKeyBackupConfirmedFingerprint: fingerprint,
|
||||
});
|
||||
|
||||
describe("key backup step", () => {
|
||||
it("is due without any confirmation", () => {
|
||||
expect(isKeyBackupDue(null, "v1", "abcdefabcdef")).toBe(true);
|
||||
expect(
|
||||
isKeyBackupDue(
|
||||
{
|
||||
encryptionKeyBackupConfirmedAt: null,
|
||||
encryptionKeyBackupConfirmedKeyId: null,
|
||||
encryptionKeyBackupConfirmedFingerprint: null,
|
||||
},
|
||||
"v1",
|
||||
"abcdefabcdef",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is done only for the exact key that was confirmed", () => {
|
||||
expect(
|
||||
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "abcdefabcdef"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is due again after a rotation (new active id)", () => {
|
||||
expect(
|
||||
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v2", "abcdefabcdef"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is due again after a re-keyed install (same id, other key)", () => {
|
||||
expect(
|
||||
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "123456123456"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the platform hint and falls back to compose", () => {
|
||||
expect(resolvePlatformHint("truenas")).toBe("truenas");
|
||||
expect(resolvePlatformHint(" Unraid ")).toBe("unraid");
|
||||
expect(resolvePlatformHint(undefined)).toBe("compose");
|
||||
expect(resolvePlatformHint("kubernetes")).toBe("compose");
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* The "Back up your encryption key" step: instance state and its rules.
|
||||
*
|
||||
* The confirmation lives on the `AppSettings` singleton, bound to the active
|
||||
* key id and that key's fingerprint, so it is an instance fact (it survives
|
||||
* the admin who gave it) and a rotation or a re-keyed install makes the step
|
||||
* due again. Nothing here ever reads, returns or stores the key itself.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { envValue } from "@/lib/env";
|
||||
import { getActiveKeyId, getKeyFingerprint } from "@/lib/crypto";
|
||||
|
||||
/** Where the operator sets the key; picks the tab the step opens on. */
|
||||
export const KEY_BACKUP_PLATFORMS = [
|
||||
"compose",
|
||||
"truenas",
|
||||
"coolify",
|
||||
"unraid",
|
||||
"portainer",
|
||||
] as const;
|
||||
export type KeyBackupPlatform = (typeof KEY_BACKUP_PLATFORMS)[number];
|
||||
|
||||
/** Wire code for a confirmation given for a key that is no longer active. */
|
||||
export const KEY_BACKUP_STALE_CODE = "encryption.keyBackup.stale";
|
||||
|
||||
/**
|
||||
* `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise.
|
||||
* Catalog templates set it so the step opens on the right instructions.
|
||||
*/
|
||||
export function resolvePlatformHint(
|
||||
raw: string | undefined = envValue("HEALTHLOG_PLATFORM"),
|
||||
): KeyBackupPlatform {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
return (KEY_BACKUP_PLATFORMS as readonly string[]).includes(value ?? "")
|
||||
? (value as KeyBackupPlatform)
|
||||
: "compose";
|
||||
}
|
||||
|
||||
export interface KeyBackupStatus {
|
||||
/** True until a confirmation exists for the active key id AND fingerprint. */
|
||||
due: boolean;
|
||||
activeKeyId: string;
|
||||
/** Fingerprint of the active key (first 12 hex of SHA-256 over its bytes). */
|
||||
fingerprint: string;
|
||||
confirmedAt: string | null;
|
||||
confirmedKeyId: string | null;
|
||||
confirmedFingerprint: string | null;
|
||||
/** The confirming admin, while that account exists. */
|
||||
confirmedBy: { id: string; email: string | null } | null;
|
||||
platformHint: KeyBackupPlatform;
|
||||
}
|
||||
|
||||
interface StoredConfirmation {
|
||||
encryptionKeyBackupConfirmedAt: Date | null;
|
||||
encryptionKeyBackupConfirmedKeyId: string | null;
|
||||
encryptionKeyBackupConfirmedFingerprint: string | null;
|
||||
}
|
||||
|
||||
/** Pure rule: is the stored confirmation for this exact key? */
|
||||
export function isKeyBackupDue(
|
||||
stored: StoredConfirmation | null,
|
||||
activeKeyId: string,
|
||||
fingerprint: string,
|
||||
): boolean {
|
||||
return !(
|
||||
stored?.encryptionKeyBackupConfirmedAt &&
|
||||
stored.encryptionKeyBackupConfirmedKeyId === activeKeyId &&
|
||||
stored.encryptionKeyBackupConfirmedFingerprint === fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
function activeKey(): { activeKeyId: string; fingerprint: string } {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
// The active id is always configured, so the fingerprint is never null.
|
||||
return { activeKeyId, fingerprint: getKeyFingerprint(activeKeyId) ?? "" };
|
||||
}
|
||||
|
||||
export async function readKeyBackupStatus(): Promise<KeyBackupStatus> {
|
||||
const { activeKeyId, fingerprint } = activeKey();
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
encryptionKeyBackupConfirmedAt: true,
|
||||
encryptionKeyBackupConfirmedKeyId: true,
|
||||
encryptionKeyBackupConfirmedFingerprint: true,
|
||||
encryptionKeyBackupConfirmedByUserId: true,
|
||||
},
|
||||
});
|
||||
const confirmedBy = row?.encryptionKeyBackupConfirmedByUserId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: row.encryptionKeyBackupConfirmedByUserId },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
due: isKeyBackupDue(row, activeKeyId, fingerprint),
|
||||
activeKeyId,
|
||||
fingerprint,
|
||||
confirmedAt: row?.encryptionKeyBackupConfirmedAt?.toISOString() ?? null,
|
||||
confirmedKeyId: row?.encryptionKeyBackupConfirmedKeyId ?? null,
|
||||
confirmedFingerprint: row?.encryptionKeyBackupConfirmedFingerprint ?? null,
|
||||
confirmedBy: confirmedBy ?? null,
|
||||
platformHint: resolvePlatformHint(),
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfirmKeyBackupResult =
|
||||
| { ok: true; status: KeyBackupStatus }
|
||||
| { ok: false; activeKeyId: string; fingerprint: string };
|
||||
|
||||
/**
|
||||
* Record the confirmation, but only for the key the admin was shown: a page
|
||||
* opened before a rotation must not confirm the new key.
|
||||
*/
|
||||
export async function confirmKeyBackup(input: {
|
||||
keyId: string;
|
||||
fingerprint: string;
|
||||
userId: string;
|
||||
}): Promise<ConfirmKeyBackupResult> {
|
||||
const { activeKeyId, fingerprint } = activeKey();
|
||||
if (input.keyId !== activeKeyId || input.fingerprint !== fingerprint) {
|
||||
return { ok: false, activeKeyId, fingerprint };
|
||||
}
|
||||
const now = new Date();
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
encryptionKeyBackupConfirmedAt: now,
|
||||
encryptionKeyBackupConfirmedKeyId: activeKeyId,
|
||||
encryptionKeyBackupConfirmedFingerprint: fingerprint,
|
||||
encryptionKeyBackupConfirmedByUserId: input.userId,
|
||||
},
|
||||
update: {
|
||||
encryptionKeyBackupConfirmedAt: now,
|
||||
encryptionKeyBackupConfirmedKeyId: activeKeyId,
|
||||
encryptionKeyBackupConfirmedFingerprint: fingerprint,
|
||||
encryptionKeyBackupConfirmedByUserId: input.userId,
|
||||
},
|
||||
});
|
||||
return { ok: true, status: await readKeyBackupStatus() };
|
||||
}
|
||||
@@ -353,6 +353,12 @@ export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
|
||||
HostMetric: "instance-wide host telemetry, not attributable to an account",
|
||||
OffhostBackupKeyUse:
|
||||
"per encryption key, when the nightly off-host run last wrote an object needing it; says nothing about any account",
|
||||
RestoreDrillOutcome:
|
||||
"the monthly restore drill's last verdict for the instance's off-host bucket; says nothing about any account",
|
||||
RecoveryReadinessNotice:
|
||||
"per backup signal, what the admins were last told; a fact about the instance's backups, not about any account",
|
||||
RecoveryKitConfirmation:
|
||||
"the instance's recovery kit confirmation (key ids and fingerprints); the confirming admin is a plain id, not a relation",
|
||||
OffhostPurgeRequest:
|
||||
"the deletions an account deletion or wipe asked of the off-host bucket; written by the wipe itself and removed once the bucket holds nothing of the account",
|
||||
CycleSymptomCategory:
|
||||
|
||||
@@ -23,12 +23,7 @@
|
||||
*/
|
||||
import { envOr } from "@/lib/env";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { createGunzip, createGzip, gunzipSync, gzipSync } from "node:zlib";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
@@ -44,6 +39,7 @@ import {
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { envValue } from "@/lib/env";
|
||||
import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue";
|
||||
import { decodeBackupKey, offhostKeyId } from "./offhost-key";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
@@ -74,17 +70,6 @@ export class OffhostBackupNotConfiguredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBackupKey(raw: string): Buffer {
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, "hex");
|
||||
if (/^[A-Za-z0-9+/=]+$/.test(raw)) {
|
||||
const buf = Buffer.from(raw, "base64");
|
||||
if (buf.length === 32) return buf;
|
||||
}
|
||||
throw new Error(
|
||||
"BACKUP_ENCRYPTION_KEY must be 64 hex chars or 32-byte base64",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this host has the five variables the nightly job needs.
|
||||
*
|
||||
@@ -200,11 +185,6 @@ function asKeyRing(key: Buffer | OffhostKeyRing): OffhostKeyRing {
|
||||
return Buffer.isBuffer(key) ? { active: key, previous: [] } : key;
|
||||
}
|
||||
|
||||
/** The id a version-4 object records for the key it was written under. */
|
||||
export function offhostKeyId(key: Buffer): string {
|
||||
return createHash("sha256").update(key).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function offhostAad(objectKey: string): Buffer {
|
||||
return Buffer.from(`${OFFHOST_AAD_PREFIX}${objectKey}`, "utf8");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The off-host backup key as a value: how the operator writes it and the id
|
||||
* an object records for it. Kept apart from the uploader so the recovery kit
|
||||
* can name the keys without loading the backup machinery.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** One off-host key as the operator writes it: 64 hex or 32-byte base64. */
|
||||
export function decodeBackupKey(raw: string): Buffer {
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, "hex");
|
||||
if (/^[A-Za-z0-9+/=]+$/.test(raw)) {
|
||||
const buf = Buffer.from(raw, "base64");
|
||||
if (buf.length === 32) return buf;
|
||||
}
|
||||
throw new Error(
|
||||
"BACKUP_ENCRYPTION_KEY must be 64 hex chars or 32-byte base64",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The id a version-4 object records for the key it was written under: the
|
||||
* first twelve hex characters of the key's SHA-256. The recovery kit uses the
|
||||
* same value as the key's fingerprint, so a kit entry matches an object header.
|
||||
*/
|
||||
export function offhostKeyId(key: Buffer): string {
|
||||
return createHash("sha256").update(key).digest("hex").slice(0, 12);
|
||||
}
|
||||
@@ -96,11 +96,13 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
|
||||
// `client_secret` are already covered by `/token/i` + `/secret/i`; the
|
||||
// verifier is the one secret-shaped key those patterns miss.
|
||||
/verifier/i,
|
||||
// v1.40.0 — the encryption key an admin pastes into "Check my copy"
|
||||
// (`POST /api/admin/encryption/key-backup/verify`). The route never logs its
|
||||
// body; this keeps it out of any excerpt a future change might take.
|
||||
// Anchored so `encryptionKeyId` and friends stay readable.
|
||||
// v1.40.0 — the encryption key an admin pastes into "Check my copy", and the
|
||||
// copy of any recovery kit key, sent as `keyCopy`
|
||||
// (`POST /api/admin/recovery-kit/verify`). The route never logs its body;
|
||||
// this keeps it out of any excerpt a future change might take. Anchored so
|
||||
// `encryptionKeyId` and friends stay readable.
|
||||
/^encryption[_-]?key$/i,
|
||||
/^key[_-]?copy$/i,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -55,9 +55,10 @@ export const adminKeys = {
|
||||
["admin", "maintenance", "measurements"] as const,
|
||||
/** v1.43 — the pulse day-mean method, its dry run and apply step. */
|
||||
adminPulseDayMean: () => ["admin", "maintenance", "pulse-day-mean"] as const,
|
||||
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
|
||||
adminEncryptionKeyBackup: () =>
|
||||
["admin", "encryption", "key-backup"] as const,
|
||||
/** The recovery kit (admin Encryption section + dashboard banner). */
|
||||
adminRecoveryKit: () => ["admin", "recovery-kit"] as const,
|
||||
/** Every backup signal's verdict (admin overview + Backups). */
|
||||
adminRecoveryReadiness: () => ["admin", "recovery-readiness"] as const,
|
||||
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
|
||||
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
|
||||
adminFeedbackRoot: () => ["admin", "feedback"] as const,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* The recovery kit rules: which keys the kit lists, what a confirmation
|
||||
* covers, and when the kit is due again.
|
||||
*
|
||||
* Mutation check: compare fingerprints out of the covered test (id only) and
|
||||
* the re-keyed-install case goes red; drop the backup needs from the
|
||||
* application list and the retired-key case goes red.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { offhostKeyId } from "@/lib/jobs/offhost-key";
|
||||
import type { BackupKeyNeeds } from "@/lib/recovery/backup-key-needs";
|
||||
import {
|
||||
applicationKitKeys,
|
||||
assessRecoveryKit,
|
||||
backupKitKeys,
|
||||
fingerprintText,
|
||||
neededKeyIds,
|
||||
parseKitEntries,
|
||||
resolvePlatformHint,
|
||||
sameKit,
|
||||
tokenKitKeys,
|
||||
type KitEntry,
|
||||
} from "@/lib/recovery/kit-rules";
|
||||
|
||||
const NOW = new Date("2026-10-25T12:00:00Z");
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const NO_NEEDS: BackupKeyNeeds = {
|
||||
stored: [],
|
||||
unrecorded: { copies: 0, oldestAt: null },
|
||||
offhost: [],
|
||||
offhostExpirationDays: null,
|
||||
retiredKeysStillNeeded: [],
|
||||
};
|
||||
|
||||
const FINGERPRINTS: Record<string, string> = {
|
||||
v1: "111111111111",
|
||||
v2: "222222222222",
|
||||
};
|
||||
|
||||
function appKeys(configured: string[], active: string, needs = NO_NEEDS) {
|
||||
return applicationKitKeys({
|
||||
activeKeyId: active,
|
||||
configuredKeyIds: configured,
|
||||
fingerprintOf: (id) => (configured.includes(id) ? FINGERPRINTS[id] : null),
|
||||
needs,
|
||||
now: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
const BACKUP_HEX = "ab".repeat(32);
|
||||
const BACKUP_OLD_HEX = "cd".repeat(32);
|
||||
|
||||
describe("which keys the kit lists", () => {
|
||||
it("lists every configured application key, the active one marked", () => {
|
||||
const keys = appKeys(["v1", "v2"], "v2");
|
||||
expect(keys.map((k) => [k.id, k.need.active, k.fingerprint])).toEqual([
|
||||
["v1", false, "111111111111"],
|
||||
["v2", true, "222222222222"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a retired key a stored copy lists, even once the server dropped it", () => {
|
||||
const needs: BackupKeyNeeds = {
|
||||
...NO_NEEDS,
|
||||
stored: [{ keyId: "v1", copies: 3, oldestAt: NOW.toISOString() }],
|
||||
};
|
||||
const [v1] = appKeys(["v2"], "v2", needs);
|
||||
expect(v1).toMatchObject({
|
||||
id: "v1",
|
||||
fingerprint: null,
|
||||
problem: "not_configured",
|
||||
need: { configured: false, storedCopies: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an off-host key while objects inside the retention need it", () => {
|
||||
const row = (neededUntil: string | null) => ({
|
||||
...NO_NEEDS,
|
||||
offhost: [
|
||||
{
|
||||
keyId: "v1",
|
||||
firstWrittenAt: NOW.toISOString(),
|
||||
lastWrittenAt: NOW.toISOString(),
|
||||
neededUntil,
|
||||
},
|
||||
],
|
||||
});
|
||||
const until = new Date(NOW.getTime() + 10 * DAY).toISOString();
|
||||
expect(appKeys(["v2"], "v2", row(until))[0].need.offhostUntil).toBe(until);
|
||||
expect(appKeys(["v2"], "v2", row(null))[0].need.offhostUntil).toBe(
|
||||
"unknown",
|
||||
);
|
||||
// Past the retention the objects are gone, so the key drops out.
|
||||
const past = new Date(NOW.getTime() - DAY).toISOString();
|
||||
expect(appKeys(["v2"], "v2", row(past)).map((k) => k.id)).toEqual(["v2"]);
|
||||
expect([...neededKeyIds(row(past), NOW)]).toEqual([]);
|
||||
});
|
||||
|
||||
it("names the off-host keys by the id their objects record", () => {
|
||||
const keys = backupKitKeys(BACKUP_HEX, ` ${BACKUP_OLD_HEX} ,`);
|
||||
expect(keys.map((k) => [k.id, k.need.active, k.source])).toEqual([
|
||||
[
|
||||
offhostKeyId(Buffer.from(BACKUP_HEX, "hex")),
|
||||
true,
|
||||
"BACKUP_ENCRYPTION_KEY",
|
||||
],
|
||||
[
|
||||
offhostKeyId(Buffer.from(BACKUP_OLD_HEX, "hex")),
|
||||
false,
|
||||
"BACKUP_ENCRYPTION_PREVIOUS_KEYS",
|
||||
],
|
||||
]);
|
||||
expect(keys.every((k) => k.fingerprint === k.id)).toBe(true);
|
||||
expect(backupKitKeys(undefined, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags an off-host key that does not decode instead of hiding it", () => {
|
||||
const [bad] = backupKitKeys("not-a-key", undefined);
|
||||
expect(bad).toMatchObject({ fingerprint: null, problem: "unreadable" });
|
||||
});
|
||||
|
||||
it("fingerprints the token key as the text the operator set", () => {
|
||||
const value = "x".repeat(40);
|
||||
const [token] = tokenKitKeys(value);
|
||||
expect(token.fingerprint).toBe(
|
||||
createHash("sha256").update(value).digest("hex").slice(0, 12),
|
||||
);
|
||||
expect(token.fingerprint).toBe(fingerprintText(value));
|
||||
expect(JSON.stringify(token)).not.toContain(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a confirmation covers", () => {
|
||||
const listed = [...appKeys(["v1"], "v1"), ...tokenKitKeys("t".repeat(40))];
|
||||
const entries = (keys: typeof listed): KitEntry[] =>
|
||||
keys.map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint }));
|
||||
|
||||
it("is due without any confirmation, and done for the exact set", () => {
|
||||
expect(assessRecoveryKit(listed, null).due).toBe(true);
|
||||
expect(assessRecoveryKit(listed, entries(listed)).due).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a confirmation of the active key only, from the old step, for that key", () => {
|
||||
const legacy: KitEntry[] = [
|
||||
{ kind: "application", id: "v1", fingerprint: "111111111111" },
|
||||
];
|
||||
const verdict = assessRecoveryKit(listed, legacy);
|
||||
expect(verdict.due).toBe(true);
|
||||
expect(verdict.keys.find((k) => k.id === "v1")?.confirmed).toBe(true);
|
||||
expect(verdict.unconfirmed.map((k) => k.id)).toEqual([
|
||||
"API_TOKEN_HMAC_KEY",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is due again after a rotation, a re-keyed install or a new backup key", () => {
|
||||
const confirmed = entries(listed);
|
||||
// Rotation: a new active id.
|
||||
const rotated = [
|
||||
...appKeys(["v1", "v2"], "v2"),
|
||||
...tokenKitKeys("t".repeat(40)),
|
||||
];
|
||||
expect(
|
||||
assessRecoveryKit(rotated, confirmed).unconfirmed.map((k) => k.id),
|
||||
).toEqual(["v2"]);
|
||||
// Same id, other key.
|
||||
const rekeyed = listed.map((k) =>
|
||||
k.id === "v1" ? { ...k, fingerprint: "999999999999" } : k,
|
||||
);
|
||||
expect(assessRecoveryKit(rekeyed, confirmed).due).toBe(true);
|
||||
// A backup key appears.
|
||||
const withBackup = [...listed, ...backupKitKeys(BACKUP_HEX, undefined)];
|
||||
expect(assessRecoveryKit(withBackup, confirmed).unconfirmed).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("is not due when a key merely drops out of the kit", () => {
|
||||
const wider = [
|
||||
...appKeys(["v1", "v2"], "v2"),
|
||||
...tokenKitKeys("t".repeat(40)),
|
||||
];
|
||||
const narrower = [
|
||||
...appKeys(["v2"], "v2"),
|
||||
...tokenKitKeys("t".repeat(40)),
|
||||
];
|
||||
expect(assessRecoveryKit(narrower, entries(wider)).due).toBe(false);
|
||||
});
|
||||
|
||||
it("covers a key this server does not hold by its id", () => {
|
||||
const needs: BackupKeyNeeds = {
|
||||
...NO_NEEDS,
|
||||
stored: [{ keyId: "v0", copies: 1, oldestAt: NOW.toISOString() }],
|
||||
};
|
||||
const kit = appKeys(["v1"], "v1", needs);
|
||||
const confirmed = entries(kit);
|
||||
expect(confirmed.find((e) => e.id === "v0")?.fingerprint).toBeNull();
|
||||
expect(assessRecoveryKit(kit, confirmed).due).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the shown kit against the current one as a set", () => {
|
||||
const a = entries(listed);
|
||||
expect(sameKit([...a].reverse(), a)).toBe(true);
|
||||
expect(sameKit(a.slice(1), a)).toBe(false);
|
||||
expect(
|
||||
sameKit(
|
||||
a.map((e) =>
|
||||
e.id === "v1" ? { ...e, fingerprint: "000000000000" } : e,
|
||||
),
|
||||
a,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a stored confirmation it cannot parse as none", () => {
|
||||
expect(parseKitEntries({ not: "a list" })).toEqual([]);
|
||||
expect(
|
||||
parseKitEntries([{ kind: "nope", id: "v1", fingerprint: null }]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform hint", () => {
|
||||
it("opens on a known platform and falls back to compose", () => {
|
||||
expect(resolvePlatformHint("TrueNAS")).toBe("truenas");
|
||||
expect(resolvePlatformHint("kubernetes")).toBe("compose");
|
||||
expect(resolvePlatformHint(undefined)).toBe("compose");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Which application keys the backups still need.
|
||||
*
|
||||
* Rotation re-encrypts every row in the database and re-seals every stored
|
||||
* backup's envelope, but the content INSIDE a backup keeps the key it was
|
||||
* written under: a restore writes it back verbatim. So "every row is on the
|
||||
* active key" is not the same as "the old key can go". This answers the second
|
||||
* question per key id, from what each copy recorded as it was written: the
|
||||
* stored copies that need it and the oldest of them, and for the off-host
|
||||
* bucket the last night an object needing it went in, plus how long the
|
||||
* bucket's lifecycle rule keeps objects after that.
|
||||
*
|
||||
* Two readers ask it: the admin encryption view, which shows the table, and
|
||||
* the recovery kit, which lists every key a restore needs. One definition, so
|
||||
* the kit cannot drop a key the encryption view still calls needed.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import { neededKeyIds } from "@/lib/recovery/kit-rules";
|
||||
|
||||
export interface BackupKeyNeeds {
|
||||
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
|
||||
/** Copies written before v1.39.3, which recorded no key ids. */
|
||||
unrecorded: { copies: number; oldestAt: string | null };
|
||||
offhost: Array<{
|
||||
keyId: string;
|
||||
firstWrittenAt: string;
|
||||
lastWrittenAt: string;
|
||||
/** When the last object needing this key expires; null when unknown. */
|
||||
neededUntil: string | null;
|
||||
}>;
|
||||
/** The bucket's expiry, when it has one the credential may read. */
|
||||
offhostExpirationDays: number | null;
|
||||
/** Retired key ids some backup still needs (never the active one). */
|
||||
retiredKeysStillNeeded: string[];
|
||||
}
|
||||
|
||||
export async function readBackupKeyNeeds(
|
||||
activeKeyId: string,
|
||||
now: Date,
|
||||
): Promise<BackupKeyNeeds> {
|
||||
const [stored, unrecorded, offhostRows] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
Array<{ key_id: string; copies: number; oldest_at: Date }>
|
||||
>`
|
||||
SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at
|
||||
FROM data_backups, unnest(inner_key_ids) AS k
|
||||
WHERE inner_key_ids_recorded
|
||||
GROUP BY k
|
||||
ORDER BY k
|
||||
`,
|
||||
prisma.dataBackup.aggregate({
|
||||
where: { innerKeyIdsRecorded: false },
|
||||
_count: { _all: true },
|
||||
_min: { createdAt: true },
|
||||
}),
|
||||
prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }),
|
||||
]);
|
||||
const lifecycle =
|
||||
offhostRows.length > 0 && offhostBackupConfigured()
|
||||
? await probeOffhostLifecycle()
|
||||
: null;
|
||||
const expirationDays =
|
||||
lifecycle?.state === "configured" ? lifecycle.expirationDays : null;
|
||||
|
||||
const offhost = offhostRows.map((row) => ({
|
||||
keyId: row.keyId,
|
||||
firstWrittenAt: row.firstWrittenAt.toISOString(),
|
||||
lastWrittenAt: row.lastWrittenAt.toISOString(),
|
||||
neededUntil:
|
||||
expirationDays === null
|
||||
? null
|
||||
: new Date(
|
||||
row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
const needs: BackupKeyNeeds = {
|
||||
stored: stored.map((row) => ({
|
||||
keyId: row.key_id,
|
||||
copies: row.copies,
|
||||
oldestAt: row.oldest_at.toISOString(),
|
||||
})),
|
||||
unrecorded: {
|
||||
copies: unrecorded._count._all,
|
||||
oldestAt: unrecorded._min.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
offhost,
|
||||
offhostExpirationDays: expirationDays,
|
||||
retiredKeysStillNeeded: [],
|
||||
};
|
||||
const retired = neededKeyIds(needs, now);
|
||||
retired.delete(activeKeyId);
|
||||
needs.retiredKeysStillNeeded = [...retired].sort();
|
||||
return needs;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* The recovery kit: every key a restore of this instance needs, by id and
|
||||
* fingerprint, and the admin's confirmation that a copy of each is kept
|
||||
* outside the server.
|
||||
*
|
||||
* It replaced a step that confirmed one key, the active application key. An
|
||||
* operator could do everything that step asked and still be unable to read a
|
||||
* single backup: the 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`. The kit lists all of them:
|
||||
*
|
||||
* - `application`: every id in `ENCRYPTION_KEYS` this server holds, plus
|
||||
* every retired id a stored copy or an off-host object inside the
|
||||
* bucket's retention still lists (`backup-key-needs.ts`);
|
||||
* - `backup`: `BACKUP_ENCRYPTION_KEY` and each key in
|
||||
* `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when set;
|
||||
* - `token`: `API_TOKEN_HMAC_KEY`.
|
||||
*
|
||||
* The confirmation records the set the admin was shown. The kit is due again
|
||||
* as soon as a key it needs is not in that set: a rotation, a new backup key,
|
||||
* a re-keyed install. A key that drops out of the kit (a retired key whose
|
||||
* last copy expired) does not make it due, because nothing is missing.
|
||||
*
|
||||
* Fingerprints only, never key material: the first twelve hex characters of
|
||||
* SHA-256 over the key, the same rule each key's own surface already uses (the
|
||||
* application key's boot check, the off-host object header). The token key is
|
||||
* hashed as the text the operator set, so `printf %s "$API_TOKEN_HMAC_KEY" |
|
||||
* sha256sum` reproduces it.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { envValue } from "@/lib/env";
|
||||
import { decodeBackupKey, offhostKeyId } from "@/lib/jobs/offhost-key";
|
||||
import type { BackupKeyNeeds } from "@/lib/recovery/backup-key-needs";
|
||||
|
||||
/** Where the operator sets the keys; picks the tab the kit opens on. */
|
||||
export const KIT_PLATFORMS = [
|
||||
"compose",
|
||||
"truenas",
|
||||
"coolify",
|
||||
"unraid",
|
||||
"portainer",
|
||||
] as const;
|
||||
export type KitPlatform = (typeof KIT_PLATFORMS)[number];
|
||||
|
||||
/** Wire code for a confirmation of a kit that has changed since it was shown. */
|
||||
export const RECOVERY_KIT_STALE_CODE = "recovery.kit.stale";
|
||||
|
||||
export const KIT_KEY_KINDS = ["application", "backup", "token"] as const;
|
||||
export type KitKeyKind = (typeof KIT_KEY_KINDS)[number];
|
||||
|
||||
/** The variable each kind of key is set in. */
|
||||
export const KIT_KEY_SOURCES = {
|
||||
application: "ENCRYPTION_KEYS",
|
||||
backup: "BACKUP_ENCRYPTION_KEY",
|
||||
backupPrevious: "BACKUP_ENCRYPTION_PREVIOUS_KEYS",
|
||||
token: "API_TOKEN_HMAC_KEY",
|
||||
} as const;
|
||||
export type KitKeySource =
|
||||
(typeof KIT_KEY_SOURCES)[keyof typeof KIT_KEY_SOURCES];
|
||||
|
||||
/** One key as a confirmation stores it. */
|
||||
export interface KitEntry {
|
||||
kind: KitKeyKind;
|
||||
id: string;
|
||||
/** Null for a key this server does not hold but a copy still needs. */
|
||||
fingerprint: string | null;
|
||||
}
|
||||
|
||||
/** Why the kit lists a key. */
|
||||
export interface KitKeyNeed {
|
||||
/** The key new data is written under. */
|
||||
active: boolean;
|
||||
/** This server holds the key. */
|
||||
configured: boolean;
|
||||
/** Stored copies in this database whose content needs it. */
|
||||
storedCopies: number;
|
||||
/**
|
||||
* Off-host objects need it until this instant; `"unknown"` when the bucket's
|
||||
* retention cannot be read, so they may need it indefinitely.
|
||||
*/
|
||||
offhostUntil: string | null;
|
||||
}
|
||||
|
||||
/** One key the kit lists, with its verdict. */
|
||||
export interface KitKey extends KitEntry {
|
||||
source: KitKeySource;
|
||||
need: KitKeyNeed;
|
||||
/** The confirmation covers this key. */
|
||||
confirmed: boolean;
|
||||
/**
|
||||
* `not_configured`: a copy needs it and this server does not hold it, so
|
||||
* only a copy kept elsewhere can read those backups. `unreadable`: the
|
||||
* variable is set but does not decode as a key.
|
||||
*/
|
||||
problem: "not_configured" | "unreadable" | null;
|
||||
}
|
||||
|
||||
export interface RecoveryKit {
|
||||
keys: KitKey[];
|
||||
/** A key the kit lists is not covered by the confirmation. */
|
||||
due: boolean;
|
||||
/** Keys the confirmation does not cover, by kind and id. */
|
||||
unconfirmed: Array<Pick<KitEntry, "kind" | "id">>;
|
||||
confirmedAt: string | null;
|
||||
/** The confirming admin, while that account exists. */
|
||||
confirmedBy: { id: string; email: string | null } | null;
|
||||
platformHint: KitPlatform;
|
||||
}
|
||||
|
||||
/**
|
||||
* `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise.
|
||||
* Catalog templates set it so the kit opens on the right instructions.
|
||||
*/
|
||||
export function resolvePlatformHint(
|
||||
raw: string | undefined = envValue("HEALTHLOG_PLATFORM"),
|
||||
): KitPlatform {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
return (KIT_PLATFORMS as readonly string[]).includes(value ?? "")
|
||||
? (value as KitPlatform)
|
||||
: "compose";
|
||||
}
|
||||
|
||||
/** The fingerprint rule for a key that is text rather than decoded bytes. */
|
||||
export function fingerprintText(value: string): string {
|
||||
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
const kitEntrySchema = z.object({
|
||||
kind: z.enum(KIT_KEY_KINDS),
|
||||
id: z.string().min(1).max(64),
|
||||
fingerprint: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{12}$/)
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/** The request body of a confirmation: the kit as the admin saw it. */
|
||||
export const kitConfirmationSchema = z
|
||||
.object({ entries: z.array(kitEntrySchema).min(1).max(64) })
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The stored entries, validated. A row that does not parse confirms nothing,
|
||||
* which leaves the kit due rather than trusting a shape it cannot read.
|
||||
*/
|
||||
export function parseKitEntries(raw: unknown): KitEntry[] {
|
||||
const parsed = z.array(kitEntrySchema).safeParse(raw);
|
||||
return parsed.success ? parsed.data : [];
|
||||
}
|
||||
|
||||
const entryKey = (e: Pick<KitEntry, "kind" | "id">) => `${e.kind}:${e.id}`;
|
||||
|
||||
/**
|
||||
* Pure rule: which listed keys a confirmation covers.
|
||||
*
|
||||
* A key this server holds is covered by an entry with the same kind, id and
|
||||
* fingerprint, so a re-keyed install under the same id is due again. A key it
|
||||
* does not hold has no fingerprint to compare, so an entry with its kind and id
|
||||
* covers it: the admin said a copy of that id is kept.
|
||||
*/
|
||||
export function assessRecoveryKit(
|
||||
listed: ReadonlyArray<Omit<KitKey, "confirmed">>,
|
||||
confirmation: readonly KitEntry[] | null,
|
||||
): { keys: KitKey[]; due: boolean; unconfirmed: KitEntry[] } {
|
||||
const exact = new Set(
|
||||
(confirmation ?? []).map((e) => `${entryKey(e)}:${e.fingerprint ?? ""}`),
|
||||
);
|
||||
const byId = new Set((confirmation ?? []).map(entryKey));
|
||||
const keys = listed.map((key) => ({
|
||||
...key,
|
||||
confirmed:
|
||||
key.fingerprint === null
|
||||
? byId.has(entryKey(key))
|
||||
: exact.has(`${entryKey(key)}:${key.fingerprint}`),
|
||||
}));
|
||||
const unconfirmed = keys
|
||||
.filter((key) => !key.confirmed)
|
||||
.map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint }));
|
||||
return { keys, due: unconfirmed.length > 0, unconfirmed };
|
||||
}
|
||||
|
||||
/** Pure rule: does what the admin was shown match what the kit lists now? */
|
||||
export function sameKit(
|
||||
shown: readonly KitEntry[],
|
||||
listed: readonly KitEntry[],
|
||||
): boolean {
|
||||
const canon = (entries: readonly KitEntry[]) =>
|
||||
[
|
||||
...new Set(entries.map((e) => `${entryKey(e)}:${e.fingerprint ?? ""}`)),
|
||||
].sort();
|
||||
const a = canon(shown);
|
||||
const b = canon(listed);
|
||||
return a.length === b.length && a.every((value, i) => value === b[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key id a stored copy lists, and every key id an off-host object still
|
||||
* inside the bucket's retention needs. An off-host key whose expiry cannot be
|
||||
* read stays needed: without the lifecycle rule nothing proves the objects are
|
||||
* gone.
|
||||
*/
|
||||
export function neededKeyIds(needs: BackupKeyNeeds, now: Date): Set<string> {
|
||||
const needed = new Set<string>();
|
||||
for (const row of needs.stored) needed.add(row.keyId);
|
||||
for (const row of needs.offhost) {
|
||||
if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) {
|
||||
needed.add(row.keyId);
|
||||
}
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: the application keys the kit lists, from the ids this server holds
|
||||
* and what the backups still need.
|
||||
*/
|
||||
export function applicationKitKeys(args: {
|
||||
activeKeyId: string;
|
||||
configuredKeyIds: readonly string[];
|
||||
fingerprintOf: (id: string) => string | null;
|
||||
needs: BackupKeyNeeds;
|
||||
now: Date;
|
||||
}): Array<Omit<KitKey, "confirmed">> {
|
||||
const needed = neededKeyIds(args.needs, args.now);
|
||||
const ids = new Set([...args.configuredKeyIds, ...needed]);
|
||||
return [...ids].sort().map((id) => {
|
||||
const fingerprint = args.fingerprintOf(id);
|
||||
const offhost = args.needs.offhost.find((row) => row.keyId === id);
|
||||
return {
|
||||
kind: "application" as const,
|
||||
id,
|
||||
fingerprint,
|
||||
source: KIT_KEY_SOURCES.application,
|
||||
need: {
|
||||
active: id === args.activeKeyId,
|
||||
configured: fingerprint !== null,
|
||||
storedCopies:
|
||||
args.needs.stored.find((row) => row.keyId === id)?.copies ?? 0,
|
||||
offhostUntil:
|
||||
offhost && needed.has(id) ? (offhost.neededUntil ?? "unknown") : null,
|
||||
},
|
||||
problem: fingerprint === null ? ("not_configured" as const) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function noNeed(active: boolean): KitKeyNeed {
|
||||
return { active, configured: true, storedCopies: 0, offhostUntil: null };
|
||||
}
|
||||
|
||||
/** Pure: the off-host keys, from the two variables as the operator set them. */
|
||||
export function backupKitKeys(
|
||||
activeRaw: string | undefined,
|
||||
previousRaw: string | undefined,
|
||||
): Array<Omit<KitKey, "confirmed">> {
|
||||
const out: Array<Omit<KitKey, "confirmed">> = [];
|
||||
const add = (raw: string, source: KitKeySource, active: boolean) => {
|
||||
let fingerprint: string | null = null;
|
||||
try {
|
||||
fingerprint = offhostKeyId(decodeBackupKey(raw));
|
||||
} catch {
|
||||
fingerprint = null;
|
||||
}
|
||||
out.push({
|
||||
kind: "backup",
|
||||
// The fingerprint is the id off-host objects record for their key, so
|
||||
// the operator matches a kit entry to an object header directly. A key
|
||||
// that does not decode has neither; it is named by where it is set.
|
||||
id: fingerprint ?? `${source}#${out.length + 1}`,
|
||||
fingerprint,
|
||||
source,
|
||||
need: noNeed(active),
|
||||
problem: fingerprint === null ? "unreadable" : null,
|
||||
});
|
||||
};
|
||||
const active = activeRaw?.trim();
|
||||
if (active) add(active, KIT_KEY_SOURCES.backup, true);
|
||||
for (const raw of (previousRaw ?? "").split(",")) {
|
||||
if (raw.trim()) add(raw.trim(), KIT_KEY_SOURCES.backupPrevious, false);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pure: the token key, which the app cannot run without. */
|
||||
export function tokenKitKeys(
|
||||
raw: string | undefined,
|
||||
): Array<Omit<KitKey, "confirmed">> {
|
||||
const value = raw ?? "";
|
||||
return [
|
||||
{
|
||||
kind: "token",
|
||||
id: KIT_KEY_SOURCES.token,
|
||||
fingerprint: value ? fingerprintText(value) : null,
|
||||
source: KIT_KEY_SOURCES.token,
|
||||
need: { ...noNeed(true), configured: value.length > 0 },
|
||||
problem: value ? null : "not_configured",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* The recovery kit on this server: which keys it lists right now, what the
|
||||
* stored confirmation covers, recording a confirmation, and "Check my copy".
|
||||
* The rules themselves are pure and live in `kit-rules.ts`.
|
||||
*/
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { envValue } from "@/lib/env";
|
||||
import {
|
||||
candidateMatchesKey,
|
||||
getActiveKeyId,
|
||||
getConfiguredKeyIds,
|
||||
getKeyFingerprint,
|
||||
} from "@/lib/crypto";
|
||||
import { decodeBackupKey, offhostKeyId } from "@/lib/jobs/offhost-key";
|
||||
import { readBackupKeyNeeds } from "@/lib/recovery/backup-key-needs";
|
||||
import {
|
||||
applicationKitKeys,
|
||||
assessRecoveryKit,
|
||||
backupKitKeys,
|
||||
KIT_KEY_SOURCES,
|
||||
parseKitEntries,
|
||||
resolvePlatformHint,
|
||||
sameKit,
|
||||
tokenKitKeys,
|
||||
type KitEntry,
|
||||
type KitKey,
|
||||
type KitKeyKind,
|
||||
type RecoveryKit,
|
||||
} from "@/lib/recovery/kit-rules";
|
||||
|
||||
/** Every key the kit lists on this server right now, without a verdict. */
|
||||
export async function listKitKeys(
|
||||
now: Date = new Date(),
|
||||
): Promise<Array<Omit<KitKey, "confirmed">>> {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const needs = await readBackupKeyNeeds(activeKeyId, now);
|
||||
return [
|
||||
...applicationKitKeys({
|
||||
activeKeyId,
|
||||
configuredKeyIds: getConfiguredKeyIds(),
|
||||
fingerprintOf: getKeyFingerprint,
|
||||
needs,
|
||||
now,
|
||||
}),
|
||||
...backupKitKeys(
|
||||
envValue("BACKUP_ENCRYPTION_KEY"),
|
||||
envValue("BACKUP_ENCRYPTION_PREVIOUS_KEYS"),
|
||||
),
|
||||
...tokenKitKeys(envValue("API_TOKEN_HMAC_KEY")),
|
||||
];
|
||||
}
|
||||
|
||||
export async function readRecoveryKit(
|
||||
now: Date = new Date(),
|
||||
): Promise<RecoveryKit> {
|
||||
const [listed, row] = await Promise.all([
|
||||
listKitKeys(now),
|
||||
prisma.recoveryKitConfirmation.findUnique({
|
||||
where: { id: "singleton" },
|
||||
}),
|
||||
]);
|
||||
const verdict = assessRecoveryKit(
|
||||
listed,
|
||||
row ? parseKitEntries(row.entries) : null,
|
||||
);
|
||||
const confirmedBy = row?.confirmedByUserId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: row.confirmedByUserId },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
keys: verdict.keys,
|
||||
due: verdict.due,
|
||||
unconfirmed: verdict.unconfirmed.map(({ kind, id }) => ({ kind, id })),
|
||||
confirmedAt: row?.confirmedAt.toISOString() ?? null,
|
||||
confirmedBy: confirmedBy ?? null,
|
||||
platformHint: resolvePlatformHint(),
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfirmRecoveryKitResult =
|
||||
{ ok: true; kit: RecoveryKit } | { ok: false; kit: RecoveryKit };
|
||||
|
||||
/**
|
||||
* Record the confirmation, but only for the kit the admin was shown: a page
|
||||
* opened before a rotation or a new backup key must not confirm a key the
|
||||
* admin never saw.
|
||||
*/
|
||||
export async function confirmRecoveryKit(input: {
|
||||
shown: readonly KitEntry[];
|
||||
userId: string;
|
||||
now?: Date;
|
||||
}): Promise<ConfirmRecoveryKitResult> {
|
||||
const now = input.now ?? new Date();
|
||||
const listed = await listKitKeys(now);
|
||||
const entries: KitEntry[] = listed.map(({ kind, id, fingerprint }) => ({
|
||||
kind,
|
||||
id,
|
||||
fingerprint,
|
||||
}));
|
||||
if (!sameKit(input.shown, entries)) {
|
||||
return { ok: false, kit: await readRecoveryKit(now) };
|
||||
}
|
||||
const data = {
|
||||
confirmedAt: now,
|
||||
confirmedByUserId: input.userId,
|
||||
entries: entries.map(({ kind, id, fingerprint }) => ({
|
||||
kind,
|
||||
id,
|
||||
fingerprint,
|
||||
})),
|
||||
};
|
||||
await prisma.recoveryKitConfirmation.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", ...data },
|
||||
update: data,
|
||||
});
|
||||
return { ok: true, kit: await readRecoveryKit(now) };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Check my copy": does `value` decode to the key the kit lists as
|
||||
* `kind`/`id` on this server? Constant time on the compared bytes; a value
|
||||
* that does not decode is simply not a match.
|
||||
*/
|
||||
export function verifyKitKey(input: {
|
||||
kind: KitKeyKind;
|
||||
id: string;
|
||||
value: string;
|
||||
}): boolean {
|
||||
const value = input.value.trim();
|
||||
switch (input.kind) {
|
||||
case "application":
|
||||
return candidateMatchesKey(value, input.id);
|
||||
case "backup": {
|
||||
let candidate: Buffer;
|
||||
try {
|
||||
candidate = decodeBackupKey(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const configured = [
|
||||
envValue("BACKUP_ENCRYPTION_KEY"),
|
||||
...(envValue("BACKUP_ENCRYPTION_PREVIOUS_KEYS") ?? "").split(","),
|
||||
]
|
||||
.map((raw) => raw?.trim() ?? "")
|
||||
.filter((raw) => raw.length > 0)
|
||||
.flatMap((raw) => {
|
||||
try {
|
||||
return [decodeBackupKey(raw)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.find((key) => offhostKeyId(key) === input.id);
|
||||
return (
|
||||
configured !== undefined &&
|
||||
configured.length === candidate.length &&
|
||||
timingSafeEqual(configured, candidate)
|
||||
);
|
||||
}
|
||||
case "token": {
|
||||
const configured = envValue("API_TOKEN_HMAC_KEY");
|
||||
if (!configured || input.id !== KIT_KEY_SOURCES.token) return false;
|
||||
// Digests, so the comparison is constant time whatever the lengths.
|
||||
const digest = (s: string) => createHash("sha256").update(s).digest();
|
||||
return timingSafeEqual(digest(value), digest(configured));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user