mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Add ENCRYPTION_KEY_CHECK=warn as an escape hatch for the boot key check
At a first start there is no recorded key yet, so an operator facing a refusal they are certain is false has no row to delete. With ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs its full outcome, /api/health names a mismatch as a warning field without failing, requests are served, and no key is recorded unless every key id passes. The variable is on the compose whitelist, in both example files, the env manifest, the Unraid and Portainer templates and the published-env guard. The log block, the mismatch page and the self-hosting pages offer it as the last resort after the three real fixes, and say that it disables a safety check.
This commit is contained in:
1 parent
4783711651
commit
69bae8bdb4
24 files changed
+319
-7
No files matched your search
@@ -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`
|
||||
|
||||
@@ -266,6 +266,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
|
||||
<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="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. 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, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</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"/>
|
||||
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7657,6 +7657,7 @@
|
||||
"fixDatabase": "또는 DATABASE_URL이 이 키에 맞는 데이터베이스를 가리키게 하세요.",
|
||||
"fixFresh": "또는 빈 데이터베이스로 새로 시작하세요. 이전 데이터와 백업은 이전 키로만 읽을 수 있습니다.",
|
||||
"restartNote": "변경 후 HealthLog를 다시 시작하세요.",
|
||||
"lastResort": "최후의 수단: 키가 맞다고 확신하고 위 세 가지가 모두 해당하지 않을 때만 {setting}을 설정하고 다시 시작하세요. HealthLog는 그래도 서비스하며 결과를 로그에만 남깁니다. 이 설정은 안전 검사를 끕니다. 키가 틀리면 모든 암호화된 값이 실패하고 새 데이터가 그 키로 기록됩니다.",
|
||||
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
@@ -31,6 +31,11 @@ export function KeyMismatchPage({ locale }: { locale: Locale }) {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.keyMismatch.restartNote")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.keyMismatch.lastResort", {
|
||||
setting: "ENCRYPTION_KEY_CHECK=warn",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.keyMismatch.codes", {
|
||||
code: "encryption.key_mismatch",
|
||||
|
||||
@@ -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<InconclusiveReason, string> = {
|
||||
"single-value": "the only stored value found under that id did not open.",
|
||||
@@ -31,13 +35,28 @@ const INCONCLUSIVE_EXPLANATION: Record<InconclusiveReason, string> = {
|
||||
};
|
||||
|
||||
export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
|
||||
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<boolean> {
|
||||
"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(", ")}`,
|
||||
|
||||
@@ -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<string> = 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;
|
||||
}
|
||||
@@ -32,6 +32,13 @@ const KEY_A =
|
||||
const KEY_B =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function configureKeys(keys: Record<string, string>, 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);
|
||||
|
||||
@@ -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<KeyCheckOutcome> {
|
||||
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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user