diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 970e10616..ca3e57174 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({ menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, session: { findUnique: vi.fn() }, + webauthnMfaCredential: { count: vi.fn() }, }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); @@ -54,6 +55,9 @@ beforeEach(() => { remaining: 9, reset: Date.now() + 3600_000, } as never); + // Default: no registered security key. Tests that exercise the + // WebAuthn-only cohort override this. + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); }); describe("POST /api/export/encrypted", () => { @@ -104,6 +108,39 @@ describe("POST /api/export/encrypted", () => { expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion"); }); + it("blocks a WebAuthn-only account (no TOTP) without a fresh second factor (401 step-up)", async () => { + // No confirmed TOTP, but a registered security key — the account is still + // MFA-enrolled and must clear step-up before exporting the whole record. + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never); + // No fresh mfaVerifiedAt on the session row. + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: null, + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(401); + }); + + it("allows a WebAuthn-only account with a fresh second factor", async () => { + vi.mocked(getSession).mockResolvedValue({ + user: { id: "user-1", role: "USER", totpConfirmedAt: null }, + session: { id: "sess-1" }, + } as never); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(), + } as never); + + const res = await POST(mkReq({ passphrase: PASSPHRASE })); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(await decryptArchive(buf, PASSPHRASE)).toContain("schemaVersion"); + }); + it("rejects a too-short passphrase with 422", async () => { vi.mocked(getSession).mockResolvedValue({ user: { id: "user-1", role: "USER", totpConfirmedAt: null }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index f832f17ab..99193bf76 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -8,9 +8,10 @@ * `application/octet-stream`. * * SECURITY: - * - Step-up: when the account has a second factor enrolled, this is gated by - * `requireFreshMfa` — exporting the whole record is a sensitive action. - * Single-factor accounts fall back to a normal authenticated session. + * - Step-up: when the account has any second factor enrolled (TOTP OR a + * security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the + * whole record is a sensitive action. Single-factor accounts fall back to a + * normal authenticated session. * - The passphrase NEVER hits a log or wide-event: it is read off the parsed * body, passed straight into the KDF, and never `annotate()`d. The egress * redaction denylist already scrubs `/passphrase/i` (key-name) and the @@ -28,8 +29,7 @@ import { prisma } from "@/lib/db"; import { z } from "zod/v4"; import { apiHandler, - requireAuth, - requireFreshMfa, + requireFreshMfaIfEnrolled, MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -60,14 +60,13 @@ const encryptedExportSchema = z .strict(); export const POST = apiHandler(async (request: NextRequest) => { - // Resolve the session first (cookie or Bearer), then escalate to a fresh - // second factor when the account has one enrolled. A single-factor account - // cannot produce a fresh-MFA proof, so gating it unconditionally would lock - // it out of its own encrypted export — the step-up applies to MFA users. - const auth = await requireAuth(); - if (auth.user.totpConfirmedAt) { - await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); - } + // Resolve the session (cookie or Bearer), then escalate to a fresh second + // factor when the account has one enrolled. `requireFreshMfaIfEnrolled` + // covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn + // security key — so a security-key-only account is gated too. A single-factor + // account cannot produce a fresh-MFA proof, so it passes straight through + // rather than being locked out of its own encrypted export. + const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); const user = auth.user; annotate({ action: { name: "user.export.encrypted" } });