mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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 },
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
|
||||
Reference in new issue
Block a user