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
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue