Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter
This commit is contained in:
parent
a7ad1f059f
commit
699b9412c1
22 changed files with 2325 additions and 9 deletions
105
apps/archive-bot/src/restore.ts
Normal file
105
apps/archive-bot/src/restore.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone phase-B restore tool.
|
||||
*
|
||||
* node dist/restore.js --bundle <path.tar.zst.age> --identity <age-identity-file> --out <dir>
|
||||
*
|
||||
* 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<Args> = {};
|
||||
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 <file> is required");
|
||||
if (args.identity === undefined) throw new Error("--identity <file> 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<void> {
|
||||
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<Uint8Array>;
|
||||
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;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue