Matrix-js: auto-repair cross-signing bootstrap

This commit is contained in:
Gustavo Madeira Santana
2026-02-23 00:43:14 -05:00
parent 93741f5385
commit f634d4018a
2 changed files with 527 additions and 5 deletions

View File

@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
class FakeMatrixEvent extends EventEmitter {
@@ -691,6 +692,60 @@ describe("MatrixClient crypto bootstrapping", () => {
);
});
it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
password: "secret-password",
});
const bootstrapSpy = vi
.fn()
.mockResolvedValueOnce({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: false,
})
.mockResolvedValueOnce({
crossSigningReady: true,
crossSigningPublished: true,
ownDeviceVerified: true,
});
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };
}
).cryptoBootstrapper.bootstrap = bootstrapSpy;
await client.start();
expect(bootstrapSpy).toHaveBeenCalledTimes(2);
expect(bootstrapSpy.mock.calls[1]?.[1]).toEqual({
forceResetCrossSigning: true,
strict: true,
});
});
it("does not force-reset bootstrap when password is unavailable", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const bootstrapSpy = vi.fn().mockResolvedValue({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: false,
});
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };
}
).cryptoBootstrapper.bootstrap = bootstrapSpy;
await client.start();
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
});
it("provides secret storage callbacks and resolves stored recovery key", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
@@ -748,4 +803,148 @@ describe("MatrixClient crypto bootstrapping", () => {
await vi.advanceTimersByTimeAsync(120_000);
expect(databasesSpy.mock.calls.length).toBe(callsAfterStop);
});
it("reports own verification status when crypto marks device as verified", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
await client.start();
const status = await client.getOwnDeviceVerificationStatus();
expect(status.encryptionEnabled).toBe(true);
expect(status.verified).toBe(true);
expect(status.userId).toBe("@bot:example.org");
expect(status.deviceId).toBe("DEVICE123");
});
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");
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
const bootstrapSecretStorage = vi.fn(async () => {});
const bootstrapCrossSigning = vi.fn(async () => {});
const getSecretStorageStatus = vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
}));
const getDeviceVerificationStatus = vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
}));
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning,
bootstrapSecretStorage,
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus,
getDeviceVerificationStatus,
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-key-"));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.verified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(result.deviceId).toBe("DEVICE123");
expect(bootstrapSecretStorage).toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalled();
});
it("reports bootstrap failure when cross-signing keys are not published", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({
userId: "@bot:example.org",
masterKeyPublished: false,
selfSigningKeyPublished: false,
userSigningKeyPublished: false,
published: false,
});
const result = await client.bootstrapOwnDeviceVerification();
expect(result.success).toBe(false);
expect(result.error).toContain(
"Cross-signing bootstrap finished but server keys are still not published",
);
});
it("reports bootstrap success when own device is verified and keys are published", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({
userId: "@bot:example.org",
masterKeyPublished: true,
selfSigningKeyPublished: true,
userSigningKeyPublished: true,
published: true,
});
const result = await client.bootstrapOwnDeviceVerification();
expect(result.success).toBe(true);
expect(result.verification.verified).toBe(true);
expect(result.crossSigning.published).toBe(true);
expect(result.cryptoBootstrap).not.toBeNull();
});
});

View File

@@ -9,6 +9,7 @@ import {
} from "matrix-js-sdk";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js";
import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js";
import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
@@ -20,6 +21,7 @@ import { type HttpMethod, type QueryParams } from "./sdk/transport.js";
import type {
MatrixClientEventMap,
MatrixCryptoBootstrapApi,
MatrixDeviceVerificationStatusLike,
MatrixRawEvent,
MessageEventContent,
} from "./sdk/types.js";
@@ -39,6 +41,54 @@ export type {
TextualMessageEventContent,
} from "./sdk/types.js";
export type MatrixOwnDeviceVerificationStatus = {
encryptionEnabled: boolean;
userId: string | null;
deviceId: string | null;
verified: boolean;
localVerified: boolean;
crossSigningVerified: boolean;
signedByOwner: boolean;
recoveryKeyStored: boolean;
recoveryKeyCreatedAt: string | null;
recoveryKeyId: string | null;
backupVersion: string | null;
};
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
success: boolean;
verifiedAt?: string;
error?: string;
};
export type MatrixOwnCrossSigningPublicationStatus = {
userId: string | null;
masterKeyPublished: boolean;
selfSigningKeyPublished: boolean;
userSigningKeyPublished: boolean;
published: boolean;
};
export type MatrixVerificationBootstrapResult = {
success: boolean;
error?: string;
verification: MatrixOwnDeviceVerificationStatus;
crossSigning: MatrixOwnCrossSigningPublicationStatus;
pendingVerifications: number;
cryptoBootstrap: MatrixCryptoBootstrapResult | null;
};
function isMatrixDeviceVerified(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return (
status?.isVerified?.() === true ||
status?.localVerified === true ||
status?.crossSigningVerified === true ||
status?.signedByOwner === true
);
}
export class MatrixClient {
private readonly client: MatrixJsClient;
private readonly emitter = new EventEmitter();
@@ -46,10 +96,12 @@ export class MatrixClient {
private readonly localTimeoutMs: number;
private readonly initialSyncLimit?: number;
private readonly encryptionEnabled: boolean;
private readonly password?: string;
private readonly idbSnapshotPath?: string;
private readonly cryptoDatabasePrefix?: string;
private bridgeRegistered = false;
private started = false;
private cryptoBootstrapped = false;
private selfUserId: string | null;
private readonly dmRoomIds = new Set<string>();
private cryptoInitialized = false;
@@ -88,6 +140,7 @@ export class MatrixClient {
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
this.initialSyncLimit = opts.initialSyncLimit;
this.encryptionEnabled = opts.encryption === true;
this.password = opts.password;
this.idbSnapshotPath = opts.idbSnapshotPath;
this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix;
this.selfUserId = opts.userId?.trim() || null;
@@ -176,6 +229,7 @@ export class MatrixClient {
await this.client.startClient({
initialSyncLimit: this.initialSyncLimit,
});
await this.bootstrapCryptoIfNeeded();
this.started = true;
this.emitOutstandingInviteEvents();
await this.refreshDmCache().catch(noop);
@@ -196,6 +250,45 @@ export class MatrixClient {
this.started = false;
}
private async bootstrapCryptoIfNeeded(): Promise<void> {
if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) {
return;
}
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return;
}
const initial = await this.cryptoBootstrapper.bootstrap(crypto);
if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) {
if (this.password?.trim()) {
try {
const repaired = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: true,
strict: true,
});
if (repaired.crossSigningPublished && repaired.ownDeviceVerified !== false) {
LogService.info(
"MatrixClientLite",
"Cross-signing/bootstrap recovered after forced reset",
);
}
} catch (err) {
LogService.warn(
"MatrixClientLite",
"Failed to recover cross-signing/bootstrap with forced reset:",
err,
);
}
} else {
LogService.warn(
"MatrixClientLite",
"Cross-signing/bootstrap incomplete and no password is configured for UIA fallback",
);
}
}
this.cryptoBootstrapped = true;
}
private async initializeCryptoIfNeeded(): Promise<void> {
if (!this.encryptionEnabled || this.cryptoInitialized) {
return;
@@ -210,11 +303,6 @@ export class MatrixClient {
});
this.cryptoInitialized = true;
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (crypto) {
await this.cryptoBootstrapper.bootstrap(crypto);
}
// Persist the crypto store after successful init (captures fresh keys on first run).
await persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
@@ -412,6 +500,241 @@ 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;
if (!this.encryptionEnabled) {
return {
encryptionEnabled: false,
userId,
deviceId,
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
backupVersion: null,
};
}
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);
}
return {
encryptionEnabled: true,
userId,
deviceId,
verified: isMatrixDeviceVerified(deviceStatus),
localVerified: deviceStatus?.localVerified === true,
crossSigningVerified: deviceStatus?.crossSigningVerified === true,
signedByOwner: deviceStatus?.signedByOwner === true,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
backupVersion: await this.resolveRoomKeyBackupVersion(),
};
}
async verifyWithRecoveryKey(
rawRecoveryKey: string,
): Promise<MatrixRecoveryKeyVerificationResult> {
if (!this.encryptionEnabled) {
return {
success: false,
error: "Matrix encryption is disabled for this client",
encryptionEnabled: false,
userId: this.client.getUserId() ?? this.selfUserId ?? null,
deviceId: this.client.getDeviceId()?.trim() || null,
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
backupVersion: null,
};
}
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return {
success: false,
error: "Matrix crypto is not available (start client with encryption enabled)",
...(await this.getOwnDeviceVerificationStatus()),
};
}
const trimmedRecoveryKey = rawRecoveryKey.trim();
if (!trimmedRecoveryKey) {
return {
success: false,
error: "Matrix recovery key is required",
...(await this.getOwnDeviceVerificationStatus()),
};
}
let defaultKeyId: string | null | undefined = undefined;
if (typeof crypto.getSecretStorageStatus === "function") {
const status = await crypto.getSecretStorageStatus().catch(() => null);
defaultKeyId = status?.defaultKeyId;
}
try {
this.recoveryKeyStore.storeEncodedRecoveryKey({
encodedPrivateKey: trimmedRecoveryKey,
keyId: defaultKeyId,
});
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
...(await this.getOwnDeviceVerificationStatus()),
};
}
await this.cryptoBootstrapper.bootstrap(crypto);
const status = await this.getOwnDeviceVerificationStatus();
if (!status.verified) {
return {
success: false,
error:
"Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.",
...status,
};
}
return {
success: true,
verifiedAt: new Date().toISOString(),
...status,
};
}
async getOwnCrossSigningPublicationStatus(): Promise<MatrixOwnCrossSigningPublicationStatus> {
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
if (!userId) {
return {
userId: null,
masterKeyPublished: false,
selfSigningKeyPublished: false,
userSigningKeyPublished: false,
published: false,
};
}
try {
const response = (await this.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, {
device_keys: { [userId]: [] as string[] },
})) as {
master_keys?: Record<string, unknown>;
self_signing_keys?: Record<string, unknown>;
user_signing_keys?: Record<string, unknown>;
};
const masterKeyPublished = Boolean(response.master_keys?.[userId]);
const selfSigningKeyPublished = Boolean(response.self_signing_keys?.[userId]);
const userSigningKeyPublished = Boolean(response.user_signing_keys?.[userId]);
return {
userId,
masterKeyPublished,
selfSigningKeyPublished,
userSigningKeyPublished,
published: masterKeyPublished && selfSigningKeyPublished && userSigningKeyPublished,
};
} catch {
return {
userId,
masterKeyPublished: false,
selfSigningKeyPublished: false,
userSigningKeyPublished: false,
published: false,
};
}
}
async bootstrapOwnDeviceVerification(params?: {
recoveryKey?: string;
forceResetCrossSigning?: boolean;
}): Promise<MatrixVerificationBootstrapResult> {
const pendingVerifications = async (): Promise<number> =>
this.crypto ? (await this.crypto.listVerifications()).length : 0;
if (!this.encryptionEnabled) {
return {
success: false,
error: "Matrix encryption is disabled for this client",
verification: await this.getOwnDeviceVerificationStatus(),
crossSigning: await this.getOwnCrossSigningPublicationStatus(),
pendingVerifications: await pendingVerifications(),
cryptoBootstrap: null,
};
}
let bootstrapError: string | undefined;
let bootstrapSummary: MatrixCryptoBootstrapResult | null = null;
try {
await this.initializeCryptoIfNeeded();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
throw new Error("Matrix crypto is not available (start client with encryption enabled)");
}
const rawRecoveryKey = params?.recoveryKey?.trim();
if (rawRecoveryKey) {
let defaultKeyId: string | null | undefined = undefined;
if (typeof crypto.getSecretStorageStatus === "function") {
const status = await crypto.getSecretStorageStatus().catch(() => null);
defaultKeyId = status?.defaultKeyId;
}
this.recoveryKeyStore.storeEncodedRecoveryKey({
encodedPrivateKey: rawRecoveryKey,
keyId: defaultKeyId,
});
}
bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: params?.forceResetCrossSigning === true,
strict: true,
});
} catch (err) {
bootstrapError = err instanceof Error ? err.message : String(err);
}
const verification = await this.getOwnDeviceVerificationStatus();
const crossSigning = await this.getOwnCrossSigningPublicationStatus();
const success = verification.verified && crossSigning.published;
const error =
bootstrapError ??
(success
? undefined
: "Matrix verification bootstrap did not produce a verified device with published cross-signing keys");
return {
success,
error,
verification,
crossSigning,
pendingVerifications: await pendingVerifications(),
cryptoBootstrap: bootstrapSummary,
};
}
private async resolveRoomKeyBackupVersion(): Promise<string | null> {
try {
const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as {
version?: string;
};
return response.version?.trim() || null;
} catch {
return null;
}
}
private registerBridge(): void {
if (this.bridgeRegistered) {
return;