mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
cd3c0b7de2
74 files changed
+4140
-508
No files matched your search
@@ -114,3 +114,30 @@ describe("GET /api/auth/registration-status", () => {
|
||||
expect(body.data.registrationEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/registration-status — sessionCookieSecure (#1097)", () => {
|
||||
it("publishes whether the server issues Secure session cookies, on every branch", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
let body = await (await callGet()).json();
|
||||
expect(body.data.sessionCookieSecure).toBe(true);
|
||||
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
body = await (await callGet()).json();
|
||||
expect(body.data.sessionCookieSecure).toBe(false);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockRejectedValue(
|
||||
new Error("db down"),
|
||||
);
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
body = await (await callGet()).json();
|
||||
expect(body.data).toEqual({
|
||||
registrationEnabled: false,
|
||||
sessionCookieSecure: true,
|
||||
});
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
@@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// A browser page on plain http:// with Secure-only cookies cannot keep the
|
||||
// session. Refuse before the password is verified (the account throttle
|
||||
// never counts it) and before an MFA ticket is minted.
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Before the ticket is loaded, an attempt reserved, or a one-time code
|
||||
// spent: a sign-in whose cookie cannot stick must not consume either.
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = mfaVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
|
||||
@@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
|
||||
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
|
||||
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Before the ticket is loaded or an attempt reserved (see mfa/verify).
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiValidationError(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { transportVerdict } from "@/lib/auth/client-transport";
|
||||
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import {
|
||||
@@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return loginError("oidc_disabled");
|
||||
}
|
||||
|
||||
// The SSO button states the page's transport. A browser on plain http://
|
||||
// with Secure-only cookies would come back from the provider and drop the
|
||||
// session cookie, so it is stopped here instead of after the round trip.
|
||||
if (!isNative) {
|
||||
const clientProto = req.nextUrl.searchParams.get("client_proto");
|
||||
const clientHost = req.nextUrl.searchParams.get("client_host") ?? "";
|
||||
if (
|
||||
(clientProto === "http" || clientProto === "https") &&
|
||||
transportVerdict(shouldEmitSecureCookie(), {
|
||||
protocol: clientProto,
|
||||
host: clientHost,
|
||||
}) === "blocked"
|
||||
) {
|
||||
annotate({ meta: { reason: "insecure_transport" } });
|
||||
return loginError("insecure_transport");
|
||||
}
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:login",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login.
|
||||
@@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
// The body is optional (the web client sends its page transport; nothing
|
||||
// else needs to send anything). A missing or unreadable body is no body, as
|
||||
// before; only a stated plain-http page refuses, before a challenge exists.
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions();
|
||||
|
||||
annotate({ action: { name: "auth.passkey.login-options" } });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,10 +16,16 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
return apiSuccess({
|
||||
registrationEnabled: settings?.registrationEnabled ?? true,
|
||||
// Whether this server issues `Secure` session cookies. The login page
|
||||
// compares it with its own scheme before any credential is sent.
|
||||
sessionCookieSecure: shouldEmitSecureCookie(),
|
||||
});
|
||||
} catch {
|
||||
// Fail closed on backend errors.
|
||||
annotate({ action: { name: "auth.registration-status" } });
|
||||
return apiSuccess({ registrationEnabled: false });
|
||||
return apiSuccess({
|
||||
registrationEnabled: false,
|
||||
sessionCookieSecure: shouldEmitSecureCookie(),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
+68
-15
@@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { sanitizeSameOriginPath } from "@/lib/url-safety";
|
||||
import {
|
||||
InsecureTransportBanner,
|
||||
useClientTransport,
|
||||
} from "@/components/auth/insecure-transport-banner";
|
||||
import { transportVerdict } from "@/lib/auth/client-transport";
|
||||
|
||||
/** The server's refusal for a page whose sign-in cookie cannot stick. */
|
||||
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -44,7 +52,8 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
const oidcError = searchParams.get("error");
|
||||
if (!oidcError) return null;
|
||||
// The SSO start refused a page on plain http://; the banner says why.
|
||||
if (!oidcError || oidcError === "insecure_transport") return null;
|
||||
const key: Record<string, string> = {
|
||||
oidc_denied: "auth.oidc.errorDenied",
|
||||
oidc_no_email: "auth.oidc.errorNoEmail",
|
||||
@@ -71,22 +80,55 @@ export default function LoginPage() {
|
||||
// standalone alert detached from the form.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
const { data: registrationEnabled } = useQuery({
|
||||
const { data: registrationStatus } = useQuery({
|
||||
queryKey: queryKeys.authRegistrationStatus(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await apiGet<{ registrationEnabled?: boolean }>(
|
||||
"/api/auth/registration-status",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return Boolean(data?.registrationEnabled ?? true);
|
||||
const data = await apiGet<{
|
||||
registrationEnabled?: boolean;
|
||||
sessionCookieSecure?: boolean;
|
||||
}>("/api/auth/registration-status", { cache: "no-store" });
|
||||
return {
|
||||
registrationEnabled: Boolean(data?.registrationEnabled ?? true),
|
||||
sessionCookieSecure: data?.sessionCookieSecure === true,
|
||||
};
|
||||
} catch {
|
||||
// Fail open, exactly as the raw `if (!res.ok) return true` did.
|
||||
return true;
|
||||
// Fail open, exactly as the raw `if (!res.ok) return true` did; and
|
||||
// no transport gate without the server's word on its cookies.
|
||||
return { registrationEnabled: true, sessionCookieSecure: false };
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const registrationEnabled = registrationStatus?.registrationEnabled;
|
||||
|
||||
// #1097 — a server that sets its sign-in cookie only over https:// cannot
|
||||
// sign anyone in on a plain http:// page: the browser drops the cookie. Say
|
||||
// so before a credential leaves the browser, and stop the sign-in controls.
|
||||
// The server refuses the same requests (409) if they are sent anyway, and
|
||||
// the SSO start sends the browser back here with `insecure_transport`.
|
||||
const clientTransport = useClientTransport();
|
||||
const [transportRefused, setTransportRefused] = useState(
|
||||
() => searchParams.get("error") === "insecure_transport",
|
||||
);
|
||||
const verdict = transportRefused
|
||||
? "blocked"
|
||||
: transportVerdict(
|
||||
registrationStatus?.sessionCookieSecure === true,
|
||||
clientTransport,
|
||||
);
|
||||
const signInBlocked = verdict === "blocked";
|
||||
|
||||
function noteRefusal(err: unknown): boolean {
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
|
||||
) {
|
||||
setTransportRefused(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data: oidcStatus } = useQuery({
|
||||
queryKey: queryKeys.authOidcStatus(),
|
||||
@@ -106,6 +148,10 @@ export default function LoginPage() {
|
||||
|
||||
function handleOidcLogin() {
|
||||
const params = new URLSearchParams({ next: getRedirectTarget() });
|
||||
if (clientTransport) {
|
||||
params.set("client_proto", clientTransport.protocol);
|
||||
params.set("client_host", clientTransport.host);
|
||||
}
|
||||
loadDocument(`/api/auth/oidc/login?${params}`);
|
||||
}
|
||||
|
||||
@@ -176,7 +222,7 @@ export default function LoginPage() {
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/login-options");
|
||||
}>("/api/auth/passkey/login-options", { clientTransport });
|
||||
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
@@ -189,6 +235,7 @@ export default function LoginPage() {
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
// Route rejections carry the envelope error verbatim; everything
|
||||
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
|
||||
if (err instanceof ApiError) {
|
||||
@@ -220,7 +267,7 @@ export default function LoginPage() {
|
||||
>("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, clientTransport }),
|
||||
});
|
||||
|
||||
if (meta?.mfaRequired && meta.mfaTicket) {
|
||||
@@ -235,6 +282,7 @@ export default function LoginPage() {
|
||||
// navigates; the shell refetches `["auth","me"]` fresh on mount.
|
||||
navigateAfterLogin();
|
||||
} catch (err) {
|
||||
if (noteRefusal(err)) return;
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
@@ -289,6 +337,9 @@ export default function LoginPage() {
|
||||
useEffect(() => {
|
||||
if (oidcStatus === undefined) return;
|
||||
if (oidcStatus.only === true) return;
|
||||
// Wait for the server's word on its cookies, and never arm the browser's
|
||||
// credential prompt for a sign-in that cannot keep its cookie.
|
||||
if (registrationStatus === undefined || signInBlocked) return;
|
||||
if (autofillStarted.current) return;
|
||||
autofillStarted.current = true;
|
||||
let cancelled = false;
|
||||
@@ -302,7 +353,7 @@ export default function LoginPage() {
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/login-options");
|
||||
}>("/api/auth/passkey/login-options", { clientTransport });
|
||||
|
||||
const credential = await startAuthentication({
|
||||
optionsJSON: options,
|
||||
@@ -327,7 +378,7 @@ export default function LoginPage() {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [oidcStatus]);
|
||||
}, [oidcStatus, registrationStatus, signInBlocked]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
@@ -380,12 +431,14 @@ export default function LoginPage() {
|
||||
data-slot="login-actions"
|
||||
className="mt-8 space-y-4"
|
||||
>
|
||||
<InsecureTransportBanner verdict={verdict} />
|
||||
{oidcStatus?.enabled && (
|
||||
<Button
|
||||
onClick={handleOidcLogin}
|
||||
variant={oidcStatus.only ? "default" : "outline"}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={signInBlocked}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
|
||||
@@ -414,7 +467,7 @@ export default function LoginPage() {
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
disabled={loading || signInBlocked}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
@@ -491,7 +544,7 @@ export default function LoginPage() {
|
||||
data-testid="login-password-submit"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
disabled={loading || signInBlocked}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -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,46 @@
|
||||
/**
|
||||
* The login page's transport notice (#1097): the blocked banner names both
|
||||
* operator fixes, the localhost note is soft, and "ok" renders nothing.
|
||||
* Mutation check: render the banner for `soft` and the second case goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsecureTransportBanner } from "@/components/auth/insecure-transport-banner";
|
||||
import type { TransportVerdict } from "@/lib/auth/client-transport";
|
||||
|
||||
function render(verdict: TransportVerdict, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<InsecureTransportBanner verdict={verdict} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("InsecureTransportBanner", () => {
|
||||
it("blocked: an alert naming SESSION_COOKIE_SECURE and https", () => {
|
||||
const html = render("blocked");
|
||||
expect(html).toContain('data-testid="insecure-transport-banner"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("SESSION_COOKIE_SECURE=false");
|
||||
expect(html).toContain("https://");
|
||||
});
|
||||
|
||||
it("soft: a note on localhost, no alert", () => {
|
||||
const html = render("soft");
|
||||
expect(html).toContain('data-testid="insecure-transport-note"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("ok: nothing", () => {
|
||||
expect(render("ok")).toBe("");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render("blocked", "de")).toContain("https://");
|
||||
expect(render("blocked", "de")).not.toContain(
|
||||
"This server only sets its sign-in cookie",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Login-page notice for a page whose session cookie cannot stick (#1097).
|
||||
*
|
||||
* `blocked`: the server sets its sign-in cookie only over https:// and this
|
||||
* page is plain http:// on a network address. The browser would drop the
|
||||
* cookie, so the sign-in controls are disabled and the two operator fixes are
|
||||
* named. `soft`: the same on localhost, where most browsers accept the cookie;
|
||||
* the form stays usable.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
ClientTransport,
|
||||
TransportVerdict,
|
||||
} from "@/lib/auth/client-transport";
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
/**
|
||||
* The page's transport, read after hydration only (the server render has no
|
||||
* `location`), so the first client render matches the server HTML.
|
||||
*/
|
||||
export function useClientTransport(): ClientTransport | undefined {
|
||||
const raw = useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
() => `${window.location.protocol}//${window.location.host}`,
|
||||
() => null,
|
||||
);
|
||||
if (!raw) return undefined;
|
||||
const [scheme, host] = raw.split("//");
|
||||
return { protocol: scheme === "https:" ? "https" : "http", host };
|
||||
}
|
||||
|
||||
export function InsecureTransportBanner({
|
||||
verdict,
|
||||
}: {
|
||||
verdict: TransportVerdict;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (verdict === "ok") return null;
|
||||
if (verdict === "soft") {
|
||||
return (
|
||||
<p
|
||||
data-testid="insecure-transport-note"
|
||||
className="text-muted-foreground rounded-lg border p-3 text-xs"
|
||||
>
|
||||
{t("auth.insecureTransport.localhostNote")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="insecure-transport-banner"
|
||||
className="border-warning/40 bg-warning/10 space-y-2 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<p className="flex items-start gap-2 font-semibold">
|
||||
<TriangleAlert
|
||||
className="text-warning mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("auth.insecureTransport.title")}
|
||||
</p>
|
||||
<p>{t("auth.insecureTransport.body")}</p>
|
||||
<ul className="list-disc space-y-1 ps-5">
|
||||
<li>{t("auth.insecureTransport.fixLan")}</li>
|
||||
<li>{t("auth.insecureTransport.fixHttps")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { currentClientTransport } from "@/lib/auth/client-transport";
|
||||
|
||||
/** The server's refusal for a page whose sign-in cookie cannot stick. */
|
||||
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type MfaMethod = "totp" | "recovery" | "webauthn";
|
||||
@@ -61,13 +65,17 @@ export function MfaLoginStep({
|
||||
method: useRecovery ? "recovery" : "totp",
|
||||
code: code.trim(),
|
||||
rememberDevice: useRecovery ? false : rememberDevice,
|
||||
clientTransport: currentClientTransport(),
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.mfa.verifyFailed"),
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
|
||||
? t("auth.insecureTransport.title")
|
||||
: err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.mfa.verifyFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -89,6 +97,7 @@ export function MfaLoginStep({
|
||||
challengeId,
|
||||
credential,
|
||||
rememberDevice,
|
||||
clientTransport: currentClientTransport(),
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
|
||||
@@ -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 inline-flex min-h-11 items-center 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,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { isLocalhostHost, transportVerdict } from "@/lib/auth/client-transport";
|
||||
import {
|
||||
cookieCannotStick,
|
||||
INSECURE_TRANSPORT_CODE,
|
||||
refuseWhenCookieCannotStick,
|
||||
} from "@/lib/auth/transport-refusal";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("isLocalhostHost", () => {
|
||||
it.each([
|
||||
"localhost",
|
||||
"localhost:3000",
|
||||
"app.localhost:8080",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:3000",
|
||||
"127.1.2.3",
|
||||
"[::1]",
|
||||
"[::1]:3000",
|
||||
"LOCALHOST",
|
||||
])("treats %s as loopback", (host) => {
|
||||
expect(isLocalhostHost(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"192.168.1.20:3000",
|
||||
"truenas.local:30180",
|
||||
"healthlog.example.com",
|
||||
"10.0.0.5",
|
||||
"127.0.0.1.nip.io",
|
||||
"localhost.example.com",
|
||||
"[fe80::1]:3000",
|
||||
])("treats %s as a network address", (host) => {
|
||||
expect(isLocalhostHost(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transportVerdict", () => {
|
||||
const http = (host: string) => ({ protocol: "http" as const, host });
|
||||
it("blocks plain http on a network address when cookies are Secure", () => {
|
||||
expect(transportVerdict(true, http("192.168.1.20:3000"))).toBe("blocked");
|
||||
});
|
||||
it("only notes plain http on localhost", () => {
|
||||
expect(transportVerdict(true, http("localhost:3000"))).toBe("soft");
|
||||
});
|
||||
it("passes https, non-Secure cookies, and an unknown transport", () => {
|
||||
expect(
|
||||
transportVerdict(true, { protocol: "https", host: "nas.local" }),
|
||||
).toBe("ok");
|
||||
expect(transportVerdict(false, http("192.168.1.20:3000"))).toBe("ok");
|
||||
expect(transportVerdict(true, undefined)).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refuseWhenCookieCannotStick", () => {
|
||||
const body = (protocol: string, host = "192.168.1.20:3000") => ({
|
||||
email: "a",
|
||||
password: "b",
|
||||
clientTransport: { protocol, host },
|
||||
});
|
||||
|
||||
it("refuses http + Secure with 409 and the code", async () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
const response = refuseWhenCookieCannotStick(body("http"));
|
||||
expect(response?.status).toBe(409);
|
||||
const json = await response!.json();
|
||||
expect(json.meta).toEqual({
|
||||
errorCode: INSECURE_TRANSPORT_CODE,
|
||||
sessionCookieSecure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes https, localhost, a body without the field, and SESSION_COOKIE_SECURE=false", () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
expect(refuseWhenCookieCannotStick(body("https"))).toBeNull();
|
||||
expect(
|
||||
refuseWhenCookieCannotStick(body("http", "localhost:3000")),
|
||||
).toBeNull();
|
||||
expect(
|
||||
refuseWhenCookieCannotStick({ email: "a", password: "b" }),
|
||||
).toBeNull();
|
||||
expect(refuseWhenCookieCannotStick(null)).toBeNull();
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
expect(cookieCannotStick(body("http"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a malformed field rather than refusing on it", () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
expect(
|
||||
cookieCannotStick({ clientTransport: { protocol: "gopher", host: "x" } }),
|
||||
).toBe(false);
|
||||
expect(cookieCannotStick({ clientTransport: "http" })).toBe(false);
|
||||
});
|
||||
|
||||
it("follows NODE_ENV when SESSION_COOKIE_SECURE is unset", () => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "");
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
expect(cookieCannotStick(body("http"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* What the browser knows and the server does not: whether this page was
|
||||
* opened over https://. A `Secure` cookie only sticks on an https:// page
|
||||
* (and, in most browsers, on http://localhost). The server knows whether it
|
||||
* will issue `Secure` cookies (`shouldEmitSecureCookie`, published as
|
||||
* `sessionCookieSecure` on `GET /api/auth/registration-status`); combining the
|
||||
* two facts says, before any credential leaves the browser, whether a sign-in
|
||||
* here can finish.
|
||||
*
|
||||
* Pure and dependency-free: imported by the login page and by the server
|
||||
* refusal (`./transport-refusal.ts`) so both sides apply the same rule.
|
||||
*/
|
||||
|
||||
export interface ClientTransport {
|
||||
protocol: "http" | "https";
|
||||
/** `location.host`: hostname plus an optional port. */
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loopback names a browser treats as a secure context. A `Secure` cookie set
|
||||
* on one of them usually sticks even over http://, so a sign-in there gets a
|
||||
* soft note, never a refusal.
|
||||
*/
|
||||
export function isLocalhostHost(host: string): boolean {
|
||||
let name = host.trim().toLowerCase();
|
||||
if (name.startsWith("[")) {
|
||||
// [::1] or [::1]:3000
|
||||
name = name.slice(
|
||||
1,
|
||||
name.indexOf("]") === -1 ? undefined : name.indexOf("]"),
|
||||
);
|
||||
} else if (name.split(":").length === 2) {
|
||||
name = name.split(":")[0];
|
||||
}
|
||||
if (name === "localhost" || name.endsWith(".localhost")) return true;
|
||||
if (name === "::1") return true;
|
||||
return /^127(?:\.\d{1,3}){3}$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* - `blocked`: the server sets `Secure` cookies and this page is plain http on
|
||||
* a non-loopback host; a sign-in cannot keep its cookie, so it must not start.
|
||||
* - `soft`: the same on a loopback host; most browsers accept it, say so.
|
||||
* - `ok`: nothing to say.
|
||||
*/
|
||||
export type TransportVerdict = "ok" | "soft" | "blocked";
|
||||
|
||||
export function transportVerdict(
|
||||
sessionCookieSecure: boolean,
|
||||
transport: ClientTransport | undefined,
|
||||
): TransportVerdict {
|
||||
if (!sessionCookieSecure || !transport || transport.protocol !== "http") {
|
||||
return "ok";
|
||||
}
|
||||
return isLocalhostHost(transport.host) ? "soft" : "blocked";
|
||||
}
|
||||
|
||||
/** The current page's transport, for the request bodies the server checks. */
|
||||
export function currentClientTransport(): ClientTransport | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return { protocol, host: window.location.host };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Refuse a browser sign-in whose session cookie cannot stick, BEFORE it spends
|
||||
* anything: before the password is verified (so the account throttle never
|
||||
* counts it), before an MFA ticket is minted, before a ticket or a one-time
|
||||
* code is consumed, before a passkey challenge is issued.
|
||||
*
|
||||
* The fact comes from the client (`clientTransport`), not from proxy headers:
|
||||
* a TLS proxy that omits `X-Forwarded-Proto` would make a header heuristic
|
||||
* refuse a correctly served install. A body without the field is never
|
||||
* refused. The header heuristic in `secure-cookie.ts` stays as the operator
|
||||
* log line for clients that do not send it.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { transportVerdict } from "@/lib/auth/client-transport";
|
||||
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
|
||||
export const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
|
||||
|
||||
/** True when the body states a transport the session cookie cannot survive. */
|
||||
export function cookieCannotStick(body: unknown): boolean {
|
||||
if (!body || typeof body !== "object") return false;
|
||||
const parsed = clientTransportSchema.safeParse(
|
||||
(body as { clientTransport?: unknown }).clientTransport,
|
||||
);
|
||||
if (!parsed.success) return false;
|
||||
return transportVerdict(shouldEmitSecureCookie(), parsed.data) === "blocked";
|
||||
}
|
||||
|
||||
/** The 409 to return, or null when the sign-in may proceed. */
|
||||
export function refuseWhenCookieCannotStick(body: unknown): Response | null {
|
||||
if (!cookieCannotStick(body)) return null;
|
||||
annotate({ action: { name: "auth.session.insecure_transport.refused" } });
|
||||
return apiError(
|
||||
"This server only sets its sign-in cookie over https://, but this page was opened over http://, so signing in cannot complete here. Open the https:// address, or set SESSION_COOKIE_SECURE=false for a server that is only reached over plain HTTP on a private network.",
|
||||
409,
|
||||
{ errorCode: INSECURE_TRANSPORT_CODE, sessionCookieSecure: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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.inconclusive.length > 0) {
|
||||
console.warn(
|
||||
`[boot] Encryption key check inconclusive for key id(s) ${outcome.inconclusive.join(", ")}: ` +
|
||||
"the only stored value found under that id did not open. Serving; " +
|
||||
"no canary written; the check runs again at the next start.",
|
||||
);
|
||||
}
|
||||
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,247 @@
|
||||
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>;
|
||||
/** delegate → field → stored values (in row order). */
|
||||
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 findMany(args: {
|
||||
select: Record<string, boolean>;
|
||||
take: number;
|
||||
skip?: number;
|
||||
}) {
|
||||
const field = Object.keys(args.select)[0];
|
||||
const skip = args.skip ?? 0;
|
||||
return (fields[field] ?? [])
|
||||
.slice(skip, skip + args.take)
|
||||
.map((value) => ({ [field]: value }));
|
||||
},
|
||||
};
|
||||
}
|
||||
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: [],
|
||||
inconclusive: [],
|
||||
});
|
||||
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"],
|
||||
inconclusive: [],
|
||||
});
|
||||
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"), encrypt("another token")];
|
||||
useKey(KEY_B);
|
||||
const { client, inserts } = fakeClient({
|
||||
probe: {
|
||||
user: {
|
||||
codexAccessTokenEncrypted: [existing[0]],
|
||||
codexRefreshTokenEncrypted: [existing[1]],
|
||||
},
|
||||
},
|
||||
});
|
||||
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("one damaged row among good ones does not take the server down", async () => {
|
||||
// A row that claims key id v1 but does not open (damaged, or sealed by
|
||||
// something else), found FIRST, then values that open.
|
||||
const damaged = "v1." + Buffer.alloc(40, 7).toString("base64");
|
||||
const { client, inserts } = fakeClient({
|
||||
probe: {
|
||||
user: {
|
||||
codexAccessTokenEncrypted: [damaged],
|
||||
codexRefreshTokenEncrypted: [encrypt("a stored token")],
|
||||
},
|
||||
},
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome.state).toBe("ok");
|
||||
expect(inserts).toEqual(["v1"]);
|
||||
});
|
||||
|
||||
it("several values in one column count, so a wrong key with one busy column still refuses", async () => {
|
||||
const values = [encrypt("one"), encrypt("two"), encrypt("three")];
|
||||
useKey(KEY_B);
|
||||
const { client, inserts } = fakeClient({
|
||||
probe: { user: { codexAccessTokenEncrypted: values } },
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
|
||||
expect(inserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("a single value that does not open is inconclusive: no mismatch, no canary", async () => {
|
||||
const existing = encrypt("the only stored token");
|
||||
useKey(KEY_B);
|
||||
const { client, inserts } = fakeClient({
|
||||
probe: { user: { codexAccessTokenEncrypted: [existing] } },
|
||||
});
|
||||
const outcome = await checkEncryptionKeyCanaries(client);
|
||||
expect(outcome).toEqual({
|
||||
state: "ok",
|
||||
written: [],
|
||||
verified: [],
|
||||
inconclusive: ["v1"],
|
||||
});
|
||||
expect(inserts).toEqual([]);
|
||||
});
|
||||
|
||||
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,271 @@
|
||||
/**
|
||||
* 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, sample existing ciphertext under that
|
||||
* key id from 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.
|
||||
*
|
||||
* The probe samples up to `PROBE_SAMPLE_LIMIT` values, one per column first
|
||||
* (different columns are different writers, so one bad writer cannot speak
|
||||
* for the whole database), then further values from columns that had any.
|
||||
* Its verdict, and what each one does:
|
||||
*
|
||||
* - `opens`: at least one value opened. A wrong key opens nothing, so the
|
||||
* key is right and any value that failed is a damaged or foreign row, not
|
||||
* a key problem. The canary is written.
|
||||
* - `fails`: two or more values were tried and none opened. That is what a
|
||||
* wrong key looks like; the process refuses (mismatch).
|
||||
* - `inconclusive`: exactly one value was found and it did not open. One
|
||||
* row cannot tell a wrong key from one damaged row, and the two possible
|
||||
* mistakes are not equal: declaring a mismatch takes a correctly keyed
|
||||
* server down with 503 on every request, while serving keeps today's
|
||||
* behaviour. So the process serves, but writes NO canary either (that
|
||||
* would seal a key that may be wrong), logs a warning, and probes again
|
||||
* at the next boot, by which time more data may decide it.
|
||||
* - `none`: nothing found inside the time budget. The canary is written,
|
||||
* which is exactly the behaviour before this check existed.
|
||||
*
|
||||
* Writes are `ON CONFLICT DO NOTHING`, so two processes racing is fine.
|
||||
*
|
||||
* 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 many existing values the probe tries at most per key id. */
|
||||
export const PROBE_SAMPLE_LIMIT = 5;
|
||||
|
||||
/** 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 = {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean>;
|
||||
take: number;
|
||||
skip?: number;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
export type ProbeVerdict = "opens" | "fails" | "inconclusive" | "none";
|
||||
|
||||
export type KeyCheckOutcome =
|
||||
| {
|
||||
state: "ok";
|
||||
/** Key ids whose canary this run wrote. */
|
||||
written: string[];
|
||||
/** Key ids whose canary existed and opened. */
|
||||
verified: string[];
|
||||
/**
|
||||
* Key ids with no canary whose only existing value did not open: not a
|
||||
* mismatch, no canary written, probed again at the next boot.
|
||||
*/
|
||||
inconclusive: 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample existing values sealed under `keyId` and try them; see the module
|
||||
* comment for what each verdict means and why.
|
||||
*/
|
||||
export async function probeExistingData(
|
||||
client: unknown,
|
||||
keyId: string,
|
||||
deadline: number,
|
||||
): Promise<ProbeVerdict> {
|
||||
const delegates = client as Record<string, ProbeDelegate | undefined>;
|
||||
const columns = ENCRYPTED_COLUMNS.filter(
|
||||
(c) => c.kind === "string" && !c.codec && !c.codecField,
|
||||
);
|
||||
let tried = 0;
|
||||
const columnsWithData: typeof columns = [];
|
||||
|
||||
const sample = async (
|
||||
column: (typeof columns)[number],
|
||||
skip: number,
|
||||
take: number,
|
||||
): Promise<string[]> => {
|
||||
const delegate =
|
||||
delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)];
|
||||
if (!delegate || typeof delegate.findMany !== "function") return [];
|
||||
try {
|
||||
const rows = await delegate.findMany({
|
||||
where: { [column.field]: { startsWith: `${keyId}.` } },
|
||||
select: { [column.field]: true },
|
||||
take,
|
||||
...(skip > 0 ? { skip } : {}),
|
||||
});
|
||||
return rows
|
||||
.map((row) => row[column.field])
|
||||
.filter((v): v is string => typeof v === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Pass 1: one value per column, so different writers are heard first.
|
||||
for (const column of columns) {
|
||||
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
|
||||
const values = await sample(column, 0, 1);
|
||||
if (values.length === 0) continue;
|
||||
columnsWithData.push(column);
|
||||
tried += 1;
|
||||
if (opensAs(values[0], keyId)) return "opens";
|
||||
}
|
||||
// Pass 2: more values from the columns that had any, up to the limit.
|
||||
for (const column of columnsWithData) {
|
||||
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
|
||||
const values = await sample(column, 1, PROBE_SAMPLE_LIMIT - tried);
|
||||
for (const value of values) {
|
||||
tried += 1;
|
||||
if (opensAs(value, keyId)) return "opens";
|
||||
}
|
||||
}
|
||||
|
||||
if (tried >= 2) return "fails";
|
||||
if (tried === 1) return "inconclusive";
|
||||
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[] = [];
|
||||
const inconclusive: string[] = [];
|
||||
for (const keyId of missing) {
|
||||
const probe = await probeExistingData(client, keyId, deadline);
|
||||
if (probe === "fails") mismatched.push(keyId);
|
||||
else if (probe === "inconclusive") inconclusive.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, inconclusive };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() };
|
||||
}
|
||||
@@ -299,6 +299,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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"auth.reproof.required",
|
||||
"auth.reproof.too_weak",
|
||||
"auth.scope.insufficient",
|
||||
"auth.session.insecure_transport",
|
||||
"auth.stepup.required",
|
||||
"auth.token.expired",
|
||||
"auth.token.invalid",
|
||||
@@ -172,6 +173,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",
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
} from "./shared";
|
||||
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
|
||||
|
||||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||||
|
||||
@@ -438,9 +439,24 @@ const registrationStatusResponse = z
|
||||
.describe(
|
||||
"Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token.",
|
||||
),
|
||||
sessionCookieSecure: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this with its own scheme before any credential is sent.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "RegistrationStatusResponse" });
|
||||
|
||||
/**
|
||||
* The browser sign-in refusal for a page whose session cookie cannot stick.
|
||||
* Only ever returned to a caller that sent `clientTransport`.
|
||||
*/
|
||||
const insecureTransportResponse = {
|
||||
description:
|
||||
'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the iOS app, scripts) is never refused this way.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const apiTokenInfo = z
|
||||
.object({
|
||||
id: z.string().describe("Row id; the handle for DELETE."),
|
||||
@@ -701,6 +717,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: loginPasswordSchema } },
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description:
|
||||
"Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`).",
|
||||
@@ -733,6 +750,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: mfaVerifySchema } },
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description:
|
||||
"Second factor verified — session + optional bearer issued.",
|
||||
@@ -1247,6 +1265,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description:
|
||||
"Second factor verified — session + optional bearer issued.",
|
||||
@@ -1613,8 +1632,15 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin a passkey sign-in (discoverable credentials)",
|
||||
description:
|
||||
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body, and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
|
||||
"Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: passkeyLoginOptionsSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"409": insecureTransportResponse,
|
||||
"200": {
|
||||
description: "Assertion options issued.",
|
||||
content: {
|
||||
@@ -2117,12 +2143,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.describe(
|
||||
"Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg.",
|
||||
),
|
||||
client_proto: z
|
||||
.enum(["http", "https"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback `client_host` while the server issues `Secure` session cookies, the start redirects to `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped on return.",
|
||||
),
|
||||
client_host: z
|
||||
.string()
|
||||
.max(255)
|
||||
.optional()
|
||||
.describe(
|
||||
"Browser leg only: `location.host` of that page, read together with `client_proto`.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"302": {
|
||||
description:
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isValidKvnr, normaliseKvnr } from "@/lib/validations/kvnr";
|
||||
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
|
||||
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
|
||||
/** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */
|
||||
const PASSWORD_MIN_LENGTH = 12;
|
||||
@@ -40,8 +41,18 @@ export const registerSchema = z.object({
|
||||
export const loginPasswordSchema = z.object({
|
||||
email: z.string().trim().min(1, "Email or username required"),
|
||||
password: z.string().min(1),
|
||||
clientTransport: clientTransportSchema.optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/auth/passkey/login-options`: an optional body, web client only. */
|
||||
export const passkeyLoginOptionsSchema = z
|
||||
.object({ clientTransport: clientTransportSchema.optional() })
|
||||
.meta({
|
||||
id: "PasskeyLoginOptionsRequest",
|
||||
description:
|
||||
"Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused before a challenge is issued. Any other caller may send no body at all.",
|
||||
});
|
||||
|
||||
export const profileSchema = z.object({
|
||||
email: z.email("Invalid email address").nullable().optional(),
|
||||
heightCm: z.number().min(50).max(300).nullable().optional(),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* The optional `clientTransport` field the web client sends on the browser
|
||||
* sign-in requests. Optional everywhere: a client that does not send it (the
|
||||
* iOS app, scripts) is never refused because of it. It can only ever cause a
|
||||
* refusal of its own request, so a forged value harms nobody but its sender.
|
||||
*/
|
||||
export const clientTransportSchema = z
|
||||
.object({
|
||||
protocol: z
|
||||
.enum(["http", "https"])
|
||||
.describe("The scheme of the page the request comes from."),
|
||||
host: z
|
||||
.string()
|
||||
.max(255)
|
||||
.describe("`location.host` of that page: hostname and optional port."),
|
||||
})
|
||||
.meta({
|
||||
id: "ClientTransport",
|
||||
description:
|
||||
"Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` before any credential, ticket or code is checked or used, because the browser would drop the session cookie.",
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
* auth.ts`) describe the identical wire contract.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { clientTransportSchema } from "@/lib/validations/client-transport";
|
||||
|
||||
/** A 6-digit TOTP code. */
|
||||
export const totpCodeSchema = z
|
||||
@@ -42,6 +43,7 @@ export const mfaVerifySchema = z
|
||||
.describe(
|
||||
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
|
||||
),
|
||||
clientTransport: clientTransportSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "MfaVerifyRequest",
|
||||
@@ -111,6 +113,7 @@ export const mfaWebauthnLoginVerifySchema = z
|
||||
.describe(
|
||||
"Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required).",
|
||||
),
|
||||
clientTransport: clientTransportSchema.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "MfaWebauthnLoginVerifyRequest",
|
||||
|
||||
Reference in new issue
Block a user