Merge remote-tracking branch 'origin/main' into feat/v140-fixes

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 20:06:16 +02:00
commit cd3c0b7de2
74 files changed
+4140 -508

No files matched your search

@@ -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
+8
View File
@@ -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) {
+6
View File
@@ -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(
+19
View File
@@ -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" });
});
});
+12 -1
View File
@@ -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 },
);
});