mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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