mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22: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
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user