mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Off-host objects are now written as envelope version 4: the header names the off-host key by fingerprint, and the object's key in the bucket (<date>/user-<id>.json.enc) is authenticated as GCM associated data, so a copy moved to another account or another night no longer opens. Objects of versions 1 to 3 still read. BACKUP_ENCRYPTION_PREVIOUS_KEYS (comma-separated, on the compose whitelist and in the env manifest) keeps retired off-host keys readable, which is the rotation path the off-host key did not have. The admin backup upload counted the bytes that arrived and none after the gunzip, so a small gzip could expand without limit into the JSON scanner and take the app down. The inflated bytes are now held to 100 times the compressed bytes read (at least 256 MB, at most 4 GB) and a file past that is refused with 413 before anything is stored.
157 lines
5.2 KiB
TypeScript
157 lines
5.2 KiB
TypeScript
/**
|
|
* scripts/restore-backup.ts <s3-key> [output-file]
|
|
*
|
|
* Downloads a single off-host backup object, decrypts it with
|
|
* `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk.
|
|
*
|
|
* Wire format (binary):
|
|
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
|
|
*
|
|
* Usage:
|
|
* BACKUP_S3_ENDPOINT=... \
|
|
* BACKUP_S3_BUCKET=... \
|
|
* BACKUP_S3_ACCESS_KEY=... \
|
|
* BACKUP_S3_SECRET_KEY=... \
|
|
* BACKUP_S3_REGION=auto \
|
|
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
|
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
|
*
|
|
* An output name ending in `.gz` writes the JSON compressed, which is the
|
|
* form to hand to the admin upload for a large account: the JSON of 1.25
|
|
* million measurements is 662 MB, the compressed file about a tenth of it.
|
|
* The file is written as a stream in either case and never held whole.
|
|
*/
|
|
import { createWriteStream } from "node:fs";
|
|
import { rename } from "node:fs/promises";
|
|
import { PassThrough, Readable } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { createGzip } from "node:zlib";
|
|
|
|
import { scanBackupJson } from "@/lib/export/backup-json-scan";
|
|
import {
|
|
loadOffhostConfig,
|
|
getS3Client,
|
|
openBackupObject,
|
|
offhostKeyRing,
|
|
OffhostBackupNotConfiguredError,
|
|
} from "@/lib/jobs/offhost-backup";
|
|
|
|
/**
|
|
* Schema-validate the decrypted JSON before writing it to disk. The wire
|
|
* format from `runOffhostBackup` always carries `userId` and the four data
|
|
* arrays — anything else means the operator may be looking at a tampered or
|
|
* mis-targeted object. We reject rather than silently writing a body that a
|
|
* later import script would trust.
|
|
*/
|
|
function validateBackupShape(payload: unknown): {
|
|
exportedAt: string;
|
|
userId: string;
|
|
measurements: unknown[];
|
|
medications: unknown[];
|
|
intakeEvents: unknown[];
|
|
moodEntries: unknown[];
|
|
} {
|
|
if (!payload || typeof payload !== "object") {
|
|
throw new Error("backup payload is not a JSON object");
|
|
}
|
|
const p = payload as Record<string, unknown>;
|
|
const required = [
|
|
"exportedAt",
|
|
"userId",
|
|
"measurements",
|
|
"medications",
|
|
"intakeEvents",
|
|
"moodEntries",
|
|
] as const;
|
|
for (const k of required) {
|
|
if (!(k in p)) throw new Error(`backup is missing required field: ${k}`);
|
|
}
|
|
if (typeof p.exportedAt !== "string")
|
|
throw new Error("backup.exportedAt must be string");
|
|
if (typeof p.userId !== "string" || p.userId.length === 0)
|
|
throw new Error("backup.userId must be a non-empty string");
|
|
for (const k of [
|
|
"measurements",
|
|
"medications",
|
|
"intakeEvents",
|
|
"moodEntries",
|
|
] as const) {
|
|
if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`);
|
|
}
|
|
return p as ReturnType<typeof validateBackupShape>;
|
|
}
|
|
|
|
async function main() {
|
|
const key = process.argv[2];
|
|
const out = process.argv[3] ?? "./restored.json";
|
|
// Optional positional arg: --user-id=<id> requires the dump's `userId`
|
|
// field to match. Defends against an operator pointing the script at the
|
|
// wrong S3 key (e.g. another tenant's dump under a similar path) and
|
|
// silently importing the wrong user's data downstream.
|
|
const expectedUserId = process.argv
|
|
.find((a) => a.startsWith("--user-id="))
|
|
?.slice("--user-id=".length);
|
|
if (!key) {
|
|
console.error(
|
|
"Usage: pnpm dlx tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const cfg = loadOffhostConfig();
|
|
if (!cfg) {
|
|
throw new OffhostBackupNotConfiguredError(
|
|
"Off-host backup is not configured. Set BACKUP_* env vars.",
|
|
);
|
|
}
|
|
|
|
const s3 = await getS3Client(cfg);
|
|
console.log(
|
|
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
|
|
);
|
|
const ciphertext = await s3.getObject(key);
|
|
const source = openBackupObject(ciphertext, offhostKeyRing(cfg), key);
|
|
|
|
// Checked first, in one streamed pass that keeps only the small sections,
|
|
// so nothing is written for an object that is not a backup.
|
|
const { document, streamedCounts } = await scanBackupJson(source(), {
|
|
streamKeys: new Set(["measurements", "intakeEvents", "moodEntries"]),
|
|
});
|
|
const body = validateBackupShape({
|
|
...document,
|
|
measurements: [],
|
|
intakeEvents: [],
|
|
moodEntries: [],
|
|
});
|
|
if (expectedUserId && body.userId !== expectedUserId) {
|
|
throw new Error(
|
|
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
|
|
);
|
|
}
|
|
|
|
// The restored file is decrypted PHI in plaintext. Written 0o600 so it is
|
|
// not world-readable on the operator's disk, to a temporary name first so
|
|
// an interrupted run never leaves a truncated file under the real one.
|
|
const partial = `${out}.part`;
|
|
let bytes = 0;
|
|
const counter = new PassThrough();
|
|
counter.on("data", (chunk: Buffer) => {
|
|
bytes += chunk.length;
|
|
});
|
|
await pipeline(
|
|
Readable.from(source()),
|
|
counter,
|
|
...(out.endsWith(".gz") ? [createGzip()] : []),
|
|
createWriteStream(partial, { mode: 0o600 }),
|
|
);
|
|
await rename(partial, out);
|
|
console.log(
|
|
`Restored userId=${body.userId} (${bytes} bytes of JSON, ${streamedCounts.measurements ?? 0} measurements) -> ${out}`,
|
|
);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("Restore failed:", err);
|
|
process.exit(2);
|
|
});
|