merge: integrate the v1.23 data export and encryption-status wave

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:46:08 +02:00
commit 268c5cef05
29 files changed
+2346 -125

No files matched your search

+10
View File
@@ -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);
});
});
+260
View File
@@ -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);
});
});
+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");
}
}
+92
View File
@@ -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;
}
+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,
+2
View File
@@ -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,