Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ automaticallyRedactForReasons:
- "spam"
- "advertising"

# When a policyserv apiKey is set, Mjolnir will talk to the policyserv instance to:
# - Auto-add rooms to policyserv when they're added to Mjolnir's protected rooms list.
# - Redact events upon command from policyserv.
# - Ignore events that were approved by policyserv when checking protections.
#
# This is not needed if your community doesn't use policyserv.
#
# More information about policyserv can be found here: https://github.com/matrix-org/policyserv
#
# Note: In future this may be more generic than just policyserv support. If other policy server implementations support
# the policyserv API, then Mjolnir will work with them too.
policyserv:
# The base URL where the policyserv Server-Centric API is hosted.
baseUrl: "https://policyserv.example.org"

# An API key to use the policyserv Server-Centric API. If not set or is empty, policyserv support is disabled.
# See https://github.com/matrix-org/policyserv/blob/main/docs/server_centric_api.md for details.
apiKey: ""

# A list of rooms to protect. Mjolnir will add this to the list it knows from its account data.
#
# It won't, however, add it to the account data.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@sentry/tracing": "^7.17.2",
"@tensorflow/tfjs-node": "^4.21.0",
"@vector-im/matrix-bot-sdk": "^0.8.0-element.2",
"another-json": "^0.2.0",
"await-lock": "^2.2.2",
"axios": "^1.12.0",
"body-parser": "^1.20.1",
Expand Down
4 changes: 3 additions & 1 deletion src/MatrixEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ limitations under the License.
*/

import EventEmitter from "events";
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { MatrixClient, IToDeviceMessage } from "@vector-im/matrix-bot-sdk";

/**
* This is an interface created in order to keep the event listener
Expand Down Expand Up @@ -43,6 +43,8 @@ export declare interface MatrixEmitter extends EventEmitter {
on(event: "room.archived", listener: (roomId: string, mxEvent: any) => void): this;
emit(event: "room.archived", roomId: string, mxEvent: any): boolean;

on(event: "to-device", listener: (message: IToDeviceMessage) => void): this;

start(): Promise<void>;
stop(): void;
}
Expand Down
84 changes: 84 additions & 0 deletions src/Mjolnir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ limitations under the License.

import {
extractRequestError,
IToDeviceMessage,
LogLevel,
LogService,
MembershipEvent,
MXCUrl,
Permalinks,
UserID,
decodeBase64,
} from "@vector-im/matrix-bot-sdk";
import crypto from "crypto";
// @ts-ignore - there are no types for this
import { stringify as canonicalStringify } from "another-json";

import { ALL_RULE_TYPES as ALL_BAN_LIST_RULE_TYPES } from "./models/ListRule";
import { COMMAND_PREFIX, handleCommand } from "./commands/CommandHandler";
Expand All @@ -45,6 +50,7 @@ import { OpenMetrics } from "./webapis/OpenMetrics";
import { LRUCache } from "lru-cache";
import { ModCache } from "./ModCache";
import { MASClient } from "./MASClient";
import { PolicyservClient } from "./PolicyservClient";

export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
Expand Down Expand Up @@ -112,6 +118,8 @@ export class Mjolnir {
*/
public MASClient: MASClient;

public readonly psClient: PolicyservClient | undefined;

/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixSendClient} client
Expand Down Expand Up @@ -231,6 +239,11 @@ export class Mjolnir {
this.MASClient = new MASClient(config);
}

if (config.policyserv?.apiKey) {
this.psClient = new PolicyservClient(config.policyserv.baseUrl, config.policyserv.apiKey);
matrixEmitter.on("to-device", this.handleToDevice.bind(this));
}

// Setup bot.

matrixEmitter.on("room.event", this.handleEvent.bind(this));
Expand Down Expand Up @@ -489,6 +502,17 @@ export class Mjolnir {
private protectRoom(roomId: string): void {
this.protectedRoomsTracker.addProtectedRoom(roomId);
this.roomJoins.addRoom(roomId);
if (this.psClient) {
this.psClient.addRoom(roomId).catch((e) => {
// The error might contain sensitive details - don't log it to the room.
LogService.error("Mjolnir", `Failed to add room to policyserv`, e);
this.managementRoomOutput.logMessage(
LogLevel.ERROR,
"Mjolnir",
`Failed to add ${roomId} to policyserv. See logs for details.`,
);
});
}
}

/**
Expand Down Expand Up @@ -583,6 +607,66 @@ export class Mjolnir {
}
}

private async handleToDevice(toDeviceMessage: IToDeviceMessage) {
LogService.debug("Mjolnir", `Received to-device message: ${JSON.stringify(toDeviceMessage)}`);

if (toDeviceMessage.type !== "org.matrix.policyserv.command") {
return; // not something this function cares about
}

if (toDeviceMessage.content.command !== "redact") {
return; // no point in validating something we don't know what to do with
}

const roomId = toDeviceMessage.content.room_id;
if (!this.protectedRoomsTracker.isProtectedRoom(roomId)) {
return; // not something we're likely to have permissions in
}

LogService.info("Mjolnir", `Received policyserv redact command in ${roomId} - verifying`);

// Get the (stable) policy server configuration event for the target room. This will give us the
// key so we can verify the signature.
let verifyName: string | undefined;
let verifyKey: string | undefined;
try {
const policyServerContent = await this.client.getRoomStateEventContent(roomId, "m.room.policy", "");
verifyName = policyServerContent["via"] as string;
verifyKey = (<any>policyServerContent)["public_keys"]?.["ed25519"];
} catch (e) {
// treat as unprotected and give up
return;
}
if (!verifyName || !verifyKey) {
return; // no policy server in the room
}

// Verify the signature on the to-device message
const publicKey = crypto.createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), decodeBase64(verifyKey)]),
format: "der",
type: "spki",
});
const signature = decodeBase64(toDeviceMessage.content["signatures"]?.[verifyName]?.["ed25519:policy_server"]);
const contentNoSig = { ...toDeviceMessage.content };
delete contentNoSig["signatures"];
const signedData = Buffer.from(canonicalStringify(contentNoSig));
if (!crypto.verify(null, signedData, publicKey, signature)) {
LogService.warn(
"Mjolnir",
`Received policyserv redact command in ${roomId} - signature verification failed (ignoring command)`,
);
return;
}

// At this point it's a valid command - do the action
LogService.info(
"Mjolnir",
`Received policyserv redact command in ${roomId} - executing on ${toDeviceMessage.content.event_id}`,
);
await this.client.redactEvent(roomId, toDeviceMessage.content.event_id);
}

public async isSynapseAdmin(): Promise<boolean> {
try {
if (this.usingMAS) {
Expand Down
57 changes: 57 additions & 0 deletions src/PolicyservClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { LogService } from "@vector-im/matrix-bot-sdk";

export class PolicyservClient {
public readonly baseUrl: string;
private apiKey: string;

constructor(baseUrl: string, apiKey: string) {
LogService.info("PolicyservClient", "Setting up policyserv client");
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}

public async checkEventId(eventId: string): Promise<boolean> {
const url = `${this.baseUrl}/_policyserv/v1/check/event_id`;
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({ event_id: eventId }),
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
});
return response.ok;
}

public async addRoom(roomId: string): Promise<void> {
const url = `${this.baseUrl}/_policyserv/v1/join/${encodeURIComponent(roomId)}`;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
});
if (!response.ok) {
if (response.status === 400) {
// policyserv uses a 400 Bad Request to indicate the room is already joined, so we can ignore that error
return;
}
throw new Error(`Failed to add room: ${response.statusText}`);
}
}
}
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ export interface IConfig {
syncOnStartup: boolean;
verifyPermissionsOnStartup: boolean;
noop: boolean;
policyserv?: {
baseUrl: string;
apiKey?: string;
};
protectedRooms: string[]; // matrix.to urls
fasterMembershipChecks: boolean;
automaticallyRedactForReasons: string[]; // case-insensitive globs
Expand Down Expand Up @@ -235,6 +239,10 @@ const defaultConfig: IConfig = {
protectedRooms: [],
fasterMembershipChecks: false,
automaticallyRedactForReasons: ["spam", "advertising"],
policyserv: {
baseUrl: "https://policy.example.org",
apiKey: "", // empty string disables policyserv support
},
protectAllJoinedRooms: false,
backgroundDelayMS: 500,
pollReports: false,
Expand Down
20 changes: 20 additions & 0 deletions src/protections/ProtectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,26 @@ export class ProtectionManager {
return;
}

// If policyserv support is enabled, see if the event was explicitly allowed by the policy server
if (this.mjolnir.psClient) {
try {
const allowed = await this.mjolnir.psClient.checkEventId(event["event_id"]);
if (allowed) {
LogService.info(
"ProtectionManager",
`Event ${event["event_id"]} was explicitly allowed by policy server - not running protections`,
);
return;
}
} catch (e) {
LogService.error(
"ProtectionManager",
`Error checking event ${event["event_id"]} against policy server - assuming unsafe: ${e}`,
);
// fall through, per log message
}
}

// Iterate all the enabled protections
for (const protection of this.enabledProtections) {
let consequences: Consequence[] | undefined = undefined;
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ ajv@^8.0.1:

another-json@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz"
resolved "https://registry.yarnpkg.com/another-json/-/another-json-0.2.0.tgz#b5f4019c973b6dd5c6506a2d93469cb6d32aeedc"
integrity sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==

ansi-colors@4.1.1, ansi-colors@^4.1.1:
Expand Down
Loading