Require full Matrix identity trust (#70401)

Merged via squash.

Prepared head SHA: d13a729681
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-24 17:58:57 -04:00
committed by GitHub
parent 0cce4cf8f6
commit 72731a37d2
26 changed files with 4917 additions and 218 deletions

View File

@@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
## 2026.4.24
### Breaking
@@ -218,6 +224,7 @@ Docs: https://docs.openclaw.ai
- Codex harness/hooks: fire `llm_input`, `llm_output`, and `agent_end` for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.
- QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus.
- Status: add an explicit `Runner:` field to `/status` so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as `codex (acp/acpx)` or `gemini (acp/acpx)`. (#70595)
- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
### Fixes

View File

@@ -310,16 +310,127 @@ Enable encryption:
Verification commands (all take `--verbose` for diagnostics and `--json` for machine-readable output):
| Command | Purpose |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `openclaw matrix verify status` | Check cross-signing and device verification state |
| `openclaw matrix verify status --include-recovery-key --json` | Include the stored recovery key |
| `openclaw matrix verify bootstrap` | Bootstrap cross-signing and verification (see below) |
| `openclaw matrix verify bootstrap --force-reset-cross-signing` | Discard the current cross-signing identity and create a new one |
| `openclaw matrix verify device "<recovery-key>"` | Verify this device with a recovery key |
| `openclaw matrix verify backup status` | Check room-key backup health |
| `openclaw matrix verify backup restore` | Restore room keys from server backup |
| `openclaw matrix verify backup reset --yes` | Delete the current backup and create a fresh baseline (may recreate secret storage) |
```bash
openclaw matrix verify status
```
Verbose status (full diagnostics):
```bash
openclaw matrix verify status --verbose
```
Include the stored recovery key in machine-readable output:
```bash
openclaw matrix verify status --include-recovery-key --json
```
Bootstrap cross-signing and verification state:
```bash
openclaw matrix verify bootstrap
```
Verbose bootstrap diagnostics:
```bash
openclaw matrix verify bootstrap --verbose
```
Force a fresh cross-signing identity reset before bootstrapping:
```bash
openclaw matrix verify bootstrap --force-reset-cross-signing
```
Verify this device with a recovery key:
```bash
openclaw matrix verify device "<your-recovery-key>"
```
This command reports three separate states:
- `Recovery key accepted`: Matrix accepted the recovery key for secret storage or device trust.
- `Backup usable`: room-key backup can be loaded with trusted recovery material.
- `Device verified by owner`: the current OpenClaw device has full Matrix cross-signing identity trust.
`Signed by owner` in verbose or JSON output is diagnostic only. OpenClaw does not
treat that as sufficient unless `Cross-signing verified` is also `yes`.
The command still exits non-zero when full Matrix identity trust is incomplete,
even if the recovery key can unlock backup material. In that case, complete
self-verification from another Matrix client:
```bash
openclaw matrix verify self
```
Accept the request in another Matrix client, compare the SAS emoji or decimals,
and type `yes` only when they match. The command waits for Matrix to report
`Cross-signing verified: yes` before it exits successfully.
Use `verify bootstrap --force-reset-cross-signing` only when you intentionally
want to replace the current cross-signing identity.
Verbose device verification details:
```bash
openclaw matrix verify device "<your-recovery-key>" --verbose
```
Check room-key backup health:
```bash
openclaw matrix verify backup status
```
Verbose backup health diagnostics:
```bash
openclaw matrix verify backup status --verbose
```
Restore room keys from server backup:
```bash
openclaw matrix verify backup restore
```
Interactive self-verification flow:
```bash
openclaw matrix verify self
```
For lower-level or inbound verification requests, use:
```bash
openclaw matrix verify accept <id>
openclaw matrix verify start <id>
openclaw matrix verify sas <id>
openclaw matrix verify confirm-sas <id>
```
Use `openclaw matrix verify cancel <id>` to cancel a request.
Verbose restore diagnostics:
```bash
openclaw matrix verify backup restore --verbose
```
Delete the current server backup and create a fresh backup baseline. If the stored
backup key cannot be loaded cleanly, this reset can also recreate secret storage so
future cold starts can load the new backup key:
```bash
openclaw matrix verify backup reset --yes
```
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
Use `--json` for full machine-readable output when scripting.
In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account <id>`.
If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly.
@@ -341,7 +452,9 @@ When encryption is disabled or unavailable for a named account, Matrix warnings
- `Cross-signing verified`: the SDK reports verification via cross-signing
- `Signed by owner`: signed by your own self-signing key
`Verified by owner` becomes `yes` only when cross-signing or owner-signing is present. Local trust alone is not enough.
`Verified by owner` becomes `yes` only when cross-signing verification is present.
Local trust or an owner signature by itself is not enough for OpenClaw to treat
the device as fully verified.
</Accordion>

View File

@@ -105,6 +105,17 @@ If your old installation had local-only encrypted history that was never backed
openclaw matrix verify device "<your-recovery-key>"
```
If the recovery key is accepted and backup is usable, but `Cross-signing verified`
is still `no`, complete self-verification from another Matrix client:
```bash
openclaw matrix verify self
```
Accept the request in another Matrix client, compare the emoji or decimals,
and type `yes` only when they match. The command exits successfully only
after `Cross-signing verified` becomes `yes`.
7. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run:
```bash
@@ -293,10 +304,17 @@ new backup key can load correctly after restart.
- Meaning: the provided key could not be parsed or did not match the expected format.
- What to do: retry with the exact recovery key from your Matrix client or recovery-key file.
`Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.`
`Matrix recovery key was applied, but this device still lacks full Matrix identity trust.`
- Meaning: the key was applied, but the device still could not complete verification.
- What to do: confirm you used the correct key and that cross-signing is available on the account, then retry.
- Meaning: OpenClaw could apply the recovery key, but Matrix still has not
established full cross-signing identity trust for this device. Check the
command output for `Recovery key accepted`, `Backup usable`,
`Cross-signing verified`, and `Device verified by owner`.
- What to do: run `openclaw matrix verify self`, accept the request in another
Matrix client, compare the SAS, and type `yes` only when it matches. The
command waits for full Matrix identity trust before reporting success. Use
`openclaw matrix verify bootstrap --recovery-key "<your-recovery-key>" --force-reset-cross-signing`
only when you intentionally want to replace the current cross-signing identity.
`Matrix key backup is not active on this device after loading from secret storage.`

View File

@@ -4,10 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerMatrixCli, resetMatrixCliStateForTests } from "./cli.js";
const bootstrapMatrixVerificationMock = vi.fn();
const acceptMatrixVerificationMock = vi.fn();
const cancelMatrixVerificationMock = vi.fn();
const confirmMatrixVerificationSasMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
const getMatrixVerificationSasMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const listMatrixOwnDevicesMock = vi.fn();
const listMatrixVerificationsMock = vi.fn();
const mismatchMatrixVerificationSasMock = vi.fn();
const pruneMatrixStaleGatewayDevicesMock = vi.fn();
const requestMatrixVerificationMock = vi.fn();
const resolveMatrixAccountConfigMock = vi.fn();
const resolveMatrixAccountMock = vi.fn();
const resolveMatrixAuthContextMock = vi.fn();
@@ -17,19 +24,31 @@ const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const resetMatrixRoomKeyBackupMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const runMatrixSelfVerificationMock = vi.fn();
const setMatrixSdkConsoleLoggingMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
const startMatrixVerificationMock = vi.fn();
const updateMatrixOwnProfileMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
const consoleLogMock = vi.fn();
const consoleErrorMock = vi.fn();
const stdoutWriteMock = vi.fn();
vi.mock("./matrix/actions/verification.js", () => ({
acceptMatrixVerification: (...args: unknown[]) => acceptMatrixVerificationMock(...args),
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
cancelMatrixVerification: (...args: unknown[]) => cancelMatrixVerificationMock(...args),
confirmMatrixVerificationSas: (...args: unknown[]) => confirmMatrixVerificationSasMock(...args),
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
getMatrixVerificationSas: (...args: unknown[]) => getMatrixVerificationSasMock(...args),
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
listMatrixVerifications: (...args: unknown[]) => listMatrixVerificationsMock(...args),
mismatchMatrixVerificationSas: (...args: unknown[]) => mismatchMatrixVerificationSasMock(...args),
requestMatrixVerification: (...args: unknown[]) => requestMatrixVerificationMock(...args),
resetMatrixRoomKeyBackup: (...args: unknown[]) => resetMatrixRoomKeyBackupMock(...args),
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
runMatrixSelfVerification: (...args: unknown[]) => runMatrixSelfVerificationMock(...args),
startMatrixVerification: (...args: unknown[]) => startMatrixVerificationMock(...args),
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
@@ -110,6 +129,27 @@ function mockMatrixVerificationStatus(params: {
});
}
function mockMatrixVerificationSummary(overrides: Record<string, unknown> = {}) {
return {
id: "self-1",
transactionId: "txn-1",
otherUserId: "@bot:example.org",
otherDeviceId: "PHONE123",
isSelfVerification: true,
initiatedByMe: true,
phaseName: "started",
pending: true,
methods: ["m.sas.v1"],
chosenMethod: "m.sas.v1",
hasSas: true,
sas: {
decimal: [1234, 5678, 9012],
},
completed: false,
...overrides,
};
}
describe("matrix CLI verification commands", () => {
beforeEach(() => {
resetMatrixCliStateForTests();
@@ -119,8 +159,13 @@ describe("matrix CLI verification commands", () => {
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) =>
consoleErrorMock(...args),
);
vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => {
stdoutWriteMock(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
}) as typeof process.stdout.write);
consoleLogMock.mockReset();
consoleErrorMock.mockReset();
stdoutWriteMock.mockReset();
matrixSetupValidateInputMock.mockReturnValue(null);
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
matrixRuntimeLoadConfigMock.mockReturnValue({});
@@ -200,6 +245,466 @@ describe("matrix CLI verification commands", () => {
expect(process.exitCode).toBe(1);
});
it("prints recovery-key and identity-trust diagnostics for device verification failures", async () => {
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: false,
error:
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "7",
backup: {
serverVersion: "7",
activeVersion: "7",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: true,
keyLoadError: null,
},
verified: false,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyAccepted: true,
backupUsable: true,
deviceOwnerVerified: false,
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "device", "valid-key"], {
from: "user",
});
expect(process.exitCode).toBe(1);
expect(consoleErrorMock).toHaveBeenCalledWith(
"Verification failed: Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
);
expect(consoleLogMock).toHaveBeenCalledWith("Recovery key accepted: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Backup usable: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: no");
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run openclaw matrix verify self and follow the prompts from another Matrix client.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- If you intend to replace the current cross-signing identity, run openclaw matrix verify bootstrap --recovery-key '<key>' --force-reset-cross-signing.",
);
});
it("runs interactive Matrix self-verification in one CLI flow", async () => {
runMatrixSelfVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
backup: {
activeVersion: "1",
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
matchesDecryptionKey: true,
serverVersion: "1",
trusted: true,
},
backupVersion: "1",
crossSigningVerified: true,
deviceId: "DEVICE123",
localVerified: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
recoveryKeyStored: true,
signedByOwner: true,
userId: "@bot:example.org",
verified: true,
},
pending: false,
phaseName: "done",
}),
);
const program = buildProgram();
await program.parseAsync(
["matrix", "verify", "self", "--account", "ops", "--timeout-ms", "5000"],
{
from: "user",
},
);
expect(runMatrixSelfVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: {},
timeoutMs: 5000,
onRequested: expect.any(Function),
onReady: expect.any(Function),
onSas: expect.any(Function),
confirmSas: expect.any(Function),
});
expect(consoleLogMock).toHaveBeenCalledWith("Self-verification complete.");
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Cross-signing verified: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Signed by owner: yes");
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
});
it("requests Matrix self-verification and prints the follow-up SAS commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "self-verify-1",
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
from: "user",
});
expect(requestMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "ops",
cfg: {},
ownUser: true,
userId: undefined,
deviceId: undefined,
roomId: undefined,
});
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: self-verify-1");
expect(consoleLogMock).toHaveBeenCalledWith("Transaction id: txn-1");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Accept the verification request in another Matrix client for this account.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --account ops -- txn-1 to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --account ops -- txn-1 to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- txn-1.",
);
});
it("prints DM lookup details in Matrix verification follow-up commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "dm-verify-1",
transactionId: "txn-dm",
roomId: "!room-'$(x):example.org",
otherUserId: "@alice:example.org",
isSelfVerification: false,
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"verify",
"request",
"--user-id",
"@alice:example.org",
"--room-id",
"!room-'$(x):example.org",
],
{ from: "user" },
);
expect(requestMatrixVerificationMock).toHaveBeenCalledWith({
accountId: "default",
cfg: {},
ownUser: undefined,
userId: "@alice:example.org",
deviceId: undefined,
roomId: "!room-'$(x):example.org",
});
expect(consoleLogMock).toHaveBeenCalledWith("Room id: !room-'$(x):example.org");
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm.",
);
});
it("terminates options before remote Matrix verification ids in follow-up commands", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "local-id",
transactionId: "--account=evil",
hasSas: false,
sas: undefined,
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
from: "user",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start --account ops -- --account=evil to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas --account ops -- --account=evil to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- --account=evil.",
);
});
it("rejects ambiguous Matrix verification request targets", async () => {
const program = buildProgram();
await program.parseAsync(
["matrix", "verify", "request", "--own-user", "--user-id", "@other:example.org"],
{ from: "user" },
);
expect(process.exitCode).toBe(1);
expect(requestMatrixVerificationMock).not.toHaveBeenCalled();
expect(consoleErrorMock).toHaveBeenCalledWith(
"Verification request failed: --own-user cannot be combined with --user-id, --device-id, or --room-id",
);
});
it("lists Matrix verification requests", async () => {
listMatrixVerificationsMock.mockResolvedValue([
mockMatrixVerificationSummary({ id: "incoming-1", initiatedByMe: false }),
]);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "list"], { from: "user" });
expect(listMatrixVerificationsMock).toHaveBeenCalledWith({ accountId: "default", cfg: {} });
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: incoming-1");
expect(consoleLogMock).toHaveBeenCalledWith("Initiated by OpenClaw: no");
});
it("sanitizes remote Matrix verification metadata before printing it", async () => {
listMatrixVerificationsMock.mockResolvedValue([
mockMatrixVerificationSummary({
id: "self-\u001B[31m1",
transactionId: "txn-\n\u009B31m1",
otherUserId: "@bot\u001B[2J\u009Dspoof\u0007:example.org",
otherDeviceId: "PHONE\r\u009B2J123",
phaseName: "started\u001B[0m",
methods: ["m.sas.v1\n\u009B31mspoof"],
chosenMethod: "m.sas.v1\u001B[1m",
sas: {
emoji: [
["🐶", "Dog\u001B[31m\u009B2J"],
["🐱", "Cat\n\u009B31mspoof"],
],
},
error: "Remote\u001B[31m cancelled\n\u009B31mforged",
}),
]);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "list"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: self-1");
expect(consoleLogMock).toHaveBeenCalledWith("Transaction id: txn-1");
expect(consoleLogMock).toHaveBeenCalledWith("Other user: @bot:example.org");
expect(consoleLogMock).toHaveBeenCalledWith("Other device: PHONE123");
expect(consoleLogMock).toHaveBeenCalledWith("Phase: started");
expect(consoleLogMock).toHaveBeenCalledWith("Methods: m.sas.v1spoof");
expect(consoleLogMock).toHaveBeenCalledWith("Chosen method: m.sas.v1");
expect(consoleLogMock).toHaveBeenCalledWith("SAS emoji: 🐶 Dog | 🐱 Catspoof");
expect(consoleLogMock).toHaveBeenCalledWith("Verification error: Remote cancelledforged");
});
it("sanitizes remote Matrix status metadata before printing diagnostics", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
userId: "@bot\u001B[2J:example.org",
deviceId: "PHONE\r\u009B2J123",
backupVersion: "1\u001B[31m",
backup: {
serverVersion: "2\u001B[31m",
activeVersion: "1\u009B2J",
trusted: false,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: "Remote\n\u009B31mforged",
},
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith("User: @bot:example.org");
expect(consoleLogMock).toHaveBeenCalledWith("Device: PHONE123");
expect(consoleLogMock).toHaveBeenCalledWith("Backup server version: 2");
expect(consoleLogMock).toHaveBeenCalledWith("Backup active on this device: 1");
expect(consoleLogMock).toHaveBeenCalledWith("Backup key load error: Remoteforged");
});
it("shell-quotes Matrix verification ids in follow-up command guidance", async () => {
requestMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "self-verify-1",
transactionId: "txn-'$(touch /tmp/pwn)",
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "request", "--own-user"], {
from: "user",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- Then run openclaw matrix verify start -- 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify sas -- 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- When the SAS matches, run openclaw matrix verify confirm-sas -- 'txn-'\\''$(touch /tmp/pwn)'.",
);
});
it("shows Matrix SAS diagnostics and confirm/mismatch guidance", async () => {
getMatrixVerificationSasMock.mockResolvedValue({
decimal: [1234, 5678, 9012],
});
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "sas", "self-1"], { from: "user" });
expect(getMatrixVerificationSasMock).toHaveBeenCalledWith("self-1", {
accountId: "default",
cfg: {},
});
expect(consoleLogMock).toHaveBeenCalledWith("SAS decimals: 1234 5678 9012");
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they match, run openclaw matrix verify confirm-sas -- self-1.",
);
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they do not match, run openclaw matrix verify mismatch-sas -- self-1.",
);
});
it("passes DM lookup details through Matrix verification follow-up commands", async () => {
startMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "dm-verify-1",
transactionId: "txn-dm",
roomId: "!dm:example.org",
otherUserId: "@alice:example.org",
}),
);
const program = buildProgram();
await program.parseAsync(
[
"matrix",
"verify",
"start",
"txn-dm",
"--user-id",
"@alice:example.org",
"--room-id",
"!dm:example.org",
"--account",
"ops",
],
{ from: "user" },
);
expect(startMatrixVerificationMock).toHaveBeenCalledWith("txn-dm", {
accountId: "ops",
cfg: {},
method: "sas",
verificationDmUserId: "@alice:example.org",
verificationDmRoomId: "!dm:example.org",
});
expect(consoleLogMock).toHaveBeenCalledWith(
"- If they match, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!dm:example.org' --account ops -- txn-dm.",
);
});
it("prints stable transaction ids in follow-up commands after accepting verification", async () => {
acceptMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({
id: "verification-1",
transactionId: "txn-stable",
}),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "accept", "verification-1"], { from: "user" });
expect(consoleLogMock).toHaveBeenCalledWith(
"- Run openclaw matrix verify start -- txn-stable to start SAS verification.",
);
});
it("confirms, rejects, accepts, starts, and cancels Matrix verification requests", async () => {
acceptMatrixVerificationMock.mockResolvedValue(mockMatrixVerificationSummary({ id: "in-1" }));
startMatrixVerificationMock.mockResolvedValue(mockMatrixVerificationSummary({ id: "in-1" }));
confirmMatrixVerificationSasMock.mockResolvedValue(
mockMatrixVerificationSummary({ id: "in-1", completed: true, pending: false }),
);
mismatchMatrixVerificationSasMock.mockResolvedValue(
mockMatrixVerificationSummary({ id: "in-1", phaseName: "cancelled", pending: false }),
);
cancelMatrixVerificationMock.mockResolvedValue(
mockMatrixVerificationSummary({ id: "in-1", phaseName: "cancelled", pending: false }),
);
const program = buildProgram();
await program.parseAsync(["matrix", "verify", "accept", "in-1"], { from: "user" });
await program.parseAsync(["matrix", "verify", "start", "in-1"], { from: "user" });
await program.parseAsync(["matrix", "verify", "confirm-sas", "in-1"], { from: "user" });
await program.parseAsync(["matrix", "verify", "mismatch-sas", "in-1"], { from: "user" });
await program.parseAsync(
["matrix", "verify", "cancel", "in-1", "--reason", "changed my mind"],
{ from: "user" },
);
expect(acceptMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
});
expect(startMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
method: "sas",
});
expect(confirmMatrixVerificationSasMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
});
expect(mismatchMatrixVerificationSasMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
});
expect(cancelMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
accountId: "default",
cfg: {},
reason: "changed my mind",
code: undefined,
});
});
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
bootstrapMatrixVerificationMock.mockResolvedValue({
success: false,
@@ -342,9 +847,9 @@ describe("matrix CLI verification commands", () => {
it("lists matrix devices", async () => {
listMatrixOwnDevicesMock.mockResolvedValue([
{
deviceId: "A7hWrQ70ea",
displayName: "OpenClaw Gateway",
lastSeenIp: "127.0.0.1",
deviceId: "A7hWr\u001B[31mQ70ea",
displayName: "OpenClaw\u001B[2J Gateway",
lastSeenIp: "127.0.0.1\u009B2J",
lastSeenTs: 1_741_507_200_000,
current: true,
},
@@ -360,7 +865,7 @@ describe("matrix CLI verification commands", () => {
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe", cfg: {} });
expect(console.log).toHaveBeenCalledWith("Account: poe");
expect(console.log).toHaveBeenCalledWith("- A7hWrQ70ea (current, OpenClaw Gateway)");
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
@@ -535,7 +1040,7 @@ describe("matrix CLI verification commands", () => {
);
expect(console.log).toHaveBeenCalledWith("Backup version: 7");
expect(console.log).toHaveBeenCalledWith(
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run 'openclaw matrix devices prune-stale --account ops'.",
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run openclaw matrix devices prune-stale --account ops.",
);
});
@@ -630,7 +1135,7 @@ describe("matrix CLI verification commands", () => {
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
const jsonOutput = consoleLogMock.mock.calls.at(-1)?.[0];
const jsonOutput = stdoutWriteMock.mock.calls.at(-1)?.[0];
expect(typeof jsonOutput).toBe("string");
expect(JSON.parse(String(jsonOutput))).toEqual(
expect.objectContaining({
@@ -765,7 +1270,7 @@ describe("matrix CLI verification commands", () => {
});
expect(process.exitCode).toBe(1);
expect(console.log).toHaveBeenCalledWith(
expect(stdoutWriteMock).toHaveBeenCalledWith(
expect.stringContaining('"error": "Matrix requires --homeserver"'),
);
});
@@ -926,7 +1431,7 @@ describe("matrix CLI verification commands", () => {
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
);
expect(console.log).toHaveBeenCalledWith(
"- Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.",
"- Backup key is not loaded on this device. Run openclaw matrix verify backup restore to load it and restore old room keys.",
);
expect(console.log).not.toHaveBeenCalledWith(
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device <key>'.",
@@ -993,7 +1498,7 @@ describe("matrix CLI verification commands", () => {
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'. This may also repair secret storage so the new backup key can be loaded after restart.",
"- If you want a fresh backup baseline and accept losing unrecoverable history, run openclaw matrix verify backup reset --yes. This may also repair secret storage so the new backup key can be loaded after restart.",
);
});
@@ -1071,10 +1576,10 @@ describe("matrix CLI verification commands", () => {
});
expect(console.log).toHaveBeenCalledWith("Account: assistant");
expect(console.log).toHaveBeenCalledWith(
"- Run 'openclaw matrix verify device <key> --account assistant' to verify this device.",
"- Run openclaw matrix verify device '<key>' --account assistant to verify this device.",
);
expect(console.log).toHaveBeenCalledWith(
"- Run 'openclaw matrix verify bootstrap --account assistant' to create a room key backup.",
"- Run openclaw matrix verify bootstrap --account assistant to create a room key backup.",
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,8 @@ let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifi
let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus;
let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus;
let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus;
let runMatrixSelfVerification: typeof import("./verification.js").runMatrixSelfVerification;
let startMatrixVerification: typeof import("./verification.js").startMatrixVerification;
describe("matrix verification actions", () => {
beforeAll(async () => {
@@ -43,6 +45,8 @@ describe("matrix verification actions", () => {
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
listMatrixVerifications,
runMatrixSelfVerification,
startMatrixVerification,
} = await import("./verification.js"));
});
@@ -55,6 +59,50 @@ describe("matrix verification actions", () => {
});
});
function mockVerifiedOwnerStatus() {
return {
backup: {
activeVersion: "1",
decryptionKeyCached: true,
keyLoadAttempted: false,
keyLoadError: null,
matchesDecryptionKey: true,
serverVersion: "1",
trusted: true,
},
backupVersion: "1",
crossSigningVerified: true,
deviceId: "DEVICE123",
localVerified: true,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
recoveryKeyStored: false,
signedByOwner: true,
userId: "@bot:example.org",
verified: true,
};
}
function mockUnverifiedOwnerStatus() {
return {
...mockVerifiedOwnerStatus(),
crossSigningVerified: false,
localVerified: false,
signedByOwner: false,
verified: false,
};
}
function mockCrossSigningPublicationStatus(published = true) {
return {
masterKeyPublished: published,
published,
selfSigningKeyPublished: published,
userId: "@bot:example.org",
userSigningKeyPublished: published,
};
}
it("points encryption guidance at the selected Matrix account", async () => {
loadConfigMock.mockReturnValue({
channels: {
@@ -213,4 +261,609 @@ describe("matrix verification actions", () => {
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2);
expect(withStartedActionClientMock).not.toHaveBeenCalled();
});
it("rehydrates DM verification requests before follow-up actions", async () => {
const tracked = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "txn-dm",
};
const started = {
...tracked,
chosenMethod: "m.sas.v1",
phaseName: "started",
};
const crypto = {
ensureVerificationDmTracked: vi.fn(async () => tracked),
startVerification: vi.fn(async () => started),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
startMatrixVerification("txn-dm", {
verificationDmRoomId: "!dm:example.org",
verificationDmUserId: "@alice:example.org",
}),
).resolves.toMatchObject({
id: "verification-1",
phaseName: "started",
});
expect(crypto.ensureVerificationDmTracked).toHaveBeenCalledWith({
roomId: "!dm:example.org",
userId: "@alice:example.org",
});
expect(crypto.startVerification).toHaveBeenCalledWith("txn-dm", "sas");
});
it("requires complete DM lookup details for verification follow-up actions", async () => {
const crypto = {
ensureVerificationDmTracked: vi.fn(),
startVerification: vi.fn(),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
startMatrixVerification("txn-dm", {
verificationDmRoomId: "!dm:example.org",
}),
).rejects.toThrow("--user-id and --room-id must be provided together");
expect(crypto.ensureVerificationDmTracked).not.toHaveBeenCalled();
expect(crypto.startVerification).not.toHaveBeenCalled();
});
it("keeps self-verification in one started Matrix client session", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const ready = {
...requested,
phaseName: "ready",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
emoji: [["🐶", "Dog"]],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const listVerifications = vi
.fn()
.mockResolvedValueOnce([ready])
.mockResolvedValueOnce([completed]);
const crypto = {
confirmVerificationSas: vi.fn(async () => sas),
listVerifications,
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const confirmSas = vi.fn(async () => true);
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceVerificationStatus,
});
});
await expect(runMatrixSelfVerification({ confirmSas, timeoutMs: 500 })).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
id: "verification-1",
ownerVerification: {
verified: true,
},
});
expect(withStartedActionClientMock).toHaveBeenCalledTimes(1);
expect(crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
expect(crypto.startVerification).toHaveBeenCalledWith("verification-1", "sas");
expect(confirmSas).toHaveBeenCalledWith(sas.sas, sas);
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-1");
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(getOwnCrossSigningPublicationStatus).not.toHaveBeenCalled();
expect(getOwnDeviceVerificationStatus).not.toHaveBeenCalled();
});
it("does not complete self-verification until the OpenClaw device has full Matrix identity trust", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi
.fn()
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
mockCrossSigningPublicationStatus(),
);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockUnverifiedOwnerStatus(),
}));
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
verified: true,
},
});
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
});
it("does not complete self-verification until cross-signing keys are published", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
const getOwnCrossSigningPublicationStatus = vi
.fn()
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(false))
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(true));
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(false),
success: false,
verification: mockVerifiedOwnerStatus(),
}));
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus,
getOwnDeviceIdentityVerificationStatus,
getOwnDeviceVerificationStatus,
trustOwnIdentityAfterSelfVerification,
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
ownerVerification: {
verified: true,
},
});
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
});
it("waits for SAS data without restarting an already-started self-verification", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const started = {
...requested,
phaseName: "started",
};
const sas = {
...started,
hasSas: true,
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn().mockResolvedValueOnce([started]).mockResolvedValueOnce([sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(),
};
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
});
expect(crypto.startVerification).not.toHaveBeenCalled();
});
it("fails immediately when an already-started self-verification uses a non-SAS method", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const started = {
...requested,
chosenMethod: "m.reciprocate.v1",
phaseName: "started",
};
const cancelled = {
...started,
phaseName: "cancelled",
};
const crypto = {
cancelVerification: vi.fn(async () => cancelled),
listVerifications: vi.fn(async () => [started]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).rejects.toThrow(
"Matrix self-verification started without SAS while waiting to show SAS emoji or decimals (method: m.reciprocate.v1)",
);
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
expect(crypto.startVerification).not.toHaveBeenCalled();
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
it("finalizes completed non-SAS self-verification without waiting for SAS", async () => {
const completed = {
completed: true,
hasSas: false,
id: "verification-1",
phaseName: "done",
transactionId: "tx-self",
};
const crypto = {
confirmVerificationSas: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(async () => completed),
startVerification: vi.fn(),
};
const confirmSas = vi.fn(async () => true);
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: true,
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
await expect(runMatrixSelfVerification({ confirmSas, timeoutMs: 500 })).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
id: "verification-1",
});
expect(crypto.listVerifications).not.toHaveBeenCalled();
expect(crypto.startVerification).not.toHaveBeenCalled();
expect(crypto.confirmVerificationSas).not.toHaveBeenCalled();
expect(confirmSas).not.toHaveBeenCalled();
});
it("allows completed self-verification when only backup health remains degraded", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(),
success: false,
error: "Matrix room key backup is not trusted by this device",
verification: mockVerifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).resolves.toMatchObject({
completed: true,
deviceOwnerVerified: true,
});
});
it("fails self-verification if SAS completes but full identity trust cannot be established", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const sas = {
...requested,
hasSas: true,
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
};
const completed = {
...sas,
completed: true,
phaseName: "done",
};
const crypto = {
cancelVerification: vi.fn(),
confirmVerificationSas: vi.fn(async () => completed),
listVerifications: vi.fn(async () => [sas]),
requestVerification: vi.fn(async () => requested),
startVerification: vi.fn(async () => sas),
};
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
crossSigning: mockCrossSigningPublicationStatus(false),
success: false,
error: "cross-signing identity is still not trusted",
verification: mockUnverifiedOwnerStatus(),
}));
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({
bootstrapOwnDeviceVerification,
crypto,
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
mockCrossSigningPublicationStatus(false),
),
getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
});
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
).rejects.toThrow(
"Timed out waiting for Matrix self-verification to establish full Matrix identity trust",
);
expect(crypto.cancelVerification).not.toHaveBeenCalled();
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
allowAutomaticCrossSigningReset: false,
strict: false,
});
});
it("cancels the pending self-verification request when acceptance times out", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const crypto = {
cancelVerification: vi.fn(async () => requested),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(async () => requested),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
).rejects.toThrow("Timed out waiting for Matrix self-verification to be accepted");
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
it("fails immediately when the self-verification request is cancelled while waiting", async () => {
const requested = {
completed: false,
hasSas: false,
id: "verification-1",
phaseName: "requested",
transactionId: "tx-self",
};
const cancelled = {
...requested,
error: "Remote cancelled",
pending: false,
phaseName: "cancelled",
};
const crypto = {
cancelVerification: vi.fn(async () => cancelled),
listVerifications: vi.fn(async () => [cancelled]),
requestVerification: vi.fn(async () => requested),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
).rejects.toThrow("Matrix self-verification was cancelled: Remote cancelled");
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
it("cancels the request when SAS mismatch submission fails", async () => {
const sas = {
completed: false,
hasSas: true,
id: "verification-1",
phaseName: "started",
sas: {
decimal: [1, 2, 3],
},
transactionId: "tx-self",
};
const crypto = {
cancelVerification: vi.fn(async () => sas),
listVerifications: vi.fn(async () => [sas]),
mismatchVerificationSas: vi.fn(async () => {
throw new Error("failed to send SAS mismatch");
}),
requestVerification: vi.fn(async () => sas),
};
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
return await run({ crypto });
});
await expect(
runMatrixSelfVerification({ confirmSas: vi.fn(async () => false), timeoutMs: 500 }),
).rejects.toThrow("failed to send SAS mismatch");
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
code: "m.user",
reason: "OpenClaw self-verification did not complete",
});
});
});

View File

@@ -1,10 +1,27 @@
import { setTimeout as sleep } from "node:timers/promises";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import type { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixVerificationSummary } from "../sdk/verification-manager.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
const DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS = 180_000;
type MatrixCryptoActionFacade = NonNullable<import("../sdk.js").MatrixClient["crypto"]>;
type MatrixActionClient = import("../sdk.js").MatrixClient;
type MatrixVerificationDmLookupOpts = {
verificationDmRoomId?: string;
verificationDmUserId?: string;
};
export type MatrixSelfVerificationResult = MatrixVerificationSummary & {
deviceOwnerVerified: boolean;
ownerVerification: MatrixOwnDeviceVerificationStatus;
};
function requireCrypto(
client: import("../sdk.js").MatrixClient,
opts: MatrixActionClientOpts,
@@ -29,6 +46,195 @@ function resolveVerificationId(input: string): string {
return normalized;
}
async function ensureMatrixVerificationDmTracked(
crypto: MatrixCryptoActionFacade,
opts: MatrixVerificationDmLookupOpts,
): Promise<void> {
const roomId = normalizeOptionalString(opts.verificationDmRoomId);
const userId = normalizeOptionalString(opts.verificationDmUserId);
if (Boolean(roomId) !== Boolean(userId)) {
throw new Error("--user-id and --room-id must be provided together for Matrix DM verification");
}
if (!roomId || !userId) {
return;
}
const tracked = await crypto.ensureVerificationDmTracked({ roomId, userId });
if (!tracked) {
throw new Error(
`Matrix DM verification request not found for room ${roomId} and user ${userId}`,
);
}
}
function isSameMatrixVerification(
left: MatrixVerificationSummary,
right: MatrixVerificationSummary,
): boolean {
return (
left.id === right.id ||
Boolean(left.transactionId && left.transactionId === right.transactionId)
);
}
function isMatrixVerificationReadyForSas(summary: MatrixVerificationSummary): boolean {
return (
summary.completed ||
summary.hasSas ||
summary.phaseName === "ready" ||
summary.phaseName === "started"
);
}
function shouldStartMatrixSasVerification(summary: MatrixVerificationSummary): boolean {
return !summary.hasSas && summary.phaseName !== "started" && !summary.completed;
}
function isMatrixVerificationCancelled(summary: MatrixVerificationSummary): boolean {
return summary.phaseName === "cancelled";
}
function isMatrixSasMethod(method: string | null | undefined): boolean {
return method === "m.sas.v1" || method === "sas";
}
function getMatrixVerificationSasWaitFailure(
summary: MatrixVerificationSummary,
label: string,
): string | null {
if (summary.hasSas || summary.phaseName === "cancelled") {
return null;
}
const method = summary.chosenMethod ? ` (method: ${summary.chosenMethod})` : "";
if (summary.completed) {
return `Matrix self-verification completed without SAS while waiting to ${label}${method}`;
}
if (
summary.phaseName === "started" &&
summary.chosenMethod &&
!isMatrixSasMethod(summary.chosenMethod)
) {
return `Matrix self-verification started without SAS while waiting to ${label}${method}`;
}
return null;
}
async function waitForMatrixVerificationSummary(params: {
crypto: MatrixCryptoActionFacade;
label: string;
request: MatrixVerificationSummary;
timeoutMs: number;
predicate: (summary: MatrixVerificationSummary) => boolean;
reject?: (summary: MatrixVerificationSummary) => string | null;
}): Promise<MatrixVerificationSummary> {
const startedAt = Date.now();
let last: MatrixVerificationSummary | undefined;
while (Date.now() - startedAt < params.timeoutMs) {
const summaries = await params.crypto.listVerifications();
const found = summaries.find((summary) => isSameMatrixVerification(summary, params.request));
if (found) {
last = found;
if (params.predicate(found)) {
return found;
}
if (isMatrixVerificationCancelled(found)) {
throw new Error(
`Matrix self-verification was cancelled${
found.error ? `: ${found.error}` : ` while waiting to ${params.label}`
}`,
);
}
const rejection = params.reject?.(found);
if (rejection) {
throw new Error(rejection);
}
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Timed out waiting for Matrix self-verification to ${params.label}${
last ? ` (last phase: ${last.phaseName})` : ""
}`,
);
}
function formatMatrixOwnerVerificationDiagnostics(
status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined,
): string {
if (!status) {
return "Matrix identity trust status was unavailable";
}
return `cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}, signed by owner: ${
status.signedByOwner ? "yes" : "no"
}, locally trusted: ${status.localVerified ? "yes" : "no"}`;
}
async function waitForMatrixSelfVerificationTrustStatus(params: {
client: MatrixActionClient;
timeoutMs: number;
}): Promise<MatrixOwnDeviceVerificationStatus> {
const startedAt = Date.now();
let last: MatrixDeviceVerificationStatus | undefined;
let crossSigningPublished = false;
while (Date.now() - startedAt < params.timeoutMs) {
const [status, crossSigning] = await Promise.all([
params.client.getOwnDeviceIdentityVerificationStatus(),
params.client.getOwnCrossSigningPublicationStatus(),
]);
last = status;
crossSigningPublished = crossSigning.published;
if (last.verified && crossSigningPublished) {
return await params.client.getOwnDeviceVerificationStatus();
}
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Timed out waiting for Matrix self-verification to establish full Matrix identity trust for this device (${formatMatrixOwnerVerificationDiagnostics(
last,
)}, cross-signing keys published: ${crossSigningPublished ? "yes" : "no"}). Complete self-verification from another Matrix client, then check Matrix verification status for details.`,
);
}
async function cancelMatrixSelfVerificationOnFailure(params: {
crypto: MatrixCryptoActionFacade;
request: MatrixVerificationSummary | undefined;
}): Promise<void> {
if (!params.request || typeof params.crypto.cancelVerification !== "function") {
return;
}
await params.crypto
.cancelVerification(params.request.id, {
reason: "OpenClaw self-verification did not complete",
code: "m.user",
})
.catch(() => undefined);
}
async function completeMatrixSelfVerification(params: {
client: MatrixActionClient;
completed: MatrixVerificationSummary;
timeoutMs: number;
}): Promise<MatrixSelfVerificationResult> {
const bootstrap = await params.client.bootstrapOwnDeviceVerification({
allowAutomaticCrossSigningReset: false,
strict: false,
});
if (!bootstrap.verification.verified) {
await params.client.trustOwnIdentityAfterSelfVerification?.();
}
const ownerVerification =
bootstrap.verification.verified && bootstrap.crossSigning.published
? bootstrap.verification
: await waitForMatrixSelfVerificationTrustStatus({
client: params.client,
timeoutMs: params.timeoutMs,
});
return {
...params.completed,
deviceOwnerVerified: ownerVerification.verified,
ownerVerification,
};
}
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
@@ -56,22 +262,118 @@ export async function requestMatrixVerification(
});
}
export async function runMatrixSelfVerification(
params: MatrixActionClientOpts & {
confirmSas: (
sas: NonNullable<MatrixVerificationSummary["sas"]>,
summary: MatrixVerificationSummary,
) => Promise<boolean>;
onReady?: (summary: MatrixVerificationSummary) => void | Promise<void>;
onRequested?: (summary: MatrixVerificationSummary) => void | Promise<void>;
onSas?: (summary: MatrixVerificationSummary) => void | Promise<void>;
timeoutMs?: number;
},
): Promise<MatrixSelfVerificationResult> {
return await withStartedActionClient(params, async (client) => {
const crypto = requireCrypto(client, params);
const timeoutMs = params.timeoutMs ?? DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS;
let requested: MatrixVerificationSummary | undefined;
let requestCompleted = false;
let handledByMismatch = false;
try {
requested = await crypto.requestVerification({ ownUser: true });
await params.onRequested?.(requested);
const ready = isMatrixVerificationReadyForSas(requested)
? requested
: await waitForMatrixVerificationSummary({
crypto,
label: "be accepted in another Matrix client",
request: requested,
timeoutMs,
predicate: isMatrixVerificationReadyForSas,
});
await params.onReady?.(ready);
if (ready.completed) {
requestCompleted = true;
return await completeMatrixSelfVerification({ client, completed: ready, timeoutMs });
}
const started = shouldStartMatrixSasVerification(ready)
? await crypto.startVerification(ready.id, "sas")
: ready;
let sasSummary = started;
if (!sasSummary.hasSas) {
const sasFailure = getMatrixVerificationSasWaitFailure(
sasSummary,
"show SAS emoji or decimals",
);
if (sasFailure) {
throw new Error(sasFailure);
}
sasSummary = await waitForMatrixVerificationSummary({
crypto,
label: "show SAS emoji or decimals",
request: started,
timeoutMs,
predicate: (summary) => summary.hasSas,
reject: (summary) =>
getMatrixVerificationSasWaitFailure(summary, "show SAS emoji or decimals"),
});
}
if (!sasSummary.sas) {
throw new Error("Matrix SAS data is not available for this verification request");
}
await params.onSas?.(sasSummary);
const matched = await params.confirmSas(sasSummary.sas, sasSummary);
if (!matched) {
await crypto.mismatchVerificationSas(sasSummary.id);
handledByMismatch = true;
throw new Error("Matrix SAS verification was not confirmed.");
}
const confirmed = await crypto.confirmVerificationSas(sasSummary.id);
const completed = confirmed.completed
? confirmed
: await waitForMatrixVerificationSummary({
crypto,
label: "complete",
request: confirmed,
timeoutMs,
predicate: (summary) => summary.completed,
});
requestCompleted = true;
return await completeMatrixSelfVerification({ client, completed, timeoutMs });
} catch (error) {
if (!requestCompleted && !handledByMismatch) {
await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested });
}
throw error;
}
});
}
export async function acceptMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.acceptVerification(resolveVerificationId(requestId));
});
}
export async function cancelMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
opts: MatrixActionClientOpts &
MatrixVerificationDmLookupOpts & { reason?: string; code?: string } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
reason: normalizeOptionalString(opts.reason),
code: normalizeOptionalString(opts.code),
@@ -81,20 +383,22 @@ export async function cancelMatrixVerification(
export async function startMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { method?: "sas" } = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts & { method?: "sas" } = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
});
}
export async function generateMatrixVerificationQr(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
});
}
@@ -102,10 +406,11 @@ export async function generateMatrixVerificationQr(
export async function scanMatrixVerificationQr(
requestId: string,
qrDataBase64: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
const payload = qrDataBase64.trim();
if (!payload) {
throw new Error("Matrix QR data is required");
@@ -116,40 +421,44 @@ export async function scanMatrixVerificationQr(
export async function getMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
});
}
export async function mismatchMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
});
}
export async function confirmMatrixVerificationReciprocateQr(
requestId: string,
opts: MatrixActionClientOpts = {},
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
) {
return await withStartedActionClient(opts, async (client) => {
const crypto = requireCrypto(client, opts);
await ensureMatrixVerificationDmTracked(crypto, opts);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
});
}

View File

@@ -33,6 +33,20 @@ function stubRuntimeFetch(fetchImpl: typeof fetch): void {
};
}
async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise<boolean> {
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
getSecretStorageKey?: (
params: { keys: Record<string, unknown> },
name: string,
) => Promise<[string, Uint8Array] | null>;
} | null;
const result = await callbacks?.getSecretStorageKey?.(
{ keys: { [keyId]: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
"m.cross_signing.master",
);
return Boolean(result);
}
class FakeMatrixEvent extends EventEmitter {
private readonly roomId: string;
private readonly eventId: string;
@@ -793,7 +807,7 @@ describe("MatrixClient event bridge", () => {
cryptoListeners.set(eventName, listener);
}),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
}));
@@ -893,7 +907,7 @@ describe("MatrixClient event bridge", () => {
cryptoListeners.set(eventName, listener);
}),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
}));
@@ -1209,6 +1223,48 @@ describe("MatrixClient crypto bootstrapping", () => {
);
});
it("trusts the own Matrix identity after completed self-verification", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const freeOwnIdentity = vi.fn();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
})),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await client.trustOwnIdentityAfterSelfVerification();
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("does not fail self-verification cleanup when own identity verify is unavailable", async () => {
const freeOwnIdentity = vi.fn();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
})),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
});
await expect(client.trustOwnIdentityAfterSelfVerification()).resolves.toBeUndefined();
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
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", {
@@ -1248,7 +1304,7 @@ describe("MatrixClient crypto bootstrapping", () => {
});
});
it("does not force-reset bootstrap when the device is already signed by its owner", async () => {
it("does not force-reset bootstrap automatically when the device has an owner signature but not full trust", async () => {
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
@@ -1273,7 +1329,7 @@ describe("MatrixClient crypto bootstrapping", () => {
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
verified: true,
verified: false,
localVerified: true,
crossSigningVerified: false,
signedByOwner: true,
@@ -1493,7 +1549,7 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(status.deviceId).toBe("DEVICE123");
});
it("does not treat local-only trust as owner verification", async () => {
it("does not treat local-only trust as Matrix identity trust", async () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
@@ -1559,7 +1615,7 @@ describe("MatrixClient crypto bootstrapping", () => {
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
const bootstrapSecretStorage = vi.fn(async () => {});
const bootstrapSecretStorage = vi.fn(consumeMatrixSecretStorageKey);
const bootstrapCrossSigning = vi.fn(async () => {});
const checkKeyBackupAndEnable = vi.fn(async () => {});
const getSecretStorageStatus = vi.fn(async () => ({
@@ -1591,6 +1647,9 @@ describe("MatrixClient crypto bootstrapping", () => {
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(false);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.verified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(result.deviceId).toBe("DEVICE123");
@@ -1600,9 +1659,167 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
});
it("fails recovery-key verification when the device is only locally trusted", async () => {
it("accepts a staged recovery key when it establishes identity trust and backup usability", async () => {
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
const encoded = encodeRecoveryKey(privateKey);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
let backupKeyLoaded = false;
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: {},
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
backupKeyLoaded = await consumeMatrixSecretStorageKey();
}),
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-used-key-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(true);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(true);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.recoveryKeyStored).toBe(true);
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
});
it("fails recovery-key verification when the device lacks full cross-signing identity trust", async () => {
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
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(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(false);
expect(result.backupUsable).toBe(false);
expect(result.deviceOwnerVerified).toBe(false);
expect(result.verified).toBe(false);
expect(result.error).toContain("full Matrix identity trust");
});
it("keeps a usable recovery key distinct from owner device verification", async () => {
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
const encoded = encodeRecoveryKey(privateKey);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
let backupKeyLoaded = false;
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: true },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
backupKeyLoaded = await consumeMatrixSecretStorageKey();
}),
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-usable-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(true);
expect(result.deviceOwnerVerified).toBe(false);
expect(result.verified).toBe(false);
expect(result.recoveryKeyStored).toBe(true);
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
});
it("does not persist a staged recovery key when backup usability came from existing material", async () => {
const previousEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
);
const attemptedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
);
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
matrixJsClient.getCrypto = vi.fn(() => ({
@@ -1621,19 +1838,122 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-cached-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
encodedPrivateKey: previousEncoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
).toString("base64"),
}),
"utf8",
);
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
recoveryKeyPath,
});
await client.start();
const result = await client.verifyWithRecoveryKey(encoded as string);
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.verified).toBe(false);
expect(result.error).toContain("not verified by its owner");
expect(result.recoveryKeyAccepted).toBe(false);
expect(result.backupUsable).toBe(true);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
});
it("does not persist a staged recovery key that secret storage did not validate", async () => {
const previousEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
);
const attemptedEncoded = encodeRecoveryKey(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
);
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(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
defaultKeyId: "SSSSKEY",
secretStorageKeyValidityMap: { SSSSKEY: false },
})),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
localVerified: true,
crossSigningVerified: false,
signedByOwner: false,
})),
checkKeyBackupAndEnable: vi.fn(async () => {}),
getActiveSessionBackupVersion: vi.fn(async () => "11"),
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
getKeyBackupInfo: vi.fn(async () => ({
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {},
version: "11",
})),
isKeyBackupTrusted: vi.fn(async () => ({
trusted: true,
matchesDecryptionKey: true,
})),
}));
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-invalid-"));
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
encodedPrivateKey: previousEncoded,
privateKeyBase64: Buffer.from(
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
).toString("base64"),
}),
"utf8",
);
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(false);
expect(result.backupUsable).toBe(true);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
});
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
@@ -1644,7 +1964,7 @@ describe("MatrixClient crypto bootstrapping", () => {
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
requestOwnUserVerification: vi.fn(async () => null),
getSecretStorageStatus: vi.fn(async () => ({
ready: true,
@@ -1680,6 +2000,9 @@ describe("MatrixClient crypto bootstrapping", () => {
const result = await client.verifyWithRecoveryKey(encoded as string);
expect(result.success).toBe(false);
expect(result.recoveryKeyAccepted).toBe(true);
expect(result.backupUsable).toBe(false);
expect(result.deviceOwnerVerified).toBe(true);
expect(result.verified).toBe(true);
expect(result.error).toContain("backup signature chain is not trusted");
expect(result.recoveryKeyStored).toBe(false);
@@ -1739,7 +2062,7 @@ describe("MatrixClient crypto bootstrapping", () => {
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
expect(result.success).toBe(false);
expect(result.error).toContain("not verified by its owner");
expect(result.error).toContain("full Matrix identity trust");
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
encodedPrivateKey?: string;
};
@@ -2538,7 +2861,7 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(result.success).toBe(false);
expect(result.verification.localVerified).toBe(true);
expect(result.verification.signedByOwner).toBe(false);
expect(result.error).toContain("not verified by its owner after bootstrap");
expect(result.error).toContain("full Matrix identity trust after bootstrap");
});
it("creates a key backup during bootstrap when none exists on the server", async () => {

View File

@@ -72,8 +72,8 @@ export type MatrixOwnDeviceVerificationStatus = {
encryptionEnabled: boolean;
userId: string | null;
deviceId: string | null;
// "verified" is intentionally strict: other Matrix clients should trust messages
// from this device without showing "not verified by its owner" warnings.
// "verified" is intentionally strict: this device must be trusted through the
// Matrix cross-signing identity chain, not merely signed by the owner key.
verified: boolean;
localVerified: boolean;
crossSigningVerified: boolean;
@@ -128,6 +128,9 @@ export type MatrixRoomKeyBackupResetResult = {
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
success: boolean;
recoveryKeyAccepted: boolean;
backupUsable: boolean;
deviceOwnerVerified: boolean;
verifiedAt?: string;
error?: string;
};
@@ -160,12 +163,15 @@ const MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS = {
} satisfies MatrixCryptoBootstrapOptions;
function createMatrixExplicitBootstrapOptions(params?: {
allowAutomaticCrossSigningReset?: boolean;
forceResetCrossSigning?: boolean;
strict?: boolean;
}): MatrixCryptoBootstrapOptions {
return {
forceResetCrossSigning: params?.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: params?.allowAutomaticCrossSigningReset !== false,
allowSecretStorageRecreateWithoutRecoveryKey: true,
strict: true,
strict: params?.strict !== false,
};
}
@@ -362,7 +368,15 @@ export class MatrixClient {
return;
}
this.verificationManager ??= new runtime.MatrixVerificationManager();
this.verificationManager ??= new runtime.MatrixVerificationManager({
trustOwnDeviceAfterSas: async (deviceId: string) => {
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (typeof crypto?.crossSignDevice !== "function") {
return;
}
await crypto.crossSignDevice(deviceId);
},
});
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => this.password,
@@ -1110,12 +1124,10 @@ export class MatrixClient {
const deviceId = this.client.getDeviceId()?.trim() || null;
const backup = await this.getRoomKeyBackupStatus();
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
const ownerVerified =
deviceVerification.crossSigningVerified || deviceVerification.signedByOwner;
return {
...deviceVerification,
verified: ownerVerified,
verified: deviceVerification.crossSigningVerified,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
recoveryKeyId: recoveryKey?.keyId ?? null,
@@ -1124,14 +1136,67 @@ export class MatrixClient {
};
}
async getOwnDeviceIdentityVerificationStatus(): Promise<MatrixDeviceVerificationStatus> {
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
const deviceId = this.client.getDeviceId()?.trim() || null;
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
return {
...deviceVerification,
verified: deviceVerification.crossSigningVerified,
};
}
async trustOwnIdentityAfterSelfVerification(): Promise<void> {
if (!this.encryptionEnabled) {
return;
}
await this.ensureStartedForCryptoControlPlane();
await this.ensureCryptoSupportInitialized();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
const ownIdentity =
crypto && typeof crypto.getOwnIdentity === "function"
? await crypto.getOwnIdentity().catch(() => undefined)
: undefined;
if (!ownIdentity) {
return;
}
try {
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
return;
}
if (typeof ownIdentity.verify !== "function") {
return;
}
await ownIdentity.verify();
} finally {
ownIdentity.free?.();
}
}
async verifyWithRecoveryKey(
rawRecoveryKey: string,
): Promise<MatrixRecoveryKeyVerificationResult> {
const fail = async (error: string): Promise<MatrixRecoveryKeyVerificationResult> => ({
success: false,
error,
...(await this.getOwnDeviceVerificationStatus()),
});
const fail = async (
error: string,
fields: Partial<
Pick<
MatrixRecoveryKeyVerificationResult,
"backupUsable" | "deviceOwnerVerified" | "recoveryKeyAccepted"
>
> = {},
): Promise<MatrixRecoveryKeyVerificationResult> => {
const status = await this.getOwnDeviceVerificationStatus();
return {
success: false,
recoveryKeyAccepted: fields.recoveryKeyAccepted ?? false,
backupUsable: fields.backupUsable ?? false,
deviceOwnerVerified: fields.deviceOwnerVerified ?? status.verified,
error,
...status,
};
};
if (!this.encryptionEnabled) {
return await fail("Matrix encryption is disabled for this client");
@@ -1144,15 +1209,21 @@ export class MatrixClient {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
}
const backupUsableBeforeStagedRecovery =
resolveMatrixRoomKeyBackupReadinessError(await this.getRoomKeyBackupStatus(), {
requireServerBackup: true,
}) === null;
const trimmedRecoveryKey = rawRecoveryKey.trim();
if (!trimmedRecoveryKey) {
return await fail("Matrix recovery key is required");
}
let stagedKeyId: string | null = null;
try {
stagedKeyId = (await this.resolveDefaultSecretStorageKeyId(crypto)) ?? null;
this.recoveryKeyStore.stageEncodedRecoveryKey({
encodedPrivateKey: trimmedRecoveryKey,
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
keyId: stagedKeyId,
});
} catch (err) {
return await fail(formatMatrixErrorMessage(err));
@@ -1168,33 +1239,88 @@ export class MatrixClient {
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
const status = await this.getOwnDeviceVerificationStatus();
if (!status.verified) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
error:
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
...status,
};
}
const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: false,
});
const backupUsable =
resolveMatrixRoomKeyBackupReadinessError(status.backup, {
requireServerBackup: true,
}) === null;
const stagedRecoveryKeyUsed = this.recoveryKeyStore.hasStagedRecoveryKeyBeenUsed();
const secretStorageStatus =
typeof crypto.getSecretStorageStatus === "function"
? await crypto.getSecretStorageStatus().catch(() => null)
: null;
const stagedRecoveryKeyConfirmedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true;
const stagedRecoveryKeyRejectedBySecretStorage =
Boolean(stagedKeyId) &&
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === false;
const stagedRecoveryKeyUnlockedBackup =
stagedRecoveryKeyUsed &&
!stagedRecoveryKeyRejectedBySecretStorage &&
!stagedRecoveryKeyConfirmedBySecretStorage &&
!backupUsableBeforeStagedRecovery &&
backupUsable;
const stagedRecoveryKeyValidated =
stagedRecoveryKeyUsed &&
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup);
const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable);
if (!status.verified) {
if (backupUsable && stagedRecoveryKeyValidated) {
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: stagedKeyId,
});
} else {
this.recoveryKeyStore.discardStagedRecoveryKey();
}
const committedStatus = recoveryKeyAccepted
? await this.getOwnDeviceVerificationStatus()
: status;
return {
success: false,
recoveryKeyAccepted,
backupUsable,
deviceOwnerVerified: false,
error:
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust. The recovery key can unlock usable backup material only when 'Backup usable' is yes; full identity trust still requires Matrix cross-signing verification.",
...committedStatus,
};
}
if (backupError) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
recoveryKeyAccepted,
backupUsable,
deviceOwnerVerified: true,
error: backupError,
...status,
};
}
if (!stagedRecoveryKeyValidated) {
this.recoveryKeyStore.discardStagedRecoveryKey();
return {
success: false,
recoveryKeyAccepted: false,
backupUsable,
deviceOwnerVerified: true,
error:
"Matrix recovery key could not be verified against active Matrix backup material; existing backup may be usable from previously loaded recovery material.",
...status,
};
}
this.recoveryKeyStore.commitStagedRecoveryKey({
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
keyId: stagedKeyId,
});
const committedStatus = await this.getOwnDeviceVerificationStatus();
return {
success: true,
recoveryKeyAccepted: true,
backupUsable,
deviceOwnerVerified: true,
verifiedAt: new Date().toISOString(),
...committedStatus,
};
@@ -1419,8 +1545,10 @@ export class MatrixClient {
}
async bootstrapOwnDeviceVerification(params?: {
allowAutomaticCrossSigningReset?: boolean;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
strict?: boolean;
}): Promise<MatrixVerificationBootstrapResult> {
const pendingVerifications = async (): Promise<number> =>
this.crypto ? (await this.crypto.listVerifications()).length : 0;
@@ -1680,12 +1808,15 @@ export class MatrixClient {
"MatrixClientLite",
"No room key backup version found on server, creating one via secret storage bootstrap",
);
// matrix-js-sdk 41.3.0 can log a transient PerSessionKeyBackupDownloader
// "current backup version ... undefined" warning while setupNewKeyBackup creates
// the backup: resetKeyBackup emits key-backup cache events before its async
// checkKeyBackupAndEnable pass has populated active backup state. Keep the
// explicit server re-check below and do not hide the SDK logs; if this needs
// fixing in code, upstream a minimal Matrix SDK repro instead of patching here.
// matrix-js-sdk 41.3.0 can log transient PerSessionKeyBackupDownloader
// diagnostics while setupNewKeyBackup creates the first backup, including
// "Got current backup version from server: undefined" and
// "Unsupported algorithm undefined". This is an expected upstream
// matrix-js-sdk race: resetKeyBackup emits key-backup cache events before
// its async checkKeyBackupAndEnable pass has populated active backup state.
// Keep the explicit server re-check below and do not hide the SDK logs; if
// this needs fixing in code, upstream a minimal Matrix SDK repro instead of
// patching here.
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
setupNewKeyBackup: true,
});

View File

@@ -196,7 +196,8 @@ describe("MatrixCryptoBootstrapper", () => {
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
.mockResolvedValueOnce(true)
.mockResolvedValue(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
@@ -253,6 +254,48 @@ describe("MatrixCryptoBootstrapper", () => {
);
});
it("does not mark the own Matrix identity verified before cross-signing the current device", async () => {
const verifyOwnIdentity = vi.fn(async () => undefined);
const freeOwnIdentity = vi.fn();
const setDeviceVerified = vi.fn(async () => {});
const crossSignDevice = vi.fn(async () => {});
const getDeviceVerificationStatus = vi
.fn()
.mockResolvedValueOnce({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: true,
})
.mockResolvedValueOnce({
isVerified: () => true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
});
const { bootstrapper, crypto } = createBootstrapperHarness({
crossSignDevice,
getDeviceVerificationStatus,
getOwnIdentity: vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
})),
isCrossSigningReady: vi.fn(async () => true),
setDeviceVerified,
userHasCrossSigningKeys: vi.fn(async () => true),
});
await bootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
expect(verifyOwnIdentity).not.toHaveBeenCalled();
expect(freeOwnIdentity).not.toHaveBeenCalled();
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
});
it("refreshes published cross-signing keys before importing private keys from secret storage", async () => {
const bootstrapCrossSigning = vi.fn(async () => {});
const userHasCrossSigningKeys = vi.fn(async () => true);
@@ -419,6 +462,47 @@ describe("MatrixCryptoBootstrapper", () => {
});
});
it("trusts the fresh own identity after a forced cross-signing reset", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const freeOwnIdentity = vi.fn();
const { crypto, bootstrapper } = createForcedResetHarness(vi.fn(async () => {}));
crypto.getOwnIdentity = vi.fn(async () => ({
free: freeOwnIdentity,
isVerified: () => false,
verify: verifyOwnIdentity,
}));
await bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
});
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
});
it("does not trust an existing unpublished identity without a reset", async () => {
const verifyOwnIdentity = vi.fn(async () => ({}));
const { crypto, bootstrapper } = createBootstrapperHarness({
bootstrapCrossSigning: vi.fn(async () => {}),
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
getOwnIdentity: vi.fn(async () => ({
isVerified: () => false,
verify: verifyOwnIdentity,
})),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
});
const result = await bootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
strict: false,
});
expect(result.crossSigningPublished).toBe(false);
expect(verifyOwnIdentity).not.toHaveBeenCalled();
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({

View File

@@ -1,3 +1,4 @@
import { setTimeout as sleep } from "node:timers/promises";
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
import { LogService } from "./logger.js";
@@ -37,6 +38,8 @@ export type MatrixCryptoBootstrapResult = {
ownDeviceVerified: boolean | null;
};
const CROSS_SIGNING_PUBLICATION_WAIT_MS = 5_000;
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private verificationHandlerRegistered = false;
@@ -83,7 +86,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
strict,
});
}
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
strict,
});
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
@@ -165,7 +170,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
const ready = await isCrossSigningReady();
const published = await hasPublishedCrossSigningKeys();
const published = ready
? await waitForPublishedCrossSigningKeys()
: await hasPublishedCrossSigningKeys();
if (ready && published) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready, published };
@@ -178,6 +185,17 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return { ready, published };
};
const waitForPublishedCrossSigningKeys = async (): Promise<boolean> => {
const startedAt = Date.now();
do {
if (await hasPublishedCrossSigningKeys()) {
return true;
}
await sleep(250);
} while (Date.now() - startedAt < CROSS_SIGNING_PUBLICATION_WAIT_MS);
return false;
};
if (options.forceResetCrossSigning) {
const resetCrossSigning = async (): Promise<void> => {
await crypto.bootstrapCrossSigning({
@@ -187,6 +205,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
};
try {
await resetCrossSigning();
await this.trustFreshOwnIdentity(crypto);
} catch (err) {
const shouldRepairSecretStorage =
options.allowSecretStorageRecreateWithoutRecoveryKey &&
@@ -202,6 +221,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
forceNewSecretStorage: true,
});
await resetCrossSigning();
await this.trustFreshOwnIdentity(crypto);
} catch (repairErr) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr);
if (options.strict) {
@@ -287,6 +307,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
await this.trustFreshOwnIdentity(crypto);
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
if (options.strict) {
@@ -298,6 +319,25 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
return await finalize();
}
private async trustFreshOwnIdentity(crypto: MatrixCryptoBootstrapApi): Promise<void> {
const ownIdentity =
typeof crypto.getOwnIdentity === "function"
? await crypto.getOwnIdentity().catch(() => undefined)
: undefined;
if (!ownIdentity) {
return;
}
try {
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
return;
}
await ownIdentity.verify?.();
} finally {
ownIdentity.free?.();
}
}
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
options: {
@@ -349,7 +389,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private async ensureOwnDeviceTrust(
crypto: MatrixCryptoBootstrapApi,
strict = false,
options: {
strict: boolean;
},
): Promise<boolean | null> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
@@ -386,8 +428,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const verified = isMatrixDeviceOwnerVerified(refreshedStatus);
if (!verified && strict) {
throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`);
if (!verified && options.strict) {
throw new Error(
`Matrix own device ${deviceId} does not have full Matrix identity trust after bootstrap`,
);
}
return verified;
}

View File

@@ -49,6 +49,7 @@ function createFacadeHarness(params?: {
client: {
getRoom: params?.client?.getRoom ?? (() => null),
getCrypto: params?.client?.getCrypto ?? (() => undefined),
getUserId: params?.client?.getUserId ?? (() => "@bot:example.org"),
},
verificationManager: createVerificationManagerMock(params?.verificationManager),
recoveryKeyStore: createRecoveryKeyStoreMock(params?.recoveryKeySummary ?? null),
@@ -194,4 +195,66 @@ describe("createMatrixCryptoFacade", () => {
expect(trackVerificationRequest).toHaveBeenCalledWith(request);
expect(summary?.transactionId).toBe("txn-dm-in-progress");
});
it("rehydrates in-progress to-device verification requests before listing", async () => {
const request = {
transactionId: "txn-self-in-progress",
otherUserId: "@bot:example.org",
initiatedByMe: true,
isSelfVerification: true,
phase: 2,
pending: true,
accepting: false,
declining: false,
methods: ["m.sas.v1"],
accept: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
startVerification: vi.fn(),
scanQRCode: vi.fn(),
generateQRCode: vi.fn(),
on: vi.fn(),
verifier: undefined,
};
const tracked = {
id: "verification-1",
transactionId: "txn-self-in-progress",
otherUserId: "@bot:example.org",
isSelfVerification: true,
initiatedByMe: true,
phase: 2,
phaseName: "ready",
pending: true,
methods: ["m.sas.v1"],
canAccept: false,
hasSas: false,
hasReciprocateQr: false,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const trackVerificationRequest = vi.fn(() => tracked);
const listVerifications = vi.fn(() => [tracked]);
const crypto = {
getVerificationRequestsToDeviceInProgress: vi.fn(() => [request]),
requestOwnUserVerification: vi.fn(async () => null),
};
const { facade } = createFacadeHarness({
client: {
getCrypto: () => crypto,
getUserId: () => "@bot:example.org",
},
verificationManager: {
listVerifications,
trackVerificationRequest,
},
});
const summaries = await facade.listVerifications();
expect(crypto.getVerificationRequestsToDeviceInProgress).toHaveBeenCalledWith(
"@bot:example.org",
);
expect(trackVerificationRequest).toHaveBeenCalledWith(request);
expect(summaries).toEqual([tracked]);
});
});

View File

@@ -10,6 +10,7 @@ import type {
type MatrixCryptoFacadeClient = {
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
getCrypto: () => unknown;
getUserId: () => string | null;
};
export type MatrixCryptoFacade = {
@@ -72,6 +73,20 @@ async function loadMatrixCryptoNodeRuntime(): Promise<MatrixCryptoNodeRuntime> {
return await matrixCryptoNodeRuntimePromise;
}
function trackInProgressToDeviceVerifications(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
}) {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
const userId = deps.client.getUserId();
if (!userId || typeof crypto?.getVerificationRequestsToDeviceInProgress !== "function") {
return;
}
for (const request of crypto.getVerificationRequestsToDeviceInProgress(userId)) {
deps.verificationManager.trackVerificationRequest(request);
}
}
export function createMatrixCryptoFacade(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
@@ -159,6 +174,7 @@ export function createMatrixCryptoFacade(deps: {
return deps.recoveryKeyStore.getRecoveryKeySummary();
},
listVerifications: async () => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.listVerifications();
},
ensureVerificationDmTracked: async ({ roomId, userId }) => {
@@ -177,30 +193,39 @@ export function createMatrixCryptoFacade(deps: {
return await deps.verificationManager.requestVerification(crypto, params);
},
acceptVerification: async (id) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.acceptVerification(id);
},
cancelVerification: async (id, params) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.cancelVerification(id, params);
},
startVerification: async (id, method = "sas") => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.startVerification(id, method);
},
generateVerificationQr: async (id) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.generateVerificationQr(id);
},
scanVerificationQr: async (id, qrDataBase64) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.scanVerificationQr(id, qrDataBase64);
},
confirmVerificationSas: async (id) => {
trackInProgressToDeviceVerifications(deps);
return await deps.verificationManager.confirmVerificationSas(id);
},
mismatchVerificationSas: async (id) => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.mismatchVerificationSas(id);
},
confirmVerificationReciprocateQr: async (id) => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.confirmVerificationReciprocateQr(id);
},
getVerificationSas: async (id) => {
trackInProgressToDeviceVerifications(deps);
return deps.verificationManager.getVerificationSas(id);
},
};

View File

@@ -34,6 +34,7 @@ export class MatrixRecoveryKeyStore {
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
>();
private stagedRecoveryKey: MatrixStoredRecoveryKey | null = null;
private stagedRecoveryKeyUsed = false;
private readonly stagedCacheKeyIds = new Set<string>();
constructor(private readonly recoveryKeyPath?: string) {}
@@ -46,6 +47,11 @@ export class MatrixRecoveryKeyStore {
return null;
}
const staged = this.resolveStagedSecretStorageKey(requestedKeyIds);
if (staged) {
return staged;
}
for (const keyId of requestedKeyIds) {
const cached = this.secretStorageKeyCache.get(keyId);
if (cached) {
@@ -53,22 +59,6 @@ export class MatrixRecoveryKeyStore {
}
}
const staged = this.stagedRecoveryKey;
if (staged?.privateKeyBase64) {
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
if (privateKey.length > 0) {
const stagedKeyId =
staged.keyId && requestedKeyIds.includes(staged.keyId)
? staged.keyId
: requestedKeyIds[0];
if (stagedKeyId) {
this.rememberSecretStorageKey(stagedKeyId, privateKey, staged.keyInfo);
this.stagedCacheKeyIds.add(stagedKeyId);
return [stagedKeyId, privateKey];
}
}
}
const stored = this.loadStoredRecoveryKey();
if (!stored?.privateKeyBase64) {
return null;
@@ -196,6 +186,10 @@ export class MatrixRecoveryKeyStore {
};
}
hasStagedRecoveryKeyBeenUsed(): boolean {
return this.stagedRecoveryKeyUsed;
}
commitStagedRecoveryKey(params?: {
keyId?: string | null;
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
@@ -264,19 +258,24 @@ export class MatrixRecoveryKeyStore {
if (recoveryKey && status?.defaultKeyId) {
const defaultKeyId = status.defaultKeyId;
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
if (!stagedRecovery && storedRecovery && storedRecovery.keyId !== defaultKeyId) {
this.saveRecoveryKeyToDisk({
keyId: defaultKeyId,
keyInfo: recoveryKey.keyInfo,
privateKey: recoveryKey.privateKey,
encodedPrivateKey: recoveryKey.encodedPrivateKey,
});
if (!stagedRecovery) {
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
if (storedRecovery && storedRecovery.keyId !== defaultKeyId) {
this.saveRecoveryKeyToDisk({
keyId: defaultKeyId,
keyInfo: recoveryKey.keyInfo,
privateKey: recoveryKey.privateKey,
encodedPrivateKey: recoveryKey.encodedPrivateKey,
});
}
}
}
const ensureRecoveryKey = async (): Promise<MatrixGeneratedSecretStorageKey> => {
if (recoveryKey) {
if (stagedRecovery) {
this.stagedRecoveryKeyUsed = true;
}
return recoveryKey;
}
if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") {
@@ -347,9 +346,38 @@ export class MatrixRecoveryKeyStore {
private clearStagedRecoveryKeyTracking(): void {
this.stagedRecoveryKey = null;
this.stagedRecoveryKeyUsed = false;
this.stagedCacheKeyIds.clear();
}
private resolveStagedSecretStorageKey(requestedKeyIds: string[]): [string, Uint8Array] | null {
const staged = this.stagedRecoveryKey;
if (!staged?.privateKeyBase64) {
return null;
}
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
if (privateKey.length === 0) {
return null;
}
const keyId =
staged.keyId && requestedKeyIds.includes(staged.keyId) ? staged.keyId : requestedKeyIds[0];
if (!keyId) {
return null;
}
this.rememberStagedSecretStorageKey(keyId, privateKey, staged.keyInfo);
this.stagedCacheKeyIds.add(keyId);
return [keyId, privateKey];
}
private rememberStagedSecretStorageKey(
keyId: string,
key: Uint8Array,
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
): void {
this.stagedRecoveryKeyUsed = true;
this.rememberSecretStorageKey(keyId, key, keyInfo);
}
private rememberSecretStorageKey(
keyId: string,
key: Uint8Array,

View File

@@ -232,6 +232,14 @@ export type MatrixCryptoBootstrapApi = {
}) => Promise<MatrixRoomKeyBackupRestoreResult>;
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
crossSignDevice?: (deviceId: string) => Promise<void>;
getOwnIdentity?: () => Promise<
| {
free?: () => void;
isVerified?: () => boolean;
verify?: () => Promise<unknown>;
}
| undefined
>;
isCrossSigningReady?: () => Promise<boolean>;
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
};

View File

@@ -188,6 +188,73 @@ describe("MatrixVerificationManager", () => {
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
});
it("reuses the tracked id when the other device id is populated later", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-device-later",
phase: VerificationPhase.Requested,
});
const second = new MockVerificationRequest({
transactionId: "txn-device-later",
phase: VerificationPhase.Ready,
otherDeviceId: "DEVICE_LATER",
pending: false,
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).toBe(firstSummary.id);
expect(secondSummary.otherDeviceId).toBe("DEVICE_LATER");
expect(manager.listVerifications()).toHaveLength(1);
});
it("keeps separate sessions when stable other device ids differ", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-different-devices",
otherDeviceId: "DEVICE_A",
});
const second = new MockVerificationRequest({
transactionId: "txn-different-devices",
otherDeviceId: "DEVICE_B",
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).not.toBe(firstSummary.id);
expect(manager.listVerifications()).toHaveLength(2);
});
it("does not overwrite a different verification request with a colliding transaction ID", async () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-collision",
initiatedByMe: true,
otherUserId: "@alice:example.org",
otherDeviceId: "ALICE1",
});
const second = new MockVerificationRequest({
transactionId: "txn-collision",
initiatedByMe: true,
otherUserId: "@mallory:example.org",
otherDeviceId: "MALLORY1",
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).not.toBe(firstSummary.id);
expect(manager.listVerifications()).toHaveLength(2);
expect(() => manager.getVerificationSas("txn-collision")).toThrow(
"Matrix verification request id is ambiguous for transaction txn-collision",
);
await manager.acceptVerification(firstSummary.id);
expect(first.accept).toHaveBeenCalledTimes(1);
expect(second.accept).not.toHaveBeenCalled();
});
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
const confirm = vi.fn(async () => {});
const mismatch = vi.fn();
@@ -231,6 +298,49 @@ describe("MatrixVerificationManager", () => {
expect(mismatch).toHaveBeenCalledTimes(1);
});
it("cross-signs the other own device after confirmed self-verification SAS", async () => {
const { confirm, verifier } = createSasVerifierFixture({
decimal: [111, 222, 333],
emoji: [["cat", "cat"]],
});
const trustOwnDeviceAfterSas = vi.fn(async () => {});
const request = new MockVerificationRequest({
isSelfVerification: true,
otherDeviceId: "OTHERDEVICE",
transactionId: "txn-self-sas",
verifier,
});
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
const tracked = manager.trackVerificationRequest(request);
await manager.startVerification(tracked.id, "sas");
await manager.confirmVerificationSas(tracked.id);
expect(confirm).toHaveBeenCalledTimes(1);
expect(trustOwnDeviceAfterSas).toHaveBeenCalledWith("OTHERDEVICE");
});
it("does not cross-sign non-self SAS verifications", async () => {
const { verifier } = createSasVerifierFixture({
decimal: [111, 222, 333],
emoji: [["cat", "cat"]],
});
const trustOwnDeviceAfterSas = vi.fn(async () => {});
const request = new MockVerificationRequest({
isSelfVerification: false,
otherDeviceId: "OTHERDEVICE",
transactionId: "txn-remote-sas",
verifier,
});
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
const tracked = manager.trackVerificationRequest(request);
await manager.startVerification(tracked.id, "sas");
await manager.confirmVerificationSas(tracked.id);
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
});
it("auto-starts an incoming verifier exposed via request change events", async () => {
const { verifier, verify } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
@@ -410,6 +520,33 @@ describe("MatrixVerificationManager", () => {
}
});
it("does not cross-sign the other own device after auto-confirmed self-verification SAS", async () => {
vi.useFakeTimers();
const { confirm, verifier } = createSasVerifierFixture({
decimal: [6158, 1986, 3513],
emoji: [["gift", "Gift"]],
});
const trustOwnDeviceAfterSas = vi.fn(async () => {});
const request = new MockVerificationRequest({
isSelfVerification: true,
otherDeviceId: "OTHERDEVICE",
transactionId: "txn-auto-confirm-self",
initiatedByMe: false,
verifier,
});
try {
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
manager.trackVerificationRequest(request);
await vi.advanceTimersByTimeAsync(30_100);
expect(confirm).toHaveBeenCalledTimes(1);
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("does not auto-confirm SAS for verifications initiated by this device", async () => {
vi.useFakeTimers();
const confirm = vi.fn(async () => {});

View File

@@ -52,6 +52,7 @@ export type MatrixVerificationSummary = {
};
type MatrixVerificationSummaryListener = (summary: MatrixVerificationSummary) => void;
type MatrixVerificationOwnerTrustCallback = (deviceId: string) => Promise<void>;
export type MatrixShowSasCallbacks = {
sas: {
@@ -101,6 +102,7 @@ export type MatrixVerificationRequestLike = {
export type MatrixVerificationCryptoApi = {
requestOwnUserVerification: () => Promise<MatrixVerificationRequestLike | null>;
getVerificationRequestsToDeviceInProgress?: (userId: string) => MatrixVerificationRequestLike[];
findVerificationRequestDMInProgress?: (
roomId: string,
userId: string,
@@ -132,6 +134,15 @@ type MatrixVerificationSession = {
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
};
type MatrixVerificationRequestIdentity = {
transactionId: string;
roomId: string;
otherUserId: string;
otherDeviceId: string;
isSelfVerification: boolean;
initiatedByMe: boolean;
};
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
const SAS_AUTO_CONFIRM_DELAY_MS = 30_000;
@@ -143,6 +154,12 @@ export class MatrixVerificationManager {
private readonly trackedVerificationVerifiers = new WeakSet<object>();
private readonly summaryListeners = new Set<MatrixVerificationSummaryListener>();
constructor(
private readonly opts: {
trustOwnDeviceAfterSas?: MatrixVerificationOwnerTrustCallback;
} = {},
) {}
private readRequestValue<T>(
request: MatrixVerificationRequestLike,
reader: () => T,
@@ -163,6 +180,40 @@ export class MatrixVerificationManager {
return isMatrixVerificationPhase(phase) ? phase : fallback;
}
private readVerificationRequestIdentity(
request: MatrixVerificationRequestLike,
): MatrixVerificationRequestIdentity {
return {
transactionId: this.readRequestValue(request, () => request.transactionId?.trim() ?? "", ""),
roomId: this.readRequestValue(request, () => request.roomId ?? "", ""),
otherUserId: this.readRequestValue(request, () => request.otherUserId, ""),
otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId ?? "", ""),
isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false),
initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false),
};
}
private isSameLogicalVerificationRequest(
left: MatrixVerificationRequestLike,
right: MatrixVerificationRequestLike,
): boolean {
const leftIdentity = this.readVerificationRequestIdentity(left);
const rightIdentity = this.readVerificationRequestIdentity(right);
return (
leftIdentity.transactionId !== "" &&
leftIdentity.transactionId === rightIdentity.transactionId &&
leftIdentity.roomId === rightIdentity.roomId &&
leftIdentity.otherUserId === rightIdentity.otherUserId &&
this.isSameOptionalIdentityValue(leftIdentity.otherDeviceId, rightIdentity.otherDeviceId) &&
leftIdentity.isSelfVerification === rightIdentity.isSelfVerification &&
leftIdentity.initiatedByMe === rightIdentity.initiatedByMe
);
}
private isSameOptionalIdentityValue(left: string, right: string): boolean {
return left === "" || right === "" || left === right;
}
private pruneVerificationSessions(nowMs: number): void {
for (const [id, session] of this.verificationSessions) {
const phase = this.readVerificationPhase(session.request, -1);
@@ -276,11 +327,21 @@ export class MatrixVerificationManager {
if (direct) {
return direct;
}
for (const session of this.verificationSessions.values()) {
const txId = this.readRequestValue(session.request, () => session.request.transactionId, "");
if (txId === id) {
return session;
}
const transactionMatches = Array.from(this.verificationSessions.values()).filter((session) => {
const txId = this.readRequestValue(
session.request,
() => session.request.transactionId?.trim(),
"",
);
return txId === id;
});
if (transactionMatches.length === 1) {
return transactionMatches[0];
}
if (transactionMatches.length > 1) {
throw new Error(
`Matrix verification request id is ambiguous for transaction ${id}; use the verification id instead`,
);
}
throw new Error(`Matrix verification request not found: ${id}`);
}
@@ -443,8 +504,7 @@ export class MatrixVerificationManager {
return;
}
session.sasAutoConfirmStarted = true;
void callbacks
.confirm()
void this.confirmSasForSession(session, callbacks, { trustOwnDevice: false })
.then(() => {
this.touchVerificationSession(session);
})
@@ -455,6 +515,17 @@ export class MatrixVerificationManager {
}, SAS_AUTO_CONFIRM_DELAY_MS);
}
private async confirmSasForSession(
session: MatrixVerificationSession,
callbacks: MatrixShowSasCallbacks,
opts: { trustOwnDevice: boolean } = { trustOwnDevice: true },
): Promise<void> {
await callbacks.confirm();
if (opts.trustOwnDevice) {
await this.trustOwnDeviceAfterConfirmedSas(session);
}
}
private ensureVerificationStarted(session: MatrixVerificationSession): void {
if (!session.activeVerifier || session.verifyStarted) {
return;
@@ -472,6 +543,21 @@ export class MatrixVerificationManager {
});
}
private async trustOwnDeviceAfterConfirmedSas(session: MatrixVerificationSession): Promise<void> {
if (!this.readRequestValue(session.request, () => session.request.isSelfVerification, false)) {
return;
}
const deviceId = this.readRequestValue(
session.request,
() => session.request.otherDeviceId?.trim(),
"",
);
if (!deviceId || !this.opts.trustOwnDeviceAfterSas) {
return;
}
await this.opts.trustOwnDeviceAfterSas(deviceId);
}
onSummaryChanged(listener: MatrixVerificationSummaryListener): () => void {
this.summaryListeners.add(listener);
return () => {
@@ -481,15 +567,17 @@ export class MatrixVerificationManager {
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
this.pruneVerificationSessions(Date.now());
const txId = this.readRequestValue(request, () => request.transactionId?.trim(), "");
const requestObj = request as unknown as object;
for (const existing of this.verificationSessions.values()) {
if ((existing.request as unknown as object) === requestObj) {
this.touchVerificationSession(existing);
return this.buildVerificationSummary(existing);
}
}
const txId = this.readVerificationRequestIdentity(request).transactionId;
if (txId) {
for (const existing of this.verificationSessions.values()) {
const existingTxId = this.readRequestValue(
existing.request,
() => existing.request.transactionId,
"",
);
if (existingTxId === txId) {
if (this.isSameLogicalVerificationRequest(existing.request, request)) {
existing.request = request;
this.ensureVerificationRequestTracked(existing);
const verifier = this.readRequestValue(request, () => request.verifier, null);
@@ -643,7 +731,7 @@ export class MatrixVerificationManager {
this.clearSasAutoConfirmTimer(session);
session.sasCallbacks = callbacks;
session.sasAutoConfirmStarted = true;
await callbacks.confirm();
await this.confirmSasForSession(session, callbacks);
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}

View File

@@ -9,7 +9,7 @@ export function isMatrixDeviceLocallyVerified(
export function isMatrixDeviceOwnerVerified(
status: MatrixDeviceVerificationStatusLike | null | undefined,
): boolean {
return status?.crossSigningVerified === true || status?.signedByOwner === true;
return status?.crossSigningVerified === true;
}
export function isMatrixDeviceVerifiedInCurrentClient(

View File

@@ -58,6 +58,8 @@ export type MatrixQaScenarioId =
| "matrix-e2ee-thread-follow-up"
| "matrix-e2ee-bootstrap-success"
| "matrix-e2ee-recovery-key-lifecycle"
| "matrix-e2ee-recovery-owner-verification-required"
| "matrix-e2ee-cli-self-verification"
| "matrix-e2ee-device-sas-verification"
| "matrix-e2ee-qr-verification"
| "matrix-e2ee-stale-device-hygiene"
@@ -567,6 +569,26 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-recovery-owner-verification-required",
timeoutMs: 90_000,
title: "Matrix E2EE recovery key backup access still requires Matrix identity trust",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-recovery-owner-verification-required",
name: "Matrix QA E2EE Recovery Owner Verification Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-cli-self-verification",
timeoutMs: 180_000,
title: "Matrix E2EE CLI interactive self-verification establishes identity trust",
topology: buildMatrixQaE2eeScenarioTopology({
scenarioId: "matrix-e2ee-cli-self-verification",
name: "Matrix QA E2EE CLI Self Verification Room",
}),
configOverrides: MATRIX_QA_E2EE_CONFIG,
},
{
id: "matrix-e2ee-device-sas-verification",
timeoutMs: 90_000,

View File

@@ -0,0 +1,44 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
formatMatrixQaCliCommand,
redactMatrixQaCliOutput,
resolveMatrixQaOpenClawCliEntryPath,
} from "./scenario-runtime-cli.js";
describe("Matrix QA CLI runtime", () => {
it("redacts secret CLI arguments in diagnostic command text", () => {
expect(
formatMatrixQaCliCommand([
"matrix",
"verify",
"backup",
"restore",
"--recovery-key",
"abcdef1234567890ghij",
]),
).toBe("openclaw matrix verify backup restore --recovery-key [REDACTED]");
expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe(
"openclaw matrix account add --access-token=[REDACTED]",
);
});
it("redacts Matrix token output before diagnostics and artifacts", () => {
expect(
redactMatrixQaCliOutput("GET /_matrix/client/v3/sync?access_token=abcdef1234567890ghij"),
).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij");
});
it("prefers the ESM OpenClaw CLI entrypoint when present", async () => {
const root = await mkdtemp(path.join(tmpdir(), "matrix-qa-cli-entry-"));
try {
await mkdir(path.join(root, "dist"));
await writeFile(path.join(root, "dist", "index.mjs"), "");
expect(resolveMatrixQaOpenClawCliEntryPath(root)).toBe(path.join(root, "dist", "index.mjs"));
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,224 @@
import { spawn as startOpenClawCliProcess } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
export type MatrixQaCliRunResult = {
args: string[];
exitCode: number;
stderr: string;
stdout: string;
};
export type MatrixQaCliSession = {
args: string[];
output: () => { stderr: string; stdout: string };
wait: () => Promise<MatrixQaCliRunResult>;
waitForOutput: (
predicate: (output: { stderr: string; stdout: string; text: string }) => boolean,
label: string,
timeoutMs: number,
) => Promise<{ stderr: string; stdout: string; text: string }>;
writeStdin: (text: string) => Promise<void>;
kill: () => void;
};
const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]);
function redactMatrixQaCliArgs(args: string[]): string[] {
return args.map((arg, index) => {
const [flag] = arg.split("=", 1);
if (MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(flag) && arg.includes("=")) {
return `${flag}=[REDACTED]`;
}
const previous = args[index - 1];
if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) {
return "[REDACTED]";
}
return arg;
});
}
export function redactMatrixQaCliOutput(text: string): string {
return redactSensitiveText(text);
}
export function formatMatrixQaCliCommand(args: string[]) {
return `openclaw ${redactMatrixQaCliArgs(args).join(" ")}`;
}
export function resolveMatrixQaOpenClawCliEntryPath(cwd: string): string {
const mjsEntryPath = path.join(cwd, "dist", "index.mjs");
if (existsSync(mjsEntryPath)) {
return mjsEntryPath;
}
return path.join(cwd, "dist", "index.js");
}
function buildMatrixQaCliResult(params: {
args: string[];
exitCode: number;
output: { stderr: string; stdout: string };
}): MatrixQaCliRunResult {
return {
args: params.args,
exitCode: params.exitCode,
stderr: params.output.stderr,
stdout: params.output.stdout,
};
}
function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) {
return [
`${formatMatrixQaCliCommand(result.args)} exited ${result.exitCode}`,
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
]
.filter(Boolean)
.join("\n");
}
export function startMatrixQaOpenClawCli(params: {
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
timeoutMs: number;
}): MatrixQaCliSession {
const cwd = params.cwd ?? process.cwd();
const distEntryPath = resolveMatrixQaOpenClawCliEntryPath(cwd);
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let closed = false;
let closeResult: MatrixQaCliRunResult | undefined;
let settleWait:
| {
reject: (error: Error) => void;
resolve: (result: MatrixQaCliRunResult) => void;
}
| undefined;
const child = startOpenClawCliProcess(process.execPath, [distEntryPath, ...params.args], {
cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
});
const readOutput = () => ({
stderr: Buffer.concat(stderr).toString("utf8"),
stdout: Buffer.concat(stdout).toString("utf8"),
});
const finish = (result: MatrixQaCliRunResult, error?: Error) => {
if (closed) {
return;
}
closed = true;
closeResult = result;
if (!settleWait) {
return;
}
if (error) {
settleWait.reject(error);
} else {
settleWait.resolve(result);
}
};
const timeout = setTimeout(() => {
const result = buildMatrixQaCliResult({
args: params.args,
exitCode: 1,
output: readOutput(),
});
child.kill("SIGTERM");
finish(
result,
new Error(`${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`),
);
}, params.timeoutMs);
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
child.on("error", (error) => {
clearTimeout(timeout);
finish(
buildMatrixQaCliResult({
args: params.args,
exitCode: 1,
output: readOutput(),
}),
error,
);
});
child.on("close", (exitCode) => {
clearTimeout(timeout);
const result = buildMatrixQaCliResult({
args: params.args,
exitCode: exitCode ?? 1,
output: readOutput(),
});
if (result.exitCode !== 0) {
finish(result, new Error(formatMatrixQaCliExitError(result)));
return;
}
finish(result);
});
return {
args: params.args,
output: readOutput,
wait: async () =>
await new Promise<MatrixQaCliRunResult>((resolve, reject) => {
if (closed && closeResult) {
if (closeResult.exitCode === 0) {
resolve(closeResult);
} else {
reject(new Error(formatMatrixQaCliExitError(closeResult)));
}
return;
}
settleWait = { reject, resolve };
}).catch((error) => {
throw new Error(
`Matrix QA CLI command failed (${formatMatrixQaCliCommand(params.args)}): ${redactMatrixQaCliOutput(formatErrorMessage(error))}`,
);
}),
waitForOutput: async (predicate, label, timeoutMs) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const output = readOutput();
const text = `${output.stdout}\n${output.stderr}`;
if (predicate({ ...output, text })) {
return { ...output, text };
}
if (closed) {
break;
}
await sleep(Math.min(100, Math.max(25, timeoutMs - (Date.now() - startedAt))));
}
const output = readOutput();
throw new Error(
`${formatMatrixQaCliCommand(params.args)} did not print ${label} before timeout\nstdout:\n${redactMatrixQaCliOutput(output.stdout.trim())}\nstderr:\n${redactMatrixQaCliOutput(output.stderr.trim())}`,
);
},
writeStdin: async (text) => {
if (!child.stdin.write(text)) {
await new Promise<void>((resolve) => child.stdin.once("drain", resolve));
}
},
kill: () => {
if (!closed) {
child.kill("SIGTERM");
}
},
};
}
export async function runMatrixQaOpenClawCli(params: {
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
timeoutMs: number;
}): Promise<MatrixQaCliRunResult> {
return await startMatrixQaOpenClawCli(params).wait();
}

View File

@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { MatrixVerificationSummary } from "@openclaw/matrix/test-api.js";
import { createMatrixQaClient } from "../../substrate/client.js";
@@ -25,6 +28,13 @@ import {
hasMatrixQaExpectedColorReply,
MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
} from "./scenario-media-fixtures.js";
import {
formatMatrixQaCliCommand,
redactMatrixQaCliOutput,
runMatrixQaOpenClawCli,
startMatrixQaOpenClawCli,
type MatrixQaCliRunResult,
} from "./scenario-runtime-cli.js";
import {
assertThreadReplyArtifact,
assertTopLevelReplyArtifact,
@@ -40,8 +50,32 @@ import type { MatrixQaReplyArtifact, MatrixQaScenarioExecution } from "./scenari
const MATRIX_QA_ROOM_KEY_BACKUP_VERSION_ENDPOINT = "/_matrix/client/v3/room_keys/version";
const MATRIX_QA_ROOM_KEY_BACKUP_FAULT_RULE_ID = "room-key-backup-version-unavailable";
const MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID = "owner-signature-upload-blocked";
const MATRIX_QA_KEYS_SIGNATURES_UPLOAD_ENDPOINT = "/_matrix/client/v3/keys/signatures/upload";
type MatrixQaE2eeBootstrapResult = Awaited<ReturnType<typeof runMatrixQaE2eeBootstrap>>;
type MatrixQaCliVerificationStatus = {
backup?: {
decryptionKeyCached?: boolean | null;
keyLoadError?: string | null;
matchesDecryptionKey?: boolean | null;
trusted?: boolean | null;
};
crossSigningVerified?: boolean;
verified?: boolean;
signedByOwner?: boolean;
deviceId?: string | null;
userId?: string | null;
};
type MatrixQaCliBackupRestoreStatus = {
success?: boolean;
backup?: MatrixQaCliVerificationStatus["backup"];
error?: string;
};
function isMatrixQaCliBackupUsable(backup: MatrixQaCliVerificationStatus["backup"]): boolean {
return Boolean(backup?.trusted && backup.matchesDecryptionKey && !backup.keyLoadError);
}
function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) {
if (!context.outputDir) {
@@ -50,6 +84,13 @@ function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) {
return context.outputDir;
}
function requireMatrixQaCliRuntimeEnv(context: MatrixQaScenarioContext) {
if (!context.gatewayRuntimeEnv) {
throw new Error("Matrix CLI QA scenarios require the gateway runtime environment");
}
return context.gatewayRuntimeEnv;
}
function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "driver" | "observer") {
const password = actor === "driver" ? context.driverPassword : context.observerPassword;
if (!password) {
@@ -76,6 +117,9 @@ function assertMatrixQaBootstrapSucceeded(label: string, result: MatrixQaE2eeBoo
if (!result.verification.verified || !result.verification.signedByOwner) {
throw new Error(`${label} bootstrap did not leave the device verified by its owner`);
}
if (!result.verification.crossSigningVerified) {
throw new Error(`${label} bootstrap did not establish full Matrix identity trust`);
}
if (!result.crossSigning.published) {
throw new Error(`${label} bootstrap did not publish cross-signing keys`);
}
@@ -190,6 +234,242 @@ function formatMatrixQaSasEmoji(summary: MatrixVerificationSummary) {
return summary.sas?.emoji?.map(([emoji, label]) => `${emoji} ${label}`) ?? [];
}
function parseMatrixQaCliJsonText(text: string): unknown {
const candidate = text.trim();
if (!candidate) {
throw new Error("no JSON payload found");
}
return JSON.parse(candidate) as unknown;
}
function parseMatrixQaCliJson(result: MatrixQaCliRunResult): unknown {
const stdout = result.stdout.trim();
const stderr = result.stderr.trim();
if (stdout && stderr) {
throw new Error(
`${formatMatrixQaCliCommand(result.args)} printed JSON with extra output\nstdout:\n${redactMatrixQaCliOutput(stdout)}\nstderr:\n${redactMatrixQaCliOutput(stderr)}`,
);
}
if (stdout) {
try {
return parseMatrixQaCliJsonText(stdout);
} catch (error) {
throw new Error(
`${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${
error instanceof Error ? error.message : String(error)
}\nstdout:\n${redactMatrixQaCliOutput(stdout)}`,
{ cause: error },
);
}
}
if (!stderr) {
throw new Error(`${formatMatrixQaCliCommand(result.args)} did not print JSON`);
}
try {
return parseMatrixQaCliJsonText(stderr);
} catch (error) {
throw new Error(
`${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${
error instanceof Error ? error.message : String(error)
}\nstderr:\n${redactMatrixQaCliOutput(stderr)}`,
{ cause: error },
);
}
}
function parseMatrixQaCliSasText(
text: string,
label: string,
): { kind: "emoji"; value: string } | { kind: "decimal"; value: string } {
const emoji = text.match(/^SAS emoji:\s*(.+)$/m)?.[1]?.trim();
if (emoji) {
return { kind: "emoji", value: emoji };
}
const decimal = text.match(/^SAS decimals:\s*(.+)$/m)?.[1]?.trim();
if (decimal) {
return { kind: "decimal", value: decimal };
}
throw new Error(`${label} did not print SAS emoji or decimals`);
}
function parseMatrixQaCliSummaryField(text: string, field: string): string | null {
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return text.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null;
}
async function writeMatrixQaCliOutputArtifacts(params: {
label: string;
result: MatrixQaCliRunResult;
rootDir: string;
}) {
await mkdir(params.rootDir, { mode: 0o700, recursive: true });
await chmod(params.rootDir, 0o700).catch(() => undefined);
const prefix = params.label.replace(/[^A-Za-z0-9_-]/g, "-");
const stdoutPath = path.join(params.rootDir, `${prefix}.stdout.txt`);
const stderrPath = path.join(params.rootDir, `${prefix}.stderr.txt`);
await Promise.all([
writeFile(stdoutPath, redactMatrixQaCliOutput(params.result.stdout), { mode: 0o600 }),
writeFile(stderrPath, redactMatrixQaCliOutput(params.result.stderr), { mode: 0o600 }),
]);
return { stderrPath, stdoutPath };
}
async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) {
if (process.platform === "win32") {
return;
}
const mode = (await stat(pathToCheck)).mode & 0o777;
if ((mode & 0o077) !== 0) {
throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`);
}
}
function assertMatrixQaCliSasMatches(params: {
cliSas: ReturnType<typeof parseMatrixQaCliSasText>;
owner: MatrixVerificationSummary;
}) {
if (params.cliSas.kind === "emoji") {
const ownerEmoji = formatMatrixQaSasEmoji(params.owner).join(" | ");
if (!ownerEmoji) {
throw new Error("Matrix owner client did not expose SAS emoji");
}
if (params.cliSas.value !== ownerEmoji) {
throw new Error("Matrix CLI SAS emoji did not match the owner client");
}
return ownerEmoji.split(" | ");
}
const ownerDecimal = params.owner.sas?.decimal?.join(" ");
if (!ownerDecimal) {
throw new Error("Matrix owner client did not expose SAS decimals");
}
if (params.cliSas.value !== ownerDecimal) {
throw new Error("Matrix CLI SAS decimals did not match the owner client");
}
return [ownerDecimal];
}
function isMatrixQaCliOwnerSelfVerification(params: {
cliDeviceId?: string;
driverUserId: string;
requireCompleted?: boolean;
requirePending?: boolean;
requireSas?: boolean;
summary: MatrixVerificationSummary;
transactionId?: string;
}) {
const summary = params.summary;
if (
!summary.isSelfVerification ||
summary.initiatedByMe ||
summary.otherUserId !== params.driverUserId
) {
return false;
}
if (params.transactionId) {
if (summary.transactionId !== params.transactionId) {
return false;
}
} else if (params.cliDeviceId && summary.otherDeviceId !== params.cliDeviceId) {
return false;
}
if (params.requirePending === true && !summary.pending) {
return false;
}
if (params.requireSas === true && !summary.hasSas) {
return false;
}
return params.requireCompleted !== true || summary.completed;
}
async function createMatrixQaCliSelfVerificationRuntime(params: {
accountId: string;
accessToken: string;
context: MatrixQaScenarioContext;
deviceId: string;
userId: string;
}) {
const outputDir = requireMatrixQaE2eeOutputDir(params.context);
const rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-matrix-cli-qa-"));
const artifactDir = path.join(
outputDir,
"cli-self-verification",
randomUUID().replaceAll("-", "").slice(0, 12),
);
const stateDir = path.join(rootDir, "state");
const configPath = path.join(rootDir, "config.json");
await chmod(rootDir, 0o700).catch(() => undefined);
await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory");
await mkdir(artifactDir, { mode: 0o700, recursive: true });
await chmod(artifactDir, 0o700).catch(() => undefined);
await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory");
await mkdir(stateDir, { mode: 0o700, recursive: true });
await chmod(stateDir, 0o700).catch(() => undefined);
await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory");
await writeFile(
configPath,
`${JSON.stringify(
{
channels: {
matrix: {
defaultAccount: params.accountId,
accounts: {
[params.accountId]: {
accessToken: params.accessToken,
deviceId: params.deviceId,
encryption: true,
homeserver: params.context.baseUrl,
initialSyncLimit: 1,
name: "Matrix QA CLI self-verification",
network: {
dangerouslyAllowPrivateNetwork: true,
},
startupVerification: "off",
userId: params.userId,
},
},
},
},
},
null,
2,
)}\n`,
{ flag: "wx", mode: 0o600 },
);
await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file");
const env = {
...requireMatrixQaCliRuntimeEnv(params.context),
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir,
};
const run = async (args: string[], timeoutMs = params.context.timeoutMs) =>
await runMatrixQaOpenClawCli({
args,
env,
timeoutMs,
});
const start = (args: string[], timeoutMs = params.context.timeoutMs) =>
startMatrixQaOpenClawCli({
args,
env,
timeoutMs,
});
return {
configPath,
dispose: async () => {
await rm(rootDir, { force: true, recursive: true });
},
run,
rootDir: artifactDir,
start,
stateDir,
};
}
function assertMatrixQaSasEmojiMatches(params: {
initiator: MatrixVerificationSummary;
recipient: MatrixVerificationSummary;
@@ -205,20 +485,6 @@ function assertMatrixQaSasEmojiMatches(params: {
return initiatorEmoji;
}
function isMatrixQaOwnerVerificationOnlyRecoveryError(error: string | undefined) {
return error?.toLowerCase().includes("device is still not verified by its owner") === true;
}
function hasMatrixQaUsableRecoveryBackup(
result: Awaited<ReturnType<MatrixQaE2eeScenarioClient["verifyWithRecoveryKey"]>>,
) {
return (
Boolean(result.backup.serverVersion) &&
result.backup.decryptionKeyCached !== false &&
result.backup.keyLoadError === null
);
}
function isMatrixQaE2eeNoticeTriggeredSutReply(params: {
event: MatrixQaObservedEvent;
noticeEventId: string;
@@ -405,6 +671,20 @@ function buildRoomKeyBackupUnavailableFaultRule(accessToken: string): MatrixQaFa
};
}
function buildOwnerSignatureUploadBlockedFaultRule(accessToken: string): MatrixQaFaultProxyRule {
return {
id: MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID,
match: (request) =>
request.method === "POST" &&
request.path === MATRIX_QA_KEYS_SIGNATURES_UPLOAD_ENDPOINT &&
request.bearerToken === accessToken,
response: () => ({
body: {},
status: 200,
}),
};
}
async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext): Promise<{
faultHits: MatrixQaFaultProxyHit[];
result: MatrixQaE2eeBootstrapResult;
@@ -434,6 +714,77 @@ async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext)
}
}
async function runMatrixQaFaultedRecoveryOwnerVerification(params: {
accessToken: string;
context: MatrixQaScenarioContext;
deviceId: string;
encodedRecoveryKey: string;
userId: string;
}): Promise<{
faultHits: MatrixQaFaultProxyHit[];
restore: Awaited<ReturnType<MatrixQaE2eeScenarioClient["restoreRoomKeyBackup"]>>;
verification: Awaited<ReturnType<MatrixQaE2eeScenarioClient["verifyWithRecoveryKey"]>>;
}> {
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: params.context.baseUrl,
rules: [buildOwnerSignatureUploadBlockedFaultRule(params.accessToken)],
});
const recoveryClient = await createMatrixQaE2eeScenarioClient({
accessToken: params.accessToken,
actorId: `driver-recovery-${randomUUID().slice(0, 8)}`,
baseUrl: proxy.baseUrl,
deviceId: params.deviceId,
observedEvents: params.context.observedEvents,
outputDir: requireMatrixQaE2eeOutputDir(params.context),
scenarioId: "matrix-e2ee-recovery-owner-verification-required",
timeoutMs: params.context.timeoutMs,
userId: params.userId,
});
try {
const verification = await recoveryClient.verifyWithRecoveryKey(params.encodedRecoveryKey);
const restore = await waitForMatrixQaNonEmptyRoomKeyRestore({
client: recoveryClient,
recoveryKey: params.encodedRecoveryKey,
timeoutMs: params.context.timeoutMs,
});
return {
faultHits: proxy.hits(),
restore,
verification,
};
} finally {
await recoveryClient.stop().catch(() => undefined);
await proxy.stop();
}
}
function assertMatrixQaFaultedRecoveryOwnerVerificationRequired(
faulted: Awaited<ReturnType<typeof runMatrixQaFaultedRecoveryOwnerVerification>>,
) {
if (faulted.faultHits.length === 0) {
throw new Error("Matrix E2EE owner signature fault proxy was not exercised");
}
if (faulted.verification.success) {
throw new Error(
"Matrix E2EE recovery verification unexpectedly succeeded while owner signature upload was blocked",
);
}
if (!faulted.verification.recoveryKeyAccepted) {
throw new Error("Matrix E2EE recovery key was not accepted");
}
if (!faulted.verification.backupUsable) {
throw new Error("Matrix E2EE recovery key did not leave room-key backup usable");
}
if (faulted.verification.deviceOwnerVerified) {
throw new Error("Matrix E2EE recovery device should still require Matrix identity trust");
}
if (!faulted.restore.success) {
throw new Error(
`Matrix E2EE room-key backup restore failed after owner-verification fault: ${faulted.restore.error ?? "unknown error"}`,
);
}
}
function assertMatrixQaExpectedBootstrapFailure(params: {
faultHits: MatrixQaFaultProxyHit[];
result: MatrixQaE2eeBootstrapResult;
@@ -617,6 +968,7 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario(
details: [
"driver bootstrap succeeded through real Matrix crypto bootstrap",
`device verified: ${result.verification.verified ? "yes" : "no"}`,
`cross-signing verified: ${result.verification.crossSigningVerified ? "yes" : "no"}`,
`signed by owner: ${result.verification.signedByOwner ? "yes" : "no"}`,
`cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`,
`room-key backup version: ${result.verification.backupVersion ?? "<none>"}`,
@@ -677,11 +1029,7 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
let cleanupRecoveryDevice = true;
try {
const recoveryVerification = await recoveryClient.verifyWithRecoveryKey(encodedRecoveryKey);
const recoveryKeyUsable =
recoveryVerification.success ||
isMatrixQaOwnerVerificationOnlyRecoveryError(recoveryVerification.error) ||
hasMatrixQaUsableRecoveryBackup(recoveryVerification);
if (!recoveryVerification.success && !recoveryKeyUsable) {
if (!recoveryVerification.success) {
throw new Error(
`Matrix E2EE recovery device verification failed: ${recoveryVerification.error ?? "unknown error"}`,
);
@@ -709,9 +1057,10 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
bootstrapSuccess: ready.bootstrap?.success ?? true,
recoveryDeviceId: recoveryDevice.deviceId,
recoveryKeyId: recoveryKey?.keyId ?? null,
recoveryKeyUsable,
recoveryKeyUsable:
recoveryVerification.recoveryKeyAccepted && recoveryVerification.backupUsable,
recoveryKeyStored: true,
recoveryVerified: recoveryVerification.success,
recoveryVerified: recoveryVerification.deviceOwnerVerified,
restoreImported: restored.imported,
restoreTotal: restored.total,
seededEventId,
@@ -721,8 +1070,8 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
`bootstrap backup version: ${ready.verification.backupVersion ?? "<none>"}`,
`seeded encrypted event: ${seededEventId}`,
`recovery device: ${recoveryDevice.deviceId}`,
`recovery key usable: ${recoveryKeyUsable ? "yes" : "no"}`,
`recovery device verified: ${recoveryVerification.success ? "yes" : "no"}`,
`recovery key usable: ${recoveryVerification.backupUsable ? "yes" : "no"}`,
`recovery device verified: ${recoveryVerification.deviceOwnerVerified ? "yes" : "no"}`,
`restore imported/total: ${restored.imported}/${restored.total}`,
`restore loaded from secret storage: ${restored.loadedFromSecretStorage ? "yes" : "no"}`,
`reset previous version: ${reset.previousVersion ?? "<none>"}`,
@@ -739,6 +1088,310 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
);
}
export async function runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(
context: MatrixQaScenarioContext,
): Promise<MatrixQaScenarioExecution> {
const driverPassword = requireMatrixQaPassword(context, "driver");
return await withMatrixQaE2eeDriver(
context,
"matrix-e2ee-recovery-owner-verification-required",
async (client) => {
const { roomId } = resolveMatrixQaE2eeScenarioGroupRoom(
context,
"matrix-e2ee-recovery-owner-verification-required",
);
const ready = await ensureMatrixQaE2eeOwnDeviceVerified({
client,
label: "driver",
});
const recoveryKey = ready.recoveryKey;
const encodedRecoveryKey = recoveryKey?.encodedPrivateKey?.trim();
if (!encodedRecoveryKey) {
throw new Error("Matrix E2EE bootstrap did not expose an encoded recovery key");
}
const seededEventId = await client.sendTextMessage({
body: `E2EE recovery owner-verification seed ${randomUUID().slice(0, 8)}`,
roomId,
});
const loginClient = createMatrixQaClient({
baseUrl: context.baseUrl,
});
const recoveryDevice = await loginClient.loginWithPassword({
deviceName: "OpenClaw Matrix QA Owner Verification Required Device",
password: driverPassword,
userId: context.driverUserId,
});
if (!recoveryDevice.deviceId) {
throw new Error("Matrix E2EE recovery login did not return a secondary device id");
}
try {
const faulted = await runMatrixQaFaultedRecoveryOwnerVerification({
accessToken: recoveryDevice.accessToken,
context,
deviceId: recoveryDevice.deviceId,
encodedRecoveryKey,
userId: recoveryDevice.userId,
});
assertMatrixQaFaultedRecoveryOwnerVerificationRequired(faulted);
return {
artifacts: {
backupRestored: faulted.restore.success,
backupUsable: faulted.verification.backupUsable,
faultHitCount: faulted.faultHits.length,
faultedEndpoints: faulted.faultHits.map((hit) => hit.path),
faultRuleId: MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID,
recoveryDeviceId: recoveryDevice.deviceId,
recoveryKeyAccepted: faulted.verification.recoveryKeyAccepted,
recoveryKeyId: recoveryKey?.keyId ?? null,
recoveryVerified: faulted.verification.deviceOwnerVerified,
restoreImported: faulted.restore.imported,
restoreTotal: faulted.restore.total,
verificationSuccess: faulted.verification.success,
},
details: [
"driver recovery key unlocked backup while owner signature upload was blocked",
`seeded encrypted event: ${seededEventId}`,
`recovery device: ${recoveryDevice.deviceId}`,
`fault hits: ${faulted.faultHits.length}`,
`recovery key accepted: ${faulted.verification.recoveryKeyAccepted ? "yes" : "no"}`,
`backup usable: ${faulted.verification.backupUsable ? "yes" : "no"}`,
`device owner verified: ${faulted.verification.deviceOwnerVerified ? "yes" : "no"}`,
`restore imported/total: ${faulted.restore.imported}/${faulted.restore.total}`,
].join("\n"),
};
} finally {
await client.deleteOwnDevices([recoveryDevice.deviceId]).catch(() => undefined);
}
},
);
}
export async function runMatrixQaE2eeCliSelfVerificationScenario(
context: MatrixQaScenarioContext,
): Promise<MatrixQaScenarioExecution> {
const driverPassword = requireMatrixQaPassword(context, "driver");
const accountId = "cli";
return await withMatrixQaE2eeDriver(
context,
"matrix-e2ee-cli-self-verification",
async (owner) => {
const ownerReady = await ensureMatrixQaE2eeOwnDeviceVerified({
client: owner,
label: "driver",
});
const encodedRecoveryKey = ownerReady.recoveryKey?.encodedPrivateKey?.trim();
if (!encodedRecoveryKey) {
throw new Error("Matrix E2EE self-verification scenario did not expose a recovery key");
}
const loginClient = createMatrixQaClient({
baseUrl: context.baseUrl,
});
const cliDevice = await loginClient.loginWithPassword({
deviceName: "OpenClaw Matrix QA CLI Self Verification Device",
password: driverPassword,
userId: context.driverUserId,
});
if (!cliDevice.deviceId) {
throw new Error("Matrix E2EE CLI verification login did not return a device id");
}
const cli = await createMatrixQaCliSelfVerificationRuntime({
accountId,
accessToken: cliDevice.accessToken,
context,
deviceId: cliDevice.deviceId,
userId: cliDevice.userId,
});
try {
const restoreResult = await cli.run([
"matrix",
"verify",
"backup",
"restore",
"--account",
accountId,
"--recovery-key",
encodedRecoveryKey,
"--json",
]);
const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({
label: "verify-backup-restore",
result: restoreResult,
rootDir: cli.rootDir,
});
const restored = parseMatrixQaCliJson(restoreResult) as MatrixQaCliBackupRestoreStatus;
if (
restored.success !== true ||
restored.backup?.decryptionKeyCached !== true ||
restored.backup?.matchesDecryptionKey !== true ||
restored.backup?.keyLoadError
) {
throw new Error(
`Matrix CLI recovery key did not load matching room-key backup material before self-verification: ${
restored.error ?? restored.backup?.keyLoadError ?? "unknown backup state"
}`,
);
}
const session = cli.start(["matrix", "verify", "self", "--account", accountId]);
try {
const requestOutput = await session.waitForOutput(
(output) => output.text.includes("Accept this verification request"),
"self-verification request guidance",
context.timeoutMs,
);
const cliTransactionId = parseMatrixQaCliSummaryField(
requestOutput.text,
"Transaction id",
);
const ownerRequested = await waitForMatrixQaVerificationSummary({
client: owner,
label: "owner received CLI self-verification request",
predicate: (summary) =>
isMatrixQaCliOwnerSelfVerification({
cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId,
driverUserId: context.driverUserId,
requirePending: true,
summary,
transactionId: cliTransactionId ?? undefined,
}),
timeoutMs: context.timeoutMs,
});
if (ownerRequested.canAccept) {
await owner.acceptVerification(ownerRequested.id);
}
const sasOutput = await session.waitForOutput(
(output) => /^SAS (?:emoji|decimals):/m.test(output.text),
"SAS emoji or decimals",
context.timeoutMs,
);
const cliSas = parseMatrixQaCliSasText(
sasOutput.text,
"interactive openclaw matrix verify self",
);
const ownerSas = await waitForMatrixQaVerificationSummary({
client: owner,
label: "owner SAS for CLI self-verification",
predicate: (summary) =>
isMatrixQaCliOwnerSelfVerification({
cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId,
driverUserId: context.driverUserId,
requireSas: true,
summary,
transactionId: cliTransactionId ?? undefined,
}),
timeoutMs: context.timeoutMs,
});
const sasArtifact = assertMatrixQaCliSasMatches({
cliSas,
owner: ownerSas,
});
await session.writeStdin("yes\n");
await owner.confirmVerificationSas(ownerSas.id);
const completedCli = await session.wait();
const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({
label: "verify-self",
result: completedCli,
rootDir: cli.rootDir,
});
if (!/^Device verified by owner:\s*yes$/m.test(completedCli.stdout)) {
throw new Error(
"Interactive Matrix CLI self-verification did not report final device verification",
);
}
if (!/^Cross-signing verified:\s*yes$/m.test(completedCli.stdout)) {
throw new Error(
"Interactive Matrix CLI self-verification did not report full Matrix identity trust",
);
}
const completedOwner = await waitForMatrixQaVerificationSummary({
client: owner,
label: "owner completed CLI self-verification",
predicate: (summary) =>
isMatrixQaCliOwnerSelfVerification({
cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId,
driverUserId: context.driverUserId,
requireCompleted: true,
summary,
transactionId: cliTransactionId ?? undefined,
}),
timeoutMs: context.timeoutMs,
});
const cliVerificationId =
completedCli.stdout.match(/^Verification id:\s*(\S+)/m)?.[1] ?? "interactive-cli";
const statusResult = await cli.run([
"matrix",
"verify",
"status",
"--account",
accountId,
"--json",
]);
const statusArtifacts = await writeMatrixQaCliOutputArtifacts({
label: "verify-status",
result: statusResult,
rootDir: cli.rootDir,
});
const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus;
if (
status.verified !== true ||
status.crossSigningVerified !== true ||
status.signedByOwner !== true ||
status.backup?.trusted !== true ||
status.backup?.matchesDecryptionKey !== true ||
status.backup?.keyLoadError
) {
throw new Error(
`Matrix CLI device was not fully usable after SAS completion: ownerVerified=${
status.verified === true &&
status.crossSigningVerified === true &&
status.signedByOwner === true
? "yes"
: "no"
}, backupUsable=${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}${
status.backup?.keyLoadError ? `, backupError=${status.backup.keyLoadError}` : ""
}`,
);
}
return {
artifacts: {
completedVerificationIds: [cliVerificationId, completedOwner.id],
currentDeviceId: status.deviceId ?? cliDevice.deviceId,
...(cliSas.kind === "emoji" ? { sasEmoji: sasArtifact } : {}),
secondaryDeviceId: cliDevice.deviceId,
},
details: [
"Matrix CLI self-verification established full Matrix identity trust through interactive openclaw matrix verify self",
"cli secret config cleaned after run: yes",
`cli backup restore stdout: ${restoreArtifacts.stdoutPath}`,
`cli backup restore stderr: ${restoreArtifacts.stderrPath}`,
`cli verify self stdout: ${selfVerificationArtifacts.stdoutPath}`,
`cli verify self stderr: ${selfVerificationArtifacts.stderrPath}`,
`cli verify status stdout: ${statusArtifacts.stdoutPath}`,
`cli verify status stderr: ${statusArtifacts.stderrPath}`,
`cli device: ${cliDevice.deviceId}`,
`cli verification id: ${cliVerificationId}`,
`owner-side verification id: ${completedOwner.id}`,
`transaction: ${completedOwner.transactionId ?? "<none>"}`,
`cli verified by owner: ${status.verified ? "yes" : "no"}`,
`cli cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`,
`cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`,
].join("\n"),
};
} finally {
session.kill();
}
} finally {
try {
await cli.dispose();
} finally {
await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined);
}
}
},
);
}
export async function runMatrixQaE2eeDeviceSasVerificationScenario(
context: MatrixQaScenarioContext,
): Promise<MatrixQaScenarioExecution> {

View File

@@ -27,6 +27,7 @@ export type MatrixQaScenarioContext = {
observerDeviceId?: string;
observerPassword?: string;
observerUserId: string;
gatewayRuntimeEnv?: NodeJS.ProcessEnv;
gatewayStateDir?: string;
outputDir?: string;
restartGateway?: () => Promise<void>;

View File

@@ -12,12 +12,14 @@ import {
runMatrixQaE2eeArtifactRedactionScenario,
runMatrixQaE2eeBasicReplyScenario,
runMatrixQaE2eeBootstrapSuccessScenario,
runMatrixQaE2eeCliSelfVerificationScenario,
runMatrixQaE2eeDeviceSasVerificationScenario,
runMatrixQaE2eeDmSasVerificationScenario,
runMatrixQaE2eeKeyBootstrapFailureScenario,
runMatrixQaE2eeMediaImageScenario,
runMatrixQaE2eeQrVerificationScenario,
runMatrixQaE2eeRecoveryKeyLifecycleScenario,
runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario,
runMatrixQaE2eeRestartResumeScenario,
runMatrixQaE2eeStaleDeviceHygieneScenario,
runMatrixQaE2eeThreadFollowUpScenario,
@@ -308,6 +310,10 @@ export async function runMatrixQaScenario(
return await runMatrixQaE2eeBootstrapSuccessScenario(context);
case "matrix-e2ee-recovery-key-lifecycle":
return await runMatrixQaE2eeRecoveryKeyLifecycleScenario(context);
case "matrix-e2ee-recovery-owner-verification-required":
return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context);
case "matrix-e2ee-cli-self-verification":
return await runMatrixQaE2eeCliSelfVerificationScenario(context);
case "matrix-e2ee-device-sas-verification":
return await runMatrixQaE2eeDeviceSasVerificationScenario(context);
case "matrix-e2ee-qr-verification":

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, beforeEach, vi } from "vitest";
@@ -11,6 +11,19 @@ const { createMatrixQaE2eeScenarioClient, runMatrixQaE2eeBootstrap, startMatrixQ
runMatrixQaE2eeBootstrap: vi.fn(),
startMatrixQaFaultProxy: vi.fn(),
}));
const {
formatMatrixQaCliCommand,
redactMatrixQaCliOutput,
resolveMatrixQaOpenClawCliEntryPath,
runMatrixQaOpenClawCli,
startMatrixQaOpenClawCli,
} = vi.hoisted(() => ({
formatMatrixQaCliCommand: (args: string[]) => `openclaw ${args.join(" ")}`,
redactMatrixQaCliOutput: (text: string) => text,
resolveMatrixQaOpenClawCliEntryPath: (cwd: string) => `${cwd}/dist/index.js`,
runMatrixQaOpenClawCli: vi.fn(),
startMatrixQaOpenClawCli: vi.fn(),
}));
vi.mock("../../substrate/client.js", () => ({
createMatrixQaClient,
@@ -22,6 +35,13 @@ vi.mock("../../substrate/e2ee-client.js", () => ({
vi.mock("../../substrate/fault-proxy.js", () => ({
startMatrixQaFaultProxy,
}));
vi.mock("./scenario-runtime-cli.js", () => ({
formatMatrixQaCliCommand,
redactMatrixQaCliOutput,
resolveMatrixQaOpenClawCliEntryPath,
runMatrixQaOpenClawCli,
startMatrixQaOpenClawCli,
}));
import {
LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
@@ -95,6 +115,8 @@ describe("matrix live qa scenarios", () => {
createMatrixQaClient.mockReset();
createMatrixQaE2eeScenarioClient.mockReset();
runMatrixQaE2eeBootstrap.mockReset();
runMatrixQaOpenClawCli.mockReset();
startMatrixQaOpenClawCli.mockReset();
startMatrixQaFaultProxy.mockReset();
});
@@ -145,6 +167,8 @@ describe("matrix live qa scenarios", () => {
"matrix-e2ee-thread-follow-up",
"matrix-e2ee-bootstrap-success",
"matrix-e2ee-recovery-key-lifecycle",
"matrix-e2ee-recovery-owner-verification-required",
"matrix-e2ee-cli-self-verification",
"matrix-e2ee-device-sas-verification",
"matrix-e2ee-qr-verification",
"matrix-e2ee-stale-device-hygiene",
@@ -2583,9 +2607,10 @@ describe("matrix live qa scenarios", () => {
serverVersion: "backup-v1",
trusted: true,
},
error:
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
success: false,
backupUsable: true,
deviceOwnerVerified: true,
recoveryKeyAccepted: true,
success: true,
});
const restoreRoomKeyBackup = vi.fn().mockResolvedValue({
imported: 1,
@@ -2618,6 +2643,7 @@ describe("matrix live qa scenarios", () => {
success: true,
verification: {
backupVersion: "backup-v1",
crossSigningVerified: true,
recoveryKeyStored: true,
signedByOwner: true,
verified: true,
@@ -2687,7 +2713,7 @@ describe("matrix live qa scenarios", () => {
backupRestored: true,
recoveryDeviceId: "RECOVERYDEVICE",
recoveryKeyUsable: true,
recoveryVerified: false,
recoveryVerified: true,
restoreImported: 1,
restoreTotal: 1,
},
@@ -2699,6 +2725,460 @@ describe("matrix live qa scenarios", () => {
);
});
it("keeps recovery-key backup access distinct from Matrix identity trust in Matrix E2EE QA", async () => {
const verifyWithRecoveryKey = vi.fn().mockResolvedValue({
backupUsable: true,
deviceOwnerVerified: false,
error:
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
recoveryKeyAccepted: true,
success: false,
});
const restoreRoomKeyBackup = vi.fn().mockResolvedValue({
imported: 1,
loadedFromSecretStorage: true,
success: true,
total: 1,
});
const driverDeleteOwnDevices = vi.fn().mockResolvedValue(undefined);
const driverStop = vi.fn().mockResolvedValue(undefined);
const recoveryStop = vi.fn().mockResolvedValue(undefined);
const proxyStop = vi.fn().mockResolvedValue(undefined);
const proxyHits = vi.fn().mockReturnValue([
{
method: "POST",
path: "/_matrix/client/v3/keys/signatures/upload",
ruleId: "owner-signature-upload-blocked",
},
]);
startMatrixQaFaultProxy.mockResolvedValue({
baseUrl: "http://127.0.0.1:39877",
hits: proxyHits,
stop: proxyStop,
});
createMatrixQaClient.mockReturnValue({
loginWithPassword: vi.fn().mockResolvedValue({
accessToken: "recovery-token",
deviceId: "RECOVERYDEVICE",
password: "driver-password",
userId: "@driver:matrix-qa.test",
}),
});
createMatrixQaE2eeScenarioClient
.mockResolvedValueOnce({
bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({
crossSigning: {
published: true,
},
success: true,
verification: {
backupVersion: "backup-v1",
crossSigningVerified: true,
recoveryKeyStored: true,
signedByOwner: true,
verified: true,
},
}),
deleteOwnDevices: driverDeleteOwnDevices,
getRecoveryKey: vi.fn().mockResolvedValue({
encodedPrivateKey: "encoded-recovery-key",
keyId: "SSSS",
}),
sendTextMessage: vi.fn().mockResolvedValue("$seeded-event"),
stop: driverStop,
})
.mockResolvedValueOnce({
restoreRoomKeyBackup,
stop: recoveryStop,
verifyWithRecoveryKey,
});
const scenario = MATRIX_QA_SCENARIOS.find(
(entry) => entry.id === "matrix-e2ee-recovery-owner-verification-required",
);
expect(scenario).toBeDefined();
await expect(
runMatrixQaScenario(scenario!, {
baseUrl: "http://127.0.0.1:28008/",
canary: undefined,
driverAccessToken: "driver-token",
driverDeviceId: "DRIVERDEVICE",
driverPassword: "driver-password",
driverUserId: "@driver:matrix-qa.test",
observedEvents: [],
observerAccessToken: "observer-token",
observerUserId: "@observer:matrix-qa.test",
outputDir: "/tmp/matrix-qa",
roomId: "!main:matrix-qa.test",
restartGateway: undefined,
syncState: {},
sutAccessToken: "sut-token",
sutUserId: "@sut:matrix-qa.test",
timeoutMs: 8_000,
topology: {
defaultRoomId: "!main:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
encrypted: true,
key: matrixQaE2eeRoomKey("matrix-e2ee-recovery-owner-verification-required"),
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "E2EE",
requireMention: true,
roomId: "!e2ee:matrix-qa.test",
},
],
},
}),
).resolves.toMatchObject({
artifacts: {
backupRestored: true,
backupUsable: true,
faultHitCount: 1,
faultRuleId: "owner-signature-upload-blocked",
recoveryDeviceId: "RECOVERYDEVICE",
recoveryKeyAccepted: true,
recoveryVerified: false,
restoreImported: 1,
restoreTotal: 1,
verificationSuccess: false,
},
});
const proxyArgs = startMatrixQaFaultProxy.mock.calls[0]?.[0];
expect(proxyArgs).toBeDefined();
if (!proxyArgs) {
throw new Error("expected Matrix QA fault proxy to start");
}
const [faultRule] = proxyArgs.rules;
expect(faultRule).toBeDefined();
if (!faultRule) {
throw new Error("expected Matrix QA fault proxy rule");
}
expect(proxyArgs.targetBaseUrl).toBe("http://127.0.0.1:28008/");
expect(
faultRule.match({
bearerToken: "recovery-token",
headers: {},
method: "POST",
path: "/_matrix/client/v3/keys/signatures/upload",
search: "",
}),
).toBe(true);
expect(
faultRule.match({
bearerToken: "recovery-token",
headers: {},
method: "GET",
path: "/_matrix/client/v3/user/%40driver%3Amatrix-qa.test/account_data/m.megolm_backup.v1",
search: "",
}),
).toBe(false);
expect(createMatrixQaE2eeScenarioClient).toHaveBeenLastCalledWith(
expect.objectContaining({
accessToken: "recovery-token",
baseUrl: "http://127.0.0.1:39877",
deviceId: "RECOVERYDEVICE",
scenarioId: "matrix-e2ee-recovery-owner-verification-required",
}),
);
expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key");
expect(restoreRoomKeyBackup).toHaveBeenCalledWith({
recoveryKey: "encoded-recovery-key",
});
expect(driverDeleteOwnDevices).toHaveBeenCalledWith(["RECOVERYDEVICE"]);
expect(recoveryStop).toHaveBeenCalledTimes(1);
expect(proxyStop).toHaveBeenCalledTimes(1);
});
it("runs Matrix self-verification through the interactive CLI command", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-self-verification-"));
try {
const acceptVerification = vi.fn().mockResolvedValue(undefined);
const confirmVerificationSas = vi.fn().mockResolvedValue(undefined);
const deleteOwnDevices = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockResolvedValue(undefined);
const bootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({
crossSigning: {
published: true,
},
success: true,
verification: {
backupVersion: "backup-v1",
crossSigningVerified: true,
recoveryKeyStored: true,
signedByOwner: true,
verified: true,
},
});
const baseSummary = {
canAccept: false,
chosenMethod: "m.sas.v1",
completed: false,
createdAt: "2026-04-22T12:00:00.000Z",
error: undefined,
hasReciprocateQr: false,
methods: ["m.sas.v1"],
otherDeviceId: "CLIDEVICE",
otherUserId: "@driver:matrix-qa.test",
pending: true,
phase: 2,
phaseName: "ready",
roomId: undefined,
transactionId: "tx-cli-self",
updatedAt: "2026-04-22T12:00:00.000Z",
};
const listVerifications = vi
.fn()
.mockResolvedValueOnce([
{
...baseSummary,
canAccept: true,
hasSas: false,
id: "owner-request",
initiatedByMe: false,
isSelfVerification: true,
phaseName: "requested",
},
])
.mockResolvedValueOnce([
{
...baseSummary,
hasSas: true,
id: "owner-request",
initiatedByMe: false,
isSelfVerification: true,
sas: {
emoji: [["🐶", "Dog"]],
},
},
])
.mockResolvedValueOnce([
{
...baseSummary,
completed: true,
hasSas: true,
id: "owner-request",
initiatedByMe: false,
isSelfVerification: true,
pending: false,
phaseName: "done",
sas: {
emoji: [["🐶", "Dog"]],
},
},
]);
createMatrixQaClient.mockReturnValue({
loginWithPassword: vi.fn().mockResolvedValue({
accessToken: "cli-token",
deviceId: "CLIDEVICE",
password: "driver-password",
userId: "@driver:matrix-qa.test",
}),
});
createMatrixQaE2eeScenarioClient.mockResolvedValueOnce({
acceptVerification,
bootstrapOwnDeviceVerification,
confirmVerificationSas,
deleteOwnDevices,
getRecoveryKey: vi.fn().mockResolvedValue({
encodedPrivateKey: "encoded-recovery-key",
keyId: "SSSS",
}),
listVerifications,
stop,
});
const waitForOutput = vi
.fn()
.mockResolvedValueOnce({
stderr: "",
stdout:
"Verification id: verification-1\nTransaction id: tx-cli-self\nAccept this verification request in another Matrix client.\n",
text: "Verification id: verification-1\nTransaction id: tx-cli-self\nAccept this verification request in another Matrix client.\n",
})
.mockResolvedValueOnce({
stderr: "",
stdout: "Verification id: verification-1\nSAS emoji: 🐶 Dog\n",
text: "Verification id: verification-1\nSAS emoji: 🐶 Dog\n",
});
const writeStdin = vi.fn().mockResolvedValue(undefined);
const wait = vi.fn().mockResolvedValue({
args: ["matrix", "verify", "self", "--account", "cli"],
exitCode: 0,
stderr: "",
stdout:
"Verification id: verification-1\nCompleted: yes\nDevice verified by owner: yes\nCross-signing verified: yes\n",
});
const kill = vi.fn();
startMatrixQaOpenClawCli.mockReturnValue({
args: ["matrix", "verify", "self", "--account", "cli"],
kill,
output: vi.fn(() => ({ stderr: "", stdout: "" })),
wait,
waitForOutput,
writeStdin,
});
let cliAccountConfigDuringRun: Record<string, unknown> | null = null;
runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => {
if (!cliAccountConfigDuringRun && env.OPENCLAW_CONFIG_PATH) {
const cliConfig = JSON.parse(
await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"),
) as {
channels?: {
matrix?: {
accounts?: Record<string, Record<string, unknown>>;
};
};
};
cliAccountConfigDuringRun = cliConfig.channels?.matrix?.accounts?.cli ?? null;
}
const joined = args.join(" ");
if (joined === "matrix verify status --account cli --json") {
return {
args,
exitCode: 0,
stderr: "",
stdout: JSON.stringify({
backup: {
decryptionKeyCached: true,
keyLoadError: null,
matchesDecryptionKey: true,
trusted: true,
},
crossSigningVerified: true,
deviceId: "CLIDEVICE",
signedByOwner: true,
userId: "@driver:matrix-qa.test",
verified: true,
}),
};
}
if (
joined ===
"matrix verify backup restore --account cli --recovery-key encoded-recovery-key --json"
) {
return {
args,
exitCode: 0,
stderr: "",
stdout: JSON.stringify({
backup: {
decryptionKeyCached: true,
keyLoadError: null,
matchesDecryptionKey: true,
trusted: false,
},
success: true,
}),
};
}
throw new Error(`unexpected CLI command: ${joined}`);
});
const scenario = MATRIX_QA_SCENARIOS.find(
(entry) => entry.id === "matrix-e2ee-cli-self-verification",
);
expect(scenario).toBeDefined();
await expect(
runMatrixQaScenario(scenario!, {
...matrixQaScenarioContext(),
driverDeviceId: "DRIVERDEVICE",
driverPassword: "driver-password",
gatewayRuntimeEnv: {
OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json",
OPENCLAW_STATE_DIR: "/tmp/gateway-state",
PATH: process.env.PATH,
},
outputDir,
}),
).resolves.toMatchObject({
artifacts: {
completedVerificationIds: ["verification-1", "owner-request"],
currentDeviceId: "CLIDEVICE",
sasEmoji: ["🐶 Dog"],
secondaryDeviceId: "CLIDEVICE",
},
});
expect(startMatrixQaOpenClawCli).toHaveBeenCalledTimes(1);
expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].args).toEqual([
"matrix",
"verify",
"self",
"--account",
"cli",
]);
expect(waitForOutput).toHaveBeenCalledTimes(2);
expect(writeStdin).toHaveBeenCalledWith("yes\n");
expect(wait).toHaveBeenCalledTimes(1);
expect(kill).toHaveBeenCalledTimes(1);
expect(runMatrixQaOpenClawCli).toHaveBeenCalledTimes(2);
expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([
[
"matrix",
"verify",
"backup",
"restore",
"--account",
"cli",
"--recovery-key",
"encoded-recovery-key",
"--json",
],
["matrix", "verify", "status", "--account", "cli", "--json"],
]);
const cliEnv = startMatrixQaOpenClawCli.mock.calls[0]?.[0].env;
expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("openclaw-matrix-cli-qa-");
expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("openclaw-matrix-cli-qa-");
const configPath = String(cliEnv?.OPENCLAW_CONFIG_PATH);
expect(cliAccountConfigDuringRun).toMatchObject({
accessToken: "cli-token",
deviceId: "CLIDEVICE",
encryption: true,
homeserver: "http://127.0.0.1:28008/",
startupVerification: "off",
userId: "@driver:matrix-qa.test",
});
await expect(readFile(configPath, "utf8")).rejects.toThrow();
await expect(readdir(String(cliEnv?.OPENCLAW_STATE_DIR))).rejects.toThrow();
expect(acceptVerification).toHaveBeenCalledWith("owner-request");
expect(confirmVerificationSas).toHaveBeenCalledWith("owner-request");
expect(deleteOwnDevices).toHaveBeenCalledWith(["CLIDEVICE"]);
const [cliRunDir] = await readdir(path.join(outputDir, "cli-self-verification"));
const cliArtifactDir = path.join(outputDir, "cli-self-verification", cliRunDir ?? "");
await expect(stat(cliArtifactDir)).resolves.toMatchObject({ mode: expect.any(Number) });
expect((await stat(cliArtifactDir)).mode & 0o777).toBe(0o700);
await expect(
readFile(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"), "utf8"),
).resolves.toContain('"success":true');
expect(
(await stat(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"))).mode & 0o777,
).toBe(0o600);
await expect(
readFile(path.join(cliArtifactDir, "verify-self.stdout.txt"), "utf8"),
).resolves.toContain("Device verified by owner: yes");
await expect(
readFile(path.join(cliArtifactDir, "verify-self.stdout.txt"), "utf8"),
).resolves.toContain("Cross-signing verified: yes");
await expect(
readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"),
).resolves.toContain('"verified":true');
await expect(
readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"),
).resolves.toContain('"crossSigningVerified":true');
} finally {
await rm(outputDir, { force: true, recursive: true });
}
});
it("runs Matrix E2EE bootstrap failure through a real faulted homeserver endpoint", async () => {
const stop = vi.fn().mockResolvedValue(undefined);
const hits = vi.fn().mockReturnValue([

View File

@@ -46,8 +46,10 @@ const MATRIX_QA_E2EE_SYNC_FILTER = {
export type MatrixQaE2eeScenarioClient = {
acceptVerification(id: string): Promise<MatrixVerificationSummary>;
bootstrapOwnDeviceVerification(params?: {
allowAutomaticCrossSigningReset?: boolean;
forceResetCrossSigning?: boolean;
recoveryKey?: string;
verifyOwnIdentity?: boolean;
}): Promise<MatrixVerificationBootstrapResult>;
confirmVerificationReciprocateQr(id: string): Promise<MatrixVerificationSummary>;
confirmVerificationSas(id: string): Promise<MatrixVerificationSummary>;