extending SDK; adding automatic bot acc creation

This commit is contained in:
gustavo
2026-02-08 19:04:00 -05:00
parent cc47efd430
commit 668c8f76f3
16 changed files with 769 additions and 17 deletions

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<string>();
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<string | null> {
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<boolean> {
let runtime: ReturnType<typeof getMatrixRuntime> | 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<string, unknown> = {
...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();
}

View File

@@ -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,

View File

@@ -21,6 +21,7 @@ export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
password?: string;
deviceId?: string;
deviceName?: string;
initialSyncLimit?: number;

View File

@@ -104,6 +104,7 @@ type MatrixJsClientStub = EventEmitter & {
fetchRoomEvent: ReturnType<typeof vi.fn>;
sendTyping: ReturnType<typeof vi.fn>;
getRoom: ReturnType<typeof vi.fn>;
getRooms: ReturnType<typeof vi.fn>;
getCrypto: ReturnType<typeof vi.fn>;
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
};
@@ -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<string, unknown> | null = null;
vi.mock("matrix-js-sdk", () => ({
ClientEvent: { Event: "event" },
ClientEvent: { Event: "event", Room: "Room" },
MatrixEventEvent: { Decrypted: "decrypted" },
createClient: vi.fn((opts: Record<string, unknown>) => {
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 () => {

View File

@@ -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<MatrixRawEvent>({
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<void> {

View File

@@ -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<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
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<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as {
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
};
expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function");
const seenAuthStages: Array<Record<string, unknown> | 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<void>>()
.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<MatrixRawEvent>,
);
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 () => {});

View File

@@ -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<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
getPassword?: () => string | undefined;
getDeviceId: () => string | null | undefined;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
@@ -20,19 +26,117 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise<void> {
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 <T>(makeRequest: (authData: MatrixAuthDict | null) => Promise<T>): Promise<T> => {
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<void> {
const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
userId,
password: this.deps.getPassword?.(),
});
const hasPublishedCrossSigningKeys = async (): Promise<boolean> => {
if (typeof crypto.userHasCrossSigningKeys !== "function") {
return true;
}
try {
return await crypto.userHasCrossSigningKeys(userId, true);
} catch {
return false;
}
};
const isCrossSigningReady = async (): Promise<boolean> => {
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<void> {

View File

@@ -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<unknown> }) => {
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",
});
});
});

View File

@@ -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<MatrixGeneratedSecretStorageKey>;
setupNewSecretStorage?: boolean;
@@ -145,7 +158,7 @@ export class MatrixRecoveryKeyStore {
setupNewKeyBackup: false,
};
if (!hasDefaultSecretStorageKey) {
if (shouldRecreateSecretStorage) {
secretStorageOptions.setupNewSecretStorage = true;
secretStorageOptions.createSecretStorageKey = ensureRecoveryKey;
}

View File

@@ -94,6 +94,7 @@ export type LocationMessageEventContent = MessageEventContent & {
export type MatrixSecretStorageStatus = {
ready: boolean;
defaultKeyId: string | null;
secretStorageKeyValidityMap?: Record<string, boolean>;
};
export type MatrixGeneratedSecretStorageKey = {
@@ -143,9 +144,18 @@ export type MatrixStoredRecoveryKey = {
};
};
export type MatrixAuthDict = Record<string, unknown>;
export type MatrixUiAuthCallback = <T>(
makeRequest: (authData: MatrixAuthDict | null) => Promise<T>,
) => Promise<T>;
export type MatrixCryptoBootstrapApi = {
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
bootstrapCrossSigning: (opts: { setupNewCrossSigning?: boolean }) => Promise<void>;
bootstrapCrossSigning: (opts: {
setupNewCrossSigning?: boolean;
authUploadDeviceSigningKeys?: MatrixUiAuthCallback;
}) => Promise<void>;
bootstrapSecretStorage: (opts?: {
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
setupNewSecretStorage?: boolean;
@@ -169,4 +179,5 @@ export type MatrixCryptoBootstrapApi = {
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
crossSignDevice?: (deviceId: string) => Promise<void>;
isCrossSigningReady?: () => Promise<boolean>;
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
};

View File

@@ -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,