From 308470f22949e94ab4c0a214a895857b4a4a90f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:06:28 +0200 Subject: [PATCH] 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. --- .env.example | 5 + .env.production.example | 10 + docker-compose.yml | 3 + docs/api/openapi.yaml | 22 +- docs/ops/deploy.md | 8 + docs/self-hosting/getting-started.md | 34 ++ docs/self-hosting/portainer/templates.json | 6 + docs/self-hosting/truenas.md | 87 +++++ docs/self-hosting/unraid.md | 8 + docs/self-hosting/unraid/healthlog.xml | 1 + .../migration.sql | 20 ++ prisma/schema.prisma | 31 ++ scripts/env-manifest.json | 5 + .../openapi-route-coverage-guard.test.ts | 12 + ...ublished-env-reaches-the-container.test.ts | 5 + .../encryption/key-backup/confirm/route.ts | 73 +++++ .../api/admin/encryption/key-backup/route.ts | 22 ++ .../encryption/key-backup/verify/route.ts | 64 ++++ src/app/api/admin/encryption/status/route.ts | 4 + src/app/api/health/__tests__/route.test.ts | 23 ++ src/app/api/health/route.ts | 13 +- src/app/key-mismatch-page.tsx | 43 +++ src/app/layout.tsx | 24 +- src/app/page-client.tsx | 2 + .../admin/__tests__/key-backup-card.test.tsx | 98 ++++++ src/components/admin/encryption-section.tsx | 45 +-- src/components/admin/key-backup-card.tsx | 309 ++++++++++++++++++ .../admin/system-status-summary.tsx | 25 +- src/components/admin/use-key-backup-status.ts | 36 ++ .../dashboard/key-backup-banner.tsx | 40 +++ src/instrumentation.ts | 13 + .../api-handler-key-mismatch.test.ts | 63 ++++ src/lib/api-handler.ts | 23 +- src/lib/boot/key-check.ts | 42 +++ src/lib/boot/key-mismatch-state.ts | 52 +++ src/lib/crypto.ts | 51 +++ src/lib/crypto/__tests__/canary.test.ts | 178 ++++++++++ .../encrypted-column-writers.test.ts | 16 +- src/lib/crypto/__tests__/key-backup.test.ts | 50 +++ src/lib/crypto/canary.ts | 211 ++++++++++++ src/lib/crypto/key-backup.ts | 142 ++++++++ src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/observability/redact-payload.ts | 5 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/health.ts | 15 +- src/lib/query-keys/admin.ts | 3 + .../integration/encryption-key-check.test.ts | 290 ++++++++++++++++ 47 files changed, 2193 insertions(+), 42 deletions(-) create mode 100644 docs/self-hosting/truenas.md create mode 100644 prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql create mode 100644 src/app/api/admin/encryption/key-backup/confirm/route.ts create mode 100644 src/app/api/admin/encryption/key-backup/route.ts create mode 100644 src/app/api/admin/encryption/key-backup/verify/route.ts create mode 100644 src/app/key-mismatch-page.tsx create mode 100644 src/components/admin/__tests__/key-backup-card.test.tsx create mode 100644 src/components/admin/key-backup-card.tsx create mode 100644 src/components/admin/use-key-backup-status.ts create mode 100644 src/components/dashboard/key-backup-banner.tsx create mode 100644 src/lib/__tests__/api-handler-key-mismatch.test.ts create mode 100644 src/lib/boot/key-check.ts create mode 100644 src/lib/boot/key-mismatch-state.ts create mode 100644 src/lib/crypto/__tests__/canary.test.ts create mode 100644 src/lib/crypto/__tests__/key-backup.test.ts create mode 100644 src/lib/crypto/canary.ts create mode 100644 src/lib/crypto/key-backup.ts create mode 100644 tests/integration/encryption-key-check.test.ts diff --git a/.env.example b/.env.example index 10bc7095e..efe6a7a8d 100644 --- a/.env.example +++ b/.env.example @@ -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` diff --git a/.env.production.example b/.env.production.example index af58314bd..e913ade7a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -257,6 +257,16 @@ API_TOKEN_HMAC_KEY="" # 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 # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 28b1b9ad1..c9216b398 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-}" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f66443ff6..20ca5766a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index f716516d4..4cfe63778 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -79,6 +79,14 @@ dev --create-only --name vX_Y_Z_rollback_`), 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 diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index af157eb85..fe1c87d1b 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -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 "" | 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 diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json index 985e01c44..6b2c51f83 100644 --- a/docs/self-hosting/portainer/templates.json +++ b/docs/self-hosting/portainer/templates.json @@ -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", diff --git a/docs/self-hosting/truenas.md b/docs/self-hosting/truenas.md new file mode 100644 index 000000000..8bebe7441 --- /dev/null +++ b/docs/self-hosting/truenas.md @@ -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://:@:5432/` | +| `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 "" | 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. diff --git a/docs/self-hosting/unraid.md b/docs/self-hosting/unraid.md index 91e5c89d6..77abd18f7 100644 --- a/docs/self-hosting/unraid.md +++ b/docs/self-hosting/unraid.md @@ -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, diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml index 3ad71c0f5..26ee42ee6 100644 --- a/docs/self-hosting/unraid/healthlog.xml +++ b/docs/self-hosting/unraid/healthlog.xml @@ -53,6 +53,7 @@ false + unraid diff --git a/prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql b/prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql new file mode 100644 index 000000000..986150e0f --- /dev/null +++ b/prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11b59cb98..0e5b877f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 3cec8225f..9639d8b0a 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -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.", diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 4e7c47b74..d46bb5b8c 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -239,6 +239,18 @@ const UNPUBLISHED: Readonly> = { 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"] }, diff --git a/src/__tests__/published-env-reaches-the-container.test.ts b/src/__tests__/published-env-reaches-the-container.test.ts index 3847c4a0f..2e1b54b21 100644 --- a/src/__tests__/published-env-reaches-the-container.test.ts +++ b/src/__tests__/published-env-reaches-the-container.test.ts @@ -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", diff --git a/src/app/api/admin/encryption/key-backup/confirm/route.ts b/src/app/api/admin/encryption/key-backup/confirm/route.ts new file mode 100644 index 000000000..b82977fd4 --- /dev/null +++ b/src/app/api/admin/encryption/key-backup/confirm/route.ts @@ -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); +}); diff --git a/src/app/api/admin/encryption/key-backup/route.ts b/src/app/api/admin/encryption/key-backup/route.ts new file mode 100644 index 000000000..a41afa6bc --- /dev/null +++ b/src/app/api/admin/encryption/key-backup/route.ts @@ -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); +}); diff --git a/src/app/api/admin/encryption/key-backup/verify/route.ts b/src/app/api/admin/encryption/key-backup/verify/route.ts new file mode 100644 index 000000000..dbbbb732b --- /dev/null +++ b/src/app/api/admin/encryption/key-backup/verify/route.ts @@ -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 }); +}); diff --git a/src/app/api/admin/encryption/status/route.ts b/src/app/api/admin/encryption/status/route.ts index 4bda28af8..2ad234a82 100644 --- a/src/app/api/admin/encryption/status/route.ts +++ b/src/app/api/admin/encryption/status/route.ts @@ -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 diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts index 60d87ca9a..691c9c6fe 100644 --- a/src/app/api/health/__tests__/route.test.ts +++ b/src/app/api/health/__tests__/route.test.ts @@ -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" }); + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 3524d61c0..2b27876a6 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -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 }, ); }); diff --git a/src/app/key-mismatch-page.tsx b/src/app/key-mismatch-page.tsx new file mode 100644 index 000000000..1d95eaf24 --- /dev/null +++ b/src/app/key-mismatch-page.tsx @@ -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 ( +
+
+

+ {t("admin.keyMismatch.title")} +

+

{t("admin.keyMismatch.body")}

+
+

+ {t("admin.keyMismatch.fixTitle")} +

+
    +
  1. {t("admin.keyMismatch.fixRestore")}
  2. +
  3. {t("admin.keyMismatch.fixDatabase")}
  4. +
  5. {t("admin.keyMismatch.fixFresh")}
  6. +
+
+

+ {t("admin.keyMismatch.restartNote")} +

+

+ {t("admin.keyMismatch.codes", { + code: "encryption.key_mismatch", + reason: "encryption_key_mismatch", + })} +

+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 39d19e2fd..70e93c9dd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ /> - - - - {/* `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. + + ) : ( + + + + {/* `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. */} - - {children} - - + + {children} + + + )} ); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index d4384b607..cede7a6fc 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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({
+ {/* 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 diff --git a/src/components/admin/__tests__/key-backup-card.test.tsx b/src/components/admin/__tests__/key-backup-card.test.tsx new file mode 100644 index 000000000..fc5163141 --- /dev/null +++ b/src/components/admin/__tests__/key-backup-card.test.tsx @@ -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( + + {node} + , + ); +} + +describe("KeyBackupCard", () => { + it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => { + const html = render(, 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(, DUE)).toContain( + "I have backed up this key", + ); + const done = render(, DONE); + expect(done).not.toContain("I have backed up this key"); + expect(done).toContain("admin@example.test"); + }); + + it("speaks German", () => { + expect(render(, DUE, "de")).toContain( + "Sichere deinen Verschlüsselungsschlüssel", + ); + }); +}); + +describe("KeyBackupBanner", () => { + it("renders for an admin while the step is due", () => { + expect(render(, DUE)).toContain( + 'data-testid="key-backup-banner"', + ); + }); + + it("renders nothing for a non-admin, or once the step is done", () => { + expect(render(, DUE)).toBe(""); + expect(render(, DONE)).toBe(""); + }); +}); diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index dda5efb26..0d8edc724 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -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 ( - - -

- {t("admin.section.encryption.coverageDetail")} -

- - {statusQuery.isLoading ? ( -
- - {t("admin.section.encryption.loading")} -
- ) : ( -

- {t("admin.section.encryption.loadError")} +

+ + + +

+ {t("admin.section.encryption.coverageDetail")}

- )} -
+ + {statusQuery.isLoading ? ( +
+ + {t("admin.section.encryption.loading")} +
+ ) : ( +

+ {t("admin.section.encryption.loadError")} +

+ )} + +
); } @@ -162,6 +166,7 @@ export function EncryptionSection() { return (
+ {/* ── Coverage summary ───────────────────────────────────────── */} ` — 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(null); + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.adminEncryptionKeyBackup(), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.adminEncryptionStatus(), + }), + ]); + + const confirm = useMutation({ + mutationFn: (s: KeyBackupStatus) => + apiPost( + "/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 ( + + + {status.isError ? ( + void status.refetch()} + /> + ) : ( +
+ + {t("admin.keyBackup.loading")} +
+ )} +
+ ); + } + + const s = status.data; + return ( + + + {t("admin.keyBackup.badgeDue")} + + ) : ( + {t("admin.keyBackup.badgeDone")} + ) + } + /> + +
+
+
+ {t("admin.keyBackup.keyId")} +
+
{s.activeKeyId}
+
+
+
+ {t("admin.keyBackup.fingerprint")} +
+
+ {s.fingerprint} +
+
+
+ +

{t("admin.keyBackup.whatIsLost")}

+ + + + {PLATFORMS.map((p) => ( + + {platformCopy(t, p).label} + + ))} + + {PLATFORMS.map((p) => ( + + {platformCopy(t, p).where} + + ))} + + +

+ {t("admin.keyBackup.fingerprintHint")} +

+ +
{ + e.preventDefault(); + if (candidate.trim()) verify.mutate(candidate); + }} + > + +
+ { + setCandidate(e.target.value); + setCheckResult(null); + }} + className="font-mono" + /> + +
+

+ {insecure + ? t("admin.keyBackup.checkInsecureNote") + : t("admin.keyBackup.checkNote")} +

+ {checkResult !== null && ( +

+ {checkResult + ? t("admin.keyBackup.checkMatches") + : t("admin.keyBackup.checkNoMatch")} +

+ )} +
+ + {!s.due && s.confirmedAt && ( +

+ {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)), + })} +

+ )} + {s.due && s.confirmedKeyId && ( +

+ {t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })} +

+ )} + + {s.due && ( + + + + )} +
+ ); +} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index f51a1c7a7..0eb76b6c9 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -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 ( + {/* v1.40.0 — the encryption key backup step, warning while due. */} + {keyBackup && ( + + )} {/* 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 diff --git a/src/components/admin/use-key-backup-status.ts b/src/components/admin/use-key-backup-status.ts new file mode 100644 index 000000000..6f4304924 --- /dev/null +++ b/src/components/admin/use-key-backup-status.ts @@ -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("/api/admin/encryption/key-backup", { + credentials: "include", + }), + enabled, + staleTime: 60_000, + }); +} diff --git a/src/components/dashboard/key-backup-banner.tsx b/src/components/dashboard/key-backup-banner.tsx new file mode 100644 index 000000000..e9d409e13 --- /dev/null +++ b/src/components/dashboard/key-backup-banner.tsx @@ -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 ( +
+
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 6860753f9..9a3e2cb25 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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. diff --git a/src/lib/__tests__/api-handler-key-mismatch.test.ts b/src/lib/__tests__/api-handler-key-mismatch.test.ts new file mode 100644 index 000000000..b4bbf5a63 --- /dev/null +++ b/src/lib/__tests__/api-handler-key-mismatch.test.ts @@ -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); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index cf464dea2..a47b66ab2 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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 Promise>( 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 diff --git a/src/lib/boot/key-check.ts b/src/lib/boot/key-check.ts new file mode 100644 index 000000000..0c77f820c --- /dev/null +++ b/src/lib/boot/key-check.ts @@ -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 { + 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; +} diff --git a/src/lib/boot/key-mismatch-state.ts b/src/lib/boot/key-mismatch-state.ts new file mode 100644 index 000000000..4f9864009 --- /dev/null +++ b/src/lib/boot/key-mismatch-state.ts @@ -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 = 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; +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index d46a06fe2..10273774a 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -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); diff --git a/src/lib/crypto/__tests__/canary.test.ts b/src/lib/crypto/__tests__/canary.test.ts new file mode 100644 index 000000000..b99220d2a --- /dev/null +++ b/src/lib/crypto/__tests__/canary.test.ts @@ -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; + probe?: Record>; +}) { + 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; + for (const [delegate, fields] of Object.entries(opts.probe ?? {})) { + client[delegate] = { + async findFirst(args: { select: Record }) { + 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); + }); +}); diff --git a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts index b6a156acf..5b2814c04 100644 --- a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts +++ b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts @@ -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 = new Set(); +const NOT_ROTATED: ReadonlySet = new Set([ + "EncryptionKeyCanary.ciphertext", +]); /** * Ciphertext written where the Prisma-call walk below cannot see it: by raw diff --git a/src/lib/crypto/__tests__/key-backup.test.ts b/src/lib/crypto/__tests__/key-backup.test.ts new file mode 100644 index 000000000..e82929db8 --- /dev/null +++ b/src/lib/crypto/__tests__/key-backup.test.ts @@ -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"); + }); +}); diff --git a/src/lib/crypto/canary.ts b/src/lib/crypto/canary.ts new file mode 100644 index 000000000..e925b8d43 --- /dev/null +++ b/src/lib/crypto/canary.ts @@ -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( + query: TemplateStringsArray, + ...values: unknown[] + ): Promise; + $executeRaw( + query: TemplateStringsArray, + ...values: unknown[] + ): Promise; +} + +type ProbeDelegate = { + findFirst: (args: { + where: Record; + select: Record; + }) => Promise | 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; + 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 | 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 { + 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"); +} diff --git a/src/lib/crypto/key-backup.ts b/src/lib/crypto/key-backup.ts new file mode 100644 index 000000000..6ea14555c --- /dev/null +++ b/src/lib/crypto/key-backup.ts @@ -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 { + 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 { + 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() }; +} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index d6769473b..5dfd0531c 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -295,6 +295,8 @@ export const WIPE_EXEMPT: Readonly> = { */ export const INSTANCE_SCOPED: Readonly> = { 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: diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index 327ad6d68..848ff09e3 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -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, ]; /** diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index d54d2e68e..1404ab306 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -165,6 +165,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "encounter.reminder-conflict", "encounter.reminder-not-found", ], + encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"], environment: [ "environment.invalid", "environment.no_home", diff --git a/src/lib/openapi/routes/health.ts b/src/lib/openapi/routes/health.ts index 8d394f38b..1f9ffa54b 100644 --- a/src/lib/openapi/routes/health.ts +++ b/src/lib/openapi/routes/health.ts @@ -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."), diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 8a90672d1..23b9603ad 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -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, diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts new file mode 100644 index 000000000..1838a7c58 --- /dev/null +++ b/tests/integration/encryption-key-check.test.ts @@ -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 { + 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; +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); + }); +});