feat(security): encrypt medication free-text notes at rest + password-change MFA step-up

Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.

Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:05:10 +02:00
1 parent bc617adc2c
commit 5ea17398bc
27 files changed
+1111 -60

No files matched your search

@@ -4,6 +4,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// v1.25 — password change requires a fresh MFA step-up for enrolled accounts.
// Spread the real module so `apiHandler` keeps working; override the step-up
// guard so each test drives the enrolled / fresh-factor outcome directly.
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireFreshMfaIfEnrolled: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
@@ -36,6 +50,11 @@ vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn().mockResolvedValue("en"),
}));
// Fail-open breach check: a null result (corpus unreachable) never blocks.
vi.mock("@/lib/auth/hibp", () => ({
checkPasswordBreach: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -50,8 +69,14 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
StepUpRequiredError,
} from "@/lib/api-handler";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +103,44 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
// Default: account clears the step-up (no MFA enrolled, or a fresh factor).
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
it("blocks an MFA-enrolled account without a fresh second factor", async () => {
vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue(
new StepUpRequiredError(),
);
const res = await POST(
postReq({
currentPassword: "old-pass-123",
newPassword: "New-Pass-456!",
}),
);
expect(res.status).toBe(401);
// The credential is never rotated when the step-up fails.
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("proceeds when the step-up is satisfied (fresh factor / no MFA)", async () => {
// vi.resetAllMocks() wiped the factory defaults; restore the success path.
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(checkPasswordStrength).mockReturnValue({
isAcceptable: true,
feedback: [],
} as never);
const res = await POST(
postReq({
currentPassword: "old-pass-123",
newPassword: "New-Pass-456!",
confirmPassword: "New-Pass-456!",
}),
);
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
});
});
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
+11 -2
View File
@@ -15,7 +15,11 @@ import {
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -23,7 +27,12 @@ import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.25 — for an account with a second factor active a password change
// requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition
// to the current-password re-proof below, so a hijacked live session cannot
// rotate the credential. Accounts without MFA keep the current-password-only
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`auth:password:${user.id}`,