mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Every configured key id now has a canary in encryption_key_canaries, checked at boot. A key that cannot open its canary, or existing data written before the canary existed, puts the process in a refusal state: every API route answers 503 encryption.key_mismatch, /api/health reports reason encryption_key_mismatch, the pages render one explanation, and neither the queue producer nor the worker starts. The process stays up so the reason is visible instead of hiding in a crash loop. The admin Encryption page gains a "Back up your encryption key" step: key id and fingerprint (never the key), where the key lives per platform, a check of a pasted copy that is compared and discarded, and a confirmation bound to the active key id and fingerprint. The overview gets a tile and admins get a dashboard banner while it is due. HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the TrueNAS self-hosting page and key backup sections in the docs.
250 lines
8.1 KiB
TypeScript
250 lines
8.1 KiB
TypeScript
/**
|
|
* GET /api/admin/encryption/status
|
|
*
|
|
* v1.23 — read-only admin view of encryption coverage + rotation progress.
|
|
* Buckets every registered encrypted column's rows by key id (the same scan the
|
|
* rotation tooling does), so an operator can SEE whether a rotation finished
|
|
* before dropping a legacy key, instead of guessing.
|
|
*
|
|
* Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW
|
|
* COUNTS — never key material. The configured-key set is surfaced as a count.
|
|
*
|
|
* Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The
|
|
* scan is on-demand with a short in-process cache so repeated panel renders
|
|
* don't re-scan the corpus each time; the rotation run state is read from the
|
|
* audit trail (DB-backed, so it is correct across the web/worker processes).
|
|
*/
|
|
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 {
|
|
scanCorpus,
|
|
type CorpusClient,
|
|
type CorpusScan,
|
|
} from "@/lib/crypto/encryption-corpus";
|
|
|
|
const SCAN_CACHE_TTL_MS = 15_000;
|
|
let scanCache: { at: number; scan: CorpusScan } | null = null;
|
|
|
|
interface RotationState {
|
|
state: "idle" | "running" | "completed" | "failed";
|
|
lastRequestedAt: string | null;
|
|
lastCompletedAt: string | null;
|
|
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
|
}
|
|
|
|
async function readRotationState(): Promise<RotationState> {
|
|
const rows = await prisma.auditLog.findMany({
|
|
where: {
|
|
action: {
|
|
in: [
|
|
"admin.encryption.rotate.requested",
|
|
"admin.encryption.rotate.completed",
|
|
"admin.encryption.rotate.failed",
|
|
],
|
|
},
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: 10,
|
|
select: { action: true, createdAt: true, details: true },
|
|
});
|
|
|
|
const requested = rows.find(
|
|
(r) => r.action === "admin.encryption.rotate.requested",
|
|
);
|
|
const finished = rows.find(
|
|
(r) =>
|
|
r.action === "admin.encryption.rotate.completed" ||
|
|
r.action === "admin.encryption.rotate.failed",
|
|
);
|
|
|
|
let lastResult: RotationState["lastResult"] = null;
|
|
if (
|
|
finished?.action === "admin.encryption.rotate.completed" &&
|
|
finished.details
|
|
) {
|
|
try {
|
|
const d = JSON.parse(finished.details) as {
|
|
scanned?: number;
|
|
rotated?: number;
|
|
errors?: number;
|
|
};
|
|
lastResult = {
|
|
scanned: d.scanned ?? 0,
|
|
rotated: d.rotated ?? 0,
|
|
errors: d.errors ?? 0,
|
|
};
|
|
} catch {
|
|
lastResult = null;
|
|
}
|
|
}
|
|
|
|
// "running" iff the most recent request is newer than the most recent
|
|
// completion/failure.
|
|
let state: RotationState["state"] = "idle";
|
|
if (requested && (!finished || requested.createdAt > finished.createdAt)) {
|
|
state = "running";
|
|
} else if (finished?.action === "admin.encryption.rotate.completed") {
|
|
state = "completed";
|
|
} else if (finished?.action === "admin.encryption.rotate.failed") {
|
|
state = "failed";
|
|
}
|
|
|
|
return {
|
|
state,
|
|
lastRequestedAt: requested?.createdAt.toISOString() ?? null,
|
|
lastCompletedAt: finished?.createdAt.toISOString() ?? null,
|
|
lastResult,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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" } });
|
|
|
|
const now = Date.now();
|
|
let scan: CorpusScan;
|
|
if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) {
|
|
scan = scanCache.scan;
|
|
} else {
|
|
scan = await scanCorpus(prisma as unknown as CorpusClient);
|
|
scanCache = { at: now, scan };
|
|
}
|
|
|
|
const rotation = await readRotationState();
|
|
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
|
const keyBackup = await readKeyBackupStatus();
|
|
|
|
annotate({
|
|
meta: {
|
|
encryption_total_rows: scan.totalRows,
|
|
encryption_stale_rows: scan.staleRows,
|
|
encryption_rotation_complete: scan.rotationComplete,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
activeKeyId: scan.activeKeyId,
|
|
// COUNT only — never the key material.
|
|
configuredKeyCount: getConfiguredKeyIds().length,
|
|
rotationComplete: scan.rotationComplete,
|
|
totalRows: scan.totalRows,
|
|
activeRows: scan.activeRows,
|
|
staleRows: scan.staleRows,
|
|
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
|
|
// existed when they were written.
|
|
safeToDropRetiredKeys:
|
|
scan.rotationComplete &&
|
|
backups.retiredKeysStillNeeded.length === 0 &&
|
|
backups.unrecorded.copies === 0,
|
|
});
|
|
});
|