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

+40
View File
@@ -1349,6 +1349,35 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/export/encrypted:
post:
tags:
- Export
summary: Download the full backup as a passphrase-encrypted archive
description: v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive
(Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a
`.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`,
cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal
session or Bearer. Shared `export:<userId>` rate bucket (10/h). The passphrase is never stored — a forgotten
passphrase makes the archive unrecoverable.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EncryptedExportRequest"
responses:
"200":
description: Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2
params | salt | iv | tag | ciphertext).
content:
application/octet-stream:
schema:
type: string
format: binary
"401": *a1
"422": *a3
"429": *a4
/api/auth/login:
post:
tags:
@@ -7612,6 +7641,17 @@ paths:
"429": *a4
components:
schemas:
EncryptedExportRequest:
type: object
properties:
passphrase:
type: string
minLength: 12
maxLength: 1024
description: User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no
server-side recovery.
required:
- passphrase
MfaVerifyRequest:
type: object
properties:
+6 -1
View File
@@ -4057,7 +4057,12 @@
},
"fullBackup": {
"title": "Voll-Backup",
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload."
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.",
"encryptToggle": "Mit einer Passphrase verschlüsseln",
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
},
"cycle": {
"title": "Zyklus",
+6 -1
View File
@@ -4057,7 +4057,12 @@
},
"fullBackup": {
"title": "Full backup",
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload."
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.",
"encryptToggle": "Encrypt with a passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
"stepUpNeeded": "Confirm your second factor, then try again."
},
"cycle": {
"title": "Cycle",
+6 -1
View File
@@ -4057,7 +4057,12 @@
},
"fullBackup": {
"title": "Copia completa",
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador."
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.",
"encryptToggle": "Cifrar con una contraseña",
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
},
"cycle": {
"title": "Ciclo",
+6 -1
View File
@@ -4057,7 +4057,12 @@
},
"fullBackup": {
"title": "Sauvegarde complète",
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur."
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.",
"encryptToggle": "Chiffrer avec une phrase secrète",
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
},
"cycle": {
"title": "Cycle",
+6 -1
View File
@@ -4057,7 +4057,12 @@
},
"fullBackup": {
"title": "Backup completo",
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore."
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.",
"encryptToggle": "Cifra con una passphrase",
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
"stepUpNeeded": "Conferma il secondo fattore e riprova."
},
"cycle": {
"title": "Ciclo",
+6 -1
View File
@@ -4057,7 +4057,12 @@
},
"fullBackup": {
"title": "Pełna kopia zapasowa",
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora."
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.",
"encryptToggle": "Zaszyfruj hasłem-frazą",
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
},
"cycle": {
"title": "Cykl",
+102
View File
@@ -0,0 +1,102 @@
/**
* scripts/decrypt-export.ts <archive.hlx> [output.json]
*
* Standalone decryptor for a passphrase-encrypted HealthLog export (`HLX1`
* wire format — see src/lib/export/passphrase-archive.ts). A user runs this
* with their passphrase to recover the plaintext JSON backup WITHOUT the app:
*
* pnpm dlx tsx scripts/decrypt-export.ts healthlog-backup-....hlx
*
* The passphrase is read interactively with echo disabled, or from the
* `EXPORT_PASSPHRASE` environment variable for non-interactive use. It is
* never written to disk or logged. There is no server-side recovery: a wrong
* or forgotten passphrase cannot be worked around — the archive is opaque.
*
* If no output path is given, the decrypted JSON is written next to the input
* with a `.json` extension; pass `-` to stream to stdout instead.
*
* This script depends ONLY on the archive codec (Argon2id + node:crypto) — no
* database, no Prisma, no app boot — so it stays runnable from a checkout with
* just `pnpm install`.
*/
import { readFile, writeFile } from "node:fs/promises";
import { createInterface } from "node:readline";
import { decryptArchive } from "@/lib/export/passphrase-archive";
function usage(): never {
console.error(
"Usage: pnpm dlx tsx scripts/decrypt-export.ts <archive.hlx> [output.json|-]",
);
process.exit(2);
}
/** Read a line from the TTY with local echo disabled. */
async function promptPassphrase(): Promise<string> {
const fromEnv = process.env.EXPORT_PASSPHRASE;
if (fromEnv && fromEnv.length > 0) return fromEnv;
if (!process.stdin.isTTY) {
console.error(
"No TTY available and EXPORT_PASSPHRASE is unset — cannot read passphrase.",
);
process.exit(2);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
// Mute echo: intercept the output stream while the prompt is active.
const out = process.stdout;
let muted = false;
const realWrite = out.write.bind(out);
(out as unknown as { write: typeof out.write }).write = ((
chunk: string | Uint8Array,
...rest: unknown[]
) => {
if (muted) return true;
// @ts-expect-error variadic passthrough
return realWrite(chunk, ...rest);
}) as typeof out.write;
process.stdout.write("Passphrase: ");
muted = true;
try {
const answer = await new Promise<string>((resolve) => {
rl.question("", resolve);
});
return answer;
} finally {
muted = false;
(out as unknown as { write: typeof out.write }).write = realWrite;
process.stdout.write("\n");
rl.close();
}
}
async function main(): Promise<void> {
const input = process.argv[2];
if (!input) usage();
const output = process.argv[3] ?? input.replace(/\.hlx$/i, "") + ".json";
const buf = await readFile(input);
const passphrase = await promptPassphrase();
let plaintext: string;
try {
plaintext = await decryptArchive(buf, passphrase);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
if (output === "-") {
process.stdout.write(plaintext);
if (!plaintext.endsWith("\n")) process.stdout.write("\n");
} else {
await writeFile(output, plaintext, "utf8");
console.error(`Decrypted -> ${output}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -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,
},
});
+79 -5
View File
@@ -38,6 +38,7 @@ import {
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -412,20 +413,55 @@ function MoodCsvCard() {
);
}
const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
function FullBackupCard() {
const { t } = useTranslations();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// v1.23 — opt-in passphrase encryption. When on, the download POSTs to
// /api/export/encrypted with the passphrase and saves an opaque `.hlx`
// archive instead of plaintext JSON.
const [encrypt, setEncrypt] = useState(false);
const [passphrase, setPassphrase] = useState("");
const passphraseTooShort =
encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH;
async function handleDownload() {
setBusy(true);
setError(null);
try {
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
if (encrypt) {
const res = await apiFetchRaw("/api/export/encrypted", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
});
if (res.status === 401) {
setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded"));
return;
}
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = `healthlog-backup-${stamp}.hlx`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} else {
await downloadFromUrl(
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
}
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
@@ -447,7 +483,7 @@ function FullBackupCard() {
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
disabled={busy}
disabled={busy || passphraseTooShort}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
@@ -458,6 +494,44 @@ function FullBackupCard() {
</Button>
}
>
<label
htmlFor="export-full-backup-encrypt"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
>
<Switch
id="export-full-backup-encrypt"
data-testid="export-full-backup-encrypt"
checked={encrypt}
onCheckedChange={(v) => {
setEncrypt(v);
setError(null);
if (!v) setPassphrase("");
}}
/>
<span className="text-muted-foreground">
{t("settings.sections.export.cards.fullBackup.encryptToggle")}
</span>
</label>
{encrypt && (
<div className="space-y-2">
<PasswordInput
data-testid="export-full-backup-passphrase"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t(
"settings.sections.export.cards.fullBackup.passphrasePlaceholder",
)}
autoComplete="new-password"
className="h-9 text-sm"
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
</p>
<p className="text-xs text-amber-600 dark:text-amber-500">
{t("settings.sections.export.cards.fullBackup.noRecoveryWarning")}
</p>
</div>
)}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import { Buffer } from "node:buffer";
import {
encryptArchive,
decryptArchive,
parseArchiveHeader,
EXPORT_ARGON2_PARAMS,
MIN_EXPORT_PASSPHRASE_LENGTH,
} from "../passphrase-archive";
const PASSPHRASE = "correct horse battery staple";
const PAYLOAD = JSON.stringify({
schemaVersion: 1,
measurements: [{ type: "WEIGHT", value: 81.2, unit: "kg" }],
note: "Zugangsdaten 🔐 € ñ",
});
describe("passphrase-encrypted export archive (HLX1)", () => {
it("round-trips encrypt -> decrypt with the right passphrase", async () => {
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
const out = await decryptArchive(archive, PASSPHRASE);
expect(out).toBe(PAYLOAD);
});
it("emits the HLX1 magic + version header", async () => {
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
expect(archive.subarray(0, 4).toString("ascii")).toBe("HLX1");
expect(archive.readUInt8(4)).toBe(0x01); // version
expect(archive.readUInt8(5)).toBe(0x01); // KDF = Argon2id
});
it("carries the Argon2id KDF params + salt in the header", async () => {
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
const { header, bodyOffset } = parseArchiveHeader(archive);
expect(header.memoryCost).toBe(EXPORT_ARGON2_PARAMS.memoryCost);
expect(header.timeCost).toBe(EXPORT_ARGON2_PARAMS.timeCost);
expect(header.parallelism).toBe(EXPORT_ARGON2_PARAMS.parallelism);
expect(header.salt.length).toBe(16);
expect(bodyOffset).toBe(16 + 16 + 12 + 16); // header + salt + iv + tag
});
it("uses a random salt + iv so two archives of the same input differ", async () => {
const a = await encryptArchive(PAYLOAD, PASSPHRASE);
const b = await encryptArchive(PAYLOAD, PASSPHRASE);
expect(Buffer.compare(a, b)).not.toBe(0);
// ...but both decrypt back to the same plaintext.
expect(await decryptArchive(a, PASSPHRASE)).toBe(PAYLOAD);
expect(await decryptArchive(b, PASSPHRASE)).toBe(PAYLOAD);
});
it("fails cleanly with a wrong passphrase (no plaintext leak)", async () => {
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
await expect(
decryptArchive(archive, "totally wrong passphrase"),
).rejects.toThrow(/wrong passphrase or corrupt archive/i);
});
it("fails when the ciphertext is tampered", async () => {
const archive = await encryptArchive(PAYLOAD, PASSPHRASE);
const tampered = Buffer.from(archive);
tampered[tampered.length - 1] ^= 0xff; // flip a ciphertext bit
await expect(decryptArchive(tampered, PASSPHRASE)).rejects.toThrow();
});
it("rejects a passphrase shorter than the minimum on encrypt", async () => {
await expect(encryptArchive(PAYLOAD, "short")).rejects.toThrow(
new RegExp(`at least ${MIN_EXPORT_PASSPHRASE_LENGTH}`),
);
});
it("rejects a non-HLX1 buffer", () => {
expect(() => parseArchiveHeader(Buffer.from("not an archive"))).toThrow();
});
it("accepts a Buffer payload, not only a string", async () => {
const buf = Buffer.from(PAYLOAD, "utf8");
const archive = await encryptArchive(buf, PASSPHRASE);
expect(await decryptArchive(archive, PASSPHRASE)).toBe(PAYLOAD);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* v1.23 — shared builder for the user-scoped full-backup payload.
*
* Extracted so both the plaintext `GET /api/export/full-backup` route and the
* passphrase-encrypted `POST /api/export/encrypted` route emit the byte-for-byte
* same shape — the one that the pg-boss `data-backup` worker writes and that
* `parseBackupPayload()` round-trips on admin restore. Keep this writer in sync
* with the `data-backup` worker (`src/lib/jobs/reminder-worker.ts`).
*
* The decrypted notes are surfaced here (the backup is the human-readable
* artefact); an admin restore re-encrypts them on re-insert.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
export interface FullBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
moodEntries: number;
cycles: number;
cycleDayLogs: number;
}
export interface FullBackupResult {
payload: Record<string, unknown>;
counts: FullBackupCounts;
}
/**
* Build the canonical full-backup payload for `userId`. Soft-deleted rows are
* excluded so a round-trip via admin restore never resurrects deleted records.
*/
export async function buildFullBackupPayload(
prisma: PrismaClient,
userId: string,
): Promise<FullBackupResult> {
const [measurements, medications, intakeEvents, moodEntries, cycle] =
await Promise.all([
prisma.measurement.findMany({
where: { userId, deletedAt: null },
orderBy: { measuredAt: "desc" },
}),
prisma.medication.findMany({
where: { userId },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
},
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
}),
buildCycleBackupSection(prisma, userId),
]);
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: new Date().toISOString(),
userId,
measurements: measurements.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
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(),
})),
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
};
return {
payload,
counts: {
measurements: measurements.length,
medications: medications.length,
intakeEvents: intakeEvents.length,
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
},
};
}
+227
View File
@@ -0,0 +1,227 @@
/**
* v1.23 — passphrase-encrypted export archive (`HLX1` wire format).
*
* A user can choose to download their export as an encrypted archive instead
* of plaintext JSON. The passphrase the user supplies is run through Argon2id
* to derive a 256-bit key, and the export bytes are sealed with AES-256-GCM.
*
* THREAT MODEL. The data is already plaintext-at-rest on the server (the
* server computes insights / rollups / FHIR over it). What this closes is the
* "plaintext health file lands in Downloads / iCloud / Google-Drive sync"
* path: the file that leaves the server is opaque without the passphrase.
* There is NO server-side recovery — the passphrase is never stored and never
* logged, so a forgotten passphrase means the archive is unrecoverable. The
* UI states this loudly.
*
* Wire format (binary), mirroring the off-host backup `HLBK` envelope so there
* is one decryption mental model in the codebase + docs:
*
* magic(4) = "HLX1"
* version(1) = 0x01
* kdf(1) = 0x01 (Argon2id)
* memoryCost(4) = uint32 BE (KiB; Argon2 `m`)
* timeCost(4) = uint32 BE (Argon2 `t`)
* parallelism(1)= uint8 (Argon2 `p`)
* saltLen(1) = uint8 (= 16)
* salt(saltLen)
* iv(12) (AES-GCM nonce)
* tag(16) (AES-GCM auth tag)
* ciphertext(...)
*
* The Argon2 params travel in the header (self-describing) so a future cost
* bump still decrypts every previously-written archive — the decryptor reads
* `m/t/p` + `salt` from the file, never from a constant. The standalone
* `scripts/decrypt-export.ts` decryptor depends ONLY on this module, so a user
* can open the archive with `pnpm dlx tsx` and their passphrase, app or not.
*
* KDF defaults are the 2025 OWASP minimum for Argon2id (RFC 9106):
* 19 MiB / t=2 / p=1, 32-byte output. Kept independent from the password
* hashing params so tuning one never silently changes the other.
*/
import { hashRaw, type Algorithm } from "@node-rs/argon2";
import {
createCipheriv,
createDecipheriv,
randomBytes,
timingSafeEqual,
} from "node:crypto";
const MAGIC = "HLX1";
const VERSION = 0x01;
const KDF_ARGON2ID = 0x01;
// `Algorithm.Argon2id` is a const enum (= 2); reference the value directly so
// the module compiles under `isolatedModules` without importing the enum.
const ARGON2ID: Algorithm = 2 as Algorithm;
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const SALT_LENGTH = 16;
const KEY_LENGTH = 32;
/**
* Argon2id KDF parameters for newly-written archives. OWASP-minimum for
* 2025 (RFC 9106): 19 MiB memory, 2 passes, single lane, 32-byte key.
* These are written into each archive header, so changing them never
* orphans archives written under the old cost.
*/
export const EXPORT_ARGON2_PARAMS = {
/** Argon2 `m` — memory cost in KiB (19 MiB). */
memoryCost: 19456,
/** Argon2 `t` — iterations. */
timeCost: 2,
/** Argon2 `p` — lanes. */
parallelism: 1,
} as const;
/**
* Minimum passphrase length the encrypt path enforces. A short passphrase
* defeats the KDF; 12 mirrors the account password floor.
*/
export const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
export interface ArchiveHeader {
version: number;
kdf: number;
memoryCost: number;
timeCost: number;
parallelism: number;
salt: Buffer;
}
/** Derive the 256-bit AES key from a passphrase + the archive's KDF params. */
async function deriveKey(
passphrase: string,
params: { memoryCost: number; timeCost: number; parallelism: number },
salt: Buffer,
): Promise<Buffer> {
return hashRaw(passphrase, {
algorithm: ARGON2ID,
memoryCost: params.memoryCost,
timeCost: params.timeCost,
parallelism: params.parallelism,
outputLen: KEY_LENGTH,
salt,
});
}
/**
* Encrypt `plaintext` into an `HLX1` archive sealed with `passphrase`.
* Throws if the passphrase is shorter than `MIN_EXPORT_PASSPHRASE_LENGTH`.
*/
export async function encryptArchive(
plaintext: string | Buffer,
passphrase: string,
): Promise<Buffer> {
if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
throw new Error(
`Passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters`,
);
}
const salt = randomBytes(SALT_LENGTH);
const key = await deriveKey(passphrase, EXPORT_ARGON2_PARAMS, salt);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const body =
typeof plaintext === "string" ? Buffer.from(plaintext, "utf8") : plaintext;
const ciphertext = Buffer.concat([cipher.update(body), cipher.final()]);
const tag = cipher.getAuthTag();
const header = Buffer.alloc(4 + 1 + 1 + 4 + 4 + 1 + 1);
header.write(MAGIC, 0, "ascii");
header.writeUInt8(VERSION, 4);
header.writeUInt8(KDF_ARGON2ID, 5);
header.writeUInt32BE(EXPORT_ARGON2_PARAMS.memoryCost, 6);
header.writeUInt32BE(EXPORT_ARGON2_PARAMS.timeCost, 10);
header.writeUInt8(EXPORT_ARGON2_PARAMS.parallelism, 14);
header.writeUInt8(SALT_LENGTH, 15);
return Buffer.concat([header, salt, iv, tag, ciphertext]);
}
/** Parse + validate the fixed `HLX1` header. Throws on any structural fault. */
export function parseArchiveHeader(buf: Buffer): {
header: ArchiveHeader;
bodyOffset: number;
} {
if (buf.length < 16) {
throw new Error("Not an HLX1 archive (too short)");
}
const magic = buf.subarray(0, 4).toString("ascii");
if (!timingSafeEqual(Buffer.from(magic), Buffer.from(MAGIC))) {
throw new Error("Not an HLX1 archive (bad magic)");
}
const version = buf.readUInt8(4);
if (version !== VERSION) {
throw new Error(`Unsupported HLX1 version ${version}`);
}
const kdf = buf.readUInt8(5);
if (kdf !== KDF_ARGON2ID) {
throw new Error(`Unsupported KDF id ${kdf}`);
}
const memoryCost = buf.readUInt32BE(6);
const timeCost = buf.readUInt32BE(10);
const parallelism = buf.readUInt8(14);
const saltLen = buf.readUInt8(15);
const saltStart = 16;
const ivStart = saltStart + saltLen;
const tagStart = ivStart + IV_LENGTH;
const bodyOffset = tagStart + TAG_LENGTH;
if (buf.length < bodyOffset) {
throw new Error("Corrupt HLX1 archive (truncated header)");
}
if (memoryCost < 1 || timeCost < 1 || parallelism < 1 || saltLen < 8) {
throw new Error("Corrupt HLX1 archive (implausible KDF params)");
}
return {
header: {
version,
kdf,
memoryCost,
timeCost,
parallelism,
salt: buf.subarray(saltStart, ivStart),
},
bodyOffset,
};
}
/**
* Decrypt an `HLX1` archive with `passphrase`. A wrong passphrase fails the
* GCM authentication tag and throws a clean, generic error (no oracle about
* which part was wrong). The returned value is the original plaintext (UTF-8).
*/
export async function decryptArchive(
buf: Buffer,
passphrase: string,
): Promise<string> {
const { header, bodyOffset } = parseArchiveHeader(buf);
const saltLen = header.salt.length;
const ivStart = 16 + saltLen;
const tagStart = ivStart + IV_LENGTH;
const iv = buf.subarray(ivStart, tagStart);
const tag = buf.subarray(tagStart, bodyOffset);
const ciphertext = buf.subarray(bodyOffset);
const key = await deriveKey(
passphrase,
{
memoryCost: header.memoryCost,
timeCost: header.timeCost,
parallelism: header.parallelism,
},
Buffer.from(header.salt),
);
try {
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const out = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return out.toString("utf8");
} catch {
// GCM tag mismatch — wrong passphrase or a tampered/corrupt archive.
// Deliberately opaque: never reveal which.
throw new Error("Decryption failed — wrong passphrase or corrupt archive");
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* OpenAPI route table — generic data export (passphrase-encrypted variant).
*
* The plaintext `GET /api/export/full-backup` + per-type CSV routes predate the
* OpenAPI registry and are not iOS-contract surfaces; this module documents the
* v1.23 passphrase-encrypted variant because it adds a request flag + a binary
* response shape the iOS client may consume.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { stdResponses } from "./shared";
const encryptedExportRequest = z
.object({
passphrase: z.string().min(12).max(1024).meta({
description:
"User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no server-side recovery.",
}),
})
.meta({ id: "EncryptedExportRequest" });
export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/export/encrypted": {
post: {
tags: ["Export"],
summary: "Download the full backup as a passphrase-encrypted archive",
description:
"v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`, cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal session or Bearer. Shared `export:<userId>` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable.",
requestBody: {
required: true,
content: {
"application/json": { schema: encryptedExportRequest },
},
},
responses: {
"200": {
description:
"Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 params | salt | iv | tag | ciphertext).",
content: {
"application/octet-stream": {
schema: z.string().meta({ format: "binary" }),
},
},
},
...stdResponses,
},
},
},
};
+2
View File
@@ -34,6 +34,7 @@ import { consentPaths } from "./consent";
import { cyclePaths } from "./cycle";
import { biomarkerPaths } from "./biomarkers";
import { devicePaths } from "./devices";
import { exportPaths } from "./export";
import { healthRecordPaths } from "./health-record";
import { illnessPaths } from "./illness";
import { importPaths } from "./import";
@@ -55,6 +56,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...cyclePaths,
...metaPaths,
...healthRecordPaths,
...exportPaths,
...authPaths,
...syncPaths,
...measurementPaths,