fix(export): apply step-up for security-key-only accounts on encrypted export

The encrypted-export route hand-rolled its step-up gate off totpConfirmedAt
alone, so an account whose only second factor is a WebAuthn security key
exported the full record on a plain session — no fresh-factor proof. Route it
through requireFreshMfaIfEnrolled, which covers both the TOTP and security-key
cohorts and lets single-factor accounts through unchanged. Cover the
security-key-only path in the unit suite.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 22:22:38 +02:00
1 parent c01d7f9626
commit 51b8db166d
2 files changed
+49 -13

No files matched your search

@@ -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 },
+12 -13
View File
@@ -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" } });