Phase B: encrypted archive export bundles (age), power-level-gated export command, encryption-state linter

This commit is contained in:
glass beadian 2026-10-12 12:46:56 +00:00
parent a7ad1f059f
commit 699b9412c1
22 changed files with 2325 additions and 9 deletions

View file

@ -8,6 +8,7 @@ import { claimKey, replyTxnId } from "./keys.js";
import type { MatrixPort, MediaDownload, SyncResult } from "./matrix.js";
import { ArchiveProcessor } from "./processor.js";
import type { ArchiveRecord, ArchiveStore, OutboxRow, RoomCursorTarget } from "./store.js";
import type { IndexedFile } from "./archive-index.js";
import type { LibraryPort, LibraryResource } from "./webdav.js";
const mapping: RoomMapping = {
@ -66,6 +67,16 @@ class FakeMatrix implements MatrixPort {
}
this.notices.push({ roomId, txnId, content });
}
powerLevels: Record<string, unknown> = { users_default: 0, users: {} };
roomEncryptionByRoom: Map<string, RoomEncryption> = new Map();
joinedRoomList: string[] = [mapping.roomId];
async roomPowerLevels(roomId: string): Promise<Record<string, unknown>> {
void roomId;
return this.powerLevels;
}
async joinedRooms(): Promise<string[]> {
return this.joinedRoomList;
}
}
class FakeLibrary implements LibraryPort {
@ -83,6 +94,14 @@ class FakeLibrary implements LibraryPort {
this.committed.push(path);
return { path, url: `https://files/${path}`, resourceId: "rid", version: "v1" };
}
/** files keyed by `${dir}/${name}` — the export read side. */
libraryFiles = new Map<string, Uint8Array>();
async getResource(dir: string[], finalName: string): Promise<ReadableStream<Uint8Array>> {
const key = [...dir, finalName].join("/");
const bytes = this.libraryFiles.get(key);
if (bytes === undefined) throw new Error(`no such resource ${key}`);
return new Blob([bytes]).stream() as ReadableStream<Uint8Array>;
}
}
class FakeStore implements ArchiveStore {
@ -137,6 +156,23 @@ class FakeStore implements ArchiveStore {
async countPending(): Promise<number> {
return [...this.outbox.values()].filter((r) => r.status === "pending").length;
}
async listRoom(_ws: string, roomId: string): Promise<IndexedFile[]> {
return this.archive.filter((r) => r.roomId === roomId).map((r, i) => ({
id: `file-${i + 1}`,
eventId: r.eventId,
mxcUri: "",
originalName: r.originalName,
safeName: r.safeName,
mime: r.mime,
mimeClass: r.mimeClass as IndexedFile["mimeClass"],
byteSize: r.byteSize,
sha256: r.sha256,
sender: r.senderMxid,
createdAt: r.createdAt.toISOString(),
ocisResourceId: "rid",
available: r.available,
}));
}
private byId(id: string): OutboxRow {
const row = [...this.outbox.values()].find((r) => r.id === id);
if (row === undefined) throw new Error("no row");
@ -274,10 +310,107 @@ describe("ArchiveProcessor", () => {
expect(library.committed).toHaveLength(1);
});
it("runs the encryption lint over joined rooms and reports violations in health", async () => {
matrix.joinedRoomList = [mapping.roomId, "!stray:x"];
matrix.roomEncryptionByRoom = new Map<string, RoomEncryption>([["!stray:x", "unencrypted"]]);
const report = await processor.lintEncryption(signal);
expect(report.issues.map((i) => i.kind)).toEqual(["unmapped-unencrypted"]);
const health = await processor.health();
expect(health.encryption).toMatchObject({ checked: 2, violations: ["!stray:x"], mappedUnencrypted: [mapping.roomId] });
});
it("handles !archive export from a room admin: uploads ciphertext and posts the object key", async () => {
const { createServer } = await import("node:http");
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const age = await import("age-encryption");
const { BundleExporter } = await import("./exporter.js");
const { R2ExportStore } = await import("./r2-export.js");
const identity = await age.generateIdentity();
const recipient = await age.identityToRecipient(identity);
const dir = await mkdtemp(join(tmpdir(), "proc-export-"));
const puts: { key: string; body: Buffer }[] = [];
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => { puts.push({ key: req.url ?? "", body: Buffer.concat(chunks) }); res.writeHead(200).end("{}"); });
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
try {
matrix.powerLevels = { users: { "@alice:example.org": 100 }, users_default: 0 };
const upload = new R2ExportStore({
endpoint: `http://127.0.0.1:${(server.address() as { port: number }).port}`,
region: "auto", accessKeyId: "k", secretAccessKey: "s", spillDir: dir, allowInsecureEndpoint: true,
});
const exportConfig = {
enabled: true, every: 0, ageRecipients: [recipient],
r2: { endpoint: "http://stub", region: "auto", accessKeyId: "k", secretAccessKey: "s", bucket: "hub-matrix-media", tmpDir: dir },
};
const exporter = new BundleExporter(store, library, upload, [recipient], "hub-matrix-media", pino({ level: "silent" }));
const proc = new ArchiveProcessor(
matrix, store, new Archiver(matrix, library, store), [mapping], pino({ level: "silent" }), () => clock.now,
exporter, exportConfig,
);
await proc.init();
// archive one attachment so the exporter has a payload to bundle
matrix.batches.push(batch("s1", [imageEvent("$a")]));
await proc.runBatch(signal);
expect(store.archive).toHaveLength(1);
const archived = store.archive[0];
if (archived === undefined) throw new Error("no archive row");
library.libraryFiles.set(`Library/${mapping.workspaceId}/${mapping.groupId}/${archived.sha256}-${archived.safeName}`, PNG);
// admin command
matrix.batches.push(batch("s2", [{
type: "m.room.message",
event_id: "$cmd",
sender: "@alice:example.org",
origin_server_ts: 1,
content: { msgtype: "m.text", body: "!archive export" },
}]));
await proc.runBatch(signal);
expect(puts).toHaveLength(1);
expect(puts[0]?.key).toContain("/hub-matrix-media/exports/");
const notice = matrix.notices.at(-1);
expect(String(notice?.content.body)).toContain("exports/");
// the uploaded bytes decrypt with the admin identity and contain the payload
const dec = new age.Decrypter();
dec.addIdentity(identity);
const plain = await dec.decrypt(new Blob([puts[0]?.body ?? Buffer.alloc(0)]).stream() as ReadableStream<Uint8Array>);
const { readTarZstd, drainToBuffer } = await import("./bundle.js");
const names: string[] = [];
for await (const entry of readTarZstd(plain)) { names.push(entry.name); await drainToBuffer(entry.stream); }
expect(names).toContain("manifest.json");
expect(names).toContain(`files/$a/${archived.safeName}`);
const health = await proc.health();
expect(health.export).toMatchObject({ enabled: true, lastObjectKey: expect.stringContaining("exports/") });
} finally {
await rm(dir, { recursive: true, force: true });
await new Promise<void>((r) => server.close(() => r()));
}
});
it("ignores !archive export when exports are not configured or the sender is not an admin", async () => {
// not configured: no exporter attached
matrix.batches.push(batch("s1", [{
type: "m.room.message",
event_id: "$cmd",
sender: "@alice:example.org",
origin_server_ts: 1,
content: { msgtype: "m.text", body: "!archive export" },
}]));
await processor.runBatch(signal);
expect(matrix.notices).toHaveLength(0);
});
it("joins invites only for mapped rooms and reports health", async () => {
matrix.batches.push({ nextBatch: "s1", joined: new Map(), invited: [mapping.roomId, "!other:x"] });
await processor.runBatch(signal);
expect(matrix.joined).toEqual([mapping.roomId]);
expect(await processor.health()).toEqual({ rooms: 1, cursor: "s1", pending: 0, lastProcessedAt: null });
expect(await processor.health()).toMatchObject({ rooms: 1, cursor: "s1", pending: 0, lastProcessedAt: null });
});
});