mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
228 lines
7.7 KiB
TypeScript
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");
|
|
}
|
|
}
|