mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(encryption): back up the key, and refuse to serve when it cannot open the database
Every configured key id now has a canary in encryption_key_canaries, checked at boot. A key that cannot open its canary, or existing data written before the canary existed, puts the process in a refusal state: every API route answers 503 encryption.key_mismatch, /api/health reports reason encryption_key_mismatch, the pages render one explanation, and neither the queue producer nor the worker starts. The process stays up so the reason is visible instead of hiding in a crash loop. The admin Encryption page gains a "Back up your encryption key" step: key id and fingerprint (never the key), where the key lives per platform, a check of a pasted copy that is compared and discarded, and a confirmation bound to the active key id and fingerprint. The overview gets a tile and admins get a dashboard banner while it is due. HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the TrueNAS self-hosting page and key backup sections in the docs.
This commit is contained in:
1 parent
556bd6d6cd
commit
308470f229
47 files changed
+2193
-42
No files matched your search
@@ -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`
|
||||
|
||||
@@ -257,6 +257,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# SESSION_COOKIE_SECURE="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Platform hint for the encryption key backup step
|
||||
# -----------------------------------------------------------------------------
|
||||
# Where this server's environment lives: compose, truenas, coolify, unraid or
|
||||
# portainer. The admin's "Back up your encryption key" step opens on the
|
||||
# instructions for that platform. Unset means compose. Catalog templates set
|
||||
# it for you.
|
||||
# HEALTHLOG_PLATFORM="compose"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -132,6 +132,9 @@ x-healthlog-env: &healthlog_env
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
# compose | truenas | coolify | unraid | portainer. Only chooses which
|
||||
# instructions the admin's "Back up your encryption key" step shows first.
|
||||
HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
|
||||
+18
-4
@@ -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
|
||||
|
||||
@@ -79,6 +79,14 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first.
|
||||
|
||||
## Catalog templates
|
||||
|
||||
A new required environment variable, or a change to the compose shape, needs a
|
||||
matching pull request to the TrueNAS apps catalog in the same release cycle
|
||||
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
|
||||
`docs/self-hosting/portainer/templates.json`). See
|
||||
`docs/self-hosting/truenas.md` for what the catalog template carries.
|
||||
|
||||
## Verify the served version
|
||||
|
||||
The queued-deploy status is **not** the source of truth — the version
|
||||
|
||||
@@ -226,6 +226,40 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
|
||||
`pull_policy: always` on the `app` service, and verify the running
|
||||
build with `GET /api/version` (it returns `version` + `buildSha`).
|
||||
|
||||
## Back up your encryption key
|
||||
|
||||
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
|
||||
Without it, the encrypted parts of the database and every in-database backup
|
||||
cannot be read. Keep a copy outside the server, then confirm it under Admin,
|
||||
Encryption: the step shows the key id and a fingerprint (the first 12 hex
|
||||
characters of SHA-256 over the key) and can check a pasted copy without
|
||||
storing it. Admins see a dashboard reminder until the active key is confirmed.
|
||||
|
||||
| Platform | Where the key is set |
|
||||
| --------- | --------------------------------------------------------------- |
|
||||
| Compose | `.env` next to `docker-compose.yml` |
|
||||
| TrueNAS | Apps, HealthLog, Edit, Environment |
|
||||
| Coolify | the application's Environment Variables |
|
||||
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
|
||||
| Portainer | the stack's Environment variables |
|
||||
|
||||
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
|
||||
`portainer`) to open the step on your platform's instructions.
|
||||
|
||||
To compute the fingerprint of your copy:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
**HealthLog refuses to serve: encryption key does not match this database.**
|
||||
At start the app checks that its key opens the stored data. If it does not,
|
||||
every API request answers `503` with `meta.errorCode`
|
||||
`encryption.key_mismatch`, `/api/health` answers `503` with
|
||||
`reason: "encryption_key_mismatch"`, and background jobs are not started.
|
||||
Restore the original key, point `DATABASE_URL` at the matching database, or
|
||||
start with an empty database; then restart.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Your database is the only stateful piece — back it up, and back up the
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
"label": "API token HMAC key",
|
||||
"description": "Generate with: openssl rand -hex 32"
|
||||
},
|
||||
{
|
||||
"name": "HEALTHLOG_PLATFORM",
|
||||
"label": "Platform hint",
|
||||
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
|
||||
"default": "portainer"
|
||||
},
|
||||
{
|
||||
"name": "SESSION_COOKIE_SECURE",
|
||||
"label": "Session cookie Secure flag",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# HealthLog on TrueNAS SCALE
|
||||
|
||||
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
|
||||
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
|
||||
is stateless; everything you care about lives in the database dataset and in
|
||||
one value you must keep yourself: the encryption key.
|
||||
|
||||
## What the app needs
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | at least 60 s, with more retries than the image's 3 × 30 s: migrations run at every boot |
|
||||
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
|
||||
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
|
||||
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
|
||||
| `APP_URL` | yes | the same address |
|
||||
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
|
||||
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
|
||||
|
||||
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
|
||||
and runs the background jobs.
|
||||
|
||||
## Plain HTTP on the LAN
|
||||
|
||||
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
|
||||
for that. If it is left at its default, the browser drops the sign-in cookie
|
||||
and the login page says so before you type a password: "This server only sets
|
||||
its sign-in cookie over https://".
|
||||
|
||||
## Back up your encryption key
|
||||
|
||||
HealthLog encrypts tokens, notes, documents and in-database backups with
|
||||
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
|
||||
and reinstalled with a new key over the old database dataset, the data is
|
||||
still there but cannot be read.
|
||||
|
||||
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
|
||||
Environment) into your password manager.
|
||||
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
|
||||
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
|
||||
Compare it with your copy:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
or paste the copy into "Check my copy" (it is compared and dropped, never
|
||||
stored or logged).
|
||||
|
||||
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
|
||||
After a key rotation the step comes back for the new key.
|
||||
|
||||
## When the key does not match the database
|
||||
|
||||
At every start HealthLog checks that its key opens the data already in the
|
||||
database. If it does not, it refuses to serve instead of failing on every
|
||||
encrypted value: pages show "HealthLog cannot open its data", API requests
|
||||
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
|
||||
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
|
||||
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
|
||||
stays visible.
|
||||
|
||||
Fix it one of three ways, then restart the app:
|
||||
|
||||
1. Put the original `ENCRYPTION_KEY` back.
|
||||
2. Point `DATABASE_URL` at the database that belongs to this key.
|
||||
3. Start fresh with an empty database. The old data and its backups need the
|
||||
old key.
|
||||
|
||||
## Updates
|
||||
|
||||
Pick the new tag, take a snapshot of the database dataset first, and update.
|
||||
Migrations run on start; the first start of a new install applies all of them,
|
||||
which is why the start period above is generous.
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
|
||||
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
|
||||
|
||||
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- The encryption key backup step and the boot check that the configured key
|
||||
-- opens this database.
|
||||
--
|
||||
-- Four nullable columns on the instance settings row record which key the
|
||||
-- operator confirmed a backup of (id and fingerprint, never the key). The
|
||||
-- canary table holds one known plaintext per key id, sealed under that key; a
|
||||
-- process whose key cannot open it refuses to serve. Both are written at
|
||||
-- runtime, so the migration only creates the shapes.
|
||||
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_at" TIMESTAMP(3);
|
||||
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_key_id" TEXT;
|
||||
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_fingerprint" TEXT;
|
||||
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_by_user_id" TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "encryption_key_canaries" (
|
||||
"key_id" TEXT NOT NULL,
|
||||
"ciphertext" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encryption_key_canaries_pkey" PRIMARY KEY ("key_id")
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -322,6 +322,11 @@
|
||||
"purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.",
|
||||
"example": "false"
|
||||
},
|
||||
{
|
||||
"name": "HEALTHLOG_PLATFORM",
|
||||
"purpose": "Where this server's environment is configured (compose, truenas, coolify, unraid, portainer). Shown back to the admin: the \"Back up your encryption key\" step opens on that platform's instructions. Unset means compose.",
|
||||
"example": "compose"
|
||||
},
|
||||
{
|
||||
"name": "TRUST_PROXY_HOPS",
|
||||
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
|
||||
|
||||
@@ -239,6 +239,18 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup/confirm": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup/verify": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
|
||||
|
||||
@@ -71,6 +71,11 @@ const MANIFEST_NAMES: string[] = (
|
||||
* entry a lie rather than an inconvenience.
|
||||
*/
|
||||
const PUBLISHED_TO_USERS = [
|
||||
{
|
||||
name: "HEALTHLOG_PLATFORM",
|
||||
shownBy:
|
||||
"the admin's encryption key backup step, which opens on that platform",
|
||||
},
|
||||
{
|
||||
name: "AUDIT_LOG_RETENTION_DAYS",
|
||||
shownBy: "the shared-access activity window and the revoke dialog",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
|
||||
*
|
||||
* The admin states that the active encryption key is backed up. The body
|
||||
* names the key the admin was shown; when that is no longer the active key
|
||||
* (rotated, or the server restarted with another key since the page loaded)
|
||||
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
|
||||
* No step-up: confirming is a low-risk write that reveals nothing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
confirmKeyBackup,
|
||||
KEY_BACKUP_STALE_CODE,
|
||||
} from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const confirmSchema = z
|
||||
.object({
|
||||
keyId: z.string().min(1).max(32),
|
||||
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = confirmSchema.safeParse(body.data);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error);
|
||||
|
||||
const result = await confirmKeyBackup({
|
||||
keyId: parsed.data.keyId,
|
||||
fingerprint: parsed.data.fingerprint,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
annotate({ meta: { key_backup_stale: true } });
|
||||
return apiError(
|
||||
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
|
||||
409,
|
||||
{
|
||||
errorCode: KEY_BACKUP_STALE_CODE,
|
||||
activeKeyId: result.activeKeyId,
|
||||
fingerprint: result.fingerprint,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("encryption.keyBackup.confirmed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
keyId: result.status.activeKeyId,
|
||||
fingerprint: result.status.fingerprint,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result.status);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/key-backup
|
||||
*
|
||||
* Whether the "Back up your encryption key" step is due, for the admin step,
|
||||
* the overview tile and the dashboard banner. A light read (one settings row),
|
||||
* separate from `/status`, which walks the whole encrypted corpus. Key id and
|
||||
* fingerprint only, never key material. Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
|
||||
const status = await readKeyBackupStatus();
|
||||
annotate({ meta: { key_backup_due: status.due } });
|
||||
return apiSuccess(status);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
|
||||
*
|
||||
* "Check my copy": the admin pastes the backed-up key and learns whether it
|
||||
* is the active key. The value is compared in memory (constant time on the
|
||||
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
|
||||
* the Zod issue list is not returned for this body (a validation failure is a
|
||||
* plain 422), and the field name is on the observability denylist.
|
||||
*
|
||||
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
|
||||
* per admin is there to keep the route from being anyone's busy loop. Over
|
||||
* plain HTTP the value crosses the network in clear, as the admin's password
|
||||
* does; the client says so before sending.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KEY_BACKUP_VERIFY_LIMIT = 5;
|
||||
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const verifySchema = z
|
||||
.object({ encryptionKey: z.string().min(1).max(256) })
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
|
||||
|
||||
const limit = await checkRateLimit(
|
||||
`key-backup-verify:${user.id}`,
|
||||
KEY_BACKUP_VERIFY_LIMIT,
|
||||
KEY_BACKUP_VERIFY_WINDOW_MS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
return apiError("Too many checks. Try again in a minute.", 429);
|
||||
}
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = verifySchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
// Deliberately not `returnAllZodIssues`: nothing derived from this body
|
||||
// goes back over the wire.
|
||||
return apiError("Paste the key as 64 hex characters.", 422);
|
||||
}
|
||||
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
|
||||
annotate({ meta: { key_backup_verify_matches: matches } });
|
||||
await auditLog("encryption.keyBackup.verified", {
|
||||
userId: user.id,
|
||||
details: { keyId: activeKeyId, matches },
|
||||
});
|
||||
return apiSuccess({ matches, keyId: activeKeyId });
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
@@ -213,6 +214,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const rotation = await readRotationState();
|
||||
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
||||
const keyBackup = await readKeyBackupStatus();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -233,6 +235,8 @@ export const GET = apiHandler(async () => {
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
backups,
|
||||
// The "Back up your encryption key" step, bound to the active key.
|
||||
keyBackup,
|
||||
// Every row rotated AND no backup still needs a retired key: the one
|
||||
// state in which dropping an old key loses nothing. Copies that recorded
|
||||
// no key ids count against it, because they may hold any key that
|
||||
|
||||
@@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => {
|
||||
expect(ready.body).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/health while the encryption key does not match", () => {
|
||||
it("answers 503 with the reason, for everyone, even with every component up", async () => {
|
||||
const { setKeyMismatchState } =
|
||||
await import("@/lib/boot/key-mismatch-state");
|
||||
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
|
||||
getGlobalBoss.mockReturnValue({});
|
||||
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
|
||||
try {
|
||||
const { response, body } = await health();
|
||||
expect(response.status).toBe(503);
|
||||
expect(body).toEqual({
|
||||
status: "degraded",
|
||||
reason: "encryption_key_mismatch",
|
||||
});
|
||||
} finally {
|
||||
setKeyMismatchState(null);
|
||||
}
|
||||
const { response, body } = await health();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* What every page renders while the boot key check has found that the
|
||||
* configured encryption key cannot open this database (see
|
||||
* `src/lib/crypto/canary.ts`). A static server-rendered page with no client
|
||||
* code and no data reads: the app behind it would only fail on every value.
|
||||
* The API answers 503 with `encryption.key_mismatch` at the same time, and
|
||||
* `/api/health` names the reason.
|
||||
*/
|
||||
export function KeyMismatchPage({ locale }: { locale: Locale }) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
return (
|
||||
<main className="bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<div className="border-border bg-card w-full max-w-xl space-y-4 rounded-xl border p-4 md:p-6">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("admin.keyMismatch.title")}
|
||||
</h1>
|
||||
<p className="text-sm">{t("admin.keyMismatch.body")}</p>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("admin.keyMismatch.fixTitle")}
|
||||
</h2>
|
||||
<ol className="list-decimal space-y-2 ps-5 text-sm">
|
||||
<li>{t("admin.keyMismatch.fixRestore")}</li>
|
||||
<li>{t("admin.keyMismatch.fixDatabase")}</li>
|
||||
<li>{t("admin.keyMismatch.fixFresh")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.keyMismatch.restartNote")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.keyMismatch.codes", {
|
||||
code: "encryption.key_mismatch",
|
||||
reason: "encryption_key_mismatch",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+16
-8
@@ -8,6 +8,8 @@ import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
|
||||
import { KeyMismatchPage } from "./key-mismatch-page";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@@ -131,17 +133,23 @@ export default async function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
{isKeyMismatch() ? (
|
||||
// The boot key check refused this process: no app shell, no data
|
||||
// reads, one page that names the fix.
|
||||
<KeyMismatchPage locale={initialLocale} />
|
||||
) : (
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
block mutations. Resolve it here (server component) and
|
||||
thread the boolean into the client shell so the demo
|
||||
banner can render without a client-side detection path. */}
|
||||
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
|
||||
{children}
|
||||
</AuthShell>
|
||||
</Providers>
|
||||
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
|
||||
{children}
|
||||
</AuthShell>
|
||||
</Providers>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
useDashboardChartReveal,
|
||||
} from "@/components/dashboard/chart-reveal";
|
||||
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
|
||||
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
|
||||
import { TodayHero } from "@/components/daily/today-hero";
|
||||
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
|
||||
import {
|
||||
@@ -913,6 +914,7 @@ export default function DashboardPageClient({
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
|
||||
<KeyBackupBanner isAdmin={user?.role === "ADMIN"} />
|
||||
|
||||
{/* Records other people share with you are reached from the account
|
||||
switcher in the top bar and the sidebar, not from a card here. The
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The key backup step and its dashboard banner.
|
||||
*
|
||||
* 1. the card shows the key id and fingerprint and never anything shaped
|
||||
* like key material; the platform hint picks the open tab;
|
||||
* 2. a due step offers the confirmation, a done step does not;
|
||||
* 3. the banner renders only for an admin and only while the step is due;
|
||||
* 4. the copy resolves in German too.
|
||||
*
|
||||
* Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red;
|
||||
* render the confirm button unconditionally and case 2 goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { KeyBackupCard } from "@/components/admin/key-backup-card";
|
||||
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
|
||||
import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/encryption",
|
||||
}));
|
||||
|
||||
const DUE: KeyBackupStatus = {
|
||||
due: true,
|
||||
activeKeyId: "v1",
|
||||
fingerprint: "a1b2c3d4e5f6",
|
||||
confirmedAt: null,
|
||||
confirmedKeyId: null,
|
||||
confirmedFingerprint: null,
|
||||
confirmedBy: null,
|
||||
platformHint: "truenas",
|
||||
};
|
||||
|
||||
const DONE: KeyBackupStatus = {
|
||||
...DUE,
|
||||
due: false,
|
||||
confirmedAt: "2026-10-03T10:00:00.000Z",
|
||||
confirmedKeyId: "v1",
|
||||
confirmedFingerprint: "a1b2c3d4e5f6",
|
||||
confirmedBy: { id: "u1", email: "admin@example.test" },
|
||||
};
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
status: KeyBackupStatus | null,
|
||||
locale: "en" | "de" = "en",
|
||||
): string {
|
||||
const client = new QueryClient();
|
||||
if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("KeyBackupCard", () => {
|
||||
it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => {
|
||||
const html = render(<KeyBackupCard />, DUE);
|
||||
expect(html).toContain("a1b2c3d4e5f6");
|
||||
expect(html).toContain(">v1<");
|
||||
expect(html).toContain("Apps, select HealthLog");
|
||||
expect(html).not.toMatch(/[0-9a-f]{64}/);
|
||||
});
|
||||
|
||||
it("offers the confirmation only while the step is due", () => {
|
||||
expect(render(<KeyBackupCard />, DUE)).toContain(
|
||||
"I have backed up this key",
|
||||
);
|
||||
const done = render(<KeyBackupCard />, DONE);
|
||||
expect(done).not.toContain("I have backed up this key");
|
||||
expect(done).toContain("admin@example.test");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render(<KeyBackupCard />, DUE, "de")).toContain(
|
||||
"Sichere deinen Verschlüsselungsschlüssel",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyBackupBanner", () => {
|
||||
it("renders for an admin while the step is due", () => {
|
||||
expect(render(<KeyBackupBanner isAdmin />, DUE)).toContain(
|
||||
'data-testid="key-backup-banner"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a non-admin, or once the step is done", () => {
|
||||
expect(render(<KeyBackupBanner isAdmin={false} />, DUE)).toBe("");
|
||||
expect(render(<KeyBackupBanner isAdmin />, DONE)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import { KeyBackupCard } from "./key-backup-card";
|
||||
|
||||
interface ColumnScan {
|
||||
model: string;
|
||||
@@ -130,27 +131,30 @@ export function EncryptionSection() {
|
||||
// the fix carried across.
|
||||
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
description={t("admin.section.encryption.coverageDescription")}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.encryption.coverageDetail")}
|
||||
</p>
|
||||
|
||||
{statusQuery.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.section.encryption.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("admin.section.encryption.loadError")}
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
description={t("admin.section.encryption.coverageDescription")}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.encryption.coverageDetail")}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{statusQuery.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.section.encryption.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("admin.section.encryption.loadError")}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +166,7 @@ export function EncryptionSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<KeyBackupCard>` — the "Back up your encryption key" step on the admin
|
||||
* Encryption page.
|
||||
*
|
||||
* Shows the active key id and its fingerprint (never the key), where the key
|
||||
* lives on each platform, what is lost without it, a "Check my copy" field
|
||||
* and the confirmation. The confirmation is bound to the key id and
|
||||
* fingerprint shown here: if the server's key changed since the page loaded,
|
||||
* the server answers 409 and the card reloads.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
useKeyBackupStatus,
|
||||
type KeyBackupPlatform,
|
||||
type KeyBackupStatus,
|
||||
} from "./use-key-backup-status";
|
||||
|
||||
const PLATFORMS: KeyBackupPlatform[] = [
|
||||
"compose",
|
||||
"truenas",
|
||||
"coolify",
|
||||
"unraid",
|
||||
"portainer",
|
||||
];
|
||||
|
||||
/** Literal keys per platform, so the i18n coverage guard sees every one. */
|
||||
function platformCopy(
|
||||
t: (key: string) => string,
|
||||
p: KeyBackupPlatform,
|
||||
): { label: string; where: string } {
|
||||
switch (p) {
|
||||
case "truenas":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.truenasLabel"),
|
||||
where: t("admin.keyBackup.platform.truenasWhere"),
|
||||
};
|
||||
case "coolify":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.coolifyLabel"),
|
||||
where: t("admin.keyBackup.platform.coolifyWhere"),
|
||||
};
|
||||
case "unraid":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.unraidLabel"),
|
||||
where: t("admin.keyBackup.platform.unraidWhere"),
|
||||
};
|
||||
case "portainer":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.portainerLabel"),
|
||||
where: t("admin.keyBackup.platform.portainerWhere"),
|
||||
};
|
||||
case "compose":
|
||||
return {
|
||||
label: t("admin.keyBackup.platform.composeLabel"),
|
||||
where: t("admin.keyBackup.platform.composeWhere"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function KeyBackupCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const status = useKeyBackupStatus();
|
||||
const [candidate, setCandidate] = useState("");
|
||||
const [checkResult, setCheckResult] = useState<boolean | null>(null);
|
||||
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminEncryptionKeyBackup(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const confirm = useMutation({
|
||||
mutationFn: (s: KeyBackupStatus) =>
|
||||
apiPost<KeyBackupStatus>(
|
||||
"/api/admin/encryption/key-backup/confirm",
|
||||
{ keyId: s.activeKeyId, fingerprint: s.fingerprint },
|
||||
{ credentials: "include" },
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast(t("admin.keyBackup.confirmed"));
|
||||
await invalidate();
|
||||
},
|
||||
onError: async (err: Error) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
toast.error(t("admin.keyBackup.stale"));
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
// Not `apiPost`: no idempotency key, so no cached response row exists
|
||||
// for a request whose body was a key.
|
||||
mutationFn: (encryptionKey: string) =>
|
||||
apiFetch<{ matches: boolean }>(
|
||||
"/api/admin/encryption/key-backup/verify",
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ encryptionKey }),
|
||||
},
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setCheckResult(data.matches);
|
||||
setCandidate("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCheckResult(null);
|
||||
toast.error(err.message || t("admin.keyBackup.checkFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
const insecure =
|
||||
typeof window !== "undefined" && window.isSecureContext === false;
|
||||
|
||||
if (!status.data) {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.keyBackup.title")}
|
||||
description={t("admin.keyBackup.description")}
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorRow
|
||||
message={t("admin.keyBackup.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.keyBackup.loading")}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
const s = status.data;
|
||||
return (
|
||||
<SettingsCard data-testid="key-backup-card">
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.keyBackup.title")}
|
||||
description={t("admin.keyBackup.description")}
|
||||
status={
|
||||
s.due ? (
|
||||
<Badge variant="outline" className="border-warning/40 text-warning">
|
||||
{t("admin.keyBackup.badgeDue")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("admin.keyBackup.badgeDone")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.keyBackup.keyId")}
|
||||
</dt>
|
||||
<dd className="font-mono">{s.activeKeyId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.keyBackup.fingerprint")}
|
||||
</dt>
|
||||
<dd className="font-mono" data-testid="key-backup-fingerprint">
|
||||
{s.fingerprint}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="text-sm">{t("admin.keyBackup.whatIsLost")}</p>
|
||||
|
||||
<Tabs defaultValue={s.platformHint}>
|
||||
<TabsList className="flex-wrap">
|
||||
{PLATFORMS.map((p) => (
|
||||
<TabsTrigger key={p} value={p}>
|
||||
{platformCopy(t, p).label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{PLATFORMS.map((p) => (
|
||||
<TabsContent key={p} value={p} className="text-sm">
|
||||
{platformCopy(t, p).where}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.keyBackup.fingerprintHint")}
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (candidate.trim()) verify.mutate(candidate);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="key-backup-candidate">
|
||||
{t("admin.keyBackup.checkLabel")}
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="key-backup-candidate"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={candidate}
|
||||
onChange={(e) => {
|
||||
setCandidate(e.target.value);
|
||||
setCheckResult(null);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!candidate.trim() || verify.isPending}
|
||||
>
|
||||
{verify.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.keyBackup.checkButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{insecure
|
||||
? t("admin.keyBackup.checkInsecureNote")
|
||||
: t("admin.keyBackup.checkNote")}
|
||||
</p>
|
||||
{checkResult !== null && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="key-backup-check-result"
|
||||
className={
|
||||
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{checkResult
|
||||
? t("admin.keyBackup.checkMatches")
|
||||
: t("admin.keyBackup.checkNoMatch")}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{!s.due && s.confirmedAt && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{s.confirmedBy?.email
|
||||
? t("admin.keyBackup.confirmedAtBy", {
|
||||
date: fmt.dateTime(new Date(s.confirmedAt)),
|
||||
who: s.confirmedBy.email,
|
||||
})
|
||||
: t("admin.keyBackup.confirmedAt", {
|
||||
date: fmt.dateTime(new Date(s.confirmedAt)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{s.due && s.confirmedKeyId && (
|
||||
<p className="text-sm">
|
||||
{t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{s.due && (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
onClick={() => confirm.mutate(s)}
|
||||
disabled={confirm.isPending}
|
||||
>
|
||||
{confirm.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.keyBackup.confirmButton")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -12,18 +12,28 @@
|
||||
* don't re-fetch.
|
||||
*/
|
||||
|
||||
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
|
||||
import {
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError, refetch } = useSystemStatus();
|
||||
const { data: version } = usePublicVersion();
|
||||
const { data: keyBackup } = useKeyBackupStatus();
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
@@ -63,6 +73,19 @@ export function SystemStatusSummary() {
|
||||
label={t("admin.overview.snapshotStarted")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
{/* v1.40.0 — the encryption key backup step, warning while due. */}
|
||||
{keyBackup && (
|
||||
<StatusItem
|
||||
icon={KeyRound}
|
||||
label={t("admin.keyBackup.tileLabel")}
|
||||
value={
|
||||
keyBackup.due
|
||||
? t("admin.keyBackup.tileDue")
|
||||
: t("admin.keyBackup.tileDone")
|
||||
}
|
||||
tone={keyBackup.due ? "warning" : "success"}
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.27 R5 — surface the offline-geo state so the maintainer
|
||||
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
|
||||
The field is undefined on legacy responses; the row only
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export type KeyBackupPlatform =
|
||||
"compose" | "truenas" | "coolify" | "unraid" | "portainer";
|
||||
|
||||
/** `GET /api/admin/encryption/key-backup`; key id and fingerprint only. */
|
||||
export interface KeyBackupStatus {
|
||||
due: boolean;
|
||||
activeKeyId: string;
|
||||
fingerprint: string;
|
||||
confirmedAt: string | null;
|
||||
confirmedKeyId: string | null;
|
||||
confirmedFingerprint: string | null;
|
||||
confirmedBy: { id: string; email: string | null } | null;
|
||||
platformHint: KeyBackupPlatform;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key backup step's state. `enabled` lets the dashboard banner ask only
|
||||
* for an admin; the route itself is admin-only and cookie-only.
|
||||
*/
|
||||
export function useKeyBackupStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminEncryptionKeyBackup(),
|
||||
queryFn: () =>
|
||||
apiGet<KeyBackupStatus>("/api/admin/encryption/key-backup", {
|
||||
credentials: "include",
|
||||
}),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Dashboard banner for an admin while the encryption key backup step is due.
|
||||
* Not dismissable by design: it goes away when the step is done, and comes
|
||||
* back when the key changes. Renders nothing for anyone who is not an admin,
|
||||
* and asks the server nothing for them either.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
|
||||
|
||||
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useKeyBackupStatus(isAdmin);
|
||||
if (!isAdmin || !data?.due) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="key-backup-banner"
|
||||
className="border-warning/40 bg-warning/10 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center"
|
||||
>
|
||||
<KeyRound className="text-warning h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("dashboard.keyBackupBanner.title")}
|
||||
</p>
|
||||
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/encryption"
|
||||
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("dashboard.keyBackupBanner.action")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,19 @@ export async function register() {
|
||||
// A readiness summary must never break boot.
|
||||
}
|
||||
|
||||
// Does the configured encryption key open this database? A process whose
|
||||
// key does not refuses every API request with one clear 503 and starts
|
||||
// neither the producer nor the worker (a wrong key must never write).
|
||||
try {
|
||||
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
|
||||
import("@/lib/boot/key-check"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
if (await runBootKeyCheck(prisma)) return;
|
||||
} catch (err) {
|
||||
console.error("[instrumentation] Encryption key check crashed:", err);
|
||||
}
|
||||
|
||||
// Every Node process watches its own event loop — web-only
|
||||
// containers included; a stall report from the process serving
|
||||
// requests is the whole point.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { setKeyMismatchState } from "@/lib/boot/key-mismatch-state";
|
||||
|
||||
const handler = vi.fn(async (_request: NextRequest) =>
|
||||
Response.json({ data: "ran", error: null }),
|
||||
);
|
||||
const route = apiHandler(handler);
|
||||
|
||||
function call(path: string) {
|
||||
return route(new NextRequest(`http://localhost${path}`));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setKeyMismatchState(null);
|
||||
handler.mockClear();
|
||||
});
|
||||
|
||||
describe("apiHandler while the encryption key does not match", () => {
|
||||
it("refuses every route with 503 encryption.key_mismatch before the handler runs", async () => {
|
||||
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
|
||||
const response = await call("/api/measurements");
|
||||
expect(response.status).toBe(503);
|
||||
const body = await response.json();
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
|
||||
expect(response.headers.get("Retry-After")).toBe("300");
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still serves /api/health and /api/version", async () => {
|
||||
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
|
||||
for (const path of ["/api/health", "/api/version"]) {
|
||||
const response = await call(path);
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("runs the handler when the key check passed", async () => {
|
||||
const response = await call("/api/measurements");
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+22
-1
@@ -53,6 +53,11 @@ import {
|
||||
assertRecordSessionFence,
|
||||
attachRecordContextEcho,
|
||||
} from "./sharing/record-session-fence";
|
||||
import {
|
||||
isKeyMismatch,
|
||||
KEY_MISMATCH_ERROR_CODE,
|
||||
KEY_MISMATCH_EXEMPT_PATHS,
|
||||
} from "./boot/key-mismatch-state";
|
||||
import {
|
||||
capturedRateLimit,
|
||||
rateLimitResponseHeaders,
|
||||
@@ -258,7 +263,23 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
eventStorage.run(evt, async () => {
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
response = await handler(...args);
|
||||
// The boot key check found that the configured encryption key
|
||||
// cannot open this database. Refuse before auth, before the
|
||||
// handler: nothing here may read or write encrypted data.
|
||||
if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) {
|
||||
annotate({ action: { name: "encryption.key_mismatch.refused" } });
|
||||
response = NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.",
|
||||
meta: { errorCode: KEY_MISMATCH_ERROR_CODE },
|
||||
},
|
||||
{ status: 503, headers: { "Retry-After": "300" } },
|
||||
);
|
||||
} else {
|
||||
response = await handler(...args);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AiUnavailableError) {
|
||||
// An AI capability is unavailable for this record. One envelope
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Runs the encryption key check once per process at boot and records the
|
||||
* verdict for the request path (`./key-mismatch-state.ts`).
|
||||
*
|
||||
* Returns true when the process must refuse: the caller then starts neither
|
||||
* the queue producer nor the worker, because a process holding the wrong key
|
||||
* must never write. The process keeps running so `/api/health` and the
|
||||
* explanation page can say why; a crash loop would hide the reason in a log
|
||||
* that the people most likely to hit this (a NAS app catalog install) never
|
||||
* open.
|
||||
*/
|
||||
import {
|
||||
checkEncryptionKeyCanaries,
|
||||
keyMismatchLogBlock,
|
||||
type CanaryClient,
|
||||
} from "@/lib/crypto/canary";
|
||||
import { setKeyMismatchState } from "./key-mismatch-state";
|
||||
|
||||
export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
if (outcome.state === "mismatch") {
|
||||
setKeyMismatchState({
|
||||
keyIds: outcome.keyIds,
|
||||
detectedAt: new Date().toISOString(),
|
||||
});
|
||||
console.error(keyMismatchLogBlock(outcome.keyIds));
|
||||
return true;
|
||||
}
|
||||
setKeyMismatchState(null);
|
||||
if (outcome.state === "error") {
|
||||
// The check could not run. The loaders and the database each have their
|
||||
// own loud signal for that; serving stays on.
|
||||
console.warn(
|
||||
`[boot] Encryption key check skipped: ${outcome.message.slice(0, 300)}`,
|
||||
);
|
||||
} else if (outcome.written.length > 0) {
|
||||
console.info(
|
||||
`[boot] Encryption key check: recorded key id(s) ${outcome.written.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Process-wide record of the boot key check's verdict.
|
||||
*
|
||||
* The check runs once in `instrumentation.ts`; the readers are `apiHandler`
|
||||
* (every API route), `/api/health` and the root layout. Those live in
|
||||
* different bundles of the same Node process, so module state is not shared
|
||||
* between them and the verdict is pinned on `globalThis` under a registry
|
||||
* symbol instead. This module imports nothing, so pulling it into the request
|
||||
* path costs nothing.
|
||||
*
|
||||
* Only a positive mismatch is recorded. A check that could not run (database
|
||||
* unreachable, keys not configured) leaves the process serving: those failures
|
||||
* already have their own loud signals, and refusing on them would turn a
|
||||
* transient database hiccup at boot into an outage.
|
||||
*/
|
||||
|
||||
export interface KeyMismatchState {
|
||||
/** Configured key ids whose canary did not open under the configured key. */
|
||||
keyIds: string[];
|
||||
/** When the check found it (ISO). */
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch");
|
||||
|
||||
type GlobalWithState = typeof globalThis & {
|
||||
[STATE_SYMBOL]?: KeyMismatchState | null;
|
||||
};
|
||||
|
||||
/** The paths that keep answering while the process refuses everything else. */
|
||||
export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet<string> = new Set([
|
||||
"/api/health",
|
||||
"/api/version",
|
||||
]);
|
||||
|
||||
/** Wire code for the refusal; catalogued in `src/lib/openapi/error-codes.ts`. */
|
||||
export const KEY_MISMATCH_ERROR_CODE = "encryption.key_mismatch";
|
||||
|
||||
/** The `/api/health` reason while the process refuses. */
|
||||
export const KEY_MISMATCH_HEALTH_REASON = "encryption_key_mismatch";
|
||||
|
||||
export function getKeyMismatchState(): KeyMismatchState | null {
|
||||
return (globalThis as GlobalWithState)[STATE_SYMBOL] ?? null;
|
||||
}
|
||||
|
||||
export function isKeyMismatch(): boolean {
|
||||
return getKeyMismatchState() !== null;
|
||||
}
|
||||
|
||||
export function setKeyMismatchState(state: KeyMismatchState | null): void {
|
||||
(globalThis as GlobalWithState)[STATE_SYMBOL] = state;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_resetCryptoCacheForTests,
|
||||
candidateMatchesKey,
|
||||
encrypt,
|
||||
encryptUnderKeyId,
|
||||
fingerprintKeyBytes,
|
||||
getKeyFingerprint,
|
||||
} from "@/lib/crypto";
|
||||
import {
|
||||
canaryPlaintext,
|
||||
checkEncryptionKeyCanaries,
|
||||
keyMismatchLogBlock,
|
||||
type CanaryClient,
|
||||
} from "../canary";
|
||||
|
||||
const KEY_A =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_B =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function useKey(hex: string) {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", hex);
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
/** An in-memory stand-in for the two raw statements and the probe delegates. */
|
||||
function fakeClient(opts: {
|
||||
canaries?: Record<string, string>;
|
||||
probe?: Record<string, Record<string, string>>;
|
||||
}) {
|
||||
const canaries = new Map(Object.entries(opts.canaries ?? {}));
|
||||
const inserts: string[] = [];
|
||||
const client = {
|
||||
async $queryRaw() {
|
||||
return [...canaries.entries()].map(([key_id, ciphertext]) => ({
|
||||
key_id,
|
||||
ciphertext,
|
||||
}));
|
||||
},
|
||||
async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) {
|
||||
const [keyId, ciphertext] = values as [string, string];
|
||||
inserts.push(keyId);
|
||||
if (!canaries.has(keyId)) canaries.set(keyId, ciphertext);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as CanaryClient & Record<string, unknown>;
|
||||
for (const [delegate, fields] of Object.entries(opts.probe ?? {})) {
|
||||
client[delegate] = {
|
||||
async findFirst(args: { select: Record<string, boolean> }) {
|
||||
const field = Object.keys(args.select)[0];
|
||||
return field in fields ? { [field]: fields[field] } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
return { client, canaries, inserts };
|
||||
}
|
||||
|
||||
describe("encryption key canary", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
useKey(KEY_A);
|
||||
});
|
||||
|
||||
it("writes a canary for a configured key with no row and no data", async () => {
|
||||
const { client, canaries, inserts } = fakeClient({});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome).toEqual({ state: "ok", written: ["v1"], verified: [] });
|
||||
expect(inserts).toEqual(["v1"]);
|
||||
expect(canaries.get("v1")?.startsWith("v1.")).toBe(true);
|
||||
});
|
||||
|
||||
it("verifies a canary written under the same key", async () => {
|
||||
const { client, inserts } = fakeClient({
|
||||
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v1"), "v1") },
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome).toEqual({ state: "ok", written: [], verified: ["v1"] });
|
||||
expect(inserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a mismatch when the key changed under the same id", async () => {
|
||||
const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
|
||||
useKey(KEY_B);
|
||||
const { client, inserts } = fakeClient({ canaries: { v1: sealedUnderA } });
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
|
||||
expect(inserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a mismatch for a canary that opens to the wrong value", async () => {
|
||||
// A row from another key id copied over: opens, but says the wrong thing.
|
||||
const { client } = fakeClient({
|
||||
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v2"), "v1") },
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome.state).toBe("mismatch");
|
||||
});
|
||||
|
||||
it("refuses to seal a wrong key when existing data predates the canary", async () => {
|
||||
const existing = encrypt("a stored token");
|
||||
useKey(KEY_B);
|
||||
const { client, inserts } = fakeClient({
|
||||
probe: { user: { codexAccessTokenEncrypted: existing } },
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
|
||||
expect(inserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes the canary when existing data opens under the key", async () => {
|
||||
const { client, inserts } = fakeClient({
|
||||
probe: { user: { codexAccessTokenEncrypted: encrypt("a stored token") } },
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome.state).toBe("ok");
|
||||
expect(inserts).toEqual(["v1"]);
|
||||
});
|
||||
|
||||
it("reports an error, not a mismatch, when the table cannot be read", async () => {
|
||||
const client = {
|
||||
$queryRaw: async () => {
|
||||
throw new Error('relation "encryption_key_canaries" does not exist');
|
||||
},
|
||||
$executeRaw: async () => 0,
|
||||
} as unknown as CanaryClient;
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome.state).toBe("error");
|
||||
});
|
||||
|
||||
it("reports an error when no key is configured", async () => {
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
_resetCryptoCacheForTests();
|
||||
const outcome = await checkEncryptionKeyCanaries(fakeClient({}).client);
|
||||
expect(outcome.state).toBe("error");
|
||||
});
|
||||
|
||||
it("names the key ids and never a key in the log block", () => {
|
||||
const block = keyMismatchLogBlock(["v1"]);
|
||||
expect(block).toContain("'v1'");
|
||||
expect(block).toContain("encryption.key_mismatch");
|
||||
expect(block).not.toContain(KEY_A);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key fingerprint and copy check", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
useKey(KEY_A);
|
||||
});
|
||||
|
||||
it("is the first 12 hex of SHA-256 over the raw key bytes", () => {
|
||||
expect(getKeyFingerprint("v1")).toBe(
|
||||
fingerprintKeyBytes(Buffer.from(KEY_A, "hex")),
|
||||
);
|
||||
expect(getKeyFingerprint("v1")).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(getKeyFingerprint("v1")).not.toBe(
|
||||
fingerprintKeyBytes(Buffer.from(KEY_B, "hex")),
|
||||
);
|
||||
expect(getKeyFingerprint("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches the configured key in hex, base64 and with whitespace", () => {
|
||||
expect(candidateMatchesKey(KEY_A, "v1")).toBe(true);
|
||||
expect(candidateMatchesKey(` ${KEY_A.toUpperCase()}\n`, "v1")).toBe(true);
|
||||
expect(
|
||||
candidateMatchesKey(Buffer.from(KEY_A, "hex").toString("base64"), "v1"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match another key, garbage, or an unknown id", () => {
|
||||
expect(candidateMatchesKey(KEY_B, "v1")).toBe(false);
|
||||
expect(candidateMatchesKey("not a key", "v1")).toBe(false);
|
||||
expect(candidateMatchesKey(KEY_A, "v9")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -60,11 +60,19 @@ const RELATION_ENVELOPE = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Columns this scan derives that are deliberately NOT in the rotation
|
||||
* registry. Empty today. An entry here is a written decision with a reason,
|
||||
* never a way to quiet the guard.
|
||||
* Columns that hold ciphertext and are deliberately NOT in the rotation
|
||||
* registry. An entry here is a written decision with a reason, never a way to
|
||||
* quiet the guard.
|
||||
*
|
||||
* `EncryptionKeyCanary.ciphertext` — one known value per key id, sealed under
|
||||
* THAT key (`encryptUnderKeyId`, written by raw SQL in
|
||||
* `src/lib/crypto/canary.ts`). Re-sealing it under the active key would make
|
||||
* every id's boot check test the active key instead of its own. A retired
|
||||
* key's row is never read again: only configured ids are checked.
|
||||
*/
|
||||
const NOT_ROTATED: ReadonlySet<string> = new Set<string>();
|
||||
const NOT_ROTATED: ReadonlySet<string> = new Set<string>([
|
||||
"EncryptionKeyCanary.ciphertext",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Ciphertext written where the Prisma-call walk below cannot see it: by raw
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isKeyBackupDue, resolvePlatformHint } from "../key-backup";
|
||||
|
||||
const confirmed = (keyId: string, fingerprint: string) => ({
|
||||
encryptionKeyBackupConfirmedAt: new Date("2026-10-03T10:00:00Z"),
|
||||
encryptionKeyBackupConfirmedKeyId: keyId,
|
||||
encryptionKeyBackupConfirmedFingerprint: fingerprint,
|
||||
});
|
||||
|
||||
describe("key backup step", () => {
|
||||
it("is due without any confirmation", () => {
|
||||
expect(isKeyBackupDue(null, "v1", "abcdefabcdef")).toBe(true);
|
||||
expect(
|
||||
isKeyBackupDue(
|
||||
{
|
||||
encryptionKeyBackupConfirmedAt: null,
|
||||
encryptionKeyBackupConfirmedKeyId: null,
|
||||
encryptionKeyBackupConfirmedFingerprint: null,
|
||||
},
|
||||
"v1",
|
||||
"abcdefabcdef",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is done only for the exact key that was confirmed", () => {
|
||||
expect(
|
||||
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "abcdefabcdef"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is due again after a rotation (new active id)", () => {
|
||||
expect(
|
||||
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v2", "abcdefabcdef"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is due again after a re-keyed install (same id, other key)", () => {
|
||||
expect(
|
||||
isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "123456123456"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the platform hint and falls back to compose", () => {
|
||||
expect(resolvePlatformHint("truenas")).toBe("truenas");
|
||||
expect(resolvePlatformHint(" Unraid ")).toBe("unraid");
|
||||
expect(resolvePlatformHint(undefined)).toBe("compose");
|
||||
expect(resolvePlatformHint("kubernetes")).toBe("compose");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Boot check: does the configured encryption key open this database?
|
||||
*
|
||||
* Without it, a server started with a different `ENCRYPTION_KEY` than the one
|
||||
* its database was written with (a reinstalled NAS app over an old dataset, a
|
||||
* restored dump under a fresh key) signs people in normally, because sessions
|
||||
* hash with `API_TOKEN_HMAC_KEY`, and then answers random 500s wherever a value
|
||||
* has to be decrypted. This module turns that into one clear refusal.
|
||||
*
|
||||
* For every configured key id, `encryption_key_canaries` holds a known
|
||||
* plaintext sealed under that key:
|
||||
*
|
||||
* - row present: it must decrypt to the expected value, else the key is not
|
||||
* the one the row was written with;
|
||||
* - row absent: before writing one, look for ANY existing ciphertext under
|
||||
* that key id in the registered string columns and try it. Without this
|
||||
* probe, the first boot after an upgrade (or a re-keyed boot over a
|
||||
* database that predates the canary) would seal the wrong key as the
|
||||
* right one. A value that opens, or no value at all, lets the canary be
|
||||
* written (`ON CONFLICT DO NOTHING`, so two processes racing is fine).
|
||||
*
|
||||
* The probe is bounded by a time budget; past it, the canary is written
|
||||
* without a verdict from existing data, which is exactly the behaviour before
|
||||
* this check existed.
|
||||
*
|
||||
* Never logs or returns key material. The canary is written by raw SQL and is
|
||||
* deliberately not in the rotation registry: it belongs to its key id.
|
||||
*/
|
||||
import {
|
||||
decrypt,
|
||||
encryptUnderKeyId,
|
||||
extractKeyId,
|
||||
getConfiguredKeyIds,
|
||||
} from "@/lib/crypto";
|
||||
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
export const CANARY_PREFIX = "healthlog-canary:";
|
||||
|
||||
export function canaryPlaintext(keyId: string): string {
|
||||
return `${CANARY_PREFIX}${keyId}`;
|
||||
}
|
||||
|
||||
/** How long the existing-data probe may take before the canary is written anyway. */
|
||||
export const PROBE_BUDGET_MS = 10_000;
|
||||
|
||||
/** The slice of a Prisma client this module needs. */
|
||||
export interface CanaryClient {
|
||||
$queryRaw<T = unknown>(
|
||||
query: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): Promise<T>;
|
||||
$executeRaw(
|
||||
query: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): Promise<number>;
|
||||
}
|
||||
|
||||
type ProbeDelegate = {
|
||||
findFirst: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean>;
|
||||
}) => Promise<Record<string, unknown> | null>;
|
||||
};
|
||||
|
||||
export type KeyCheckOutcome =
|
||||
| {
|
||||
state: "ok";
|
||||
/** Key ids whose canary this run wrote. */
|
||||
written: string[];
|
||||
/** Key ids whose canary existed and opened. */
|
||||
verified: string[];
|
||||
}
|
||||
| { state: "mismatch"; keyIds: string[] }
|
||||
| { state: "error"; message: string };
|
||||
|
||||
function opensAs(ciphertext: string, keyId: string, expected?: string) {
|
||||
try {
|
||||
if (extractKeyId(ciphertext) !== keyId) return false;
|
||||
const plain = decrypt(ciphertext);
|
||||
return expected === undefined ? true : plain === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for one existing value sealed under `keyId` and try it. Returns
|
||||
* `"opens"`, `"fails"`, or `"none"` (no value found inside the budget).
|
||||
*/
|
||||
export async function probeExistingData(
|
||||
client: unknown,
|
||||
keyId: string,
|
||||
deadline: number,
|
||||
): Promise<"opens" | "fails" | "none"> {
|
||||
const delegates = client as Record<string, ProbeDelegate | undefined>;
|
||||
for (const column of ENCRYPTED_COLUMNS) {
|
||||
if (Date.now() > deadline) return "none";
|
||||
if (column.kind !== "string" || column.codec || column.codecField) {
|
||||
continue;
|
||||
}
|
||||
const delegate =
|
||||
delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)];
|
||||
if (!delegate || typeof delegate.findFirst !== "function") continue;
|
||||
let row: Record<string, unknown> | null;
|
||||
try {
|
||||
row = await delegate.findFirst({
|
||||
where: { [column.field]: { startsWith: `${keyId}.` } },
|
||||
select: { [column.field]: true },
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const value = row?.[column.field];
|
||||
if (typeof value !== "string") continue;
|
||||
return opensAs(value, keyId) ? "opens" : "fails";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the check for every configured key id. Never throws: a failure to run
|
||||
* the check at all is reported as `error`, and only a value that provably does
|
||||
* not open is reported as `mismatch`.
|
||||
*/
|
||||
export async function checkEncryptionKeyCanaries(
|
||||
client: CanaryClient,
|
||||
options: { probeBudgetMs?: number } = {},
|
||||
): Promise<KeyCheckOutcome> {
|
||||
let keyIds: string[];
|
||||
try {
|
||||
keyIds = getConfiguredKeyIds();
|
||||
} catch (err) {
|
||||
return { state: "error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
let rows: Array<{ key_id: string; ciphertext: string }>;
|
||||
try {
|
||||
rows = await client.$queryRaw<
|
||||
Array<{ key_id: string; ciphertext: string }>
|
||||
>`SELECT key_id, ciphertext FROM encryption_key_canaries WHERE key_id = ANY(${keyIds})`;
|
||||
} catch (err) {
|
||||
return { state: "error", message: (err as Error).message };
|
||||
}
|
||||
const stored = new Map(rows.map((r) => [r.key_id, r.ciphertext]));
|
||||
|
||||
const mismatched: string[] = [];
|
||||
const verified: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const keyId of keyIds) {
|
||||
const ciphertext = stored.get(keyId);
|
||||
if (ciphertext === undefined) {
|
||||
missing.push(keyId);
|
||||
} else if (opensAs(ciphertext, keyId, canaryPlaintext(keyId))) {
|
||||
verified.push(keyId);
|
||||
} else {
|
||||
mismatched.push(keyId);
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + (options.probeBudgetMs ?? PROBE_BUDGET_MS);
|
||||
const toWrite: string[] = [];
|
||||
for (const keyId of missing) {
|
||||
const probe = await probeExistingData(client, keyId, deadline);
|
||||
if (probe === "fails") mismatched.push(keyId);
|
||||
else toWrite.push(keyId);
|
||||
}
|
||||
|
||||
if (mismatched.length > 0) {
|
||||
// Nothing is written while any key fails: a process in this state must
|
||||
// not leave anything behind sealed under a key that may be wrong.
|
||||
return { state: "mismatch", keyIds: mismatched.sort() };
|
||||
}
|
||||
|
||||
try {
|
||||
for (const keyId of toWrite) {
|
||||
const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId);
|
||||
await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`;
|
||||
}
|
||||
} catch (err) {
|
||||
return { state: "error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
return { state: "ok", written: toWrite, verified };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one block an operator reads in the container log. English literal on
|
||||
* purpose: logs are not translated. Names the key ids, never a key.
|
||||
*/
|
||||
export function keyMismatchLogBlock(keyIds: string[]): string {
|
||||
const ids = keyIds.map((id) => `'${id}'`).join(", ");
|
||||
return [
|
||||
"============================================================",
|
||||
" HealthLog refuses to serve: encryption key does not match",
|
||||
"============================================================",
|
||||
` The encryption key configured for key id ${ids} cannot open the`,
|
||||
" data already stored in this database. Serving anyway would fail on",
|
||||
" every encrypted value, so every API request answers 503 with",
|
||||
" errorCode 'encryption.key_mismatch' and /api/health reports",
|
||||
" reason 'encryption_key_mismatch'. Background jobs are not started.",
|
||||
"",
|
||||
" Fix it one of three ways, then restart:",
|
||||
" 1. Restore the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry)",
|
||||
" this database was written with.",
|
||||
" 2. Point DATABASE_URL at the database that belongs to this key.",
|
||||
" 3. Start fresh: an empty database with this key. Stored data and",
|
||||
" in-database backups of the old database cannot be read without",
|
||||
" the old key.",
|
||||
"============================================================",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* The "Back up your encryption key" step: instance state and its rules.
|
||||
*
|
||||
* The confirmation lives on the `AppSettings` singleton, bound to the active
|
||||
* key id and that key's fingerprint, so it is an instance fact (it survives
|
||||
* the admin who gave it) and a rotation or a re-keyed install makes the step
|
||||
* due again. Nothing here ever reads, returns or stores the key itself.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { envValue } from "@/lib/env";
|
||||
import { getActiveKeyId, getKeyFingerprint } from "@/lib/crypto";
|
||||
|
||||
/** Where the operator sets the key; picks the tab the step opens on. */
|
||||
export const KEY_BACKUP_PLATFORMS = [
|
||||
"compose",
|
||||
"truenas",
|
||||
"coolify",
|
||||
"unraid",
|
||||
"portainer",
|
||||
] as const;
|
||||
export type KeyBackupPlatform = (typeof KEY_BACKUP_PLATFORMS)[number];
|
||||
|
||||
/** Wire code for a confirmation given for a key that is no longer active. */
|
||||
export const KEY_BACKUP_STALE_CODE = "encryption.keyBackup.stale";
|
||||
|
||||
/**
|
||||
* `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise.
|
||||
* Catalog templates set it so the step opens on the right instructions.
|
||||
*/
|
||||
export function resolvePlatformHint(
|
||||
raw: string | undefined = envValue("HEALTHLOG_PLATFORM"),
|
||||
): KeyBackupPlatform {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
return (KEY_BACKUP_PLATFORMS as readonly string[]).includes(value ?? "")
|
||||
? (value as KeyBackupPlatform)
|
||||
: "compose";
|
||||
}
|
||||
|
||||
export interface KeyBackupStatus {
|
||||
/** True until a confirmation exists for the active key id AND fingerprint. */
|
||||
due: boolean;
|
||||
activeKeyId: string;
|
||||
/** Fingerprint of the active key (first 12 hex of SHA-256 over its bytes). */
|
||||
fingerprint: string;
|
||||
confirmedAt: string | null;
|
||||
confirmedKeyId: string | null;
|
||||
confirmedFingerprint: string | null;
|
||||
/** The confirming admin, while that account exists. */
|
||||
confirmedBy: { id: string; email: string | null } | null;
|
||||
platformHint: KeyBackupPlatform;
|
||||
}
|
||||
|
||||
interface StoredConfirmation {
|
||||
encryptionKeyBackupConfirmedAt: Date | null;
|
||||
encryptionKeyBackupConfirmedKeyId: string | null;
|
||||
encryptionKeyBackupConfirmedFingerprint: string | null;
|
||||
}
|
||||
|
||||
/** Pure rule: is the stored confirmation for this exact key? */
|
||||
export function isKeyBackupDue(
|
||||
stored: StoredConfirmation | null,
|
||||
activeKeyId: string,
|
||||
fingerprint: string,
|
||||
): boolean {
|
||||
return !(
|
||||
stored?.encryptionKeyBackupConfirmedAt &&
|
||||
stored.encryptionKeyBackupConfirmedKeyId === activeKeyId &&
|
||||
stored.encryptionKeyBackupConfirmedFingerprint === fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
function activeKey(): { activeKeyId: string; fingerprint: string } {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
// The active id is always configured, so the fingerprint is never null.
|
||||
return { activeKeyId, fingerprint: getKeyFingerprint(activeKeyId) ?? "" };
|
||||
}
|
||||
|
||||
export async function readKeyBackupStatus(): Promise<KeyBackupStatus> {
|
||||
const { activeKeyId, fingerprint } = activeKey();
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
encryptionKeyBackupConfirmedAt: true,
|
||||
encryptionKeyBackupConfirmedKeyId: true,
|
||||
encryptionKeyBackupConfirmedFingerprint: true,
|
||||
encryptionKeyBackupConfirmedByUserId: true,
|
||||
},
|
||||
});
|
||||
const confirmedBy = row?.encryptionKeyBackupConfirmedByUserId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: row.encryptionKeyBackupConfirmedByUserId },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
due: isKeyBackupDue(row, activeKeyId, fingerprint),
|
||||
activeKeyId,
|
||||
fingerprint,
|
||||
confirmedAt: row?.encryptionKeyBackupConfirmedAt?.toISOString() ?? null,
|
||||
confirmedKeyId: row?.encryptionKeyBackupConfirmedKeyId ?? null,
|
||||
confirmedFingerprint: row?.encryptionKeyBackupConfirmedFingerprint ?? null,
|
||||
confirmedBy: confirmedBy ?? null,
|
||||
platformHint: resolvePlatformHint(),
|
||||
};
|
||||
}
|
||||
|
||||
export type ConfirmKeyBackupResult =
|
||||
| { ok: true; status: KeyBackupStatus }
|
||||
| { ok: false; activeKeyId: string; fingerprint: string };
|
||||
|
||||
/**
|
||||
* Record the confirmation, but only for the key the admin was shown: a page
|
||||
* opened before a rotation must not confirm the new key.
|
||||
*/
|
||||
export async function confirmKeyBackup(input: {
|
||||
keyId: string;
|
||||
fingerprint: string;
|
||||
userId: string;
|
||||
}): Promise<ConfirmKeyBackupResult> {
|
||||
const { activeKeyId, fingerprint } = activeKey();
|
||||
if (input.keyId !== activeKeyId || input.fingerprint !== fingerprint) {
|
||||
return { ok: false, activeKeyId, fingerprint };
|
||||
}
|
||||
const now = new Date();
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
encryptionKeyBackupConfirmedAt: now,
|
||||
encryptionKeyBackupConfirmedKeyId: activeKeyId,
|
||||
encryptionKeyBackupConfirmedFingerprint: fingerprint,
|
||||
encryptionKeyBackupConfirmedByUserId: input.userId,
|
||||
},
|
||||
update: {
|
||||
encryptionKeyBackupConfirmedAt: now,
|
||||
encryptionKeyBackupConfirmedKeyId: activeKeyId,
|
||||
encryptionKeyBackupConfirmedFingerprint: fingerprint,
|
||||
encryptionKeyBackupConfirmedByUserId: input.userId,
|
||||
},
|
||||
});
|
||||
return { ok: true, status: await readKeyBackupStatus() };
|
||||
}
|
||||
@@ -295,6 +295,8 @@ export const WIPE_EXEMPT: Readonly<Record<string, string>> = {
|
||||
*/
|
||||
export const INSTANCE_SCOPED: Readonly<Record<string, string>> = {
|
||||
AppSettings: "instance-wide configuration singleton",
|
||||
EncryptionKeyCanary:
|
||||
"one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account",
|
||||
RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring",
|
||||
HostMetric: "instance-wide host telemetry, not attributable to an account",
|
||||
OffhostBackupKeyUse:
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,6 +165,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"encounter.reminder-conflict",
|
||||
"encounter.reminder-not-found",
|
||||
],
|
||||
encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"],
|
||||
environment: [
|
||||
"environment.invalid",
|
||||
"environment.no_home",
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -48,6 +48,9 @@ export const adminKeys = {
|
||||
adminBackupRestores: () => ["admin", "backup-restores"] as const,
|
||||
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
|
||||
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
|
||||
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
|
||||
adminEncryptionKeyBackup: () =>
|
||||
["admin", "encryption", "key-backup"] as const,
|
||||
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
|
||||
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
|
||||
adminFeedbackRoot: () => ["admin", "feedback"] as const,
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* The boot encryption key check and the "Back up your encryption key" step,
|
||||
* against real Postgres.
|
||||
*
|
||||
* - a first boot writes a canary; a second boot with the same key opens it;
|
||||
* - a boot with another key under the same id refuses: the state is set,
|
||||
* every API route answers 503 `encryption.key_mismatch`, `/api/health`
|
||||
* names the reason, and nothing is written;
|
||||
* - a database written before the canary existed is probed: data that does
|
||||
* not open under the configured key refuses rather than sealing the wrong
|
||||
* key as the right one;
|
||||
* - the confirmation binds to the active key id and fingerprint, refuses a
|
||||
* stale one with 409, and is due again after a re-keyed install;
|
||||
* - "Check my copy" answers a boolean, stores nothing, and is rate limited.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
_resetCryptoCacheForTests,
|
||||
encrypt,
|
||||
getKeyFingerprint,
|
||||
} from "@/lib/crypto";
|
||||
import { runBootKeyCheck } from "@/lib/boot/key-check";
|
||||
import {
|
||||
isKeyMismatch,
|
||||
setKeyMismatchState,
|
||||
} from "@/lib/boot/key-mismatch-state";
|
||||
|
||||
const KEY_A =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_B =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function useKey(hex: string) {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", hex);
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
async function seedAdmin(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "key-admin",
|
||||
email: "key-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
/** The GET handlers take no parameter; `apiHandler` still reads the request. */
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
const asRoute = (fn: unknown) => fn as RouteFn;
|
||||
|
||||
function post(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.$executeRaw`DELETE FROM encryption_key_canaries`;
|
||||
await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'key-backup-verify:%'`;
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
useKey(KEY_A);
|
||||
setKeyMismatchState(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setKeyMismatchState(null);
|
||||
vi.unstubAllEnvs();
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
describe("boot encryption key check (real Postgres)", () => {
|
||||
it("writes a canary on first boot and opens it on the next", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
expect(await runBootKeyCheck(prisma)).toBe(false);
|
||||
const rows = await prisma.encryptionKeyCanary.findMany();
|
||||
expect(rows.map((r) => r.keyId)).toEqual(["v1"]);
|
||||
expect(rows[0].ciphertext.startsWith("v1.")).toBe(true);
|
||||
|
||||
expect(await runBootKeyCheck(prisma)).toBe(false);
|
||||
expect(isKeyMismatch()).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to serve when the key changed under the same id", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await runBootKeyCheck(prisma);
|
||||
const before = await prisma.encryptionKeyCanary.findMany();
|
||||
|
||||
useKey(KEY_B);
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(await runBootKeyCheck(prisma)).toBe(true);
|
||||
expect(errors.mock.calls[0]?.[0]).toContain("encryption.key_mismatch");
|
||||
errors.mockRestore();
|
||||
expect(isKeyMismatch()).toBe(true);
|
||||
// Nothing re-sealed under the wrong key.
|
||||
expect(await prisma.encryptionKeyCanary.findMany()).toEqual(before);
|
||||
|
||||
await seedAdmin();
|
||||
const { GET: keyBackupGet } =
|
||||
await import("@/app/api/admin/encryption/key-backup/route");
|
||||
const refused = await asRoute(keyBackupGet)(
|
||||
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
|
||||
);
|
||||
expect(refused.status).toBe(503);
|
||||
const body = await refused.json();
|
||||
expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" });
|
||||
|
||||
const { GET: healthGet } = await import("@/app/api/health/route");
|
||||
const health = await asRoute(healthGet)(
|
||||
new NextRequest("http://localhost/api/health"),
|
||||
);
|
||||
expect(health.status).toBe(503);
|
||||
const healthBody = await health.json();
|
||||
expect(healthBody.status).toBe("degraded");
|
||||
expect(healthBody.reason).toBe("encryption_key_mismatch");
|
||||
|
||||
// Restoring the original key clears it on the next boot.
|
||||
useKey(KEY_A);
|
||||
expect(await runBootKeyCheck(prisma)).toBe(false);
|
||||
expect(isKeyMismatch()).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses rather than sealing the wrong key over data that predates the canary", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: "existing",
|
||||
email: "existing@example.test",
|
||||
codexAccessTokenEncrypted: encrypt("a stored token"),
|
||||
},
|
||||
});
|
||||
|
||||
useKey(KEY_B);
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(await runBootKeyCheck(prisma)).toBe(true);
|
||||
errors.mockRestore();
|
||||
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
|
||||
|
||||
// With the right key the same data lets the canary be written.
|
||||
useKey(KEY_A);
|
||||
expect(await runBootKeyCheck(prisma)).toBe(false);
|
||||
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryption key backup step (real Postgres)", () => {
|
||||
it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const adminId = await seedAdmin();
|
||||
const { GET } = await import("@/app/api/admin/encryption/key-backup/route");
|
||||
const { POST: confirm } =
|
||||
await import("@/app/api/admin/encryption/key-backup/confirm/route");
|
||||
const status = async () =>
|
||||
(
|
||||
await (
|
||||
await asRoute(GET)(
|
||||
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
|
||||
)
|
||||
).json()
|
||||
).data;
|
||||
|
||||
const first = await status();
|
||||
expect(first.due).toBe(true);
|
||||
expect(first.activeKeyId).toBe("v1");
|
||||
expect(first.fingerprint).toBe(getKeyFingerprint("v1"));
|
||||
expect(first.platformHint).toBe("compose");
|
||||
expect(JSON.stringify(first)).not.toContain(KEY_A);
|
||||
|
||||
const stale = await confirm(
|
||||
post("/api/admin/encryption/key-backup/confirm", {
|
||||
keyId: "v1",
|
||||
fingerprint: "000000000000",
|
||||
}),
|
||||
);
|
||||
expect(stale.status).toBe(409);
|
||||
expect((await stale.json()).meta.errorCode).toBe(
|
||||
"encryption.keyBackup.stale",
|
||||
);
|
||||
expect(
|
||||
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
|
||||
?.encryptionKeyBackupConfirmedAt ?? null,
|
||||
).toBeNull();
|
||||
|
||||
const ok = await confirm(
|
||||
post("/api/admin/encryption/key-backup/confirm", {
|
||||
keyId: "v1",
|
||||
fingerprint: first.fingerprint,
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
const confirmed = (await ok.json()).data;
|
||||
expect(confirmed.due).toBe(false);
|
||||
expect(confirmed.confirmedBy).toEqual({
|
||||
id: adminId,
|
||||
email: "key-admin@example.test",
|
||||
});
|
||||
const audit = await prisma.auditLog.findFirst({
|
||||
where: { action: "encryption.keyBackup.confirmed" },
|
||||
});
|
||||
expect(audit).not.toBeNull();
|
||||
|
||||
vi.stubEnv("HEALTHLOG_PLATFORM", "truenas");
|
||||
useKey(KEY_B);
|
||||
const after = await status();
|
||||
expect(after.due).toBe(true);
|
||||
expect(after.confirmedKeyId).toBe("v1");
|
||||
expect(after.platformHint).toBe("truenas");
|
||||
});
|
||||
|
||||
it("checks a copy without storing it, and limits the checks", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdmin();
|
||||
const { POST: verify } =
|
||||
await import("@/app/api/admin/encryption/key-backup/verify/route");
|
||||
const check = (encryptionKey: string) =>
|
||||
verify(
|
||||
post("/api/admin/encryption/key-backup/verify", { encryptionKey }),
|
||||
);
|
||||
|
||||
const good = await check(`${KEY_A}\n`);
|
||||
expect(good.status).toBe(200);
|
||||
expect((await good.json()).data).toEqual({ matches: true, keyId: "v1" });
|
||||
|
||||
const bad = await check(KEY_B);
|
||||
expect((await bad.json()).data.matches).toBe(false);
|
||||
|
||||
const invalid = await verify(
|
||||
post("/api/admin/encryption/key-backup/verify", { encryptionKey: "" }),
|
||||
);
|
||||
expect(invalid.status).toBe(422);
|
||||
expect(JSON.stringify(await invalid.json())).not.toContain("issues");
|
||||
|
||||
// Nothing of the candidate is stored anywhere a row could hold it.
|
||||
const audits = await prisma.auditLog.findMany();
|
||||
expect(JSON.stringify(audits)).not.toContain(KEY_A);
|
||||
expect(JSON.stringify(audits)).not.toContain(KEY_B);
|
||||
expect(
|
||||
(await prisma.appSettings.findUnique({ where: { id: "singleton" } }))
|
||||
?.encryptionKeyBackupConfirmedAt ?? null,
|
||||
).toBeNull();
|
||||
|
||||
await check(KEY_B);
|
||||
await check(KEY_B);
|
||||
const limited = await check(KEY_A);
|
||||
expect(limited.status).toBe(429);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user