Matrix: expose E2EE QA verification hooks

This commit is contained in:
Gustavo Madeira Santana
2026-04-16 16:14:08 -04:00
parent 21d500a65f
commit 0f7c40e508
13 changed files with 374 additions and 26 deletions

View File

@@ -98,6 +98,7 @@ export function resolveMatrixRoomKeyBackupIssue(
export function resolveMatrixRoomKeyBackupReadinessError(
backup: MatrixRoomKeyBackupStatusLike,
opts: {
allowUntrustedMatchingKey?: boolean;
requireServerBackup: boolean;
},
): string | null {
@@ -108,6 +109,14 @@ export function resolveMatrixRoomKeyBackupReadinessError(
if (issue.code === "ok") {
return null;
}
if (
issue.code === "untrusted-signature" &&
opts.allowUntrustedMatchingKey === true &&
backup.matchesDecryptionKey === true &&
backup.decryptionKeyCached === true
) {
return null;
}
if (issue.message) {
return `Matrix room key backup is not usable: ${issue.message}.`;
}

View File

@@ -256,11 +256,13 @@ export function createMatrixTextMessageEvent(params: {
originServerTs?: number;
relatesTo?: RoomMessageEventContent["m.relates_to"];
mentions?: RoomMessageEventContent["m.mentions"];
unsigned?: MatrixRawEvent["unsigned"];
}): MatrixRawEvent {
return createMatrixRoomMessageEvent({
eventId: params.eventId,
sender: params.sender,
originServerTs: params.originServerTs,
unsigned: params.unsigned,
content: {
msgtype: "m.text",
body: params.body,
@@ -274,6 +276,7 @@ export function createMatrixRoomMessageEvent(params: {
eventId: string;
sender?: string;
originServerTs?: number;
unsigned?: MatrixRawEvent["unsigned"];
content: RoomMessageEventContent;
}): MatrixRawEvent {
return {
@@ -282,6 +285,7 @@ export function createMatrixRoomMessageEvent(params: {
event_id: params.eventId,
origin_server_ts: params.originServerTs ?? Date.now(),
content: params.content,
...(params.unsigned ? { unsigned: params.unsigned } : {}),
} as MatrixRawEvent;
}

View File

@@ -564,6 +564,32 @@ describe("matrix monitor handler pairing account scope", () => {
expect(resolveAgentRoute).toHaveBeenCalledTimes(1);
});
it("drops root events that carry a bundled replacement relation", async () => {
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
isDirectMessage: false,
mentionRegexes: [/@bot/i],
getMemberDisplayName: async () => "sender",
});
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$edited-root",
body: "@bot please reply",
mentions: { user_ids: ["@bot:example.org"] },
unsigned: {
"m.relations": {
"m.replace": {
event_id: "$edit",
},
},
},
}),
);
expect(recordInboundSession).not.toHaveBeenCalled();
});
it("skips media downloads for unmentioned group media messages", async () => {
const downloadContent = vi.fn(async () => Buffer.from("image"));
const getMemberDisplayName = vi.fn(async () => "sender");

View File

@@ -169,6 +169,14 @@ function resolveMatrixMentionPrecheckText(params: {
return "";
}
function hasBundledMatrixReplacementRelation(event: MatrixRawEvent) {
const relations = event.unsigned?.["m.relations"];
if (!relations || typeof relations !== "object") {
return false;
}
return relations[RelationType.Replace] !== undefined;
}
function resolveMatrixInboundBodyText(params: {
rawBody: string;
filename?: string;
@@ -500,6 +508,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (relates && "rel_type" in relates && relates.rel_type === RelationType.Replace) {
return undefined;
}
if (hasBundledMatrixReplacementRelation(event)) {
return undefined;
}
if (eventId && inboundDeduper) {
claimedInboundEvent = inboundDeduper.claimEvent({ roomId, eventId });
if (!claimedInboundEvent) {

View File

@@ -1484,6 +1484,38 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(status.verified).toBe(false);
});
it("reports peer device trust from the current client", async () => {
const getDeviceVerificationStatus = vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
}));
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getDeviceVerificationStatus,
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await client.start();
const status = await client.getDeviceVerificationStatus("@peer:example.org", "PEERDEVICE");
expect(getDeviceVerificationStatus).toHaveBeenCalledWith("@peer:example.org", "PEERDEVICE");
expect(status).toMatchObject({
deviceId: "PEERDEVICE",
encryptionEnabled: true,
localVerified: true,
signedByOwner: false,
userId: "@peer:example.org",
verified: true,
});
});
it("verifies with a provided recovery key and reports success", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
expect(encoded).toBeTypeOf("string");
@@ -1885,6 +1917,38 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(restoreKeyBackup).toHaveBeenCalledTimes(1);
});
it("restores backup keys when the matching decryption key is cached but signature trust is stale", async () => {
const restoreKeyBackup = vi.fn(async () => ({ imported: 3, total: 3 }));
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getActiveSessionBackupVersion: vi.fn(async () => "42"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "42",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: false,
matchesDecryptionKey: true,
})),
restoreKeyBackup,
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
vi.spyOn(client, "doRequest").mockResolvedValue({ version: "42" });
const result = await client.restoreRoomKeyBackup();
expect(result.success).toBe(true);
expect(result.imported).toBe(3);
expect(result.total).toBe(3);
expect(result.backup.trusted).toBe(false);
expect(result.backup.matchesDecryptionKey).toBe(true);
expect(restoreKeyBackup).toHaveBeenCalledTimes(1);
});
it("activates backup after loading the key from secret storage before restore", async () => {
const getActiveSessionBackupVersion = vi
.fn()

View File

@@ -83,6 +83,16 @@ export type MatrixOwnDeviceVerificationStatus = {
backup: MatrixRoomKeyBackupStatus;
};
export type MatrixDeviceVerificationStatus = {
encryptionEnabled: boolean;
userId: string | null;
deviceId: string | null;
verified: boolean;
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
};
export type MatrixRoomKeyBackupStatus = {
serverVersion: string | null;
activeVersion: string | null;
@@ -332,7 +342,7 @@ export class MatrixClient {
const runtime = await loadMatrixCryptoRuntime();
this.decryptBridge ??= new runtime.MatrixDecryptBridge<MatrixRawEvent>({
client: this.client,
toRaw: (event) => matrixEventToRaw(event),
toRaw: (event) => matrixEventToRaw(event, { contentMode: "original" }),
emitDecryptedEvent: (roomId, event) => {
this.emitter.emit("room.decrypted_event", roomId, event);
},
@@ -669,6 +679,7 @@ export class MatrixClient {
databasePrefix: this.cryptoDatabasePrefix,
}).catch(noop);
}, MATRIX_IDB_PERSIST_INTERVAL_MS);
this.idbPersistTimer.unref?.();
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err);
}
@@ -1044,44 +1055,61 @@ export class MatrixClient {
};
}
async getOwnDeviceVerificationStatus(): Promise<MatrixOwnDeviceVerificationStatus> {
const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary();
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
const deviceId = this.client.getDeviceId()?.trim() || null;
const backup = await this.getRoomKeyBackupStatus();
async getDeviceVerificationStatus(
userId: string | null | undefined,
deviceId: string | null | undefined,
): Promise<MatrixDeviceVerificationStatus> {
const normalizedUserId = userId?.trim() || null;
const normalizedDeviceId = deviceId?.trim() || null;
if (!this.encryptionEnabled) {
return {
encryptionEnabled: false,
userId,
deviceId,
userId: normalizedUserId,
deviceId: normalizedDeviceId,
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
backupVersion: backup.serverVersion,
backup,
};
}
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
let deviceStatus: MatrixDeviceVerificationStatusLike | null = null;
if (crypto && userId && deviceId && typeof crypto.getDeviceVerificationStatus === "function") {
deviceStatus = await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null);
if (
crypto &&
normalizedUserId &&
normalizedDeviceId &&
typeof crypto.getDeviceVerificationStatus === "function"
) {
deviceStatus = await crypto
.getDeviceVerificationStatus(normalizedUserId, normalizedDeviceId)
.catch(() => null);
}
const { isMatrixDeviceOwnerVerified } = await loadMatrixCryptoRuntime();
const { isMatrixDeviceVerifiedInCurrentClient } = await loadMatrixCryptoRuntime();
return {
encryptionEnabled: true,
userId,
deviceId,
verified: isMatrixDeviceOwnerVerified(deviceStatus),
userId: normalizedUserId,
deviceId: normalizedDeviceId,
verified: isMatrixDeviceVerifiedInCurrentClient(deviceStatus),
localVerified: deviceStatus?.localVerified === true,
crossSigningVerified: deviceStatus?.crossSigningVerified === true,
signedByOwner: deviceStatus?.signedByOwner === true,
};
}
async getOwnDeviceVerificationStatus(): Promise<MatrixOwnDeviceVerificationStatus> {
const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary();
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
const deviceId = this.client.getDeviceId()?.trim() || null;
const backup = await this.getRoomKeyBackupStatus();
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
const ownerVerified =
deviceVerification.crossSigningVerified || deviceVerification.signedByOwner;
return {
...deviceVerification,
verified: ownerVerified,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
@@ -1211,6 +1239,7 @@ export class MatrixClient {
const backup = await this.getRoomKeyBackupStatus();
loadedFromSecretStorage = backup.keyLoadAttempted && !backup.keyLoadError;
const backupError = resolveMatrixRoomKeyBackupReadinessError(backup, {
allowUntrustedMatchingKey: true,
requireServerBackup: true,
});
if (backupError) {
@@ -1668,7 +1697,7 @@ export class MatrixClient {
return;
}
const raw = matrixEventToRaw(event);
const raw = matrixEventToRaw(event, { contentMode: "original" });
const isEncryptedEvent = raw.type === "m.room.encrypted";
this.emitter.emit("room.event", roomId, raw);
if (isEncryptedEvent) {

View File

@@ -320,6 +320,84 @@ describe("MatrixCryptoBootstrapper", () => {
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
});
it("recreates secret storage and retries a forced reset when stale server SSSS blocks it", async () => {
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"))
.mockResolvedValueOnce(undefined);
const { deps, crypto, bootstrapper } = createBootstrapperHarness({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
});
await bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
},
);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(3);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("re-exports cross-signing keys after forced reset creates secret storage", async () => {
const bootstrapCrossSigning = vi.fn(async () => {});
const { deps, crypto, bootstrapper } = createBootstrapperHarness({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
});
await bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
},
);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -59,7 +59,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
}
const crossSigning = await this.bootstrapCrossSigning(crypto, {
let crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
allowSecretStorageRecreateWithoutRecoveryKey:
@@ -74,6 +74,15 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
if (deferSecretStorageBootstrapUntilAfterCrossSigning) {
crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: false,
allowAutomaticCrossSigningReset: false,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
strict,
});
}
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
return {
crossSigningReady: crossSigning.ready,
@@ -160,12 +169,38 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
};
if (options.forceResetCrossSigning) {
try {
const resetCrossSigning = async (): Promise<void> => {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
};
try {
await resetCrossSigning();
} catch (err) {
const shouldRepairSecretStorage =
options.allowSecretStorageRecreateWithoutRecoveryKey &&
isRepairableSecretStorageAccessError(err);
if (shouldRepairSecretStorage) {
LogService.warn(
"MatrixClientLite",
"Forced cross-signing reset could not unlock secret storage; recreating secret storage and retrying.",
);
try {
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
});
await resetCrossSigning();
} catch (repairErr) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr);
if (options.strict) {
throw repairErr instanceof Error ? repairErr : new Error(String(repairErr));
}
return { ready: false, published: false };
}
return await finalize();
}
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));

View File

@@ -8,4 +8,7 @@ export { MatrixDecryptBridge } from "./decrypt-bridge.js";
export { persistIdbToDisk, restoreIdbFromDisk } from "./idb-persistence.js";
export { MatrixVerificationManager } from "./verification-manager.js";
export type { MatrixVerificationSummary } from "./verification-manager.js";
export { isMatrixDeviceOwnerVerified } from "./verification-status.js";
export {
isMatrixDeviceOwnerVerified,
isMatrixDeviceVerifiedInCurrentClient,
} from "./verification-status.js";

View File

@@ -57,4 +57,61 @@ describe("event-helpers", () => {
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org");
});
it("serializes current content by default for read APIs", () => {
const event = {
getId: () => "$root",
getSender: () => "@alice:example.org",
getType: () => "m.room.message",
getTs: () => 1000,
getOriginalContent: () => ({ body: "original", msgtype: "m.text" }),
getContent: () => ({
body: "@bot edited",
"m.mentions": { user_ids: ["@bot:example.org"] },
msgtype: "m.text",
}),
getUnsigned: () => ({
"m.relations": {
"m.replace": { event_id: "$edit" },
},
}),
} as unknown as MatrixEvent;
expect(matrixEventToRaw(event)).toMatchObject({
content: {
body: "@bot edited",
"m.mentions": { user_ids: ["@bot:example.org"] },
msgtype: "m.text",
},
});
});
it("can serialize original content for inbound trigger filtering", () => {
const event = {
getId: () => "$root",
getSender: () => "@alice:example.org",
getType: () => "m.room.message",
getTs: () => 1000,
getOriginalContent: () => ({ body: "original", msgtype: "m.text" }),
getContent: () => ({
body: "@bot edited",
"m.mentions": { user_ids: ["@bot:example.org"] },
msgtype: "m.text",
}),
getUnsigned: () => ({
"m.relations": {
"m.replace": { event_id: "$edit" },
},
}),
} as unknown as MatrixEvent;
expect(matrixEventToRaw(event, { contentMode: "original" })).toMatchObject({
content: { body: "original", msgtype: "m.text" },
unsigned: {
"m.relations": {
"m.replace": { event_id: "$edit" },
},
},
});
});
});

View File

@@ -1,17 +1,29 @@
import type { MatrixEvent } from "matrix-js-sdk";
import type { MatrixRawEvent } from "./types.js";
export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent {
export type MatrixEventContentMode = "current" | "original";
export function matrixEventToRaw(
event: MatrixEvent,
opts: { contentMode?: MatrixEventContentMode } = {},
): MatrixRawEvent {
const unsigned = (event.getUnsigned?.() ?? {}) as {
age?: number;
redacted_because?: unknown;
};
const eventWithOriginalContent = event as {
getOriginalContent?: () => Record<string, unknown>;
};
const content =
opts.contentMode === "original"
? (eventWithOriginalContent.getOriginalContent?.() ?? event.getContent?.() ?? {})
: (event.getContent?.() ?? eventWithOriginalContent.getOriginalContent?.() ?? {});
const raw: MatrixRawEvent = {
event_id: event.getId() ?? "",
sender: event.getSender() ?? "",
type: event.getType() ?? "",
origin_server_ts: event.getTs() ?? 0,
content: (event.getContent?.() ?? {}) || {},
content: content || {},
unsigned,
};
const stateKey = resolveMatrixStateKey(event);

View File

@@ -12,6 +12,7 @@ export type MatrixRawEvent = {
content: Record<string, unknown>;
unsigned?: {
age?: number;
"m.relations"?: Record<string, unknown>;
redacted_because?: unknown;
};
state_key?: string;

View File

@@ -1,2 +1,21 @@
export { matrixPlugin } from "./src/channel.js";
export { MatrixClient } from "./src/matrix/sdk.js";
export type {
EncryptedFile,
MatrixDeviceVerificationStatus,
MatrixOwnDeviceDeleteResult,
MatrixOwnDeviceInfo,
MatrixOwnDeviceVerificationStatus,
MatrixRecoveryKeyVerificationResult,
MatrixRawEvent,
MatrixRoomKeyBackupResetResult,
MatrixRoomKeyBackupRestoreResult,
MatrixRoomKeyBackupStatus,
MatrixVerificationBootstrapResult,
MessageEventContent,
} from "./src/matrix/sdk.js";
export type {
MatrixVerificationMethod,
MatrixVerificationSummary,
} from "./src/matrix/sdk/verification-manager.js";
export { setMatrixRuntime } from "./src/runtime.js";