Files
HealthLog/src/lib/crypto.ts
T
Marc-André BombeckandMarc-André Bombeck d7778c1085 feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI

Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.

scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.

Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens

Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.

New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.

Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.

Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE

Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.

docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.

docs/self-hosting/scaling.md walks through when + how to split.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage

New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.

Wire format documented in docs/ops/backup-restore.md:
  magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext

The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.

Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.

scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.

Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates

Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:

Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
  (column doesn't exist in the schema; the script crashed on the AppSettings
  step). Add the three encrypted columns it was missing —
  NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
  — so rotating to a new key actually re-encrypts every encrypted row.
  Without this, dropping the old key id from ENCRYPTION_KEYS would leave
  notification channels and web-push subscriptions permanently
  undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
  legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
  a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
  and run scripts/rotate-encryption-key.ts before removing it" so an
  operator who pulled the v1 too early notices immediately instead of
  hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
  troubleshooting section. Spells out that rotation is a forward-only
  operation and what to do if the rollback path is needed.

Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
  X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
  The worker container (which still runs `node server.js` for now) no
  longer accepts requests, so a misrouted client gets a clear hint instead
  of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
  worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
  "[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
  silently 302-redirect through the auth gate (the prior wget treated the
  login page as a 200 success and would never notice an unhealthy backend).

Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
  refresh tokens for the user, not only those scoped to the same deviceId.
  An attacker who rotated without an X-Device-Id header would otherwise
  keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
  access token cannot outlive the refresh-token sibling that the caller
  just killed (logout-on-device).

Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
  Retention is the bucket's lifecycle-policy job; the worker keeps a
  PutObject + GetObject grant only. A compromised worker can no longer
  wipe the backup history. Per-user errors are now surfaced as Wide
  Event warnings + a structured failures[] array on the report so an
  operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
  writing to disk, plus an optional --user-id=<id> flag that refuses to
  proceed unless the dump's userId matches. Prevents silent
  cross-tenant restores when an operator points the script at the wrong
  S3 key.

Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
  (access-token prefix); also block `hlr_` so a future caller wrapping
  the new /api/auth/refresh endpoint with withIdempotency() can't
  accidentally cache a freshly-issued refresh token.

Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
  transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
  partial-deploy retry is a no-op instead of "relation already exists";
  guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
  composite `@@index([userId, deviceId])` (every lookup also filters on
  userId; the old index would never have been used). Comment on
  accessTokenHash corrected to HMAC-SHA-256.

Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
  pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
  Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
  the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
  BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
  (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
  BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
  comments and the all-or-none semantics for the backup block.

491 tests still green, typecheck clean.

* chore: retrigger CI

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 14:33:39 +02:00

257 lines
8.5 KiB
TypeScript

/**
* AES-256-GCM encryption for sensitive data at rest.
*
* v1.4: supports key versioning + rotation. Multiple keys can coexist via
* `ENCRYPTION_KEYS` (JSON map of `{ "<id>": "<base64-or-hex-key>" }`) plus
* `ENCRYPTION_ACTIVE_KEY_ID` selecting which one to write with. Backwards
* compatible: if `ENCRYPTION_KEYS` is absent, the legacy single
* `ENCRYPTION_KEY` is used with synthetic id `"v1"`.
*
* Ciphertext format:
* - Versioned (new): "<keyId>.<base64(iv|authTag|ciphertext)>"
* - Legacy (v1): "base64(iv|authTag|ciphertext)" — still readable.
*
* The decryption path tries the versioned format first; if no `.` is present
* (or the prefix is not a known key id), it falls back to the legacy single
* key so that rows written before the rotation continue to decrypt.
*/
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from "node:crypto";
import { getEvent } from "@/lib/logging/context";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const LEGACY_KEY_ID = "v1";
interface KeyMaterial {
id: string;
key: Buffer;
}
let cachedKeys: Map<string, Buffer> | null = null;
let cachedActiveId: string | null = null;
let cachedSignature: string | null = null;
function envSignature(): string {
return [
process.env.ENCRYPTION_KEYS ?? "",
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "",
process.env.ENCRYPTION_KEY ?? "",
].join("|");
}
function decodeKey(raw: string, label: string): Buffer {
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return Buffer.from(raw, "hex");
}
// base64 form (32 bytes / 256 bits)
if (/^[A-Za-z0-9+/=]+$/.test(raw)) {
try {
const buf = Buffer.from(raw, "base64");
if (buf.length === 32) return buf;
} catch {
// fall through
}
}
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length >= 32) {
if (process.env.NODE_ENV === "production") {
throw new Error(
`Encryption key '${label}' must be 64 hex characters (256 bits) in production. ` +
`Generate one with: openssl rand -hex 32`,
);
}
const msg = `Encryption key '${label}' should be exactly 64 hex characters (current length: ${raw.length}). Padding with SHA-256 for dev use only.`;
const event = getEvent();
if (event) event.addWarning(msg);
else if (typeof console !== "undefined") console.warn("[crypto]", msg);
const padded = raw + createHash("sha256").update(raw, "utf8").digest("hex");
return Buffer.from(padded.slice(0, 64), "hex");
}
throw new Error(
`Encryption key '${label}' must be hex (>= 32 chars) or base64 (32 bytes). ` +
`Generate one with: openssl rand -hex 32`,
);
}
function loadKeys(): { keys: Map<string, Buffer>; activeId: string } {
const sig = envSignature();
if (cachedKeys && cachedActiveId && cachedSignature === sig) {
return { keys: cachedKeys, activeId: cachedActiveId };
}
const keys = new Map<string, Buffer>();
let activeId: string | null = null;
const rawMap = process.env.ENCRYPTION_KEYS;
if (rawMap && rawMap.trim() !== "") {
let parsed: unknown;
try {
parsed = JSON.parse(rawMap);
} catch (err) {
throw new Error(
`ENCRYPTION_KEYS must be valid JSON (e.g. {"v2":"<hex>"}): ${(err as Error).message}`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("ENCRYPTION_KEYS must be a JSON object map");
}
for (const [id, value] of Object.entries(
parsed as Record<string, unknown>,
)) {
if (typeof value !== "string" || !id) {
throw new Error(`ENCRYPTION_KEYS entry '${id}' is not a string`);
}
if (!/^[A-Za-z0-9_-]{1,32}$/.test(id)) {
throw new Error(
`ENCRYPTION_KEYS id '${id}' must match [A-Za-z0-9_-]{1,32}`,
);
}
keys.set(id, decodeKey(value, id));
}
activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null;
if (!activeId) {
if (keys.size === 1) {
activeId = keys.keys().next().value as string;
} else {
throw new Error(
"ENCRYPTION_ACTIVE_KEY_ID must be set when ENCRYPTION_KEYS has multiple entries",
);
}
}
if (!keys.has(activeId)) {
throw new Error(
`ENCRYPTION_ACTIVE_KEY_ID='${activeId}' has no matching entry in ENCRYPTION_KEYS`,
);
}
}
// Always also include the legacy single-key under id `v1` if set, so old
// rows decrypt. If the operator supplies the same id explicitly, that wins.
const legacyRaw = process.env.ENCRYPTION_KEY;
if (legacyRaw && legacyRaw.trim() !== "") {
if (!keys.has(LEGACY_KEY_ID)) {
keys.set(LEGACY_KEY_ID, decodeKey(legacyRaw, LEGACY_KEY_ID));
}
if (!activeId) activeId = LEGACY_KEY_ID;
}
if (keys.size === 0 || !activeId) {
throw new Error(
"Encryption is not configured. Set ENCRYPTION_KEY or ENCRYPTION_KEYS+ENCRYPTION_ACTIVE_KEY_ID.",
);
}
cachedKeys = keys;
cachedActiveId = activeId;
cachedSignature = sig;
return { keys, activeId };
}
function getActiveKey(): KeyMaterial {
const { keys, activeId } = loadKeys();
return { id: activeId, key: keys.get(activeId)! };
}
function getKeyById(id: string): Buffer | null {
const { keys } = loadKeys();
return keys.get(id) ?? null;
}
/** Test helper — drops the cached key map so env stubs take effect. */
export function _resetCryptoCacheForTests(): void {
cachedKeys = null;
cachedActiveId = null;
cachedSignature = null;
}
export function getActiveKeyId(): string {
return getActiveKey().id;
}
function encryptWithKey(plaintext: string, key: Buffer): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, enc]).toString("base64");
}
function decryptWithKey(payload: string, key: Buffer): string {
const packed = Buffer.from(payload, "base64");
const iv = packed.subarray(0, IV_LENGTH);
const tag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ct = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const dec = createDecipheriv(ALGORITHM, key, iv);
dec.setAuthTag(tag);
return Buffer.concat([dec.update(ct), dec.final()]).toString("utf8");
}
/**
* Encrypt a plaintext string with the active key.
* Returns format: `<keyId>.<base64(iv+authTag+ciphertext)>`
*/
export function encrypt(plaintext: string): string {
const { id, key } = getActiveKey();
return `${id}.${encryptWithKey(plaintext, key)}`;
}
/**
* Decrypt a previously encrypted string. Accepts both the versioned
* `<id>.<payload>` format and the legacy bare-base64 format.
*
* Once the prefix is recognised as a versioned id we trust the key id.
* If decrypt fails under that key the error surfaces — silently retrying as
* legacy would mask data corruption.
*/
export function decrypt(encoded: string): string {
const dot = encoded.indexOf(".");
if (dot > 0 && dot < encoded.length - 1) {
const id = encoded.slice(0, dot);
const payload = encoded.slice(dot + 1);
if (/^[A-Za-z0-9_-]{1,32}$/.test(id)) {
const key = getKeyById(id);
if (!key) {
throw new Error(
`Encryption key id '${id}' is not configured. Add it to ` +
`ENCRYPTION_KEYS before decrypting rows written under that key.`,
);
}
return decryptWithKey(payload, key);
}
}
// Legacy bare-base64 row. Refuse to decrypt under the active key — that
// would give an opaque GCM tag error AND succeed silently with junk if a
// key collision ever occurs. Require the operator to keep the v1 key in
// `ENCRYPTION_KEYS` until rotation has fully drained legacy rows.
const legacy = getKeyById(LEGACY_KEY_ID);
if (!legacy) {
throw new Error(
"Found a legacy-format ciphertext but no v1 key is configured. " +
"Restore the original ENCRYPTION_KEY (or add a 'v1' entry to " +
"ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before " +
"removing it.",
);
}
return decryptWithKey(encoded, legacy);
}
/** Re-encrypt a row with the currently active key. Used by the rotation CLI. */
export function reencryptToActive(encoded: string): string {
return encrypt(decrypt(encoded));
}
/** Returns the key id portion of a versioned ciphertext, or null for legacy. */
export function extractKeyId(encoded: string): string | null {
const dot = encoded.indexOf(".");
if (dot <= 0 || dot >= encoded.length - 1) return null;
const id = encoded.slice(0, dot);
if (!/^[A-Za-z0-9_-]{1,32}$/.test(id)) return null;
return id;
}