feat(export): offer a passphrase-encrypted archive

Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.

POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:27:07 +02:00
1 parent 6d053c3667
commit 0ba77778d5
17 files changed
+999 -125

No files matched your search

@@ -0,0 +1,116 @@
/**
* v1.23 — POST /api/export/encrypted unit coverage.
*
* Mock-based. Pins the step-up gating contract (single-factor accounts export
* without step-up; MFA accounts need a fresh second factor) and that the
* passphrase produces a decryptable HLX1 archive — never a plaintext body.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn().mockResolvedValue([]) },
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
lookupIpAsn: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitStructuredLog: vi.fn() }));
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { POST } from "../route";
import { decryptArchive } from "@/lib/export/passphrase-archive";
const PASSPHRASE = "a-strong-passphrase-123";
function mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/export/encrypted", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: Date.now() + 3600_000,
} as never);
});
describe("POST /api/export/encrypted", () => {
it("returns a decryptable HLX1 archive for a single-factor account", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
session: { id: "sess-1" },
} as never);
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.subarray(0, 4).toString("ascii")).toBe("HLX1");
const plaintext = await decryptArchive(buf, PASSPHRASE);
const payload = JSON.parse(plaintext);
expect(payload).toHaveProperty("schemaVersion");
expect(payload).toHaveProperty("measurements");
});
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
session: { id: "sess-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 an MFA account with a fresh second factor", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
session: { id: "sess-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 },
session: { id: "sess-1" },
} as never);
const res = await POST(mkReq({ passphrase: "short" }));
expect(res.status).toBe(422);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* POST /api/export/encrypted
*
* v1.23 — passphrase-encrypted variant of the full-backup export. Returns the
* same restore-compatible payload as `GET /api/export/full-backup`, but sealed
* into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under a passphrase
* the caller supplies in the request body. The binary archive is returned as
* `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.
* - 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
* `passphrase=` query-string form as defence in depth.
* - There is NO server-side recovery — the passphrase is not stored. A
* forgotten passphrase means the archive is unrecoverable; the UI says so.
*
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
* second factor for MFA accounts (Bearer can never satisfy step-up).
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
* plaintext export so the encrypted variant cannot be used to bypass the cap.
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
*/
import { prisma } from "@/lib/db";
import { z } from "zod/v4";
import {
apiHandler,
requireAuth,
requireFreshMfa,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import {
encryptArchive,
MIN_EXPORT_PASSPHRASE_LENGTH,
} from "@/lib/export/passphrase-archive";
import { NextRequest, NextResponse } from "next/server";
const encryptedExportSchema = z
.object({
passphrase: z
.string()
.min(MIN_EXPORT_PASSPHRASE_LENGTH)
// A generous upper bound — a passphrase, not a file. Keeps the KDF input
// bounded.
.max(1024),
})
.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);
}
const user = auth.user;
annotate({ action: { name: "user.export.encrypted" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
// The body is a single passphrase string — small and bounded. 8 KB is far
// above any legitimate passphrase while rejecting a large body before parse.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = encryptedExportSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error);
}
const { passphrase } = parsed.data;
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
// Encrypt the JSON bytes. The passphrase goes no further than the KDF.
const archive = await encryptArchive(JSON.stringify(payload), passphrase);
await auditLog("user.export.encrypted", {
userId: user.id,
ipAddress: getClientIp(request),
details: { counts, format: "HLX1" },
});
annotate({
meta: {
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
export_archive_bytes: archive.byteLength,
},
});
const stamp = new Date().toISOString().slice(0, 10);
// Return the raw binary archive (NOT the apiSuccess envelope). The file is a
// self-contained `.hlx` archive openable with the user's passphrase via
// scripts/decrypt-export.ts.
return new NextResponse(new Uint8Array(archive), {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="healthlog-backup-${user.id}-${stamp}.hlx"`,
"Cache-Control": "no-store",
},
});
});
+11 -114
View File
@@ -21,9 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -35,125 +33,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
const [measurements, medications, intakeEvents, moodEntries, cycle] =
await Promise.all([
prisma.measurement.findMany({
// v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the
// user-initiated backup bundle so the round-trip via admin
// restore does not resurrect deleted measurements.
where: { userId: user.id, deletedAt: null },
orderBy: { measuredAt: "desc" },
}),
// Explicit `select` on the schedule columns — see the matching
// comment in `medications/route.ts` for the rationale (the
// integration testbed has an unmigrated `days_of_week` column on
// the schema branch we ship alongside).
prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
},
},
}),
prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows (mirrors the measurement
// `deletedAt: null` filter above on this user-facing export).
where: { userId: user.id, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId: user.id, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
}),
// v1.15.0 — cycle tables (profile + observed spans + day-logs). The
// shared helper carries `notesEncrypted` verbatim (never decrypted).
buildCycleBackupSection(prisma, user.id),
]);
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
// `parseBackupPayload()` validator round-trips both blobs. Keep these
// two writers in sync — `src/lib/jobs/reminder-worker.ts` is the
// canonical reference.
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
userId: user.id,
measurements: measurements.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
// v1.23 — decrypt the note for the human-readable backup; an admin
// restore re-encrypts it on re-insert.
notes: readNote(m.notesEncrypted, m.notes),
})),
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
})),
})),
intakeEvents: intakeEvents.map((e) => ({
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
skipped: e.skipped,
source: e.source,
})),
moodEntries: moodEntries.map((e) => ({
date: e.date,
mood: e.mood,
score: e.score,
tags: e.tags,
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
})),
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
};
// v1.23 — the payload builder is shared with the passphrase-encrypted
// export route so both emit the byte-for-byte same restore-compatible shape.
const { payload, counts } = await buildFullBackupPayload(prisma, user.id);
await auditLog("user.export.full-backup", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
counts: {
measurements: measurements.length,
medications: medications.length,
intakeEvents: intakeEvents.length,
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
},
},
details: { counts },
});
annotate({
meta: {
export_measurements_count: measurements.length,
export_medications_count: medications.length,
export_intake_count: intakeEvents.length,
export_mood_count: moodEntries.length,
export_cycle_count: cycle.cycles.length,
export_cycle_day_log_count: cycle.cycleDayLogs.length,
export_measurements_count: counts.measurements,
export_medications_count: counts.medications,
export_intake_count: counts.intakeEvents,
export_mood_count: counts.moodEntries,
export_cycle_count: counts.cycles,
export_cycle_day_log_count: counts.cycleDayLogs,
},
});