feat(encryption): back up the key, and refuse to serve when it cannot open the database

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:06:28 +02:00
1 parent 556bd6d6cd
commit 308470f229
47 files changed
+2193 -42

No files matched your search

+5
View File
@@ -62,6 +62,11 @@ NODE_ENV="development"
# internet; the session cookie crosses the wire in cleartext.
# SESSION_COOKIE_SECURE="false"
# Where this server's environment is configured: compose, truenas, coolify,
# unraid or portainer. Only picks which instructions the admin's "Back up
# your encryption key" step shows first; unset means compose.
# HEALTHLOG_PLATFORM="compose"
# -----------------------------------------------------------------------------
# Required Secrets — generate with `openssl rand -hex 32`
+10
View File
@@ -257,6 +257,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# SESSION_COOKIE_SECURE="false"
# -----------------------------------------------------------------------------
# Platform hint for the encryption key backup step
# -----------------------------------------------------------------------------
# Where this server's environment lives: compose, truenas, coolify, unraid or
# portainer. The admin's "Back up your encryption key" step opens on the
# instructions for that platform. Unset means compose. Catalog templates set
# it for you.
# HEALTHLOG_PLATFORM="compose"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
+3
View File
@@ -132,6 +132,9 @@ x-healthlog-env: &healthlog_env
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
# compose | truenas | coolify | unraid | portainer. Only chooses which
# instructions the admin's "Back up your encryption key" step shows first.
HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
+18 -4
View File
@@ -31744,10 +31744,11 @@ components:
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment
— environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found;
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption
— encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.invalid,
environment.no_home, environment.range_too_large, environment.travel.not_found; export —
export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback —
feedback.body.invalid; health_score_config — health_score_config.invalid,
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
@@ -74129,6 +74130,8 @@ components:
properties:
status:
$ref: "#/components/schemas/HealthStatus"
reason:
$ref: "#/components/schemas/HealthReason"
timestamp:
type: string
format: date-time
@@ -74169,11 +74172,22 @@ components:
description: "`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound
when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any
of those failing."
HealthReason:
description: "Present only when the process refuses to serve for a reason a person has to fix.
`encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at
boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs
are not started. Fix: restore the original key, point the server at the matching database, or start with an
empty one; then restart."
type: string
enum:
- encryption_key_mismatch
HealthPublicResponse:
type: object
properties:
status:
$ref: "#/components/schemas/HealthStatus"
reason:
$ref: "#/components/schemas/HealthReason"
required:
- status
additionalProperties: false
+8
View File
@@ -79,6 +79,14 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
## Catalog templates
A new required environment variable, or a change to the compose shape, needs a
matching pull request to the TrueNAS apps catalog in the same release cycle
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
`docs/self-hosting/portainer/templates.json`). See
`docs/self-hosting/truenas.md` for what the catalog template carries.
## Verify the served version
The queued-deploy status is **not** the source of truth — the version
+34
View File
@@ -226,6 +226,40 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
`pull_policy: always` on the `app` service, and verify the running
build with `GET /api/version` (it returns `version` + `buildSha`).
## Back up your encryption key
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
Without it, the encrypted parts of the database and every in-database backup
cannot be read. Keep a copy outside the server, then confirm it under Admin,
Encryption: the step shows the key id and a fingerprint (the first 12 hex
characters of SHA-256 over the key) and can check a pasted copy without
storing it. Admins see a dashboard reminder until the active key is confirmed.
| Platform | Where the key is set |
| --------- | --------------------------------------------------------------- |
| Compose | `.env` next to `docker-compose.yml` |
| TrueNAS | Apps, HealthLog, Edit, Environment |
| Coolify | the application's Environment Variables |
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
| Portainer | the stack's Environment variables |
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
`portainer`) to open the step on your platform's instructions.
To compute the fingerprint of your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
```
**HealthLog refuses to serve: encryption key does not match this database.**
At start the app checks that its key opens the stored data. If it does not,
every API request answers `503` with `meta.errorCode`
`encryption.key_mismatch`, `/api/health` answers `503` with
`reason: "encryption_key_mismatch"`, and background jobs are not started.
Restore the original key, point `DATABASE_URL` at the matching database, or
start with an empty database; then restart.
## Backup and restore
Your database is the only stateful piece — back it up, and back up the
@@ -30,6 +30,12 @@
"label": "API token HMAC key",
"description": "Generate with: openssl rand -hex 32"
},
{
"name": "HEALTHLOG_PLATFORM",
"label": "Platform hint",
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
"default": "portainer"
},
{
"name": "SESSION_COOKIE_SECURE",
"label": "Session cookie Secure flag",
+87
View File
@@ -0,0 +1,87 @@
# HealthLog on TrueNAS SCALE
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
is stateless; everything you care about lives in the database dataset and in
one value you must keep yourself: the encryption key.
## What the app needs
| Setting | Value |
| ------------ | ---------------------------------------------------------------------------------------- |
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
| Port | `3000` (HTTP) |
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 3 × 30 s: migrations run at every boot |
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
| Volumes | none for the app; the database gets its own dataset |
## Environment
| Variable | Required | Notes |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
| `APP_URL` | yes | the same address |
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
and runs the background jobs.
## Plain HTTP on the LAN
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
for that. If it is left at its default, the browser drops the sign-in cookie
and the login page says so before you type a password: "This server only sets
its sign-in cookie over https://".
## Back up your encryption key
HealthLog encrypts tokens, notes, documents and in-database backups with
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
and reinstalled with a new key over the old database dataset, the data is
still there but cannot be read.
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
Environment) into your password manager.
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
Compare it with your copy:
```bash
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
```
or paste the copy into "Check my copy" (it is compared and dropped, never
stored or logged).
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
After a key rotation the step comes back for the new key.
## When the key does not match the database
At every start HealthLog checks that its key opens the data already in the
database. If it does not, it refuses to serve instead of failing on every
encrypted value: pages show "HealthLog cannot open its data", API requests
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
stays visible.
Fix it one of three ways, then restart the app:
1. Put the original `ENCRYPTION_KEY` back.
2. Point `DATABASE_URL` at the database that belongs to this key.
3. Start fresh with an empty database. The old data and its backups need the
old key.
## Updates
Pick the new tag, take a snapshot of the database dataset first, and update.
Migrations run on start; the first start of a new install applies all of them,
which is why the start period above is generous.
+8
View File
@@ -121,6 +121,14 @@ Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
and `APP_URL` at the public `https://` URL.
## Back up your encryption key
Copy `ENCRYPTION_KEY` from the template into your password manager, then
confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step
opens on the Unraid instructions). A reinstall with a new key over the old
database makes the data unreadable; HealthLog then refuses to serve and says
so. See [Getting started](getting-started.md#back-up-your-encryption-key).
## Updates
Pin a release tag (`ghcr.io/mbombeck/healthlog:vX.Y.Z`) and bump deliberately,
+1
View File
@@ -53,6 +53,7 @@
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
@@ -0,0 +1,20 @@
-- The encryption key backup step and the boot check that the configured key
-- opens this database.
--
-- Four nullable columns on the instance settings row record which key the
-- operator confirmed a backup of (id and fingerprint, never the key). The
-- canary table holds one known plaintext per key id, sealed under that key; a
-- process whose key cannot open it refuses to serve. Both are written at
-- runtime, so the migration only creates the shapes.
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_at" TIMESTAMP(3);
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_key_id" TEXT;
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_fingerprint" TEXT;
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_by_user_id" TEXT;
CREATE TABLE IF NOT EXISTS "encryption_key_canaries" (
"key_id" TEXT NOT NULL,
"ciphertext" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encryption_key_canaries_pkey" PRIMARY KEY ("key_id")
);
+31
View File
@@ -4876,9 +4876,40 @@ model AppSettings {
documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes")
documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes")
// The operator's confirmation that the encryption key is backed up, bound to
// the key it was given for: the active key id and its fingerprint (the first
// twelve hex characters of SHA-256 over the raw key bytes). A rotation or a
// re-keyed install changes either one, and the step is due again. Never the
// key itself. Written by `POST /api/admin/encryption/key-backup/confirm`;
// consumer: `readKeyBackupStatus` (the admin step, the overview tile and the
// dashboard banner for admins).
encryptionKeyBackupConfirmedAt DateTime? @map("encryption_key_backup_confirmed_at")
encryptionKeyBackupConfirmedKeyId String? @map("encryption_key_backup_confirmed_key_id")
encryptionKeyBackupConfirmedFingerprint String? @map("encryption_key_backup_confirmed_fingerprint")
/// Plain id, not a relation: the instance row must not become user-scoped,
/// and a deleted admin leaves the confirmation standing. Consumer: the admin
/// step names who confirmed when the account still exists.
encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id")
@@map("app_settings")
}
/// One known plaintext per configured encryption key id, sealed under that
/// key. At boot every configured id is checked against its row: a row that no
/// longer opens means the key in the environment is not the key this database
/// was written with, and the process refuses to serve instead of answering
/// with random errors. A missing row is written (`ON CONFLICT DO NOTHING`).
/// Deliberately outside key rotation: a canary belongs to its key id, and
/// re-sealing it under the active key would make every id's check test the
/// active key. Written and read only by `src/lib/crypto/canary.ts`.
model EncryptionKeyCanary {
keyId String @id @map("key_id")
ciphertext String
createdAt DateTime @default(now()) @map("created_at")
@@map("encryption_key_canaries")
}
// ─── Audit Log ─────────────────────────────────────────────
model AuditLog {
+5
View File
@@ -322,6 +322,11 @@
"purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.",
"example": "false"
},
{
"name": "HEALTHLOG_PLATFORM",
"purpose": "Where this server's environment is configured (compose, truenas, coolify, unraid, portainer). Shown back to the admin: the \"Back up your encryption key\" step opens on that platform's instructions. Unset means compose.",
"example": "compose"
},
{
"name": "TRUST_PROXY_HOPS",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
@@ -239,6 +239,18 @@ 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"] },
@@ -71,6 +71,11 @@ const MANIFEST_NAMES: string[] = (
* entry a lie rather than an inconvenience.
*/
const PUBLISHED_TO_USERS = [
{
name: "HEALTHLOG_PLATFORM",
shownBy:
"the admin's encryption key backup step, which opens on that platform",
},
{
name: "AUDIT_LOG_RETENTION_DAYS",
shownBy: "the shared-access activity window and the revoke dialog",
@@ -0,0 +1,73 @@
/**
* 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,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
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 body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = confirmSchema.safeParse(body.data);
if (!parsed.success) return returnAllZodIssues(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);
});
@@ -0,0 +1,22 @@
/**
* 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);
});
@@ -0,0 +1,64 @@
/**
* 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,6 +17,7 @@
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 {
@@ -213,6 +214,7 @@ export const GET = apiHandler(async () => {
const rotation = await readRotationState();
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
const keyBackup = await readKeyBackupStatus();
annotate({
meta: {
@@ -233,6 +235,8 @@ 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
@@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => {
expect(ready.body).toEqual({ status: "ok" });
});
});
describe("GET /api/health while the encryption key does not match", () => {
it("answers 503 with the reason, for everyone, even with every component up", async () => {
const { setKeyMismatchState } =
await import("@/lib/boot/key-mismatch-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
try {
const { response, body } = await health();
expect(response.status).toBe(503);
expect(body).toEqual({
status: "degraded",
reason: "encryption_key_mismatch",
});
} finally {
setKeyMismatchState(null);
}
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
});
+12 -1
View File
@@ -6,6 +6,10 @@ import { getSession } from "@/lib/auth/session";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
isKeyMismatch,
KEY_MISMATCH_HEALTH_REASON,
} from "@/lib/boot/key-mismatch-state";
export const dynamic = "force-dynamic";
@@ -22,7 +26,13 @@ export const GET = apiHandler(async () => {
const worker = getWorkerStatus();
const producerReady = getGlobalBoss() !== null;
// A process whose encryption key cannot open the database refuses every
// other route; the probe says so in a word every caller may see, because
// it is the one place a catalog install's status page reads.
const keyMismatch = isKeyMismatch();
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
const status =
!keyMismatch &&
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
@@ -40,6 +50,7 @@ export const GET = apiHandler(async () => {
return NextResponse.json(
{
status,
...(reason ? { reason } : {}),
timestamp: new Date().toISOString(),
database: dbOk ? "connected" : "disconnected",
worker: worker.running ? "running" : "stopped",
@@ -52,7 +63,7 @@ export const GET = apiHandler(async () => {
}
return NextResponse.json(
{ status },
{ status, ...(reason ? { reason } : {}) },
{ status: statusCode, headers: cacheHeaders },
);
});
+43
View File
@@ -0,0 +1,43 @@
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
/**
* What every page renders while the boot key check has found that the
* configured encryption key cannot open this database (see
* `src/lib/crypto/canary.ts`). A static server-rendered page with no client
* code and no data reads: the app behind it would only fail on every value.
* The API answers 503 with `encryption.key_mismatch` at the same time, and
* `/api/health` names the reason.
*/
export function KeyMismatchPage({ locale }: { locale: Locale }) {
const { t } = getServerTranslator(locale);
return (
<main className="bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10">
<div className="border-border bg-card w-full max-w-xl space-y-4 rounded-xl border p-4 md:p-6">
<h1 className="text-xl font-semibold">
{t("admin.keyMismatch.title")}
</h1>
<p className="text-sm">{t("admin.keyMismatch.body")}</p>
<div className="space-y-2">
<h2 className="text-base font-semibold">
{t("admin.keyMismatch.fixTitle")}
</h2>
<ol className="list-decimal space-y-2 ps-5 text-sm">
<li>{t("admin.keyMismatch.fixRestore")}</li>
<li>{t("admin.keyMismatch.fixDatabase")}</li>
<li>{t("admin.keyMismatch.fixFresh")}</li>
</ol>
</div>
<p className="text-muted-foreground text-sm">
{t("admin.keyMismatch.restartNote")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.keyMismatch.codes", {
code: "encryption.key_mismatch",
reason: "encryption_key_mismatch",
})}
</p>
</div>
</main>
);
}
+16 -8
View File
@@ -8,6 +8,8 @@ import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
variable: "--font-sans",
@@ -131,17 +133,23 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
{isKeyMismatch() ? (
// The boot key check refused this process: no app shell, no data
// reads, one page that names the fix.
<KeyMismatchPage locale={initialLocale} />
) : (
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
block mutations. Resolve it here (server component) and
thread the boolean into the client shell so the demo
banner can render without a client-side detection path. */}
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
)}
</body>
</html>
);
+2
View File
@@ -50,6 +50,7 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import { TodayHero } from "@/components/daily/today-hero";
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
import {
@@ -913,6 +914,7 @@ export default function DashboardPageClient({
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
<KeyBackupBanner 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
@@ -0,0 +1,98 @@
/**
* 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";
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("");
});
});
+25 -20
View File
@@ -42,6 +42,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import { KeyBackupCard } from "./key-backup-card";
interface ColumnScan {
model: string;
@@ -130,27 +131,30 @@ export function EncryptionSection() {
// the fix carried across.
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
{statusQuery.isLoading ? (
<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.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
<div className="space-y-6">
<KeyBackupCard />
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
)}
</SettingsCard>
{statusQuery.isLoading ? (
<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.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
</div>
);
}
@@ -162,6 +166,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
<KeyBackupCard />
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
+309
View File
@@ -0,0 +1,309 @@
"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 { 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) {
toast.error(t("admin.keyBackup.stale"));
await invalidate();
return;
}
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
},
});
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);
toast.error(err.message || t("admin.keyBackup.checkFailed"));
},
});
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
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>
);
}
+24 -1
View File
@@ -12,18 +12,28 @@
* don't re-fetch.
*/
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
import {
Clock,
Cog,
Database,
Globe,
KeyRound,
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 { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
import { useKeyBackupStatus } from "./use-key-backup-status";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError, refetch } = useSystemStatus();
const { data: version } = usePublicVersion();
const { data: keyBackup } = useKeyBackupStatus();
return (
<SettingsCard
@@ -63,6 +73,19 @@ 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 R5 — surface the offline-geo state so the maintainer
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
The field is undefined on legacy responses; the row only
@@ -0,0 +1,36 @@
"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,40 @@
"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.
*/
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";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
const { data } = useKeyBackupStatus(isAdmin);
if (!isAdmin || !data?.due) return null;
return (
<div
role="status"
data-testid="key-backup-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")}
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
</div>
<Link
href="/admin/encryption"
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
>
{t("dashboard.keyBackupBanner.action")}
</Link>
</div>
);
}
+13
View File
@@ -15,6 +15,19 @@ export async function register() {
// A readiness summary must never break boot.
}
// Does the configured encryption key open this database? A process whose
// key does not refuses every API request with one clear 503 and starts
// neither the producer nor the worker (a wrong key must never write).
try {
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
import("@/lib/boot/key-check"),
import("@/lib/db"),
]);
if (await runBootKeyCheck(prisma)) return;
} catch (err) {
console.error("[instrumentation] Encryption key check crashed:", err);
}
// Every Node process watches its own event loop — web-only
// containers included; a stall report from the process serving
// requests is the whole point.
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { setKeyMismatchState } from "@/lib/boot/key-mismatch-state";
const handler = vi.fn(async (_request: NextRequest) =>
Response.json({ data: "ran", error: null }),
);
const route = apiHandler(handler);
function call(path: string) {
return route(new NextRequest(`http://localhost${path}`));
}
afterEach(() => {
setKeyMismatchState(null);
handler.mockClear();
});
describe("apiHandler while the encryption key does not match", () => {
it("refuses every route with 503 encryption.key_mismatch before the handler runs", async () => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
const response = await call("/api/measurements");
expect(response.status).toBe(503);
const body = await response.json();
expect(body.data).toBeNull();
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
expect(response.headers.get("Retry-After")).toBe("300");
expect(handler).not.toHaveBeenCalled();
});
it("still serves /api/health and /api/version", async () => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
for (const path of ["/api/health", "/api/version"]) {
const response = await call(path);
expect(response.status).toBe(200);
}
expect(handler).toHaveBeenCalledTimes(2);
});
it("runs the handler when the key check passed", async () => {
const response = await call("/api/measurements");
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
});
+22 -1
View File
@@ -53,6 +53,11 @@ import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
import {
isKeyMismatch,
KEY_MISMATCH_ERROR_CODE,
KEY_MISMATCH_EXEMPT_PATHS,
} from "./boot/key-mismatch-state";
import {
capturedRateLimit,
rateLimitResponseHeaders,
@@ -258,7 +263,23 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
response = await handler(...args);
// The boot key check found that the configured encryption key
// cannot open this database. Refuse before auth, before the
// handler: nothing here may read or write encrypted data.
if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) {
annotate({ action: { name: "encryption.key_mismatch.refused" } });
response = NextResponse.json(
{
data: null,
error:
"The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.",
meta: { errorCode: KEY_MISMATCH_ERROR_CODE },
},
{ status: 503, headers: { "Retry-After": "300" } },
);
} else {
response = await handler(...args);
}
} catch (error) {
if (error instanceof AiUnavailableError) {
// An AI capability is unavailable for this record. One envelope
+42
View File
@@ -0,0 +1,42 @@
/**
* Runs the encryption key check once per process at boot and records the
* verdict for the request path (`./key-mismatch-state.ts`).
*
* Returns true when the process must refuse: the caller then starts neither
* the queue producer nor the worker, because a process holding the wrong key
* must never write. The process keeps running so `/api/health` and the
* explanation page can say why; a crash loop would hide the reason in a log
* that the people most likely to hit this (a NAS app catalog install) never
* open.
*/
import {
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
} from "@/lib/crypto/canary";
import { setKeyMismatchState } from "./key-mismatch-state";
export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
const outcome = await checkEncryptionKeyCanaries(client);
if (outcome.state === "mismatch") {
setKeyMismatchState({
keyIds: outcome.keyIds,
detectedAt: new Date().toISOString(),
});
console.error(keyMismatchLogBlock(outcome.keyIds));
return true;
}
setKeyMismatchState(null);
if (outcome.state === "error") {
// The check could not run. The loaders and the database each have their
// own loud signal for that; serving stays on.
console.warn(
`[boot] Encryption key check skipped: ${outcome.message.slice(0, 300)}`,
);
} else if (outcome.written.length > 0) {
console.info(
`[boot] Encryption key check: recorded key id(s) ${outcome.written.join(", ")}`,
);
}
return false;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Process-wide record of the boot key check's verdict.
*
* The check runs once in `instrumentation.ts`; the readers are `apiHandler`
* (every API route), `/api/health` and the root layout. Those live in
* different bundles of the same Node process, so module state is not shared
* between them and the verdict is pinned on `globalThis` under a registry
* symbol instead. This module imports nothing, so pulling it into the request
* path costs nothing.
*
* Only a positive mismatch is recorded. A check that could not run (database
* unreachable, keys not configured) leaves the process serving: those failures
* already have their own loud signals, and refusing on them would turn a
* transient database hiccup at boot into an outage.
*/
export interface KeyMismatchState {
/** Configured key ids whose canary did not open under the configured key. */
keyIds: string[];
/** When the check found it (ISO). */
detectedAt: string;
}
const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch");
type GlobalWithState = typeof globalThis & {
[STATE_SYMBOL]?: KeyMismatchState | null;
};
/** The paths that keep answering while the process refuses everything else. */
export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet<string> = new Set([
"/api/health",
"/api/version",
]);
/** Wire code for the refusal; catalogued in `src/lib/openapi/error-codes.ts`. */
export const KEY_MISMATCH_ERROR_CODE = "encryption.key_mismatch";
/** The `/api/health` reason while the process refuses. */
export const KEY_MISMATCH_HEALTH_REASON = "encryption_key_mismatch";
export function getKeyMismatchState(): KeyMismatchState | null {
return (globalThis as GlobalWithState)[STATE_SYMBOL] ?? null;
}
export function isKeyMismatch(): boolean {
return getKeyMismatchState() !== null;
}
export function setKeyMismatchState(state: KeyMismatchState | null): void {
(globalThis as GlobalWithState)[STATE_SYMBOL] = state;
}
+51
View File
@@ -22,6 +22,7 @@ import {
createHash,
hkdfSync,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { Buffer } from "node:buffer";
import { getEvent } from "@/lib/logging/context";
@@ -195,6 +196,56 @@ export function getConfiguredKeyIds(): string[] {
return [...keys.keys()];
}
/**
* A short, public fingerprint of one configured key: the first twelve hex
* characters of SHA-256 over the raw key bytes. Lets an operator tell which key
* a confirmation or a copy belongs to without the key ever leaving the process
* (48 bits of a hash of a 256-bit key; nothing to invert). Null for an id that
* is not configured.
*/
export function getKeyFingerprint(id: string): string | null {
const key = getKeyById(id);
return key ? fingerprintKeyBytes(key) : null;
}
/** The fingerprint rule itself, for a key that is already decoded. */
export function fingerprintKeyBytes(key: Buffer): string {
return createHash("sha256").update(key).digest("hex").slice(0, 12);
}
/**
* Whether `candidate` (as an operator would paste it: 64 hex characters or a
* base64 32-byte value) decodes to the configured key `id`. Constant-time on
* the decoded bytes. A value that does not decode is simply not a match; the
* decoder's error text is never surfaced, because it can quote the input's
* length.
*/
export function candidateMatchesKey(candidate: string, id: string): boolean {
const key = getKeyById(id);
if (!key) return false;
let decoded: Buffer;
try {
decoded = decodeKey(candidate.trim(), id);
} catch {
return false;
}
return decoded.length === key.length && timingSafeEqual(decoded, key);
}
/**
* Seal `plaintext` under a SPECIFIC configured key id rather than the active
* one. Exists for one caller, the boot key check (`src/lib/crypto/canary.ts`),
* which keeps one known value per key id; everything else writes with
* `encrypt()`. Throws for an id that is not configured.
*/
export function encryptUnderKeyId(plaintext: string, id: string): string {
const key = getKeyById(id);
if (!key) {
throw new Error(`Encryption key id '${id}' is not configured.`);
}
return `${id}.${encryptWithKey(plaintext, key)}`;
}
function encryptWithKey(plaintext: string, key: Buffer): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
+178
View File
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetCryptoCacheForTests,
candidateMatchesKey,
encrypt,
encryptUnderKeyId,
fingerprintKeyBytes,
getKeyFingerprint,
} from "@/lib/crypto";
import {
canaryPlaintext,
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
} from "../canary";
const KEY_A =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
/** An in-memory stand-in for the two raw statements and the probe delegates. */
function fakeClient(opts: {
canaries?: Record<string, string>;
probe?: Record<string, Record<string, string>>;
}) {
const canaries = new Map(Object.entries(opts.canaries ?? {}));
const inserts: string[] = [];
const client = {
async $queryRaw() {
return [...canaries.entries()].map(([key_id, ciphertext]) => ({
key_id,
ciphertext,
}));
},
async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) {
const [keyId, ciphertext] = values as [string, string];
inserts.push(keyId);
if (!canaries.has(keyId)) canaries.set(keyId, ciphertext);
return 1;
},
} as unknown as CanaryClient & Record<string, unknown>;
for (const [delegate, fields] of Object.entries(opts.probe ?? {})) {
client[delegate] = {
async findFirst(args: { select: Record<string, boolean> }) {
const field = Object.keys(args.select)[0];
return field in fields ? { [field]: fields[field] } : null;
},
};
}
return { client, canaries, inserts };
}
describe("encryption key canary", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
});
it("writes a canary for a configured key with no row and no data", async () => {
const { client, canaries, inserts } = fakeClient({});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "ok", written: ["v1"], verified: [] });
expect(inserts).toEqual(["v1"]);
expect(canaries.get("v1")?.startsWith("v1.")).toBe(true);
});
it("verifies a canary written under the same key", async () => {
const { client, inserts } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v1"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "ok", written: [], verified: ["v1"] });
expect(inserts).toEqual([]);
});
it("reports a mismatch when the key changed under the same id", async () => {
const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
useKey(KEY_B);
const { client, inserts } = fakeClient({ canaries: { v1: sealedUnderA } });
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("reports a mismatch for a canary that opens to the wrong value", async () => {
// A row from another key id copied over: opens, but says the wrong thing.
const { client } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v2"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("mismatch");
});
it("refuses to seal a wrong key when existing data predates the canary", async () => {
const existing = encrypt("a stored token");
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: existing } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("writes the canary when existing data opens under the key", async () => {
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: encrypt("a stored token") } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
it("reports an error, not a mismatch, when the table cannot be read", async () => {
const client = {
$queryRaw: async () => {
throw new Error('relation "encryption_key_canaries" does not exist');
},
$executeRaw: async () => 0,
} as unknown as CanaryClient;
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("error");
});
it("reports an error when no key is configured", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
const outcome = await checkEncryptionKeyCanaries(fakeClient({}).client);
expect(outcome.state).toBe("error");
});
it("names the key ids and never a key in the log block", () => {
const block = keyMismatchLogBlock(["v1"]);
expect(block).toContain("'v1'");
expect(block).toContain("encryption.key_mismatch");
expect(block).not.toContain(KEY_A);
});
});
describe("key fingerprint and copy check", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
});
it("is the first 12 hex of SHA-256 over the raw key bytes", () => {
expect(getKeyFingerprint("v1")).toBe(
fingerprintKeyBytes(Buffer.from(KEY_A, "hex")),
);
expect(getKeyFingerprint("v1")).toMatch(/^[0-9a-f]{12}$/);
expect(getKeyFingerprint("v1")).not.toBe(
fingerprintKeyBytes(Buffer.from(KEY_B, "hex")),
);
expect(getKeyFingerprint("nope")).toBeNull();
});
it("matches the configured key in hex, base64 and with whitespace", () => {
expect(candidateMatchesKey(KEY_A, "v1")).toBe(true);
expect(candidateMatchesKey(` ${KEY_A.toUpperCase()}\n`, "v1")).toBe(true);
expect(
candidateMatchesKey(Buffer.from(KEY_A, "hex").toString("base64"), "v1"),
).toBe(true);
});
it("does not match another key, garbage, or an unknown id", () => {
expect(candidateMatchesKey(KEY_B, "v1")).toBe(false);
expect(candidateMatchesKey("not a key", "v1")).toBe(false);
expect(candidateMatchesKey(KEY_A, "v9")).toBe(false);
});
});
@@ -60,11 +60,19 @@ const RELATION_ENVELOPE = new Set([
]);
/**
* Columns this scan derives that are deliberately NOT in the rotation
* registry. Empty today. An entry here is a written decision with a reason,
* never a way to quiet the guard.
* Columns that hold ciphertext and are deliberately NOT in the rotation
* registry. An entry here is a written decision with a reason, never a way to
* quiet the guard.
*
* `EncryptionKeyCanary.ciphertext` — one known value per key id, sealed under
* THAT key (`encryptUnderKeyId`, written by raw SQL in
* `src/lib/crypto/canary.ts`). Re-sealing it under the active key would make
* every id's boot check test the active key instead of its own. A retired
* key's row is never read again: only configured ids are checked.
*/
const NOT_ROTATED: ReadonlySet<string> = new Set<string>();
const NOT_ROTATED: ReadonlySet<string> = new Set<string>([
"EncryptionKeyCanary.ciphertext",
]);
/**
* Ciphertext written where the Prisma-call walk below cannot see it: by raw
@@ -0,0 +1,50 @@
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");
});
});
+211
View File
@@ -0,0 +1,211 @@
/**
* Boot check: does the configured encryption key open this database?
*
* Without it, a server started with a different `ENCRYPTION_KEY` than the one
* its database was written with (a reinstalled NAS app over an old dataset, a
* restored dump under a fresh key) signs people in normally, because sessions
* hash with `API_TOKEN_HMAC_KEY`, and then answers random 500s wherever a value
* has to be decrypted. This module turns that into one clear refusal.
*
* For every configured key id, `encryption_key_canaries` holds a known
* plaintext sealed under that key:
*
* - row present: it must decrypt to the expected value, else the key is not
* the one the row was written with;
* - row absent: before writing one, look for ANY existing ciphertext under
* that key id in the registered string columns and try it. Without this
* probe, the first boot after an upgrade (or a re-keyed boot over a
* database that predates the canary) would seal the wrong key as the
* right one. A value that opens, or no value at all, lets the canary be
* written (`ON CONFLICT DO NOTHING`, so two processes racing is fine).
*
* The probe is bounded by a time budget; past it, the canary is written
* without a verdict from existing data, which is exactly the behaviour before
* this check existed.
*
* Never logs or returns key material. The canary is written by raw SQL and is
* deliberately not in the rotation registry: it belongs to its key id.
*/
import {
decrypt,
encryptUnderKeyId,
extractKeyId,
getConfiguredKeyIds,
} from "@/lib/crypto";
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
export const CANARY_PREFIX = "healthlog-canary:";
export function canaryPlaintext(keyId: string): string {
return `${CANARY_PREFIX}${keyId}`;
}
/** How long the existing-data probe may take before the canary is written anyway. */
export const PROBE_BUDGET_MS = 10_000;
/** The slice of a Prisma client this module needs. */
export interface CanaryClient {
$queryRaw<T = unknown>(
query: TemplateStringsArray,
...values: unknown[]
): Promise<T>;
$executeRaw(
query: TemplateStringsArray,
...values: unknown[]
): Promise<number>;
}
type ProbeDelegate = {
findFirst: (args: {
where: Record<string, unknown>;
select: Record<string, boolean>;
}) => Promise<Record<string, unknown> | null>;
};
export type KeyCheckOutcome =
| {
state: "ok";
/** Key ids whose canary this run wrote. */
written: string[];
/** Key ids whose canary existed and opened. */
verified: string[];
}
| { state: "mismatch"; keyIds: string[] }
| { state: "error"; message: string };
function opensAs(ciphertext: string, keyId: string, expected?: string) {
try {
if (extractKeyId(ciphertext) !== keyId) return false;
const plain = decrypt(ciphertext);
return expected === undefined ? true : plain === expected;
} catch {
return false;
}
}
/**
* Look for one existing value sealed under `keyId` and try it. Returns
* `"opens"`, `"fails"`, or `"none"` (no value found inside the budget).
*/
export async function probeExistingData(
client: unknown,
keyId: string,
deadline: number,
): Promise<"opens" | "fails" | "none"> {
const delegates = client as Record<string, ProbeDelegate | undefined>;
for (const column of ENCRYPTED_COLUMNS) {
if (Date.now() > deadline) return "none";
if (column.kind !== "string" || column.codec || column.codecField) {
continue;
}
const delegate =
delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)];
if (!delegate || typeof delegate.findFirst !== "function") continue;
let row: Record<string, unknown> | null;
try {
row = await delegate.findFirst({
where: { [column.field]: { startsWith: `${keyId}.` } },
select: { [column.field]: true },
});
} catch {
continue;
}
const value = row?.[column.field];
if (typeof value !== "string") continue;
return opensAs(value, keyId) ? "opens" : "fails";
}
return "none";
}
/**
* Run the check for every configured key id. Never throws: a failure to run
* the check at all is reported as `error`, and only a value that provably does
* not open is reported as `mismatch`.
*/
export async function checkEncryptionKeyCanaries(
client: CanaryClient,
options: { probeBudgetMs?: number } = {},
): Promise<KeyCheckOutcome> {
let keyIds: string[];
try {
keyIds = getConfiguredKeyIds();
} catch (err) {
return { state: "error", message: (err as Error).message };
}
let rows: Array<{ key_id: string; ciphertext: string }>;
try {
rows = await client.$queryRaw<
Array<{ key_id: string; ciphertext: string }>
>`SELECT key_id, ciphertext FROM encryption_key_canaries WHERE key_id = ANY(${keyIds})`;
} catch (err) {
return { state: "error", message: (err as Error).message };
}
const stored = new Map(rows.map((r) => [r.key_id, r.ciphertext]));
const mismatched: string[] = [];
const verified: string[] = [];
const missing: string[] = [];
for (const keyId of keyIds) {
const ciphertext = stored.get(keyId);
if (ciphertext === undefined) {
missing.push(keyId);
} else if (opensAs(ciphertext, keyId, canaryPlaintext(keyId))) {
verified.push(keyId);
} else {
mismatched.push(keyId);
}
}
const deadline = Date.now() + (options.probeBudgetMs ?? PROBE_BUDGET_MS);
const toWrite: string[] = [];
for (const keyId of missing) {
const probe = await probeExistingData(client, keyId, deadline);
if (probe === "fails") mismatched.push(keyId);
else toWrite.push(keyId);
}
if (mismatched.length > 0) {
// Nothing is written while any key fails: a process in this state must
// not leave anything behind sealed under a key that may be wrong.
return { state: "mismatch", keyIds: mismatched.sort() };
}
try {
for (const keyId of toWrite) {
const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId);
await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`;
}
} catch (err) {
return { state: "error", message: (err as Error).message };
}
return { state: "ok", written: toWrite, verified };
}
/**
* The one block an operator reads in the container log. English literal on
* purpose: logs are not translated. Names the key ids, never a key.
*/
export function keyMismatchLogBlock(keyIds: string[]): string {
const ids = keyIds.map((id) => `'${id}'`).join(", ");
return [
"============================================================",
" HealthLog refuses to serve: encryption key does not match",
"============================================================",
` The encryption key configured for key id ${ids} cannot open the`,
" data already stored in this database. Serving anyway would fail on",
" every encrypted value, so every API request answers 503 with",
" errorCode 'encryption.key_mismatch' and /api/health reports",
" reason 'encryption_key_mismatch'. Background jobs are not started.",
"",
" Fix it one of three ways, then restart:",
" 1. Restore the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry)",
" this database was written with.",
" 2. Point DATABASE_URL at the database that belongs to this key.",
" 3. Start fresh: an empty database with this key. Stored data and",
" in-database backups of the old database cannot be read without",
" the old key.",
"============================================================",
].join("\n");
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 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() };
}
+2
View File
@@ -295,6 +295,8 @@ export const WIPE_EXEMPT: Readonly<Record<string, string>> = {
*/
export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
AppSettings: "instance-wide configuration singleton",
EncryptionKeyCanary:
"one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account",
RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
HostMetric: "instance-wide host telemetry, not attributable to an account",
OffhostBackupKeyUse:
+5
View File
@@ -96,6 +96,11 @@ 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.
/^encryption[_-]?key$/i,
];
/**
+1
View File
@@ -165,6 +165,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"],
environment: [
"environment.invalid",
"environment.no_home",
+12 -3
View File
@@ -25,15 +25,24 @@ const healthStatusEnum = z.enum(["ok", "degraded"]).meta({
"`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing.",
});
const healthPublicResponse = z.object({ status: healthStatusEnum }).meta({
id: "HealthPublicResponse",
const healthReason = z.enum(["encryption_key_mismatch"]).optional().meta({
id: "HealthReason",
description:
"What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.",
"Present only when the process refuses to serve for a reason a person has to fix. `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart.",
});
const healthPublicResponse = z
.object({ status: healthStatusEnum, reason: healthReason })
.meta({
id: "HealthPublicResponse",
description:
"What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.",
});
const healthAdminResponse = z
.object({
status: healthStatusEnum,
reason: healthReason,
timestamp: z.iso
.datetime({ offset: true })
.describe("When the probe ran. UTC (`Z`) — no user context is loaded."),
+3
View File
@@ -48,6 +48,9 @@ export const adminKeys = {
adminBackupRestores: () => ["admin", "backup-restores"] as const,
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
adminEncryptionKeyBackup: () =>
["admin", "encryption", "key-backup"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
adminFeedbackRoot: () => ["admin", "feedback"] as const,
@@ -0,0 +1,290 @@
/**
* The boot encryption key check and the "Back up your encryption key" step,
* against real Postgres.
*
* - a first boot writes a canary; a second boot with the same key opens it;
* - a boot with another key under the same id refuses: the state is set,
* every API route answers 503 `encryption.key_mismatch`, `/api/health`
* names the reason, and nothing is written;
* - a database written before the canary existed is probed: data that does
* not open under the configured key refuses rather than sealing the wrong
* key as the right one;
* - the confirmation binds to the active key id and fingerprint, refuses a
* stale one with 409, and is due again after a re-keyed install;
* - "Check my copy" answers a boolean, stores nothing, and is rate limited.
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import {
_resetCryptoCacheForTests,
encrypt,
getKeyFingerprint,
} from "@/lib/crypto";
import { runBootKeyCheck } from "@/lib/boot/key-check";
import {
isKeyMismatch,
setKeyMismatchState,
} from "@/lib/boot/key-mismatch-state";
const KEY_A =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
async function seedAdmin(): Promise<string> {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "key-admin",
email: "key-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: { userId: admin.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return admin.id;
}
/** The GET handlers take no parameter; `apiHandler` still reads the request. */
type RouteFn = (request: NextRequest) => Promise<Response>;
const asRoute = (fn: unknown) => fn as RouteFn;
function post(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.$executeRaw`DELETE FROM encryption_key_canaries`;
await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'key-backup-verify:%'`;
cookieJar.clear();
headerJar.clear();
useKey(KEY_A);
setKeyMismatchState(null);
});
afterEach(() => {
setKeyMismatchState(null);
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
describe("boot encryption key check (real Postgres)", () => {
it("writes a canary on first boot and opens it on the next", async () => {
const prisma = getPrismaClient();
expect(await runBootKeyCheck(prisma)).toBe(false);
const rows = await prisma.encryptionKeyCanary.findMany();
expect(rows.map((r) => r.keyId)).toEqual(["v1"]);
expect(rows[0].ciphertext.startsWith("v1.")).toBe(true);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
});
it("refuses to serve when the key changed under the same id", async () => {
const prisma = getPrismaClient();
await runBootKeyCheck(prisma);
const before = await prisma.encryptionKeyCanary.findMany();
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
expect(errors.mock.calls[0]?.[0]).toContain("encryption.key_mismatch");
errors.mockRestore();
expect(isKeyMismatch()).toBe(true);
// Nothing re-sealed under the wrong key.
expect(await prisma.encryptionKeyCanary.findMany()).toEqual(before);
await seedAdmin();
const { GET: keyBackupGet } =
await import("@/app/api/admin/encryption/key-backup/route");
const refused = await asRoute(keyBackupGet)(
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
);
expect(refused.status).toBe(503);
const body = await refused.json();
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
const { GET: healthGet } = await import("@/app/api/health/route");
const health = await asRoute(healthGet)(
new NextRequest("http://localhost/api/health"),
);
expect(health.status).toBe(503);
const healthBody = await health.json();
expect(healthBody.status).toBe("degraded");
expect(healthBody.reason).toBe("encryption_key_mismatch");
// Restoring the original key clears it on the next boot.
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
});
it("refuses rather than sealing the wrong key over data that predates the canary", async () => {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
username: "existing",
email: "existing@example.test",
codexAccessTokenEncrypted: encrypt("a stored token"),
},
});
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
errors.mockRestore();
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
// With the right key the same data lets the canary be written.
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
});
describe("encryption key backup step (real Postgres)", () => {
it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => {
const prisma = getPrismaClient();
const adminId = await seedAdmin();
const { GET } = await import("@/app/api/admin/encryption/key-backup/route");
const { POST: confirm } =
await import("@/app/api/admin/encryption/key-backup/confirm/route");
const status = async () =>
(
await (
await asRoute(GET)(
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
)
).json()
).data;
const first = await status();
expect(first.due).toBe(true);
expect(first.activeKeyId).toBe("v1");
expect(first.fingerprint).toBe(getKeyFingerprint("v1"));
expect(first.platformHint).toBe("compose");
expect(JSON.stringify(first)).not.toContain(KEY_A);
const stale = await confirm(
post("/api/admin/encryption/key-backup/confirm", {
keyId: "v1",
fingerprint: "000000000000",
}),
);
expect(stale.status).toBe(409);
expect((await stale.json()).meta.errorCode).toBe(
"encryption.keyBackup.stale",
);
expect(
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
?.encryptionKeyBackupConfirmedAt ?? null,
).toBeNull();
const ok = await confirm(
post("/api/admin/encryption/key-backup/confirm", {
keyId: "v1",
fingerprint: first.fingerprint,
}),
);
expect(ok.status).toBe(200);
const confirmed = (await ok.json()).data;
expect(confirmed.due).toBe(false);
expect(confirmed.confirmedBy).toEqual({
id: adminId,
email: "key-admin@example.test",
});
const audit = await prisma.auditLog.findFirst({
where: { action: "encryption.keyBackup.confirmed" },
});
expect(audit).not.toBeNull();
vi.stubEnv("HEALTHLOG_PLATFORM", "truenas");
useKey(KEY_B);
const after = await status();
expect(after.due).toBe(true);
expect(after.confirmedKeyId).toBe("v1");
expect(after.platformHint).toBe("truenas");
});
it("checks a copy without storing it, and limits the checks", async () => {
const prisma = getPrismaClient();
await seedAdmin();
const { POST: verify } =
await import("@/app/api/admin/encryption/key-backup/verify/route");
const check = (encryptionKey: string) =>
verify(
post("/api/admin/encryption/key-backup/verify", { encryptionKey }),
);
const good = await check(`${KEY_A}\n`);
expect(good.status).toBe(200);
expect((await good.json()).data).toEqual({ matches: true, keyId: "v1" });
const bad = await check(KEY_B);
expect((await bad.json()).data.matches).toBe(false);
const invalid = await verify(
post("/api/admin/encryption/key-backup/verify", { encryptionKey: "" }),
);
expect(invalid.status).toBe(422);
expect(JSON.stringify(await invalid.json())).not.toContain("issues");
// Nothing of the candidate is stored anywhere a row could hold it.
const audits = await prisma.auditLog.findMany();
expect(JSON.stringify(audits)).not.toContain(KEY_A);
expect(JSON.stringify(audits)).not.toContain(KEY_B);
expect(
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
?.encryptionKeyBackupConfirmedAt ?? null,
).toBeNull();
await check(KEY_B);
await check(KEY_B);
const limited = await check(KEY_A);
expect(limited.status).toBe(429);
});
});