mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user