diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index d96c48c05b..c57fa78fde 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -41,6 +41,7 @@ export async function resolveActionClient( homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, + password: auth.password, deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index b2480f8541..82fc5ce6ac 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -5,6 +5,8 @@ import * as credentialsModule from "./credentials.js"; import * as sdkModule from "./sdk.js"; const saveMatrixCredentialsMock = vi.fn(); +const prepareMatrixRegisterModeMock = vi.fn(async () => null); +const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false); vi.mock("./credentials.js", () => ({ loadMatrixCredentials: vi.fn(() => null), @@ -13,6 +15,13 @@ vi.mock("./credentials.js", () => ({ touchMatrixCredentials: vi.fn(), })); +vi.mock("./client/register-mode.js", () => ({ + prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args), + finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) => + finalizeMatrixRegisterConfigAfterSuccessMock(...args), + resetPreparedMatrixRegisterModesForTests: vi.fn(), +})); + describe("resolveMatrixConfig", () => { it("prefers config over env", () => { const cfg = { @@ -96,6 +105,8 @@ describe("resolveMatrixAuth", () => { vi.restoreAllMocks(); vi.unstubAllGlobals(); saveMatrixCredentialsMock.mockReset(); + prepareMatrixRegisterModeMock.mockReset(); + finalizeMatrixRegisterConfigAfterSuccessMock.mockReset(); }); it("uses the hardened client request path for password login and persists deviceId", async () => { @@ -180,6 +191,7 @@ describe("resolveMatrixAuth", () => { undefined, expect.objectContaining({ type: "m.login.password", + device_id: undefined, }), ); expect(doRequestSpy).toHaveBeenNthCalledWith( @@ -199,6 +211,94 @@ describe("resolveMatrixAuth", () => { deviceId: "REGDEVICE123", encryption: true, }); + expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({ + cfg, + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + env: {} as NodeJS.ProcessEnv, + }); + expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + deviceId: "REGDEVICE123", + }); + }); + + it("ignores cached credentials when matrix.register=true", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "cached-token", + deviceId: "CACHEDDEVICE", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ + access_token: "tok-123", + user_id: "@bot:example.org", + device_id: "DEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + register: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(doRequestSpy).toHaveBeenCalledWith( + "POST", + "/_matrix/client/v3/login", + undefined, + expect.objectContaining({ + type: "m.login.password", + }), + ); + expect(auth.accessToken).toBe("tok-123"); + expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1); + }); + + it("requires matrix.password when matrix.register=true", async () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + register: true, + }, + }, + } as CoreConfig; + + await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( + "Matrix password is required when matrix.register=true", + ); + expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled(); + expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled(); + }); + + it("requires matrix.userId when matrix.register=true", async () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + password: "secret", + register: true, + }, + }, + } as CoreConfig; + + await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( + "Matrix userId is required when matrix.register=true", + ); + expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled(); + expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled(); }); it("falls back to config deviceId when cached credentials are missing it", async () => { diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 9646178416..09f05534c1 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -3,6 +3,10 @@ import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; import { getMatrixRuntime } from "../../runtime.js"; import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import { + finalizeMatrixRegisterConfigAfterSuccess, + prepareMatrixRegisterMode, +} from "./register-mode.js"; function clean(value?: string): string { return value?.trim() ?? ""; @@ -42,6 +46,7 @@ async function registerMatrixPasswordAccount(params: { homeserver: string; userId: string; password: string; + deviceId?: string; deviceName?: string; }): Promise<{ access_token?: string; @@ -53,6 +58,7 @@ async function registerMatrixPasswordAccount(params: { username: resolveMatrixLocalpart(params.userId), password: params.password, inhibit_login: false, + device_id: params.deviceId, initial_device_display_name: params.deviceName ?? "OpenClaw Gateway", }; @@ -128,6 +134,7 @@ export async function resolveMatrixAuth(params?: { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; const resolved = resolveMatrixConfig(cfg, env); + const registerFromConfig = cfg.channels?.matrix?.register === true; if (!resolved.homeserver) { throw new Error("Matrix homeserver is required (matrix.homeserver)"); } @@ -149,8 +156,23 @@ export async function resolveMatrixAuth(params?: { ? cached : null; + if (registerFromConfig) { + if (!resolved.userId) { + throw new Error("Matrix userId is required when matrix.register=true"); + } + if (!resolved.password) { + throw new Error("Matrix password is required when matrix.register=true"); + } + await prepareMatrixRegisterMode({ + cfg, + homeserver: resolved.homeserver, + userId: resolved.userId, + env, + }); + } + // If we have an access token, we can fetch userId via whoami if not provided - if (resolved.accessToken) { + if (resolved.accessToken && !registerFromConfig) { let userId = resolved.userId; const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken; let knownDeviceId = hasMatchingCachedToken @@ -196,6 +218,7 @@ export async function resolveMatrixAuth(params?: { homeserver: resolved.homeserver, userId, accessToken: resolved.accessToken, + password: resolved.password, deviceId: knownDeviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, @@ -203,12 +226,13 @@ export async function resolveMatrixAuth(params?: { }; } - if (cachedCredentials) { + if (cachedCredentials && !registerFromConfig) { touchMatrixCredentials(env); return { homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, accessToken: cachedCredentials.accessToken, + password: resolved.password, deviceId: cachedCredentials.deviceId || resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, @@ -239,6 +263,7 @@ export async function resolveMatrixAuth(params?: { type: "m.login.password", identifier: { type: "m.id.user", user: resolved.userId }, password: resolved.password, + device_id: resolved.deviceId, initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", })) as { access_token?: string; @@ -254,6 +279,7 @@ export async function resolveMatrixAuth(params?: { homeserver: resolved.homeserver, userId: resolved.userId, password: resolved.password, + deviceId: resolved.deviceId, deviceName: resolved.deviceName, }); } catch (registerErr) { @@ -275,7 +301,8 @@ export async function resolveMatrixAuth(params?: { homeserver: resolved.homeserver, userId: login.user_id ?? resolved.userId, accessToken, - deviceId: login.device_id, + password: resolved.password, + deviceId: login.device_id ?? resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, @@ -285,8 +312,16 @@ export async function resolveMatrixAuth(params?: { homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, - deviceId: login.device_id, + deviceId: auth.deviceId, }); + if (registerFromConfig) { + await finalizeMatrixRegisterConfigAfterSuccess({ + homeserver: auth.homeserver, + userId: auth.userId, + deviceId: auth.deviceId, + }); + } + return auth; } diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 8c49616d5e..7626a6c847 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -11,6 +11,7 @@ export async function createMatrixClient(params: { homeserver: string; userId?: string; accessToken: string; + password?: string; deviceId?: string; encryption?: boolean; localTimeoutMs?: number; @@ -43,6 +44,7 @@ export async function createMatrixClient(params: { return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, { userId: matrixClientUserId, + password: params.password, deviceId: params.deviceId, encryption: params.encryption, localTimeoutMs: params.localTimeoutMs, diff --git a/extensions/matrix/src/matrix/client/register-mode.test.ts b/extensions/matrix/src/matrix/client/register-mode.test.ts new file mode 100644 index 0000000000..9b4354538e --- /dev/null +++ b/extensions/matrix/src/matrix/client/register-mode.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "../../types.js"; +import * as runtimeModule from "../../runtime.js"; +import { + finalizeMatrixRegisterConfigAfterSuccess, + prepareMatrixRegisterMode, + resetPreparedMatrixRegisterModesForTests, +} from "./register-mode.js"; + +describe("matrix register mode helpers", () => { + const tempDirs: string[] = []; + + afterEach(() => { + resetPreparedMatrixRegisterModesForTests(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + it("moves existing matrix state into a .bak snapshot before fresh registration", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-")); + tempDirs.push(stateDir); + const credentialsDir = path.join(stateDir, "credentials", "matrix"); + const accountsDir = path.join(credentialsDir, "accounts"); + fs.mkdirSync(accountsDir, { recursive: true }); + fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n'); + fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n"); + + const cfg = { + channels: { + matrix: { + userId: "@pinguini:matrix.gumadeiras.com", + register: true, + encryption: true, + }, + }, + } as CoreConfig; + + const backupDir = await prepareMatrixRegisterMode({ + cfg, + homeserver: "https://matrix.gumadeiras.com", + userId: "@pinguini:matrix.gumadeiras.com", + env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv, + }); + + expect(backupDir).toBeTruthy(); + expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false); + expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false); + expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true); + expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true); + expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true); + }); + + it("updates matrix config after successful register mode auth", async () => { + const writeConfigFile = vi.fn(async () => {}); + vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({ + config: { + loadConfig: () => + ({ + channels: { + matrix: { + register: true, + accessToken: "stale-token", + userId: "@pinguini:matrix.gumadeiras.com", + }, + }, + }) as CoreConfig, + writeConfigFile, + }, + } as never); + + const updated = await finalizeMatrixRegisterConfigAfterSuccess({ + homeserver: "https://matrix.gumadeiras.com", + userId: "@pinguini:matrix.gumadeiras.com", + deviceId: "DEVICE123", + }); + expect(updated).toBe(true); + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: expect.objectContaining({ + matrix: expect.objectContaining({ + register: false, + homeserver: "https://matrix.gumadeiras.com", + userId: "@pinguini:matrix.gumadeiras.com", + deviceId: "DEVICE123", + }), + }), + }), + ); + const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig; + expect(written.channels?.matrix?.accessToken).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/client/register-mode.ts b/extensions/matrix/src/matrix/client/register-mode.ts new file mode 100644 index 0000000000..45bef00a14 --- /dev/null +++ b/extensions/matrix/src/matrix/client/register-mode.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { CoreConfig } from "../../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { resolveMatrixCredentialsDir } from "../credentials.js"; + +const preparedRegisterKeys = new Set(); + +function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string { + try { + return getMatrixRuntime().state.resolveStateDir(env, os.homedir); + } catch { + // fall through to deterministic fallback for tests/early init + } + const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); + if (override) { + if (override.startsWith("~")) { + const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir()); + return path.resolve(expanded); + } + return path.resolve(override); + } + return path.join(os.homedir(), ".openclaw"); +} + +function buildRegisterKey(params: { homeserver: string; userId: string }): string { + return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`; +} + +function buildBackupDirName(now = new Date()): string { + const ts = now.toISOString().replace(/[:.]/g, "-"); + const suffix = Math.random().toString(16).slice(2, 8); + return `${ts}-${suffix}`; +} + +export async function prepareMatrixRegisterMode(params: { + cfg: CoreConfig; + homeserver: string; + userId: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; + const registerKey = buildRegisterKey({ + homeserver: params.homeserver, + userId: params.userId, + }); + if (preparedRegisterKeys.has(registerKey)) { + return null; + } + + const stateDir = resolveStateDirFromEnv(env); + const credentialsDir = resolveMatrixCredentialsDir(env, stateDir); + if (!fs.existsSync(credentialsDir)) { + return null; + } + + const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak"); + if (entries.length === 0) { + return null; + } + + const backupRoot = path.join(credentialsDir, ".bak"); + fs.mkdirSync(backupRoot, { recursive: true }); + const backupDir = path.join(backupRoot, buildBackupDirName()); + fs.mkdirSync(backupDir, { recursive: true }); + + const matrixConfig = params.cfg.channels?.matrix ?? {}; + fs.writeFileSync( + path.join(backupDir, "matrix-config.json"), + JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"), + "utf-8", + ); + + for (const entry of entries) { + fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry)); + } + + preparedRegisterKeys.add(registerKey); + return backupDir; +} + +export async function finalizeMatrixRegisterConfigAfterSuccess(params: { + homeserver: string; + userId: string; + deviceId?: string; +}): Promise { + let runtime: ReturnType | null = null; + try { + runtime = getMatrixRuntime(); + } catch { + return false; + } + + const cfg = runtime.config.loadConfig() as CoreConfig; + if (cfg.channels?.matrix?.register !== true) { + return false; + } + + const matrixCfg = cfg.channels?.matrix ?? {}; + const nextMatrix: Record = { + ...matrixCfg, + register: false, + homeserver: params.homeserver, + userId: params.userId, + ...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}), + }; + // Registration mode should continue relying on password + cached credentials, not stale inline token. + delete nextMatrix.accessToken; + + const next: CoreConfig = { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + matrix: nextMatrix as CoreConfig["channels"]["matrix"], + }, + }; + + await runtime.config.writeConfigFile(next as never); + return true; +} + +export function resetPreparedMatrixRegisterModesForTests(): void { + preparedRegisterKeys.clear(); +} diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index 3b43b49c7b..11b72e6ad0 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -36,6 +36,7 @@ async function createSharedMatrixClient(params: { homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, + password: params.auth.password, deviceId: params.auth.deviceId, encryption: params.auth.encryption, localTimeoutMs: params.timeoutMs, diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index ac2839ea58..438a16e424 100644 --- a/extensions/matrix/src/matrix/client/types.ts +++ b/extensions/matrix/src/matrix/client/types.ts @@ -21,6 +21,7 @@ export type MatrixAuth = { homeserver: string; userId: string; accessToken: string; + password?: string; deviceId?: string; deviceName?: string; initialSyncLimit?: number; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index d0dce7a065..e2c6bdcfdf 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -104,6 +104,7 @@ type MatrixJsClientStub = EventEmitter & { fetchRoomEvent: ReturnType; sendTyping: ReturnType; getRoom: ReturnType; + getRooms: ReturnType; getCrypto: ReturnType; decryptEventIfNeeded: ReturnType; }; @@ -132,6 +133,7 @@ function createMatrixJsClientStub(): MatrixJsClientStub { client.fetchRoomEvent = vi.fn(async () => ({})); client.sendTyping = vi.fn(async () => {}); client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false })); + client.getRooms = vi.fn(() => []); client.getCrypto = vi.fn(() => undefined); client.decryptEventIfNeeded = vi.fn(async () => {}); return client; @@ -141,7 +143,7 @@ let matrixJsClient = createMatrixJsClientStub(); let lastCreateClientOpts: Record | null = null; vi.mock("matrix-js-sdk", () => ({ - ClientEvent: { Event: "event" }, + ClientEvent: { Event: "event", Room: "Room" }, MatrixEventEvent: { Decrypted: "decrypted" }, createClient: vi.fn((opts: Record) => { lastCreateClientOpts = opts; @@ -599,6 +601,46 @@ describe("MatrixClient event bridge", () => { expect(invites).toEqual(["!room:example.org"]); }); + + it("emits room.invite when SDK emits Room event with invite membership", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + matrixJsClient.emit("Room", { + roomId: "!invite:example.org", + getMyMembership: () => "invite", + }); + + expect(invites).toEqual(["!invite:example.org"]); + }); + + it("replays outstanding invite rooms at startup", async () => { + matrixJsClient.getRooms = vi.fn(() => [ + { + roomId: "!pending:example.org", + getMyMembership: () => "invite", + }, + { + roomId: "!joined:example.org", + getMyMembership: () => "join", + }, + ]); + + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + expect(invites).toEqual(["!pending:example.org"]); + }); }); describe("MatrixClient crypto bootstrapping", () => { @@ -642,9 +684,11 @@ describe("MatrixClient crypto bootstrapping", () => { await client.start(); - expect(bootstrapCrossSigning).toHaveBeenCalledWith({ - setupNewCrossSigning: true, - }); + expect(bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); }); it("provides secret storage callbacks and resolves stored recovery key", async () => { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index a6c4e7feba..018148a3cd 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -74,6 +74,7 @@ export class MatrixClient { _cryptoStorage?: unknown, opts: { userId?: string; + password?: string; deviceId?: string; localTimeoutMs?: number; encryption?: boolean; @@ -123,6 +124,7 @@ export class MatrixClient { }); this.cryptoBootstrapper = new MatrixCryptoBootstrapper({ getUserId: () => this.getUserId(), + getPassword: () => opts.password, getDeviceId: () => this.client.getDeviceId(), verificationManager: this.verificationManager, recoveryKeyStore: this.recoveryKeyStore, @@ -175,6 +177,7 @@ export class MatrixClient { initialSyncLimit: this.initialSyncLimit, }); this.started = true; + this.emitOutstandingInviteEvents(); await this.refreshDmCache().catch(noop); } @@ -450,6 +453,58 @@ export class MatrixClient { this.decryptBridge.attachEncryptedEvent(event, roomId); } }); + + // Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events. + this.client.on(ClientEvent.Room, (room) => { + this.emitMembershipForRoom(room); + }); + } + + private emitMembershipForRoom(room: unknown): void { + const roomObj = room as { + roomId?: string; + getMyMembership?: () => string | null | undefined; + selfMembership?: string | null | undefined; + }; + const roomId = roomObj.roomId?.trim(); + if (!roomId) { + return; + } + const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined; + const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; + if (!selfUserId) { + return; + } + const raw: MatrixRawEvent = { + type: "m.room.member", + room_id: roomId, + sender: selfUserId, + state_key: selfUserId, + content: { membership }, + origin_server_ts: Date.now(), + unsigned: { age: 0 }, + }; + if (membership === "invite") { + this.emitter.emit("room.invite", roomId, raw); + return; + } + if (membership === "join") { + this.emitter.emit("room.join", roomId, raw); + } + } + + private emitOutstandingInviteEvents(): void { + const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms; + if (typeof listRooms !== "function") { + return; + } + const rooms = listRooms.call(this.client); + if (!Array.isArray(rooms)) { + return; + } + for (const room of rooms) { + this.emitMembershipForRoom(room); + } } private async refreshDmCache(): Promise { diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts index d60a72e997..a75f75062a 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -5,6 +5,7 @@ import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./c function createBootstrapperDeps() { return { getUserId: vi.fn(async () => "@bot:example.org"), + getPassword: vi.fn(() => "super-secret-password"), getDeviceId: vi.fn(() => "DEVICE123"), verificationManager: { trackVerificationRequest: vi.fn(), @@ -46,15 +47,138 @@ describe("MatrixCryptoBootstrapper", () => { await bootstrapper.bootstrap(crypto); - expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith({ - setupNewCrossSigning: true, - }); + expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( crypto, ); + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2); expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto); }); + it("forces new cross-signing keys only when readiness check still fails", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true), + userHasCrossSigningKeys: vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("uses password UIA fallback when null and dummy auth fail", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as { + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }; + expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function"); + + const seenAuthStages: Array | null> = []; + const result = await firstCall.authUploadDeviceSigningKeys?.(async (authData) => { + seenAuthStages.push(authData); + if (authData === null) { + throw new Error("need auth"); + } + if (authData.type === "m.login.dummy") { + throw new Error("dummy rejected"); + } + if (authData.type === "m.login.password") { + return "ok"; + } + throw new Error("unexpected auth stage"); + }); + + expect(result).toBe("ok"); + expect(seenAuthStages).toEqual([ + null, + { type: "m.login.dummy" }, + { + type: "m.login.password", + identifier: { type: "m.id.user", user: "@bot:example.org" }, + password: "super-secret-password", + }, + ]); + }); + + it("resets cross-signing when first bootstrap attempt throws", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("first attempt failed")) + .mockResolvedValueOnce(undefined); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + it("marks own device verified and cross-signs it when needed", async () => { const deps = createBootstrapperDeps(); const setDeviceVerified = vi.fn(async () => {}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index beaef999c2..5667661582 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -1,7 +1,12 @@ import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; -import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js"; +import type { + MatrixAuthDict, + MatrixCryptoBootstrapApi, + MatrixRawEvent, + MatrixUiAuthCallback, +} from "./types.js"; import type { MatrixVerificationManager, MatrixVerificationRequestLike, @@ -10,6 +15,7 @@ import { LogService } from "./logger.js"; export type MatrixCryptoBootstrapperDeps = { getUserId: () => Promise; + getPassword?: () => string | undefined; getDeviceId: () => string | null | undefined; verificationManager: MatrixVerificationManager; recoveryKeyStore: MatrixRecoveryKeyStore; @@ -20,19 +26,117 @@ export class MatrixCryptoBootstrapper { constructor(private readonly deps: MatrixCryptoBootstrapperDeps) {} async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise { + await this.bootstrapSecretStorage(crypto); await this.bootstrapCrossSigning(crypto); await this.bootstrapSecretStorage(crypto); await this.ensureOwnDeviceTrust(crypto); this.registerVerificationRequestHandler(crypto); } + private createSigningKeysUiAuthCallback(params: { + userId: string; + password?: string; + }): MatrixUiAuthCallback { + return async (makeRequest: (authData: MatrixAuthDict | null) => Promise): Promise => { + try { + return await makeRequest(null); + } catch { + // Some homeservers require an explicit dummy UIA stage even when no user interaction is needed. + try { + return await makeRequest({ type: "m.login.dummy" }); + } catch { + if (!params.password?.trim()) { + throw new Error( + "Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback", + ); + } + return await makeRequest({ + type: "m.login.password", + identifier: { type: "m.id.user", user: params.userId }, + password: params.password, + }); + } + } + }; + } + private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise { + const userId = await this.deps.getUserId(); + const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({ + userId, + password: this.deps.getPassword?.(), + }); + const hasPublishedCrossSigningKeys = async (): Promise => { + if (typeof crypto.userHasCrossSigningKeys !== "function") { + return true; + } + try { + return await crypto.userHasCrossSigningKeys(userId, true); + } catch { + return false; + } + }; + const isCrossSigningReady = async (): Promise => { + if (typeof crypto.isCrossSigningReady !== "function") { + return true; + } + try { + return await crypto.isCrossSigningReady(); + } catch { + return false; + } + }; + + // First pass: preserve existing cross-signing identity and ensure public keys are uploaded. try { - await crypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); - LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + }); } catch (err) { - LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", err); + LogService.warn( + "MatrixClientLite", + "Initial cross-signing bootstrap failed, trying reset:", + err, + ); + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (resetErr) { + LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr); + return; + } } + + const firstPassReady = await isCrossSigningReady(); + const firstPassPublished = await hasPublishedCrossSigningKeys(); + if (firstPassReady && firstPassPublished) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return; + } + + // Fallback: recover from broken local/server state by creating a fresh identity. + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (err) { + LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err); + return; + } + + const finalReady = await isCrossSigningReady(); + const finalPublished = await hasPublishedCrossSigningKeys(); + if (finalReady && finalPublished) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return; + } + LogService.warn( + "MatrixClientLite", + "Cross-signing bootstrap finished but server keys are still not published", + ); } private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): Promise { diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts index 939134dd95..91ff58120e 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts @@ -135,4 +135,42 @@ describe("MatrixRecoveryKeyStore", () => { keyId: "NEW", }); }); + + it("recreates secret storage when default key exists but is not usable locally", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "RECOVERED", + keyInfo: { name: "recovered" }, + privateKey: new Uint8Array([1, 1, 2, 3]), + encodedPrivateKey: "encoded-recovered-key", + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "RECOVERED", + encodedPrivateKey: "encoded-recovered-key", + }); + }); }); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts index 023df41d0a..460218c2df 100644 --- a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -99,6 +99,9 @@ export class MatrixRecoveryKeyStore { } const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId); + const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some( + (valid) => valid === false, + ); let generatedRecoveryKey = false; const storedRecovery = this.loadStoredRecoveryKey(); let recoveryKey = storedRecovery @@ -137,6 +140,16 @@ export class MatrixRecoveryKeyStore { return recoveryKey; }; + const shouldRecreateSecretStorage = + !hasDefaultSecretStorageKey || + (!recoveryKey && status?.ready === false) || + hasKnownInvalidSecrets; + + if (hasKnownInvalidSecrets) { + // Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key. + recoveryKey = null; + } + const secretStorageOptions: { createSecretStorageKey?: () => Promise; setupNewSecretStorage?: boolean; @@ -145,7 +158,7 @@ export class MatrixRecoveryKeyStore { setupNewKeyBackup: false, }; - if (!hasDefaultSecretStorageKey) { + if (shouldRecreateSecretStorage) { secretStorageOptions.setupNewSecretStorage = true; secretStorageOptions.createSecretStorageKey = ensureRecoveryKey; } diff --git a/extensions/matrix/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts index e417d127b0..da5448f3ae 100644 --- a/extensions/matrix/src/matrix/sdk/types.ts +++ b/extensions/matrix/src/matrix/sdk/types.ts @@ -94,6 +94,7 @@ export type LocationMessageEventContent = MessageEventContent & { export type MatrixSecretStorageStatus = { ready: boolean; defaultKeyId: string | null; + secretStorageKeyValidityMap?: Record; }; export type MatrixGeneratedSecretStorageKey = { @@ -143,9 +144,18 @@ export type MatrixStoredRecoveryKey = { }; }; +export type MatrixAuthDict = Record; + +export type MatrixUiAuthCallback = ( + makeRequest: (authData: MatrixAuthDict | null) => Promise, +) => Promise; + export type MatrixCryptoBootstrapApi = { on: (eventName: string, listener: (...args: unknown[]) => void) => void; - bootstrapCrossSigning: (opts: { setupNewCrossSigning?: boolean }) => Promise; + bootstrapCrossSigning: (opts: { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?: MatrixUiAuthCallback; + }) => Promise; bootstrapSecretStorage: (opts?: { createSecretStorageKey?: () => Promise; setupNewSecretStorage?: boolean; @@ -169,4 +179,5 @@ export type MatrixCryptoBootstrapApi = { setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise; crossSignDevice?: (deviceId: string) => Promise; isCrossSigningReady?: () => Promise; + userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; }; diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 1b09bfdd77..746573f9bc 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -49,6 +49,7 @@ export async function resolveMatrixClient(opts: { homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, + password: auth.password, deviceId: auth.deviceId, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs,