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
|
|
@ -7,6 +7,10 @@ import { PermanentArchiveError } from "./inspector.js";
|
|||
import { claimKey, replyTxnId } from "./keys.js";
|
||||
import { MatrixHttpError, type MatrixPort } from "./matrix.js";
|
||||
import type { ArchiveStore, RoomCursorTarget } from "./store.js";
|
||||
import type { BundleExporter } from "./exporter.js";
|
||||
import { exportNotice, isExportCommand } from "./exporter.js";
|
||||
import { senderIsRoomAdmin, toEncryptionHealth, type EncryptionLintReport, type MatrixLintPort } from "./encryption-lint.js";
|
||||
import type { ExportConfig } from "./config.js";
|
||||
|
||||
export interface BatchResult {
|
||||
/** True when the sync cursor was durably advanced past this batch. */
|
||||
|
|
@ -20,10 +24,24 @@ export interface HealthSnapshot {
|
|||
cursor: string | null;
|
||||
pending: number;
|
||||
lastProcessedAt: string | null;
|
||||
/** Phase-B: encrypted export bundle state, null when exports are not configured. */
|
||||
export: {
|
||||
enabled: boolean;
|
||||
every: number;
|
||||
lastExportAt: string | null;
|
||||
lastObjectKey: string | null;
|
||||
roomsExported: string[];
|
||||
} | null;
|
||||
/** Phase-B: encryption-state lint (unmapped unencrypted rooms = violations). */
|
||||
encryption: ReturnType<typeof toEncryptionHealth>;
|
||||
}
|
||||
|
||||
type Outcome = { blockedUntil: Date } | null;
|
||||
|
||||
function lintEmpty(now: Date): EncryptionLintReport {
|
||||
return { checked: 0, mappedUnencrypted: [], issues: [], checkedAt: now.toISOString() };
|
||||
}
|
||||
|
||||
export function replyContent(safeName: string, eventId: string): Record<string, unknown> {
|
||||
return {
|
||||
msgtype: "m.notice",
|
||||
|
|
@ -63,6 +81,12 @@ export class ArchiveProcessor {
|
|||
private readonly byRoom: Map<string, RoomMapping>;
|
||||
private readonly cursorRooms: RoomCursorTarget[];
|
||||
private readonly workspaceIds: string[];
|
||||
private readonly exporter: BundleExporter | null;
|
||||
private readonly exportConfig: ExportConfig | null;
|
||||
private lastExportAt: Date | null = null;
|
||||
private lastObjectKey: string | null = null;
|
||||
private readonly roomsExported = new Set<string>();
|
||||
private encryptionReport: EncryptionLintReport | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly matrix: MatrixPort,
|
||||
|
|
@ -71,10 +95,14 @@ export class ArchiveProcessor {
|
|||
mappings: readonly RoomMapping[],
|
||||
private readonly logger: Logger,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
exporter: BundleExporter | null = null,
|
||||
exportConfig: ExportConfig | null = null,
|
||||
) {
|
||||
this.byRoom = new Map(mappings.map((m) => [m.roomId, m]));
|
||||
this.cursorRooms = mappings.map((m) => ({ roomId: m.roomId, workspaceId: m.workspaceId }));
|
||||
this.workspaceIds = [...new Set(mappings.map((m) => m.workspaceId))];
|
||||
this.exporter = exporter;
|
||||
this.exportConfig = exportConfig;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
|
|
@ -87,9 +115,29 @@ export class ArchiveProcessor {
|
|||
cursor: this.cursor,
|
||||
pending: await this.store.countPending(this.workspaceIds),
|
||||
lastProcessedAt: this.lastProcessedAt?.toISOString() ?? null,
|
||||
export: this.exportConfig === null ? null : {
|
||||
enabled: this.exportConfig.enabled,
|
||||
every: this.exportConfig.every,
|
||||
lastExportAt: this.lastExportAt?.toISOString() ?? null,
|
||||
lastObjectKey: this.lastObjectKey,
|
||||
roomsExported: [...this.roomsExported],
|
||||
},
|
||||
encryption: toEncryptionHealth(this.encryptionReport),
|
||||
};
|
||||
}
|
||||
|
||||
/** One encryption-lint pass over every joined room (startup + periodic). */
|
||||
async lintEncryption(signal: AbortSignal): Promise<EncryptionLintReport> {
|
||||
const lintPort = this.matrix as MatrixLintPort;
|
||||
if (typeof lintPort.joinedRooms !== "function") {
|
||||
this.logger.warn("archive-bot: matrix port lacks joinedRooms; encryption lint skipped");
|
||||
return this.encryptionReport ?? lintEmpty(new Date());
|
||||
}
|
||||
const { scanEncryption } = await import("./encryption-lint.js");
|
||||
this.encryptionReport = await scanEncryption(lintPort, [...this.byRoom.keys()], this.logger, signal, this.now());
|
||||
return this.encryptionReport;
|
||||
}
|
||||
|
||||
async runBatch(signal: AbortSignal): Promise<BatchResult> {
|
||||
const since = this.cursor;
|
||||
const sync = await this.matrix.sync(since, signal);
|
||||
|
|
@ -166,6 +214,11 @@ export class ArchiveProcessor {
|
|||
encryption: RoomEncryption,
|
||||
signal: AbortSignal,
|
||||
): Promise<Outcome> {
|
||||
// Admin export command: an m.text message, so it is never an attachment;
|
||||
// check it before classification (which would skip it as not-an-attachment).
|
||||
if (isExportCommand(event.content)) {
|
||||
return this.handleExportCommand(mapping, event, signal);
|
||||
}
|
||||
const classified = classifyEvent(event, mapping, encryption, this.matrix.userId);
|
||||
switch (classified.kind) {
|
||||
case "skip":
|
||||
|
|
@ -205,6 +258,7 @@ export class ArchiveProcessor {
|
|||
this.logger.info({ ...log, fileId: archived.file.id, sha256: archived.file.checksum.value }, "archive-bot: archived");
|
||||
this.lastProcessedAt = this.now();
|
||||
this.unsentReplies.set(key, archived.safeName);
|
||||
await this.maybeAutoExport(mapping, signal);
|
||||
return this.sendReply(mapping, eventId, key, signal);
|
||||
} catch (err) {
|
||||
if (signal.aborted) throw err;
|
||||
|
|
@ -220,6 +274,66 @@ export class ArchiveProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `!archive export` from a mapped room. Power level >= 50 required; the
|
||||
* bundle is built, uploaded to R2 and the ciphertext object key is posted
|
||||
* back as a notice (the key material lives only in the E2EE admin room).
|
||||
*/
|
||||
private async handleExportCommand(
|
||||
mapping: RoomMapping,
|
||||
event: MatrixEvent,
|
||||
signal: AbortSignal,
|
||||
): Promise<Outcome> {
|
||||
if (this.exporter === null || this.exportConfig === null || !this.exportConfig.enabled) {
|
||||
this.logger.info({ roomId: mapping.roomId }, "archive-export: not configured; command ignored");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const powerLevels = await this.matrix.roomPowerLevels(mapping.roomId, signal);
|
||||
if (!senderIsRoomAdmin(powerLevels, event.sender)) {
|
||||
this.logger.warn({ roomId: mapping.roomId, sender: event.sender }, "archive-export: sender is not a room admin");
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn({ roomId: mapping.roomId, err: errorText(err) }, "archive-export: power-level check failed");
|
||||
return null;
|
||||
}
|
||||
const result = await this.exporter.exportRoom(mapping, signal, "manual");
|
||||
this.recordExport(mapping.roomId, result.objectKey);
|
||||
await this.matrix.sendNotice(
|
||||
mapping.roomId,
|
||||
replyTxnId(`export:${result.objectKey}`),
|
||||
exportNotice(result.objectKey, this.exportConfig.r2.bucket, this.exportConfig.ageRecipients),
|
||||
signal,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
private recordExport(roomId: string, objectKey: string): void {
|
||||
this.lastExportAt = this.now();
|
||||
this.lastObjectKey = objectKey;
|
||||
this.roomsExported.add(roomId);
|
||||
}
|
||||
|
||||
/** Rolling auto-export after ARCHIVE_EXPORT_EVERY archived attachments in a room. */
|
||||
private async maybeAutoExport(mapping: RoomMapping, signal: AbortSignal): Promise<void> {
|
||||
if (this.exporter === null || this.exportConfig === null || !this.exportConfig.enabled) return;
|
||||
this.exporter.countAttachment(mapping.roomId);
|
||||
if (!this.exporter.shouldAutoExport(mapping.roomId, this.exportConfig.every)) return;
|
||||
try {
|
||||
const result = await this.exporter.exportRoom(mapping.roomId === mapping.roomId ? mapping : mapping, signal, "auto");
|
||||
this.recordExport(mapping.roomId, result.objectKey);
|
||||
await this.matrix.sendNotice(
|
||||
mapping.roomId,
|
||||
replyTxnId(`export:${result.objectKey}`),
|
||||
exportNotice(result.objectKey, this.exportConfig.r2.bucket, this.exportConfig.ageRecipients),
|
||||
signal,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error({ roomId: mapping.roomId, err: errorText(err) }, "archive-export: automatic export failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Exactly one reply per committed claim; the stable txn ID makes resends idempotent server-side. */
|
||||
private async sendReply(mapping: RoomMapping, eventId: string, key: string, signal: AbortSignal): Promise<Outcome> {
|
||||
const safeName = this.unsentReplies.get(key);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue