#!/usr/bin/env node /** * Standalone phase-B restore tool. * * node dist/restore.js --bundle --identity --out * * Decrypts an archive bundle with an age identity (AGE-SECRET-KEY-1…, one per * line; the private key never touches the archive bot), untars it, verifies * every file's sha256 against manifest.json and writes the verified tree. * Exit code 0 only when every file matched. */ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { Readable } from "node:stream"; import { ageDecryptStream, drainToBuffer, readTarZstd } from "./bundle.js"; import { MANIFEST_PATH, type BundleManifest } from "./archive-index.js"; interface Args { bundle: string; identity: string; out: string; } function parseArgs(argv: string[]): Args { const args: Partial = {}; for (let i = 2; i < argv.length; i += 2) { const flag = argv[i]; const value = argv[i + 1]; if (value === undefined) throw new Error(`missing value for ${flag}`); if (flag === "--bundle") args.bundle = value; else if (flag === "--identity") args.identity = value; else if (flag === "--out") args.out = value; else throw new Error(`unknown flag ${flag}`); } if (args.bundle === undefined) throw new Error("--bundle is required"); if (args.identity === undefined) throw new Error("--identity is required"); args.out ??= "restored-archive"; return args as Args; } /** Parse identities: `#` comments and blank lines ignored, one key per line. */ export function parseIdentityFile(text: string): string[] { return text .split("\n") .map((line) => line.trim()) .filter((line) => line !== "" && !line.startsWith("#")); } async function main(): Promise { const args = parseArgs(process.argv); const identities = parseIdentityFile(await readFile(args.identity, "utf8")); if (identities.length === 0) throw new Error("identity file contains no AGE-SECRET-KEY-1… keys"); const ciphertext = Readable.toWeb(Readable.from(await readFile(args.bundle))) as ReadableStream; const plain = await ageDecryptStream(ciphertext, identities); const root = resolve(args.out); let manifest: BundleManifest | null = null; let verified = 0; let failures = 0; let unexpected = 0; const manifestPathFor = (entry: { eventId: string; safeName: string }): string => `${"files"}/${entry.eventId}/${entry.safeName}`; for await (const entry of readTarZstd(plain)) { if (entry.name === MANIFEST_PATH) { const bytes = await drainToBuffer(entry.stream); manifest = JSON.parse(new TextDecoder().decode(bytes)) as BundleManifest; await mkdir(join(root, MANIFEST_PATH, ".."), { recursive: true }); await writeFile(join(root, MANIFEST_PATH), bytes, { mode: 0o600 }); continue; } if (manifest === null) { // manifest.json must precede payloads in the bundle; fail loudly otherwise. throw new Error(`payload ${entry.name} appeared before manifest.json`); } if (entry.name === MANIFEST_PATH) continue; // handled above const bytes = await drainToBuffer(entry.stream); const digest = createHash("sha256").update(bytes).digest("hex"); const record = manifest.files.find((f) => manifestPathFor(f) === entry.name); const target = join(root, entry.name); await mkdir(dirname(target), { recursive: true }); await writeFile(target, bytes, { mode: 0o600 }); if (record === undefined) { unexpected += 1; console.error(`restore: ${entry.name} is not in the manifest`); } else if (record.sha256 === digest) { verified += 1; } else { failures += 1; console.error(`restore: sha256 mismatch for ${entry.name}: expected ${record.sha256}, got ${digest}`); } } if (manifest === null) throw new Error("bundle contains no manifest.json"); const expected = manifest.files.filter((f) => f.available).length; console.log(`restore: ${verified}/${expected} files verified${failures > 0 ? `, ${failures} failed` : ""}${unexpected > 0 ? `, ${unexpected} unexpected` : ""}`); console.log(`restore: room ${manifest.roomId} exported ${manifest.exportedAt}`); if (verified !== expected || failures > 0 || unexpected > 0) process.exitCode = 1; } void main().catch((error: unknown) => { console.error(`restore: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; });