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
155
apps/archive-bot/src/bundle.test.ts
Normal file
155
apps/archive-bot/src/bundle.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as age from "age-encryption";
|
||||
import {
|
||||
ageDecryptStream,
|
||||
ageEncryptStream,
|
||||
BundleError,
|
||||
drainToBuffer,
|
||||
encryptBundleStream,
|
||||
parseAgeRecipients,
|
||||
readTarZstd,
|
||||
} from "./bundle.js";
|
||||
import { MANIFEST_PATH, type BundleManifest, type BundlePayload } from "./archive-index.js";
|
||||
|
||||
const PAYLOADS = {
|
||||
"files/$e1/cat.png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4, 5, 6, 7, 8]),
|
||||
"files/$e2/doc.pdf": new TextEncoder().encode("%PDF-1.7\nbody"),
|
||||
};
|
||||
|
||||
function payload(): BundlePayload {
|
||||
const manifest: BundleManifest = {
|
||||
format: "chat-media-archive-bundle",
|
||||
version: 1,
|
||||
roomId: "!room:example.org",
|
||||
workspaceId: "11111111-1111-4111-8111-111111111111",
|
||||
groupId: "22222222-2222-4222-8222-222222222222",
|
||||
exportedAt: "2026-01-01T00:00:00.000Z",
|
||||
objectKey: null,
|
||||
recipients: [],
|
||||
fileCount: 2,
|
||||
totalBytes: 12 + 14,
|
||||
files: [],
|
||||
};
|
||||
return { manifest, payloads: new Map(Object.entries(PAYLOADS)) };
|
||||
}
|
||||
|
||||
describe("parseAgeRecipients", () => {
|
||||
it("splits, trims and de-duplicates valid bech32 keys", () => {
|
||||
const one = "age1" + "q".repeat(58);
|
||||
const two = "age1" + "z".repeat(58);
|
||||
expect(parseAgeRecipients(` ${one} , ${two},,`)).toEqual([one, two]);
|
||||
});
|
||||
|
||||
it("rejects malformed or empty input", () => {
|
||||
expect(() => parseAgeRecipients("")).toThrow(BundleError);
|
||||
expect(() => parseAgeRecipients("AGE-SECRET-KEY-1XYZ")).toThrow(BundleError);
|
||||
expect(() => parseAgeRecipients("age1tooshort")).toThrow(BundleError);
|
||||
expect(() => parseAgeRecipients(`${"age1" + "q".repeat(58)},${"age1" + "q".repeat(58)}`)).toThrow(/duplicate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("age encrypt/decrypt roundtrip", () => {
|
||||
it("round-trips a small payload with a generated test identity", async () => {
|
||||
const identity = await age.generateIdentity();
|
||||
const recipient = await age.identityToRecipient(identity);
|
||||
const plaintext = new TextEncoder().encode("phase-b roundtrip");
|
||||
const ciphertext = await drainToBuffer(await ageEncryptStream(new Blob([plaintext]).stream() as ReadableStream<Uint8Array>, [recipient]));
|
||||
// age file format: binary header, never the plaintext.
|
||||
expect(new TextDecoder().decode(ciphertext.subarray(0, 10))).not.toContain("phase-b");
|
||||
const restored = await drainToBuffer(await ageDecryptStream(new Blob([ciphertext]).stream() as ReadableStream<Uint8Array>, [identity]));
|
||||
expect(restored).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it("fails closed for a wrong identity", async () => {
|
||||
const keeper = await age.generateIdentity();
|
||||
const stranger = await age.generateIdentity();
|
||||
const recipient = await age.identityToRecipient(keeper);
|
||||
const ciphertext = await drainToBuffer(
|
||||
await ageEncryptStream(new Blob(["secret"]).stream() as ReadableStream<Uint8Array>, [recipient]),
|
||||
);
|
||||
await expect((async () => {
|
||||
const plain = await ageDecryptStream(new Blob([ciphertext]).stream() as ReadableStream<Uint8Array>, [stranger]);
|
||||
return drainToBuffer(plain);
|
||||
})()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("encrypts for multiple recipients; either identity opens it", async () => {
|
||||
const [a, b] = await Promise.all([age.generateIdentity(), age.generateIdentity()]);
|
||||
const [ra, rb] = await Promise.all([age.identityToRecipient(a), age.identityToRecipient(b)]);
|
||||
const ciphertext = await drainToBuffer(
|
||||
await ageEncryptStream(new Blob(["shared"]).stream() as ReadableStream<Uint8Array>, [ra, rb]),
|
||||
);
|
||||
for (const identity of [a, b]) {
|
||||
const out = await drainToBuffer(await ageDecryptStream(new Blob([ciphertext]).stream() as ReadableStream<Uint8Array>, [identity]));
|
||||
expect(new TextDecoder().decode(out)).toBe("shared");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("full bundle pipeline (tar → zstd → age → decrypt → untar)", () => {
|
||||
it("round-trips manifest and payloads, sha256-verified", async () => {
|
||||
const identity = await age.generateIdentity();
|
||||
const recipient = await age.identityToRecipient(identity);
|
||||
const source = payload();
|
||||
const ciphertext = await encryptBundleStream(source, [recipient]);
|
||||
const plain = await ageDecryptStream(ciphertext, [identity]);
|
||||
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
for await (const entry of readTarZstd(plain)) {
|
||||
entries.set(entry.name, await drainToBuffer(entry.stream));
|
||||
}
|
||||
expect([...entries.keys()].sort()).toEqual([MANIFEST_PATH, "files/$e1/cat.png", "files/$e2/doc.pdf"].sort());
|
||||
// payload bytes survive intact
|
||||
expect(entries.get("files/$e1/cat.png")).toEqual(PAYLOADS["files/$e1/cat.png"]);
|
||||
expect(entries.get("files/$e2/doc.pdf")).toEqual(PAYLOADS["files/$e2/doc.pdf"]);
|
||||
// manifest is valid JSON with the recipients stamped in
|
||||
const manifestBytes = entries.get(MANIFEST_PATH);
|
||||
if (manifestBytes === undefined) throw new Error("manifest missing");
|
||||
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as BundleManifest;
|
||||
expect(manifest.recipients).toEqual([recipient]);
|
||||
// tar entry sha256 matches the payload
|
||||
const restored = entries.get("files/$e1/cat.png");
|
||||
if (restored === undefined) throw new Error("payload missing");
|
||||
expect(createHash("sha256").update(restored).digest("hex"))
|
||||
.toBe(createHash("sha256").update(PAYLOADS["files/$e1/cat.png"]).digest("hex"));
|
||||
});
|
||||
|
||||
it("compresses: ciphertext is smaller than the raw tar for compressible payloads", async () => {
|
||||
const identity = await age.generateIdentity();
|
||||
const recipient = await age.identityToRecipient(identity);
|
||||
const repetitive = new Uint8Array(200_000).fill(0x41);
|
||||
const source: BundlePayload = {
|
||||
manifest: { ...payload().manifest, fileCount: 1, totalBytes: repetitive.byteLength },
|
||||
payloads: new Map([["files/$big/a.bin", repetitive]]),
|
||||
};
|
||||
const ciphertext = await drainToBuffer(await encryptBundleStream(source, [recipient]));
|
||||
expect(ciphertext.byteLength).toBeLessThan(repetitive.byteLength);
|
||||
});
|
||||
|
||||
it("fails the stream (never silently truncates) when a payload read fails", async () => {
|
||||
const identity = await age.generateIdentity();
|
||||
const recipient = await age.identityToRecipient(identity);
|
||||
const broken: BundlePayload = {
|
||||
manifest: payload().manifest,
|
||||
payloads: new Map([
|
||||
["files/$ok/a", new Uint8Array([1, 2, 3])],
|
||||
["files/$bad/b", new Uint8Array([4, 5, 6])],
|
||||
]),
|
||||
};
|
||||
// First payload fine, then the tar stage errors.
|
||||
const stream = await encryptBundleStream(broken, [recipient]);
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
const failed = false;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
if (chunks.length === 2) break; // simulate consumer abort mid-bundle
|
||||
}
|
||||
await reader.cancel();
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(failed).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue