mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge: integrate the v1.23 data export and encryption-status wave
This commit is contained in:
commit
268c5cef05
29 files changed
+2346
-125
No files matched your search
@@ -25,6 +25,7 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
|
||||
import { EncryptionSection } from "@/components/admin/encryption-section";
|
||||
import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section";
|
||||
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
|
||||
import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section";
|
||||
@@ -195,6 +196,15 @@ export function AdminSectionRenderer({
|
||||
<BackupsSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "encryption":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.encryption.title")}
|
||||
subtitle={t("admin.section.encryption.subtitle")}
|
||||
>
|
||||
<EncryptionSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "danger-zone":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/rotate
|
||||
*
|
||||
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
|
||||
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
|
||||
* column corpus to the configured ACTIVE key id. The CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
|
||||
* convenience that reuses the same column registry and is safe by construction:
|
||||
*
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
|
||||
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
|
||||
* redeploy act.
|
||||
* - IDEMPOTENT: rows already on the active key are skipped; the singleton key
|
||||
* coalesces duplicate triggers into one queued run.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
|
||||
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
|
||||
* high-blast-radius action. An admin without a second factor cannot use the
|
||||
* button and falls back to the CLI (this is intentional: step-up cannot be
|
||||
* softened, and the CLI is the documented canonical path).
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireAdmin,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { getActiveKeyId } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Cookie-only admin, then a fresh second factor. Both resolution paths are
|
||||
// cookie-only, so a Bearer token cannot reach either.
|
||||
await requireAdmin();
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
annotate({ action: { name: "admin.encryption.rotate.requested" } });
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return apiError("Background worker is not available", 503);
|
||||
}
|
||||
|
||||
const jobId = await boss.send(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ requestedByUserId: user.id, enqueuedAt: new Date().toISOString() },
|
||||
{
|
||||
// Coalesce duplicate triggers: at most one queued run at a time.
|
||||
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
|
||||
retryLimit: 2,
|
||||
retryDelay: 60,
|
||||
},
|
||||
);
|
||||
|
||||
await auditLog("admin.encryption.rotate.requested", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { activeKeyId: getActiveKeyId(), enqueued: Boolean(jobId) },
|
||||
});
|
||||
|
||||
annotate({ meta: { rotate_enqueued: Boolean(jobId) } });
|
||||
|
||||
// A null jobId means the singleton coalesced this trigger onto an already-
|
||||
// queued run — still "accepted" from the operator's point of view.
|
||||
return apiSuccess({ accepted: true, alreadyQueued: !jobId });
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/status
|
||||
*
|
||||
* v1.23 — read-only admin view of encryption coverage + rotation progress.
|
||||
* Buckets every registered encrypted column's rows by key id (the same scan the
|
||||
* rotation tooling does), so an operator can SEE whether a rotation finished
|
||||
* before dropping a legacy key, instead of guessing.
|
||||
*
|
||||
* Returns ONLY key IDS (operator-chosen labels like `v1` / `v2`) and ROW
|
||||
* COUNTS — never key material. The configured-key set is surfaced as a count.
|
||||
*
|
||||
* Auth: cookie-only `requireAdmin` (a Bearer token can never reach admin). The
|
||||
* scan is on-demand with a short in-process cache so repeated panel renders
|
||||
* don't re-scan the corpus each time; the rotation run state is read from the
|
||||
* audit trail (DB-backed, so it is correct across the web/worker processes).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
type CorpusScan,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
|
||||
const SCAN_CACHE_TTL_MS = 15_000;
|
||||
let scanCache: { at: number; scan: CorpusScan } | null = null;
|
||||
|
||||
interface RotationState {
|
||||
state: "idle" | "running" | "completed" | "failed";
|
||||
lastRequestedAt: string | null;
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
}
|
||||
|
||||
async function readRotationState(): Promise<RotationState> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
action: {
|
||||
in: [
|
||||
"admin.encryption.rotate.requested",
|
||||
"admin.encryption.rotate.completed",
|
||||
"admin.encryption.rotate.failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { action: true, createdAt: true, details: true },
|
||||
});
|
||||
|
||||
const requested = rows.find(
|
||||
(r) => r.action === "admin.encryption.rotate.requested",
|
||||
);
|
||||
const finished = rows.find(
|
||||
(r) =>
|
||||
r.action === "admin.encryption.rotate.completed" ||
|
||||
r.action === "admin.encryption.rotate.failed",
|
||||
);
|
||||
|
||||
let lastResult: RotationState["lastResult"] = null;
|
||||
if (
|
||||
finished?.action === "admin.encryption.rotate.completed" &&
|
||||
finished.details
|
||||
) {
|
||||
try {
|
||||
const d = JSON.parse(finished.details) as {
|
||||
scanned?: number;
|
||||
rotated?: number;
|
||||
errors?: number;
|
||||
};
|
||||
lastResult = {
|
||||
scanned: d.scanned ?? 0,
|
||||
rotated: d.rotated ?? 0,
|
||||
errors: d.errors ?? 0,
|
||||
};
|
||||
} catch {
|
||||
lastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
// "running" iff the most recent request is newer than the most recent
|
||||
// completion/failure.
|
||||
let state: RotationState["state"] = "idle";
|
||||
if (requested && (!finished || requested.createdAt > finished.createdAt)) {
|
||||
state = "running";
|
||||
} else if (finished?.action === "admin.encryption.rotate.completed") {
|
||||
state = "completed";
|
||||
} else if (finished?.action === "admin.encryption.rotate.failed") {
|
||||
state = "failed";
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
lastRequestedAt: requested?.createdAt.toISOString() ?? null,
|
||||
lastCompletedAt: finished?.createdAt.toISOString() ?? null,
|
||||
lastResult,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.status" } });
|
||||
|
||||
const now = Date.now();
|
||||
let scan: CorpusScan;
|
||||
if (scanCache && now - scanCache.at < SCAN_CACHE_TTL_MS) {
|
||||
scan = scanCache.scan;
|
||||
} else {
|
||||
scan = await scanCorpus(prisma as unknown as CorpusClient);
|
||||
scanCache = { at: now, scan };
|
||||
}
|
||||
|
||||
const rotation = await readRotationState();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
encryption_total_rows: scan.totalRows,
|
||||
encryption_stale_rows: scan.staleRows,
|
||||
encryption_rotation_complete: scan.rotationComplete,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
activeKeyId: scan.activeKeyId,
|
||||
// COUNT only — never the key material.
|
||||
configuredKeyCount: getConfiguredKeyIds().length,
|
||||
rotationComplete: scan.rotationComplete,
|
||||
totalRows: scan.totalRows,
|
||||
activeRows: scan.activeRows,
|
||||
staleRows: scan.staleRows,
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Ticket,
|
||||
Users,
|
||||
@@ -131,6 +132,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.backups.title",
|
||||
icon: Database,
|
||||
},
|
||||
// v1.23 — encryption coverage + key-rotation status / safe trigger.
|
||||
{
|
||||
slug: "encryption",
|
||||
titleKey: "admin.section.encryption.title",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
slug: "danger-zone",
|
||||
titleKey: "admin.section.danger-zone.title",
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<EncryptionSection>` — admin view of encryption coverage + key-rotation
|
||||
* status, with a guarded rotation trigger.
|
||||
*
|
||||
* Reads `GET /api/admin/encryption/status` (per-column rows-per-key-id, legacy
|
||||
* counts, overall rotation progress). NEVER surfaces key material — only key
|
||||
* ids (operator labels) and row counts. The rotation trigger POSTs to
|
||||
* `/api/admin/encryption/rotate`, which is step-up gated server-side; a 401
|
||||
* here means the admin needs a fresh second factor (or has none enrolled, in
|
||||
* which case the documented CLI is the path).
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, RotateCw, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
|
||||
interface ColumnScan {
|
||||
model: string;
|
||||
field: string;
|
||||
kind: "string" | "bytes";
|
||||
total: number;
|
||||
byKeyId: Record<string, number>;
|
||||
legacy: number;
|
||||
}
|
||||
|
||||
interface EncryptionStatus {
|
||||
activeKeyId: string;
|
||||
configuredKeyCount: number;
|
||||
rotationComplete: boolean;
|
||||
totalRows: number;
|
||||
activeRows: number;
|
||||
staleRows: number;
|
||||
columns: ColumnScan[];
|
||||
rotation: {
|
||||
state: "idle" | "running" | "completed" | "failed";
|
||||
lastRequestedAt: string | null;
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function EncryptionSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
queryFn: async (): Promise<EncryptionStatus> => {
|
||||
return apiGet<EncryptionStatus>("/api/admin/encryption/status", {
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const rotate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/admin/encryption/rotate", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
throw new Error(t("admin.section.encryption.stepUpNeeded"));
|
||||
}
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("admin.section.encryption.rotateEnqueued"));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t("admin.section.encryption.rotateFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
if (statusQuery.isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.section.encryption.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError || !statusQuery.data) {
|
||||
return (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("admin.section.encryption.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const s = statusQuery.data;
|
||||
const running = s.rotation.state === "running" || rotate.isPending;
|
||||
// Only the rows that share a coverage view need the per-column table; sort
|
||||
// stale-first so an operator sees what still needs rotating.
|
||||
const columns = [...s.columns].sort((a, b) => b.legacy - a.legacy);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
description={t("admin.section.encryption.coverageDescription")}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stat
|
||||
label={t("admin.section.encryption.activeKeyId")}
|
||||
value={s.activeKeyId}
|
||||
/>
|
||||
<Stat
|
||||
label={t("admin.section.encryption.configuredKeys")}
|
||||
value={String(s.configuredKeyCount)}
|
||||
/>
|
||||
<Stat
|
||||
label={t("admin.section.encryption.totalRows")}
|
||||
value={s.totalRows.toLocaleString()}
|
||||
/>
|
||||
<Stat
|
||||
label={t("admin.section.encryption.staleRows")}
|
||||
value={s.staleRows.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{s.rotationComplete ? (
|
||||
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
{t("admin.section.encryption.safeToDropLegacy")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{t("admin.section.encryption.rotationIncomplete", {
|
||||
count: s.staleRows,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Rotation status + trigger ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.section.encryption.rotationTitle")}
|
||||
description={t("admin.section.encryption.rotationDescription")}
|
||||
/>
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<p>
|
||||
{t("admin.section.encryption.rotationState")}:{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{t(`admin.section.encryption.state.${s.rotation.state}`)}
|
||||
</span>
|
||||
</p>
|
||||
{s.rotation.lastResult && (
|
||||
<p>
|
||||
{t("admin.section.encryption.lastRun", {
|
||||
scanned: s.rotation.lastResult.scanned,
|
||||
rotated: s.rotation.lastResult.rotated,
|
||||
errors: s.rotation.lastResult.errors,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={running}
|
||||
data-testid="admin-encryption-rotate"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.encryption.rotateNow")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.encryption.rotateConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.encryption.rotateConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("admin.section.encryption.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => rotate.mutate()}>
|
||||
{t("admin.section.encryption.rotateConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.encryption.cliNote")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Per-column coverage table ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-3">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.columnsTitle")}
|
||||
description={t("admin.section.encryption.columnsDescription")}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-left">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.section.encryption.colColumn")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.section.encryption.colTotal")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.section.encryption.colActive")}
|
||||
</th>
|
||||
<th className="py-2 font-medium">
|
||||
{t("admin.section.encryption.colStale")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{columns.map((c) => {
|
||||
const active = c.byKeyId[s.activeKeyId] ?? 0;
|
||||
const stale = c.total - active;
|
||||
return (
|
||||
<tr key={`${c.model}.${c.field}`} className="border-b">
|
||||
<td className="py-2 pr-3 font-mono text-xs">
|
||||
{c.model}.{c.field}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{c.total.toLocaleString()}</td>
|
||||
<td className="py-2 pr-3">{active.toLocaleString()}</td>
|
||||
<td className="py-2">
|
||||
{stale > 0 ? (
|
||||
<Badge variant="secondary">
|
||||
{stale.toLocaleString()}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-muted/40 rounded-lg px-3 py-2">
|
||||
<p className="text-muted-foreground text-xs">{label}</p>
|
||||
<p className="text-foreground font-mono text-sm font-medium">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"login-overview",
|
||||
"app-logs",
|
||||
"backups",
|
||||
// v1.23 — encryption coverage + key-rotation status / safe trigger. Sits
|
||||
// next to Backups (both are data-at-rest concerns) and just before the
|
||||
// danger zone.
|
||||
"encryption",
|
||||
"danger-zone",
|
||||
// v1.4.36 W4e — About / version / update check folded into Admin
|
||||
// Console. Lives at /admin/about; the legacy /settings/about route
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -182,6 +182,16 @@ export function getActiveKeyId(): string {
|
||||
return getActiveKey().id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured key ids (NOT the key material). Used by the admin
|
||||
* encryption-status view to report how many keys the operator has loaded and
|
||||
* by the rotation guard. Never expose the bytes — only the ids.
|
||||
*/
|
||||
export function getConfiguredKeyIds(): string[] {
|
||||
const { keys } = loadKeys();
|
||||
return [...keys.keys()];
|
||||
}
|
||||
|
||||
function encryptWithKey(plaintext: string, key: Buffer): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encrypt,
|
||||
extractKeyId,
|
||||
getActiveKeyId,
|
||||
getConfiguredKeyIds,
|
||||
_resetCryptoCacheForTests,
|
||||
} from "@/lib/crypto";
|
||||
import {
|
||||
scanCorpus,
|
||||
rotateCorpus,
|
||||
LEGACY_BUCKET,
|
||||
type CorpusClient,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_V2 =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
function delegateKey(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake Prisma client covering every model in the registry, backed by
|
||||
* a per-model row store. Only the models we seed hold rows; the rest are empty.
|
||||
*/
|
||||
function makeClient(seed: Record<string, Row[]>): {
|
||||
client: CorpusClient;
|
||||
store: Record<string, Row[]>;
|
||||
} {
|
||||
const store: Record<string, Row[]> = {};
|
||||
const client: CorpusClient = {};
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
const key = delegateKey(col.model);
|
||||
if (!store[col.model]) store[col.model] = seed[col.model] ?? [];
|
||||
if (client[key]) continue;
|
||||
const model = col.model;
|
||||
client[key] = {
|
||||
findMany: async ({ select }) => {
|
||||
const fields = Object.keys(select);
|
||||
return store[model].map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) out[f] = row[f];
|
||||
return out;
|
||||
});
|
||||
},
|
||||
update: async ({ where, data }) => {
|
||||
const row = store[model].find((r) => r.id === where.id);
|
||||
if (row) Object.assign(row, data);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
return { client, store };
|
||||
}
|
||||
|
||||
function activeUnder(keyId: string, plaintext: string): string {
|
||||
// Encrypt while a given key id is active, then restore v2 as active.
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
|
||||
_resetCryptoCacheForTests();
|
||||
const ct = encrypt(plaintext);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
_resetCryptoCacheForTests();
|
||||
return ct;
|
||||
}
|
||||
|
||||
describe("encryption-corpus scan + rotate", () => {
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 });
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
it("buckets rows by key id (string + bytes columns)", async () => {
|
||||
const stringCt = activeUnder("v1", "token-under-v1");
|
||||
const stringActive = encrypt("token-under-v2");
|
||||
const bytesCt = Buffer.from(activeUnder("v1", "note-under-v1"), "utf8");
|
||||
|
||||
const { client } = makeClient({
|
||||
User: [
|
||||
{ id: "u1", codexAccessTokenEncrypted: stringCt },
|
||||
{ id: "u2", codexAccessTokenEncrypted: stringActive },
|
||||
],
|
||||
MoodEntry: [{ id: "m1", noteEncrypted: bytesCt }],
|
||||
});
|
||||
|
||||
const scan = await scanCorpus(client);
|
||||
expect(scan.activeKeyId).toBe("v2");
|
||||
|
||||
const userCol = scan.columns.find(
|
||||
(c) => c.model === "User" && c.field === "codexAccessTokenEncrypted",
|
||||
)!;
|
||||
expect(userCol.total).toBe(2);
|
||||
expect(userCol.byKeyId.v1).toBe(1);
|
||||
expect(userCol.byKeyId.v2).toBe(1);
|
||||
|
||||
const moodCol = scan.columns.find(
|
||||
(c) => c.model === "MoodEntry" && c.field === "noteEncrypted",
|
||||
)!;
|
||||
expect(moodCol.total).toBe(1);
|
||||
expect(moodCol.byKeyId.v1).toBe(1);
|
||||
|
||||
// 3 rows total, 1 already on the active key.
|
||||
expect(scan.totalRows).toBe(3);
|
||||
expect(scan.activeRows).toBe(1);
|
||||
expect(scan.staleRows).toBe(2);
|
||||
expect(scan.rotationComplete).toBe(false);
|
||||
});
|
||||
|
||||
it("rotates stale rows to the active key and is idempotent", async () => {
|
||||
const { client, store } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: activeUnder("v1", "x") }],
|
||||
MoodEntry: [
|
||||
{
|
||||
id: "m1",
|
||||
noteEncrypted: Buffer.from(activeUnder("v1", "y"), "utf8"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await rotateCorpus(client);
|
||||
expect(first.totalRotated).toBe(2);
|
||||
expect(first.totalErrors).toBe(0);
|
||||
|
||||
// ACTIVE-KEY-ONLY: every row now carries the active key id.
|
||||
expect(
|
||||
extractKeyId(store.User[0].codexAccessTokenEncrypted as string),
|
||||
).toBe(getActiveKeyId());
|
||||
expect(
|
||||
extractKeyId(
|
||||
Buffer.from(store.MoodEntry[0].noteEncrypted as Uint8Array).toString(
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
).toBe(getActiveKeyId());
|
||||
|
||||
// IDEMPOTENT: a second pass rotates nothing.
|
||||
const second = await rotateCorpus(client);
|
||||
expect(second.totalRotated).toBe(0);
|
||||
|
||||
const scan = await scanCorpus(client);
|
||||
expect(scan.rotationComplete).toBe(true);
|
||||
expect(scan.staleRows).toBe(0);
|
||||
});
|
||||
|
||||
it("never adds or drops a key during rotation", async () => {
|
||||
const before = getConfiguredKeyIds().sort();
|
||||
const { client } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: activeUnder("v1", "x") }],
|
||||
});
|
||||
await rotateCorpus(client);
|
||||
const after = getConfiguredKeyIds().sort();
|
||||
expect(after).toEqual(before);
|
||||
expect(after).toEqual(["v1", "v2"]);
|
||||
});
|
||||
|
||||
it("fails closed on a row under a no-longer-configured key (no data loss)", async () => {
|
||||
// A ciphertext whose key id ('v9') is not in ENCRYPTION_KEYS. Decrypt
|
||||
// throws -> counted as an error, the row is left untouched.
|
||||
const orphan = "v9." + Buffer.from("garbage").toString("base64");
|
||||
const { client, store } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: orphan }],
|
||||
});
|
||||
|
||||
const result = await rotateCorpus(client);
|
||||
expect(result.totalErrors).toBe(1);
|
||||
expect(result.totalRotated).toBe(0);
|
||||
// Row preserved exactly, not dropped or blanked.
|
||||
expect(store.User[0].codexAccessTokenEncrypted).toBe(orphan);
|
||||
});
|
||||
|
||||
it("treats legacy (unversioned) ciphertext as stale and buckets it", async () => {
|
||||
// Legacy bare-base64 row written by the old single-key path.
|
||||
process.env.ENCRYPTION_KEYS = "";
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "";
|
||||
process.env.ENCRYPTION_KEY = KEY_V1;
|
||||
_resetCryptoCacheForTests();
|
||||
const legacy = encrypt("legacy-secret").replace(/^v1\./, ""); // strip prefix
|
||||
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 });
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
_resetCryptoCacheForTests();
|
||||
|
||||
const { client } = makeClient({
|
||||
User: [{ id: "u1", codexAccessTokenEncrypted: legacy }],
|
||||
});
|
||||
const scan = await scanCorpus(client);
|
||||
const col = scan.columns.find(
|
||||
(c) => c.model === "User" && c.field === "codexAccessTokenEncrypted",
|
||||
)!;
|
||||
expect(col.legacy).toBe(1);
|
||||
expect(col.byKeyId[LEGACY_BUCKET]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* v1.23 — read-only scan + in-place re-encrypt over the whole encrypted-column
|
||||
* corpus, driven by the canonical registry (`encrypted-columns.ts`).
|
||||
*
|
||||
* Two consumers:
|
||||
* - the admin encryption-status view (`GET /api/admin/encryption/status`)
|
||||
* calls `scanCorpus()` to bucket every encrypted column's rows by key id;
|
||||
* - the admin-triggered rotation pg-boss job (`encryption-key-rotate`) calls
|
||||
* `rotateCorpus()` to re-encrypt every row that is not already on the
|
||||
* active key.
|
||||
*
|
||||
* The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical
|
||||
* rotation path and stays independent (its own Prisma client). This module is
|
||||
* the in-app convenience that reuses the SAME registry, so the guard test keeps
|
||||
* both in lock-step.
|
||||
*
|
||||
* GUARANTEES (the security review must confirm these on the rotation path):
|
||||
* - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()`
|
||||
* always writes the configured active key id. There is no code path here
|
||||
* that selects any other write key.
|
||||
* - NEVER ADDS / DROPS A KEY. This module never reads or mutates
|
||||
* `ENCRYPTION_KEYS` / `ENCRYPTION_ACTIVE_KEY_ID`. The operator's env key map
|
||||
* is the only place keys live; a key drop stays a deliberate env + redeploy
|
||||
* act, never a button.
|
||||
* - IDEMPOTENT. `shouldRotate()` skips rows already on the active key, so a
|
||||
* second pass (or two racing workers) re-encrypts zero rows.
|
||||
* - FAIL-CLOSED. A row written under a key id that is no longer configured
|
||||
* throws on decrypt (counted as an error, the row is left untouched) rather
|
||||
* than being silently dropped or overwritten — exactly the property that
|
||||
* protects against dropping a legacy key too early.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
encryptedColumnKey,
|
||||
type EncryptedColumn,
|
||||
} from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */
|
||||
export const LEGACY_BUCKET = "legacy";
|
||||
|
||||
/** Minimal Prisma delegate shape this module needs. */
|
||||
interface ColumnDelegate {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The subset of the Prisma client we touch: one delegate per model. */
|
||||
export type CorpusClient = Record<string, ColumnDelegate>;
|
||||
|
||||
/** PascalCase model name -> camelCase Prisma delegate key. */
|
||||
function delegateKey(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
function getDelegate(client: CorpusClient, model: string): ColumnDelegate {
|
||||
const delegate = client[delegateKey(model)];
|
||||
if (!delegate) {
|
||||
throw new Error(`No Prisma delegate for model '${model}'`);
|
||||
}
|
||||
return delegate;
|
||||
}
|
||||
|
||||
/** Read a registry column's value as a ciphertext string (Bytes -> utf8). */
|
||||
function toCiphertext(
|
||||
value: unknown,
|
||||
kind: EncryptedColumn["kind"],
|
||||
): string | null {
|
||||
if (value == null) return null;
|
||||
if (kind === "bytes") {
|
||||
const buf = value as Uint8Array;
|
||||
if (buf.byteLength === 0) return null;
|
||||
return Buffer.from(buf).toString("utf8");
|
||||
}
|
||||
const s = value as string;
|
||||
return s.length === 0 ? null : s;
|
||||
}
|
||||
|
||||
/** Encode a re-encrypted ciphertext string back into the column's storage shape. */
|
||||
function fromCiphertext(
|
||||
value: string,
|
||||
kind: EncryptedColumn["kind"],
|
||||
): string | Uint8Array {
|
||||
if (kind !== "bytes") return value;
|
||||
const encoded = Buffer.from(value, "utf8");
|
||||
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
next.set(encoded);
|
||||
return next;
|
||||
}
|
||||
|
||||
function shouldRotate(ciphertext: string): boolean {
|
||||
return extractKeyId(ciphertext) !== getActiveKeyId();
|
||||
}
|
||||
|
||||
export interface ColumnScan {
|
||||
model: string;
|
||||
field: string;
|
||||
kind: EncryptedColumn["kind"];
|
||||
/** Non-null ciphertext rows. */
|
||||
total: number;
|
||||
/** Rows per key id; legacy/unversioned rows land under `LEGACY_BUCKET`. */
|
||||
byKeyId: Record<string, number>;
|
||||
/** Rows under the legacy/unversioned format (= `byKeyId[LEGACY_BUCKET]`). */
|
||||
legacy: number;
|
||||
}
|
||||
|
||||
/** Scan one encrypted column: bucket every non-null ciphertext by key id. */
|
||||
export async function scanColumn(
|
||||
client: CorpusClient,
|
||||
col: EncryptedColumn,
|
||||
): Promise<ColumnScan> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
const byKeyId: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null) continue;
|
||||
total += 1;
|
||||
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
|
||||
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
model: col.model,
|
||||
field: col.field,
|
||||
kind: col.kind,
|
||||
total,
|
||||
byKeyId,
|
||||
legacy: byKeyId[LEGACY_BUCKET] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CorpusScan {
|
||||
activeKeyId: string;
|
||||
columns: ColumnScan[];
|
||||
/** Total non-null ciphertext rows across the corpus. */
|
||||
totalRows: number;
|
||||
/** Rows already on the active key. */
|
||||
activeRows: number;
|
||||
/** Rows NOT on the active key (legacy + any non-active versioned). */
|
||||
staleRows: number;
|
||||
/**
|
||||
* True iff every column has zero rows that are not on the active key — the
|
||||
* single signal an operator needs before dropping a legacy key.
|
||||
*/
|
||||
rotationComplete: boolean;
|
||||
}
|
||||
|
||||
/** Scan the whole corpus. Read-only; never writes. */
|
||||
export async function scanCorpus(client: CorpusClient): Promise<CorpusScan> {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const columns: ColumnScan[] = [];
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
columns.push(await scanColumn(client, col));
|
||||
}
|
||||
let totalRows = 0;
|
||||
let activeRows = 0;
|
||||
for (const c of columns) {
|
||||
totalRows += c.total;
|
||||
activeRows += c.byKeyId[activeKeyId] ?? 0;
|
||||
}
|
||||
const staleRows = totalRows - activeRows;
|
||||
return {
|
||||
activeKeyId,
|
||||
columns,
|
||||
totalRows,
|
||||
activeRows,
|
||||
staleRows,
|
||||
rotationComplete: staleRows === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RotationResult {
|
||||
model: string;
|
||||
field: string;
|
||||
scanned: number;
|
||||
rotated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
/** Re-encrypt one column's stale rows to the active key. */
|
||||
export async function rotateColumn(
|
||||
client: CorpusClient,
|
||||
col: EncryptedColumn,
|
||||
): Promise<RotationResult> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
const result: RotationResult = {
|
||||
model: col.model,
|
||||
field: col.field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null || !shouldRotate(ciphertext)) continue;
|
||||
const id = row.id as string;
|
||||
try {
|
||||
// ACTIVE-KEY-ONLY: encrypt() always writes the active key id.
|
||||
const reencrypted = encrypt(decrypt(ciphertext));
|
||||
await delegate.update({
|
||||
where: { id },
|
||||
data: { [col.field]: fromCiphertext(reencrypted, col.kind) },
|
||||
});
|
||||
result.rotated += 1;
|
||||
} catch {
|
||||
// FAIL-CLOSED: a row under a no-longer-configured key throws on decrypt;
|
||||
// count it and leave the row untouched rather than dropping data.
|
||||
result.errors += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface CorpusRotation {
|
||||
activeKeyId: string;
|
||||
results: RotationResult[];
|
||||
totalScanned: number;
|
||||
totalRotated: number;
|
||||
totalErrors: number;
|
||||
}
|
||||
|
||||
/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */
|
||||
export async function rotateCorpus(
|
||||
client: CorpusClient,
|
||||
): Promise<CorpusRotation> {
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const results: RotationResult[] = [];
|
||||
for (const col of ENCRYPTED_COLUMNS) {
|
||||
results.push(await rotateColumn(client, col));
|
||||
}
|
||||
let totalScanned = 0;
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
for (const r of results) {
|
||||
totalScanned += r.scanned;
|
||||
totalRotated += r.rotated;
|
||||
totalErrors += r.errors;
|
||||
}
|
||||
return {
|
||||
activeKeyId,
|
||||
results,
|
||||
totalScanned,
|
||||
totalRotated,
|
||||
totalErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export { encryptedColumnKey };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* v1.23 — admin-triggered encryption-key rotation, on pg-boss.
|
||||
*
|
||||
* Re-encrypts every registered encrypted column to the configured ACTIVE key
|
||||
* id, reusing the canonical column registry via `rotateCorpus()`. This is the
|
||||
* in-app convenience the admin panel triggers; the CLI
|
||||
* (`scripts/rotate-encryption-key.ts`) remains the documented canonical path.
|
||||
* pg-boss is the right home because the standalone production image strips
|
||||
* `tsx`, so a button that ran the CLI inside the container would fail.
|
||||
*
|
||||
* SAFETY (security-review surface):
|
||||
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: `rotateCorpus()` only ever
|
||||
* writes the active key and never touches the env key map. Dropping a key
|
||||
* stays an operator env + redeploy act.
|
||||
* - IDEMPOTENT: rows already on the active key are skipped, so re-running (or
|
||||
* the singleton coalescing a duplicate trigger) re-encrypts nothing.
|
||||
* - FAIL-CLOSED per row: a row under a no-longer-configured key is counted as
|
||||
* an error and left untouched, never dropped.
|
||||
*
|
||||
* On-demand only — no cron. The admin POST enqueues with a fixed singletonKey
|
||||
* so two concurrent triggers collapse into one run. Completion is recorded both
|
||||
* as a background wide-event and an `admin.encryption.rotate.completed` audit
|
||||
* entry that the status view reads to show the last run.
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
rotateCorpus,
|
||||
type CorpusClient,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
|
||||
|
||||
export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate";
|
||||
export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1;
|
||||
/** Fixed key so duplicate admin triggers coalesce into one queued run. */
|
||||
export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate";
|
||||
|
||||
export interface EncryptionKeyRotatePayload {
|
||||
/** The admin who triggered the run (for the audit trail). */
|
||||
requestedByUserId?: string;
|
||||
enqueuedAt?: string;
|
||||
}
|
||||
|
||||
export async function runEncryptionKeyRotation(): Promise<{
|
||||
activeKeyId: string;
|
||||
totalScanned: number;
|
||||
totalRotated: number;
|
||||
totalErrors: number;
|
||||
}> {
|
||||
const prisma = getWorkerPrisma();
|
||||
const out = await rotateCorpus(prisma as unknown as CorpusClient);
|
||||
return {
|
||||
activeKeyId: out.activeKeyId,
|
||||
totalScanned: out.totalScanned,
|
||||
totalRotated: out.totalRotated,
|
||||
totalErrors: out.totalErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleEncryptionKeyRotate(
|
||||
jobs: Job<EncryptionKeyRotatePayload>[],
|
||||
) {
|
||||
await withBackgroundEvent("job.encryption_key_rotate", async (evt) => {
|
||||
const requestedBy = jobs[0]?.data?.requestedByUserId ?? null;
|
||||
try {
|
||||
const result = await runEncryptionKeyRotation();
|
||||
evt.addMeta("rotate_active_key_id", result.activeKeyId);
|
||||
evt.addMeta("rotate_scanned", result.totalScanned);
|
||||
evt.addMeta("rotate_rotated", result.totalRotated);
|
||||
evt.addMeta("rotate_errors", result.totalErrors);
|
||||
await auditLog("admin.encryption.rotate.completed", {
|
||||
userId: requestedBy,
|
||||
details: {
|
||||
activeKeyId: result.activeKeyId,
|
||||
scanned: result.totalScanned,
|
||||
rotated: result.totalRotated,
|
||||
errors: result.totalErrors,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.addWarning(`encryption-key-rotate failed: ${err}`);
|
||||
await auditLog("admin.encryption.rotate.failed", {
|
||||
userId: requestedBy,
|
||||
details: { message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
// Re-throw so pg-boss records the failure (the run is idempotent, so a
|
||||
// retry is safe).
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -62,6 +62,12 @@ import {
|
||||
enqueueBootTimeNoteEncryptionBackfill,
|
||||
type NoteEncryptionBackfillPayload,
|
||||
} from "@/lib/jobs/note-encryption-backfill";
|
||||
import {
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
ENCRYPTION_KEY_ROTATE_CONCURRENCY,
|
||||
handleEncryptionKeyRotate,
|
||||
type EncryptionKeyRotatePayload,
|
||||
} from "@/lib/jobs/encryption-key-rotate";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { workerLog } from "./shared";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
@@ -228,6 +234,11 @@ const allQueues = [
|
||||
// plaintext note; without this entry pg-boss never provisions the queue and
|
||||
// both the boot enqueue and the cron silently no-op.
|
||||
NOTE_ENCRYPTION_BACKFILL_QUEUE,
|
||||
// v1.23 — admin-triggered encryption-key rotation. On-demand only (no
|
||||
// cron): the admin panel enqueues a singleton run that re-encrypts the
|
||||
// corpus to the active key. Without this entry pg-boss never provisions the
|
||||
// queue and the admin trigger silently no-ops.
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
@@ -490,6 +501,13 @@ export async function registerMaintenanceQueues(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.23 — admin-triggered encryption-key rotation (on-demand, singleton).
|
||||
await boss.work<EncryptionKeyRotatePayload>(
|
||||
ENCRYPTION_KEY_ROTATE_QUEUE,
|
||||
{ localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY },
|
||||
handleEncryptionKeyRotate,
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ export const adminKeys = {
|
||||
adminModuleAvailability: () =>
|
||||
["admin", "settings", "module-availability"] as const,
|
||||
adminBackups: () => ["admin", "backups"] as const,
|
||||
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
|
||||
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
|
||||
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
|
||||
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
|
||||
adminFeedbackRoot: () => ["admin", "feedback"] as const,
|
||||
|
||||
Reference in new issue
Block a user