diff --git a/.env.example b/.env.example index efe6a7a8d..8d7802659 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,13 @@ NODE_ENV="development" # your encryption key" step shows first; unset means compose. # HEALTHLOG_PLATFORM="compose" +# Start-up encryption key check: enforce (default) refuses to serve when the +# key does not open the stored data; warn only logs it and serves anyway. A +# last resort for a refusal you are certain is false (a first start, with no +# recorded key to reset). It disables a safety check: with a wrong key every +# encrypted value fails and new rows are written under that key. +# ENCRYPTION_KEY_CHECK="enforce" + # ----------------------------------------------------------------------------- # Required Secrets — generate with `openssl rand -hex 32` diff --git a/.env.production.example b/.env.production.example index e913ade7a..ef4445e8b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -266,6 +266,14 @@ API_TOKEN_HMAC_KEY="" # it for you. # HEALTHLOG_PLATFORM="compose" +# Start-up encryption key check: enforce (default) refuses to serve when the +# key does not open the stored data; warn only logs it, reports it as a +# warning on /api/health, and serves anyway. A last resort for a refusal you +# are certain is false (a first start, with no recorded key to reset). It +# disables a safety check: with a wrong key every encrypted value fails and +# new rows are written under that key. Remove it once the key is confirmed. +# ENCRYPTION_KEY_CHECK="enforce" + # ----------------------------------------------------------------------------- # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback diff --git a/docker-compose.yml b/docker-compose.yml index c9216b398..d4022f1a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,11 @@ x-healthlog-env: &healthlog_env # compose | truenas | coolify | unraid | portainer. Only chooses which # instructions the admin's "Back up your encryption key" step shows first. HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}" + # enforce (default) | warn. The start-up encryption key check refuses to + # serve when the key does not open the stored data; warn only logs it and + # serves anyway. A last resort for a refusal you are certain is false: it + # disables a safety check. See docs/self-hosting/getting-started.md. + ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}" BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}" BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index cc0499067..88b88f0eb 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -295,6 +295,15 @@ one clear refusal. After a key rotation the rotation script removes the record of a retired key id itself, once it reports that no values remain under that id. +At a first start there is no record yet, so there is nothing to reset. If +the check refuses there and you are certain the key is right, the last +resort is `ENCRYPTION_KEY_CHECK=warn` (default `enforce`): the check still +runs and logs its full result, `/api/health` names it as +`warning: "encryption_key_mismatch"` without failing, requests are served, +and no key is recorded while the check does not pass. This disables a +safety check. With a wrong key every encrypted value fails and new rows are +written under that key, so remove the setting once the key is confirmed. + ## Backup and restore Your database is the only stateful piece — back it up, and back up the diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json index 6b2c51f83..bfa70a388 100644 --- a/docs/self-hosting/portainer/templates.json +++ b/docs/self-hosting/portainer/templates.json @@ -36,6 +36,12 @@ "description": "Opens the admin's encryption key backup step on the Portainer instructions.", "default": "portainer" }, + { + "name": "ENCRYPTION_KEY_CHECK", + "label": "Encryption key check", + "description": "enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false; it disables a safety check.", + "default": "enforce" + }, { "name": "SESSION_COOKIE_SECURE", "label": "Session cookie Secure flag", diff --git a/docs/self-hosting/truenas.md b/docs/self-hosting/truenas.md index c39f6e016..0b34fe4eb 100644 --- a/docs/self-hosting/truenas.md +++ b/docs/self-hosting/truenas.md @@ -106,6 +106,15 @@ fails on every encrypted value instead of refusing clearly, so only do this when you are sure. After a key rotation you never need this for the old key id: the rotation script removes its record once no values remain under it. +On a first start there is no record to remove. If the check refuses there and +you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under +Apps, HealthLog, Edit, Environment as a last resort. The check still runs and +logs, `/api/health` reports `warning: "encryption_key_mismatch"` without +failing, the app serves, and no key is recorded until the check passes. This +switches off a safety check: with a wrong key every encrypted value fails and +new data is written under that key. Remove the setting once the key is +confirmed. + ## Updates Pick the new tag, take a snapshot of the database dataset first, and update. diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml index 26ee42ee6..c6e92f981 100644 --- a/docs/self-hosting/unraid/healthlog.xml +++ b/docs/self-hosting/unraid/healthlog.xml @@ -54,6 +54,7 @@ false unraid + enforce diff --git a/messages/de.json b/messages/de.json index 26ece40e6..720463289 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7657,6 +7657,7 @@ "fixDatabase": "Oder lass DATABASE_URL auf die Datenbank zeigen, die zu diesem Schlüssel gehört.", "fixFresh": "Oder starte neu mit einer leeren Datenbank. Die alten Daten und ihre Sicherungen lassen sich nur mit dem alten Schlüssel lesen.", "restartNote": "Starte HealthLog nach der Änderung neu.", + "lastResort": "Letzter Ausweg, nur wenn du sicher bist, dass der Schlüssel stimmt, und keiner der drei Wege passt: setze {setting} und starte neu. HealthLog liefert dann trotzdem aus und schreibt den Befund nur ins Log. Damit ist eine Sicherheitsprüfung abgeschaltet: Mit einem falschen Schlüssel scheitert jeder verschlüsselte Wert, und neue Daten werden unter diesem Schlüssel geschrieben.", "codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}." } }, diff --git a/messages/en.json b/messages/en.json index e94c08bce..a181746f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7657,6 +7657,7 @@ "fixDatabase": "Or point DATABASE_URL at the database that belongs to this key.", "fixFresh": "Or start fresh with an empty database. The old data and its backups can only be read with the old key.", "restartNote": "Restart HealthLog after the change.", + "lastResort": "Last resort, only if you are certain the key is right and none of the three applies: set {setting} and restart. HealthLog then serves anyway and only logs the finding. This disables a safety check: with a wrong key every encrypted value fails and new data is written under that key.", "codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}." } }, diff --git a/messages/es.json b/messages/es.json index a2fff14e5..11b4babc8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7657,6 +7657,7 @@ "fixDatabase": "O haz que DATABASE_URL apunte a la base de datos que corresponde a esta clave.", "fixFresh": "O empieza de cero con una base de datos vacía. Los datos antiguos y sus copias solo se pueden leer con la clave antigua.", "restartNote": "Reinicia HealthLog después del cambio.", + "lastResort": "Último recurso, solo si estás seguro de que la clave es correcta y ninguna de las tres opciones sirve: define {setting} y reinicia. HealthLog sirve igualmente y solo registra el hallazgo. Esto desactiva una comprobación de seguridad: con una clave incorrecta falla cada valor cifrado y los datos nuevos se escriben con esa clave.", "codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}." } }, diff --git a/messages/fr.json b/messages/fr.json index 572487db8..b90e7c26f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7657,6 +7657,7 @@ "fixDatabase": "Ou fais pointer DATABASE_URL vers la base de données qui correspond à cette clé.", "fixFresh": "Ou repars de zéro avec une base vide. Les anciennes données et leurs sauvegardes ne se lisent qu'avec l'ancienne clé.", "restartNote": "Redémarre HealthLog après la modification.", + "lastResort": "Dernier recours, seulement si tu es sûr que la clé est la bonne et qu'aucune des trois solutions ne convient : définis {setting} et redémarre. HealthLog sert alors quand même et se contente de journaliser le constat. Cela désactive une vérification de sécurité : avec une mauvaise clé, chaque valeur chiffrée échoue et les nouvelles données sont écrites sous cette clé.", "codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}." } }, diff --git a/messages/it.json b/messages/it.json index 58d510fb7..883f22bc2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7657,6 +7657,7 @@ "fixDatabase": "Oppure fai puntare DATABASE_URL al database che appartiene a questa chiave.", "fixFresh": "Oppure ricomincia con un database vuoto. I vecchi dati e i loro backup si leggono solo con la vecchia chiave.", "restartNote": "Riavvia HealthLog dopo la modifica.", + "lastResort": "Ultima risorsa, solo se sei certo che la chiave sia giusta e nessuna delle tre soluzioni vale: imposta {setting} e riavvia. HealthLog serve comunque e si limita a registrare il problema nel log. Così si disattiva un controllo di sicurezza: con una chiave sbagliata ogni valore cifrato fallisce e i nuovi dati vengono scritti con quella chiave.", "codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}." } }, diff --git a/messages/ko.json b/messages/ko.json index 47f95296b..1e388d50f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7657,6 +7657,7 @@ "fixDatabase": "또는 DATABASE_URL이 이 키에 맞는 데이터베이스를 가리키게 하세요.", "fixFresh": "또는 빈 데이터베이스로 새로 시작하세요. 이전 데이터와 백업은 이전 키로만 읽을 수 있습니다.", "restartNote": "변경 후 HealthLog를 다시 시작하세요.", + "lastResort": "최후의 수단: 키가 맞다고 확신하고 위 세 가지가 모두 해당하지 않을 때만 {setting}을 설정하고 다시 시작하세요. HealthLog는 그래도 서비스하며 결과를 로그에만 남깁니다. 이 설정은 안전 검사를 끕니다. 키가 틀리면 모든 암호화된 값이 실패하고 새 데이터가 그 키로 기록됩니다.", "codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다." } }, diff --git a/messages/pl.json b/messages/pl.json index 3cee9f5c9..c45f77f06 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7657,6 +7657,7 @@ "fixDatabase": "Albo ustaw DATABASE_URL na bazę danych, która należy do tego klucza.", "fixFresh": "Albo zacznij od nowa z pustą bazą. Stare dane i ich kopie można odczytać tylko starym kluczem.", "restartNote": "Po zmianie uruchom HealthLog ponownie.", + "lastResort": "Ostateczność, tylko jeśli masz pewność, że klucz jest właściwy, a żadna z trzech dróg nie pasuje: ustaw {setting} i uruchom ponownie. HealthLog będzie wtedy działać mimo to i tylko zapisze wynik w logu. To wyłącza kontrolę bezpieczeństwa: przy złym kluczu każda zaszyfrowana wartość zawiedzie, a nowe dane zostaną zapisane tym kluczem.", "codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}." } }, diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 9639d8b0a..d63628946 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -327,6 +327,11 @@ "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": "ENCRYPTION_KEY_CHECK", + "purpose": "The start-up encryption key check. enforce (default) refuses to serve when the configured key does not open the stored data; warn only logs the finding, names it as a warning on /api/health, serves anyway and records no key until the check passes. A last resort for a refusal the operator is certain is false; it disables a safety check.", + "example": "enforce" + }, { "name": "TRUST_PROXY_HOPS", "purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.", diff --git a/src/__tests__/published-env-reaches-the-container.test.ts b/src/__tests__/published-env-reaches-the-container.test.ts index 2e1b54b21..11ccefdea 100644 --- a/src/__tests__/published-env-reaches-the-container.test.ts +++ b/src/__tests__/published-env-reaches-the-container.test.ts @@ -76,6 +76,11 @@ const PUBLISHED_TO_USERS = [ shownBy: "the admin's encryption key backup step, which opens on that platform", }, + { + name: "ENCRYPTION_KEY_CHECK", + shownBy: + "the key mismatch page and the start-up log, which offer it as the last resort", + }, { name: "AUDIT_LOG_RETENTION_DAYS", shownBy: "the shared-access activity window and the revoke dialog", diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts index 691c9c6fe..d3865a248 100644 --- a/src/app/api/health/__tests__/route.test.ts +++ b/src/app/api/health/__tests__/route.test.ts @@ -140,3 +140,26 @@ describe("GET /api/health while the encryption key does not match", () => { expect(body).toEqual({ status: "ok" }); }); }); + +describe("GET /api/health under ENCRYPTION_KEY_CHECK=warn", () => { + it("names the mismatch as a warning and stays ok", async () => { + const { setKeyMismatchWarning } = + await import("@/lib/boot/key-mismatch-state"); + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + getGlobalBoss.mockReturnValue({}); + setKeyMismatchWarning({ + keyIds: ["v1"], + detectedAt: "2026-10-03T00:00:00Z", + }); + try { + const { response, body } = await health(); + expect(response.status).toBe(200); + expect(body).toEqual({ + status: "ok", + warning: "encryption_key_mismatch", + }); + } finally { + setKeyMismatchWarning(null); + } + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 2b27876a6..2e7764036 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -7,6 +7,7 @@ import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { + getKeyMismatchWarning, isKeyMismatch, KEY_MISMATCH_HEALTH_REASON, } from "@/lib/boot/key-mismatch-state"; @@ -31,6 +32,12 @@ export const GET = apiHandler(async () => { // it is the one place a catalog install's status page reads. const keyMismatch = isKeyMismatch(); const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined; + // Under ENCRYPTION_KEY_CHECK=warn the same finding does not refuse; it is + // named as a warning and leaves the status alone. + const warning = + !keyMismatch && getKeyMismatchWarning() + ? KEY_MISMATCH_HEALTH_REASON + : undefined; const status = !keyMismatch && dbOk && @@ -51,6 +58,7 @@ export const GET = apiHandler(async () => { { status, ...(reason ? { reason } : {}), + ...(warning ? { warning } : {}), timestamp: new Date().toISOString(), database: dbOk ? "connected" : "disconnected", worker: worker.running ? "running" : "stopped", @@ -63,7 +71,7 @@ export const GET = apiHandler(async () => { } return NextResponse.json( - { status, ...(reason ? { reason } : {}) }, + { status, ...(reason ? { reason } : {}), ...(warning ? { warning } : {}) }, { status: statusCode, headers: cacheHeaders }, ); }); diff --git a/src/app/key-mismatch-page.tsx b/src/app/key-mismatch-page.tsx index 1d95eaf24..5517f1964 100644 --- a/src/app/key-mismatch-page.tsx +++ b/src/app/key-mismatch-page.tsx @@ -31,6 +31,11 @@ export function KeyMismatchPage({ locale }: { locale: Locale }) {

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

+

+ {t("admin.keyMismatch.lastResort", { + setting: "ENCRYPTION_KEY_CHECK=warn", + })} +

{t("admin.keyMismatch.codes", { code: "encryption.key_mismatch", diff --git a/src/lib/boot/key-check.ts b/src/lib/boot/key-check.ts index c4deeb0b7..96b42100c 100644 --- a/src/lib/boot/key-check.ts +++ b/src/lib/boot/key-check.ts @@ -15,7 +15,11 @@ import { type CanaryClient, type InconclusiveReason, } from "@/lib/crypto/canary"; -import { setKeyMismatchState } from "./key-mismatch-state"; +import { + getKeyCheckMode, + setKeyMismatchState, + setKeyMismatchWarning, +} from "./key-mismatch-state"; const INCONCLUSIVE_EXPLANATION: Record = { "single-value": "the only stored value found under that id did not open.", @@ -31,13 +35,28 @@ const INCONCLUSIVE_EXPLANATION: Record = { }; export async function runBootKeyCheck(client: CanaryClient): Promise { - const outcome = await checkEncryptionKeyCanaries(client); + const mode = getKeyCheckMode(); + const outcome = await checkEncryptionKeyCanaries(client, { mode }); + setKeyMismatchWarning(null); if (outcome.state === "mismatch") { - setKeyMismatchState({ + const state = { keyIds: outcome.keyIds, detectedAt: new Date().toISOString(), - }); + }; console.error(keyMismatchLogBlock(outcome.keyIds)); + if (mode === "warn") { + // The operator turned the refusal off. Say so next to the block, keep + // the finding for /api/health, and serve. + setKeyMismatchState(null); + setKeyMismatchWarning(state); + console.error( + "[boot] ENCRYPTION_KEY_CHECK=warn: serving anyway. No key is recorded " + + "while the check does not pass. Remove the setting once the key is " + + "confirmed.", + ); + return false; + } + setKeyMismatchState(state); return true; } setKeyMismatchState(null); @@ -55,6 +74,12 @@ export async function runBootKeyCheck(client: CanaryClient): Promise { "the check runs again at the next start.", ); } + if (mode === "warn" && outcome.inconclusive.length > 0) { + console.warn( + "[boot] ENCRYPTION_KEY_CHECK=warn: no key recorded on this start " + + "because the check did not pass for every key id.", + ); + } if (outcome.written.length > 0) { console.info( `[boot] Encryption key check: recorded key id(s) ${outcome.written.join(", ")}`, diff --git a/src/lib/boot/key-mismatch-state.ts b/src/lib/boot/key-mismatch-state.ts index 4f9864009..8e41c7016 100644 --- a/src/lib/boot/key-mismatch-state.ts +++ b/src/lib/boot/key-mismatch-state.ts @@ -22,11 +22,30 @@ export interface KeyMismatchState { } const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch"); +const WARNING_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatchWarning"); type GlobalWithState = typeof globalThis & { [STATE_SYMBOL]?: KeyMismatchState | null; + [WARNING_SYMBOL]?: KeyMismatchState | null; }; +/** + * `ENCRYPTION_KEY_CHECK`: `enforce` (the default, and what any other value + * means) refuses on a mismatch; `warn` runs the check, logs it, reports it as + * a warning on `/api/health`, and serves anyway. `warn` is the operator's way + * out of a refusal they are certain is false at a first boot, where there is + * no recorded key yet whose row they could delete. It disables a safety + * check: a process with the wrong key then serves errors on every encrypted + * value and writes new rows under that key. + */ +export type KeyCheckMode = "enforce" | "warn"; + +export function getKeyCheckMode(): KeyCheckMode { + return process.env.ENCRYPTION_KEY_CHECK?.trim().toLowerCase() === "warn" + ? "warn" + : "enforce"; +} + /** The paths that keep answering while the process refuses everything else. */ export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet = new Set([ "/api/health", @@ -50,3 +69,15 @@ export function isKeyMismatch(): boolean { export function setKeyMismatchState(state: KeyMismatchState | null): void { (globalThis as GlobalWithState)[STATE_SYMBOL] = state; } + +/** + * A mismatch the check found while `ENCRYPTION_KEY_CHECK=warn`: recorded so + * `/api/health` can name it as a warning, never read by the refusal. + */ +export function getKeyMismatchWarning(): KeyMismatchState | null { + return (globalThis as GlobalWithState)[WARNING_SYMBOL] ?? null; +} + +export function setKeyMismatchWarning(state: KeyMismatchState | null): void { + (globalThis as GlobalWithState)[WARNING_SYMBOL] = state; +} diff --git a/src/lib/crypto/__tests__/canary.test.ts b/src/lib/crypto/__tests__/canary.test.ts index bb70ee45b..f816bbb4b 100644 --- a/src/lib/crypto/__tests__/canary.test.ts +++ b/src/lib/crypto/__tests__/canary.test.ts @@ -32,6 +32,13 @@ const KEY_A = const KEY_B = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; +function configureKeys(keys: Record, active: string) { + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(keys)); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); +} + function configureKey(hex: string) { vi.stubEnv("ENCRYPTION_KEYS", ""); vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); @@ -394,6 +401,48 @@ describe("encryption key canary", () => { }); }); + describe("ENCRYPTION_KEY_CHECK=warn", () => { + it("records nothing for any key id while one is inconclusive", async () => { + configureKeys({ v1: KEY_A, v2: KEY_B }, "v2"); + const lone = encryptUnderKeyId("only value", "v1"); + configureKeys({ v1: KEY_B, v2: KEY_A }, "v2"); + const fake = fakeClient({ + probe: { "User.codexAccessTokenEncrypted": [lone] }, + }); + // Enforce records the proven id beside the inconclusive one. + expect(await fake.check()).toEqual( + ok({ + written: ["v2"], + inconclusive: [{ keyId: "v1", reason: "single-value" }], + }), + ); + const warn = fakeClient({ + probe: { "User.codexAccessTokenEncrypted": [lone] }, + }); + expect( + await checkEncryptionKeyCanaries(warn.client, { + sampler: warn.sampler, + mode: "warn", + }), + ).toEqual( + ok({ inconclusive: [{ keyId: "v1", reason: "single-value" }] }), + ); + expect(warn.inserts).toEqual([]); + }); + + it("records a key that passes for every id, as enforce does", async () => { + const warn = fakeClient({ + probe: { "User.codexAccessTokenEncrypted": [encrypt("opens")] }, + }); + expect( + await checkEncryptionKeyCanaries(warn.client, { + sampler: warn.sampler, + mode: "warn", + }), + ).toEqual(ok({ written: ["v1"] })); + }); + }); + describe("the first-seal race", () => { it("losing it to a process with another key is a mismatch", async () => { configureKey(KEY_B); diff --git a/src/lib/crypto/canary.ts b/src/lib/crypto/canary.ts index 60ee6077f..9cb3b0ce5 100644 --- a/src/lib/crypto/canary.ts +++ b/src/lib/crypto/canary.ts @@ -472,7 +472,16 @@ export async function columnsHoldingKeyId( */ export async function checkEncryptionKeyCanaries( client: CanaryClient, - options: { probeBudgetMs?: number; sampler?: ColumnSampler } = {}, + options: { + probeBudgetMs?: number; + sampler?: ColumnSampler; + /** + * `warn` (`ENCRYPTION_KEY_CHECK=warn`): record nothing unless every key + * id passed. The process serves whatever the outcome, so a key that is + * not proven for every id must not be recorded beside it. + */ + mode?: "enforce" | "warn"; + } = {}, ): Promise { let keyIds: string[]; try { @@ -531,8 +540,10 @@ export async function checkEncryptionKeyCanaries( } const written: string[] = []; + const sealable = + options.mode === "warn" && inconclusive.length > 0 ? [] : toWrite; try { - for (const keyId of toWrite) { + for (const keyId of sealable) { const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId); const inserted = await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`; @@ -617,6 +628,11 @@ export function keyMismatchLogBlock(keyIds: string[]): string { ...keyIds.map( (id) => ` DELETE FROM encryption_key_canaries WHERE key_id = '${id}';`, ), + "", + " Last resort, when no record exists yet to reset (a first start) and you", + " are certain the key is right: ENCRYPTION_KEY_CHECK=warn serves anyway", + " and only logs. It disables this safety check; with a wrong key every", + " encrypted value then fails and new rows are written under that key.", "============================================================", ].join("\n"); } diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 6e7a99a51..6be82f674 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -59,8 +59,10 @@ import { type CanaryClient, } from "@/lib/crypto/canary"; import { + getKeyMismatchWarning, isKeyMismatch, setKeyMismatchState, + setKeyMismatchWarning, } from "@/lib/boot/key-mismatch-state"; const KEY_A = @@ -392,6 +394,97 @@ describe("boot key check: what decides, and what may seal (real Postgres)", () = }); }); +describe("ENCRYPTION_KEY_CHECK (real Postgres)", () => { + afterEach(() => setKeyMismatchWarning(null)); + + it("warn: a mismatch is logged and reported, never refused, never recorded", async () => { + const prisma = getPrismaClient(); + await seedUser("warned", new Date("2024-01-01T00:00:00Z"), sealNow); + useKey(KEY_B); + vi.stubEnv("ENCRYPTION_KEY_CHECK", "warn"); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(false); + const logged = errors.mock.calls.map((c) => String(c[0])).join("\n"); + errors.mockRestore(); + expect(logged).toContain("encryption.key_mismatch"); + expect(logged).toContain("ENCRYPTION_KEY_CHECK=warn"); + expect(isKeyMismatch()).toBe(false); + expect(getKeyMismatchWarning()?.keyIds).toEqual(["v1"]); + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + + // Requests are served, through apiHandler and outside it. + await seedAdmin(); + const { GET: keyBackupGet } = + await import("@/app/api/admin/encryption/key-backup/route"); + const served = await asRoute(keyBackupGet)( + new NextRequest("http://localhost/api/admin/encryption/key-backup"), + ); + expect(served.status).toBe(200); + const { POST: mcp } = await import("@/app/mcp/route"); + const mcpRes = await mcp( + new Request("http://localhost/mcp", { method: "POST", body: "{}" }), + ); + expect(mcpRes.status).not.toBe(503); + + const { GET: healthGet } = await import("@/app/api/health/route"); + const healthBody = await ( + await asRoute(healthGet)(new NextRequest("http://localhost/api/health")) + ).json(); + expect(healthBody.warning).toBe("encryption_key_mismatch"); + expect(healthBody.reason).toBeUndefined(); + }); + + it("warn: nothing is recorded while any key id is inconclusive", async () => { + const prisma = getPrismaClient(); + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B })); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1"); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); + await prisma.user.create({ + data: { + username: "lone", + email: "lone@example.test", + codexAccessTokenEncrypted: encrypt("the only value"), + }, + }); + // v1 now holds a different key: its one value does not open. v2 has no + // data, which on its own would be recorded. + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_B, v2: KEY_A })); + _resetCryptoCacheForTests(); + vi.stubEnv("ENCRYPTION_KEY_CHECK", "warn"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(false); + warn.mockRestore(); + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + + // Enforce records the proven id, as before. + vi.stubEnv("ENCRYPTION_KEY_CHECK", "enforce"); + const warn2 = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(false); + warn2.mockRestore(); + const ids = (await prisma.encryptionKeyCanary.findMany()).map( + (r) => r.keyId, + ); + expect(ids).toEqual(["v2"]); + }); + + it("enforce, set explicitly or by any other value, refuses as before", async () => { + const prisma = getPrismaClient(); + await seedUser("enforced", new Date("2024-01-01T00:00:00Z"), sealNow); + useKey(KEY_B); + for (const value of ["enforce", "", "off"]) { + vi.stubEnv("ENCRYPTION_KEY_CHECK", value); + setKeyMismatchState(null); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(true); + errors.mockRestore(); + expect(isKeyMismatch()).toBe(true); + expect(getKeyMismatchWarning()).toBeNull(); + } + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + }); +}); + describe("retiring a key id after rotation (real Postgres)", () => { it("removes the canary of a key id only once no ciphertext remains under it", async () => { const prisma = getPrismaClient();