mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user