fix(matrix): fix E2EE SSSS bootstrap for passwordless token-auth bots (#66228)

Merged via squash.

Prepared head SHA: c62cebf7c3
Co-authored-by: SARAMALI15792 <140950904+SARAMALI15792@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
saram ali
2026-04-15 20:48:29 +05:00
committed by GitHub
parent 568df95736
commit b2753fd0de
6 changed files with 114 additions and 19 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
## 2026.4.15-beta.1

View File

@@ -613,7 +613,8 @@ if you want a shorter or longer retry window.
Startup also performs a conservative crypto bootstrap pass automatically.
That pass tries to reuse the current secret storage and cross-signing identity first, and avoids resetting cross-signing unless you run an explicit bootstrap repair flow.
If startup finds broken bootstrap state and `channels.matrix.password` is configured, OpenClaw can attempt a stricter repair path.
If startup still finds broken bootstrap state, OpenClaw can attempt a guarded repair path even when `channels.matrix.password` is not configured.
If the homeserver requires password-based UIA for that repair, OpenClaw logs a warning and keeps startup non-fatal instead of aborting the bot.
If the current device is already owner-signed, OpenClaw preserves that identity instead of resetting it automatically.
See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages.

View File

@@ -1283,16 +1283,24 @@ describe("MatrixClient crypto bootstrapping", () => {
});
});
it("does not force-reset bootstrap when password is unavailable", async () => {
it("attempts repair bootstrap even when no password is configured", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
// no password — passwordless token-auth bot
});
const bootstrapSpy = vi.fn().mockResolvedValue({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: false,
});
const bootstrapSpy = vi
.fn()
.mockResolvedValueOnce({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: false,
})
.mockResolvedValueOnce({
crossSigningReady: true,
crossSigningPublished: true,
ownDeviceVerified: true,
});
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
@@ -1306,7 +1314,45 @@ describe("MatrixClient crypto bootstrapping", () => {
await client.start();
expect(bootstrapSpy).toHaveBeenCalledTimes(1);
expect(bootstrapSpy).toHaveBeenCalledTimes(2);
expect((bootstrapSpy.mock.calls as unknown[][])[1]?.[1] ?? {}).toEqual({
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
strict: true,
});
});
it("catches and logs repair bootstrap failure when UIA is unavailable without password", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
// no password
});
const uiaError = new Error("Interactive auth required");
const bootstrapSpy = vi
.fn()
.mockResolvedValueOnce({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: false,
})
.mockRejectedValueOnce(uiaError);
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
}
).ensureCryptoSupportInitialized();
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };
}
).cryptoBootstrapper.bootstrap = bootstrapSpy;
// start() must NOT throw even when the repair bootstrap fails
await expect(client.start()).resolves.not.toThrow();
// repair was attempted
expect(bootstrapSpy).toHaveBeenCalledTimes(2);
});
it("provides secret storage callbacks and resolves stored recovery key", async () => {

View File

@@ -606,7 +606,9 @@ export class MatrixClient {
"MatrixClientLite",
"Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.",
);
} else if (this.password?.trim()) {
} else {
// No password guard: passwordless token-auth bots should still attempt repair.
// UIA failures inside bootstrap() are caught below and logged as warnings.
try {
// The repair path already force-resets cross-signing; allow secret storage
// recreation so the new keys can be persisted. Without this, a device that
@@ -630,11 +632,6 @@ export class MatrixClient {
err,
);
}
} else {
LogService.warn(
"MatrixClientLite",
"Cross-signing/bootstrap incomplete and no password is configured for UIA fallback",
);
}
}
this.cryptoBootstrapped = true;

View File

@@ -277,6 +277,49 @@ describe("MatrixCryptoBootstrapper", () => {
expectSecretStorageRepairRetry(deps, crypto, bootstrapCrossSigning);
});
it("does not mutate secret storage before forced repair fails on password UIA without a password", async () => {
const deps = createBootstrapperDeps();
deps.getPassword = vi.fn(() => undefined);
const bootstrapCrossSigning = vi.fn<
({
authUploadDeviceSigningKeys,
}: {
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
}) => Promise<void>
>(async ({ authUploadDeviceSigningKeys }) => {
await authUploadDeviceSigningKeys?.(async (authData) => {
if (authData === null) {
throw new Error("need auth");
}
if (authData.type === "m.login.dummy") {
throw new Error("dummy rejected");
}
return undefined;
});
});
const crypto = createCryptoApi({
bootstrapCrossSigning,
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await expect(
bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
}),
).rejects.toThrow(
"Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback",
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -47,14 +47,18 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options: MatrixCryptoBootstrapOptions = {},
): Promise<MatrixCryptoBootstrapResult> {
const strict = options.strict === true;
const deferSecretStorageBootstrapUntilAfterCrossSigning =
options.forceResetCrossSigning === true;
// Register verification listeners before expensive bootstrap work so incoming requests
// are not missed during startup.
this.registerVerificationRequestHandler(crypto);
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
if (!deferSecretStorageBootstrapUntilAfterCrossSigning) {
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
}
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
@@ -62,6 +66,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
strict,
});
// Forced repair may need password UIA to upload new cross-signing keys. Delay any
// secret-storage repair/recreation until after that step succeeds so passwordless bots do
// not partially mutate SSSS on homeservers that require password-based UIA.
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey: