Files
HealthLog/src/lib/export/passphrase-archive.ts
T
Marc-André Bombeck 0ba77778d5 feat(export): offer a passphrase-encrypted archive
Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.

POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
2026-06-27 20:27:07 +02:00

228 lines
7.7 KiB
TypeScript

/**
* 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");
}
}