mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:10:49 +00:00
matrix: require full identity trust
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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.`
|
||||
|
||||
|
||||
@@ -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,245 @@ 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(
|
||||
"- Accept the verification request in another Matrix client for this account.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Then run 'openclaw matrix verify start self-verify-1 --account ops' to start SAS verification.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Run 'openclaw matrix verify sas self-verify-1 --account ops' to display the SAS emoji or decimals.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- When the SAS matches, run 'openclaw matrix verify confirm-sas self-verify-1 --account ops'.",
|
||||
);
|
||||
});
|
||||
|
||||
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("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("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,
|
||||
@@ -360,7 +644,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");
|
||||
@@ -630,7 +914,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 +1049,7 @@ describe("matrix CLI verification commands", () => {
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect(stdoutWriteMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"error": "Matrix requires --homeserver"'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,11 +6,20 @@ import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accou
|
||||
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
|
||||
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
|
||||
import {
|
||||
acceptMatrixVerification,
|
||||
bootstrapMatrixVerification,
|
||||
cancelMatrixVerification,
|
||||
confirmMatrixVerificationSas,
|
||||
getMatrixVerificationSas,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
listMatrixVerifications,
|
||||
mismatchMatrixVerificationSas,
|
||||
requestMatrixVerification,
|
||||
resetMatrixRoomKeyBackup,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
runMatrixSelfVerification,
|
||||
startMatrixVerification,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./matrix/actions/verification.js";
|
||||
import { resolveMatrixRoomKeyBackupIssue } from "./matrix/backup-health.js";
|
||||
@@ -53,7 +62,11 @@ function scheduleMatrixCliExit(): void {
|
||||
matrixCliExitScheduled = true;
|
||||
// matrix-js-sdk rust crypto can leave background async work alive after command completion.
|
||||
setTimeout(() => {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
process.stdout.write("", () => {
|
||||
process.stderr.write("", () => {
|
||||
process.exit(process.exitCode ?? 0);
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -66,7 +79,7 @@ function toErrorMessage(err: unknown): string {
|
||||
}
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function formatLocalTimestamp(value: string | null | undefined): string | null {
|
||||
@@ -92,8 +105,7 @@ function printAccountLabel(accountId?: string): void {
|
||||
}
|
||||
|
||||
function resolveMatrixCliAccountId(accountId?: string): string {
|
||||
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
|
||||
return resolveMatrixAuthContext({ cfg, accountId }).accountId;
|
||||
return resolveMatrixCliAccountContext(accountId).accountId;
|
||||
}
|
||||
|
||||
function resolveMatrixCliAccountContext(accountId?: string): {
|
||||
@@ -301,7 +313,7 @@ async function addMatrixAccount(params: {
|
||||
staleOpenClawDeviceIds: [],
|
||||
};
|
||||
try {
|
||||
const addedDevices = await listMatrixOwnDevices({ accountId });
|
||||
const addedDevices = await listMatrixOwnDevices({ accountId, cfg: updated });
|
||||
deviceHealth = {
|
||||
currentDeviceId: addedDevices.find((device) => device.current)?.deviceId ?? null,
|
||||
staleOpenClawDeviceIds: addedDevices
|
||||
@@ -357,12 +369,13 @@ async function inspectMatrixDirectRoom(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}): Promise<MatrixCliDirectRoomInspection> {
|
||||
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
|
||||
const [{ withResolvedActionClient }, { inspectMatrixDirectRooms }] = await Promise.all([
|
||||
loadMatrixActionClientModule(),
|
||||
loadMatrixDirectManagementModule(),
|
||||
]);
|
||||
return await withResolvedActionClient(
|
||||
{ accountId: params.accountId },
|
||||
{ accountId: params.accountId, cfg },
|
||||
async (client) => {
|
||||
const inspection = await inspectMatrixDirectRooms({
|
||||
client,
|
||||
@@ -392,7 +405,7 @@ async function repairMatrixDirectRoom(params: {
|
||||
loadMatrixActionClientModule(),
|
||||
loadMatrixDirectManagementModule(),
|
||||
]);
|
||||
return await withStartedActionClient({ accountId: params.accountId }, async (client) => {
|
||||
return await withStartedActionClient({ accountId: params.accountId, cfg }, async (client) => {
|
||||
const repaired = await repairMatrixDirectRooms({
|
||||
client,
|
||||
remoteUserId: params.userId,
|
||||
@@ -490,6 +503,43 @@ type MatrixCliVerificationStatus = {
|
||||
recoveryKeyStored: boolean;
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
pendingVerifications: number;
|
||||
recoveryKeyAccepted?: boolean;
|
||||
backupUsable?: boolean;
|
||||
deviceOwnerVerified?: boolean;
|
||||
};
|
||||
|
||||
type MatrixCliVerificationCommandOptions = {
|
||||
account?: string;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type MatrixCliSelfVerificationCommandOptions = {
|
||||
account?: string;
|
||||
timeoutMs?: string;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type MatrixCliVerificationSummary = {
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
otherUserId: string;
|
||||
otherDeviceId?: string;
|
||||
isSelfVerification: boolean;
|
||||
initiatedByMe: boolean;
|
||||
phaseName: string;
|
||||
pending: boolean;
|
||||
methods: string[];
|
||||
chosenMethod?: string | null;
|
||||
hasSas: boolean;
|
||||
sas?: MatrixCliVerificationSas;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type MatrixCliVerificationSas = {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
|
||||
type MatrixCliDirectRoomCandidate = {
|
||||
@@ -595,6 +645,151 @@ function printVerificationTrustDiagnostics(status: {
|
||||
console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`);
|
||||
}
|
||||
|
||||
function printMatrixVerificationSummary(summary: MatrixCliVerificationSummary): void {
|
||||
console.log(`Verification id: ${summary.id}`);
|
||||
if (summary.transactionId) {
|
||||
console.log(`Transaction id: ${summary.transactionId}`);
|
||||
}
|
||||
console.log(`Other user: ${summary.otherUserId}`);
|
||||
console.log(`Other device: ${summary.otherDeviceId ?? "unknown"}`);
|
||||
console.log(`Self-verification: ${summary.isSelfVerification ? "yes" : "no"}`);
|
||||
console.log(`Initiated by OpenClaw: ${summary.initiatedByMe ? "yes" : "no"}`);
|
||||
console.log(`Phase: ${summary.phaseName}`);
|
||||
console.log(`Pending: ${summary.pending ? "yes" : "no"}`);
|
||||
console.log(`Completed: ${summary.completed ? "yes" : "no"}`);
|
||||
console.log(`Methods: ${summary.methods.length ? summary.methods.join(", ") : "none"}`);
|
||||
if (summary.chosenMethod) {
|
||||
console.log(`Chosen method: ${summary.chosenMethod}`);
|
||||
}
|
||||
if (summary.hasSas && summary.sas?.emoji?.length) {
|
||||
console.log(
|
||||
`SAS emoji: ${summary.sas.emoji.map(([emoji, label]) => `${emoji} ${label}`).join(" | ")}`,
|
||||
);
|
||||
} else if (summary.hasSas && summary.sas?.decimal) {
|
||||
console.log(`SAS decimals: ${summary.sas.decimal.join(" ")}`);
|
||||
}
|
||||
if (summary.error) {
|
||||
console.log(`Verification error: ${summary.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printMatrixVerificationSummaries(summaries: MatrixCliVerificationSummary[]): void {
|
||||
if (summaries.length === 0) {
|
||||
console.log("Verifications: none");
|
||||
return;
|
||||
}
|
||||
summaries.forEach((summary, index) => {
|
||||
if (index > 0) {
|
||||
console.log("");
|
||||
}
|
||||
printMatrixVerificationSummary(summary);
|
||||
});
|
||||
}
|
||||
|
||||
function printMatrixVerificationSas(sas: MatrixCliVerificationSas): void {
|
||||
if (sas.emoji?.length) {
|
||||
console.log(`SAS emoji: ${sas.emoji.map(([emoji, label]) => `${emoji} ${label}`).join(" | ")}`);
|
||||
} else if (sas.decimal) {
|
||||
console.log(`SAS decimals: ${sas.decimal.join(" ")}`);
|
||||
} else {
|
||||
console.log("SAS: unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function printMatrixVerificationSasGuidance(requestId: string, accountId?: string): void {
|
||||
printGuidance([
|
||||
`Compare the emoji or decimals with the other Matrix client.`,
|
||||
`If they match, run '${formatMatrixCliCommand(`verify confirm-sas ${requestId}`, accountId)}'.`,
|
||||
`If they do not match, run '${formatMatrixCliCommand(`verify mismatch-sas ${requestId}`, accountId)}'.`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function promptMatrixVerificationSasMatch(): Promise<boolean> {
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const prompt = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
try {
|
||||
const answer = await prompt.question("Do the emoji or decimals match? Type yes to confirm: ");
|
||||
return /^(?:y|yes)$/i.test(answer.trim());
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
function printMatrixVerificationRequestGuidance(requestId: string, accountId?: string): void {
|
||||
printGuidance([
|
||||
`Accept the verification request in another Matrix client for this account.`,
|
||||
`Then run '${formatMatrixCliCommand(`verify start ${requestId}`, accountId)}' to start SAS verification.`,
|
||||
`Run '${formatMatrixCliCommand(`verify sas ${requestId}`, accountId)}' to display the SAS emoji or decimals.`,
|
||||
`When the SAS matches, run '${formatMatrixCliCommand(`verify confirm-sas ${requestId}`, accountId)}'.`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function runMatrixCliVerificationSummaryCommand(params: {
|
||||
options: MatrixCliVerificationCommandOptions;
|
||||
run: (accountId: string, cfg: CoreConfig) => Promise<MatrixCliVerificationSummary>;
|
||||
afterText?: (summary: MatrixCliVerificationSummary, accountId: string) => void;
|
||||
errorPrefix: string;
|
||||
}): Promise<void> {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(params.options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: params.options.verbose === true,
|
||||
json: params.options.json === true,
|
||||
run: async () => await params.run(accountId, cfg),
|
||||
onText: (summary) => {
|
||||
printAccountLabel(accountId);
|
||||
printMatrixVerificationSummary(summary);
|
||||
params.afterText?.(summary, accountId);
|
||||
},
|
||||
errorPrefix: params.errorPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
async function runMatrixCliSelfVerificationCommand(
|
||||
options: MatrixCliSelfVerificationCommandOptions,
|
||||
): Promise<void> {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: false,
|
||||
run: async () =>
|
||||
await runMatrixSelfVerification({
|
||||
accountId,
|
||||
cfg,
|
||||
timeoutMs: parseOptionalInt(options.timeoutMs, "--timeout-ms"),
|
||||
onRequested: (summary) => {
|
||||
printAccountLabel(accountId);
|
||||
printMatrixVerificationSummary(summary);
|
||||
console.log("Accept this verification request in another Matrix client.");
|
||||
},
|
||||
onReady: (summary) => {
|
||||
console.log("Verification request accepted.");
|
||||
if (!summary.hasSas) {
|
||||
console.log("Starting SAS verification...");
|
||||
}
|
||||
},
|
||||
onSas: (summary) => {
|
||||
printMatrixVerificationSas(summary.sas ?? {});
|
||||
console.log("Compare this SAS with the other Matrix client.");
|
||||
},
|
||||
confirmSas: async () => await promptMatrixVerificationSasMatch(),
|
||||
}),
|
||||
onText: (summary, verbose) => {
|
||||
printMatrixVerificationSummary(summary);
|
||||
console.log(`Device verified by owner: ${summary.deviceOwnerVerified ? "yes" : "no"}`);
|
||||
printVerificationTrustDiagnostics(summary.ownerVerification);
|
||||
printVerificationBackupSummary(summary.ownerVerification);
|
||||
if (verbose) {
|
||||
printVerificationBackupStatus(summary.ownerVerification);
|
||||
}
|
||||
console.log("Self-verification complete.");
|
||||
},
|
||||
errorPrefix: "Self-verification failed",
|
||||
});
|
||||
}
|
||||
|
||||
function printVerificationGuidance(status: MatrixCliVerificationStatus, accountId?: string): void {
|
||||
printGuidance(buildVerificationGuidance(status, accountId));
|
||||
}
|
||||
@@ -615,9 +810,18 @@ function buildVerificationGuidance(
|
||||
const backupIssue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
const nextSteps = new Set<string>();
|
||||
if (!status.verified) {
|
||||
nextSteps.add(
|
||||
`Run '${formatMatrixCliCommand("verify device <key>", accountId)}' to verify this device.`,
|
||||
);
|
||||
if (status.recoveryKeyAccepted === true && status.backupUsable === true) {
|
||||
nextSteps.add(
|
||||
`Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run '${formatMatrixCliCommand("verify self", accountId)}' and follow the prompts from another Matrix client.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you intend to replace the current cross-signing identity, run '${formatMatrixCliCommand("verify bootstrap --recovery-key <key> --force-reset-cross-signing", accountId)}'.`,
|
||||
);
|
||||
} else {
|
||||
nextSteps.add(
|
||||
`Run '${formatMatrixCliCommand("verify device <key>", accountId)}' to verify this device.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (backupIssue.code === "missing-server-backup") {
|
||||
nextSteps.add(
|
||||
@@ -922,6 +1126,204 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
|
||||
const verify = root.command("verify").description("Device verification for Matrix E2EE");
|
||||
|
||||
verify
|
||||
.command("list")
|
||||
.description("List pending Matrix verification requests")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await listMatrixVerifications({ accountId, cfg }),
|
||||
onText: (summaries) => {
|
||||
printAccountLabel(accountId);
|
||||
printMatrixVerificationSummaries(summaries);
|
||||
},
|
||||
errorPrefix: "Verification listing failed",
|
||||
});
|
||||
});
|
||||
|
||||
verify
|
||||
.command("self")
|
||||
.description("Interactively self-verify this Matrix device")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--timeout-ms <ms>", "How long to wait for the other Matrix client")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.action(async (options: MatrixCliSelfVerificationCommandOptions) => {
|
||||
await runMatrixCliSelfVerificationCommand(options);
|
||||
});
|
||||
|
||||
verify
|
||||
.command("request")
|
||||
.description("Request Matrix device verification from another Matrix client")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--own-user", "Request self-verification for this Matrix account")
|
||||
.option("--user-id <id>", "Matrix user ID to verify")
|
||||
.option("--device-id <id>", "Matrix device ID to verify")
|
||||
.option("--room-id <id>", "Matrix direct-message room ID for verification")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => {
|
||||
if (
|
||||
options.ownUser === true &&
|
||||
(options.userId || options.deviceId || options.roomId)
|
||||
) {
|
||||
throw new Error(
|
||||
"--own-user cannot be combined with --user-id, --device-id, or --room-id",
|
||||
);
|
||||
}
|
||||
return await requestMatrixVerification({
|
||||
accountId,
|
||||
cfg,
|
||||
ownUser: options.ownUser === true ? true : undefined,
|
||||
userId: options.userId,
|
||||
deviceId: options.deviceId,
|
||||
roomId: options.roomId,
|
||||
});
|
||||
},
|
||||
onText: (summary) => {
|
||||
printAccountLabel(accountId);
|
||||
printMatrixVerificationSummary(summary);
|
||||
printMatrixVerificationRequestGuidance(summary.id, accountId);
|
||||
},
|
||||
errorPrefix: "Verification request failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
verify
|
||||
.command("accept <id>")
|
||||
.description("Accept an inbound Matrix verification request")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (id: string, options: MatrixCliVerificationCommandOptions) => {
|
||||
await runMatrixCliVerificationSummaryCommand({
|
||||
options,
|
||||
run: async (accountId, cfg) => await acceptMatrixVerification(id, { accountId, cfg }),
|
||||
afterText: (summary, accountId) => {
|
||||
printGuidance([
|
||||
`Run '${formatMatrixCliCommand(`verify start ${summary.id}`, accountId)}' to start SAS verification.`,
|
||||
]);
|
||||
},
|
||||
errorPrefix: "Verification accept failed",
|
||||
});
|
||||
});
|
||||
|
||||
verify
|
||||
.command("start <id>")
|
||||
.description("Start SAS verification for a Matrix verification request")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (id: string, options: MatrixCliVerificationCommandOptions) => {
|
||||
await runMatrixCliVerificationSummaryCommand({
|
||||
options,
|
||||
run: async (accountId, cfg) =>
|
||||
await startMatrixVerification(id, { accountId, cfg, method: "sas" }),
|
||||
afterText: (summary, accountId) =>
|
||||
printMatrixVerificationSasGuidance(summary.id, accountId),
|
||||
errorPrefix: "Verification start failed",
|
||||
});
|
||||
});
|
||||
|
||||
verify
|
||||
.command("sas <id>")
|
||||
.description("Show SAS emoji or decimals for a Matrix verification request")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (id: string, options: MatrixCliVerificationCommandOptions) => {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await getMatrixVerificationSas(id, { accountId, cfg }),
|
||||
onText: (sas) => {
|
||||
printAccountLabel(accountId);
|
||||
console.log(`Verification id: ${id}`);
|
||||
printMatrixVerificationSas(sas);
|
||||
printMatrixVerificationSasGuidance(id, accountId);
|
||||
},
|
||||
errorPrefix: "Verification SAS lookup failed",
|
||||
});
|
||||
});
|
||||
|
||||
verify
|
||||
.command("confirm-sas <id>")
|
||||
.description("Confirm matching SAS emoji or decimals for a Matrix verification request")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (id: string, options: MatrixCliVerificationCommandOptions) => {
|
||||
await runMatrixCliVerificationSummaryCommand({
|
||||
options,
|
||||
run: async (accountId, cfg) => await confirmMatrixVerificationSas(id, { accountId, cfg }),
|
||||
errorPrefix: "Verification SAS confirm failed",
|
||||
});
|
||||
});
|
||||
|
||||
verify
|
||||
.command("mismatch-sas <id>")
|
||||
.description("Reject a Matrix SAS verification when the emoji or decimals do not match")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (id: string, options: MatrixCliVerificationCommandOptions) => {
|
||||
await runMatrixCliVerificationSummaryCommand({
|
||||
options,
|
||||
run: async (accountId, cfg) => await mismatchMatrixVerificationSas(id, { accountId, cfg }),
|
||||
errorPrefix: "Verification SAS mismatch failed",
|
||||
});
|
||||
});
|
||||
|
||||
verify
|
||||
.command("cancel <id>")
|
||||
.description("Cancel a Matrix verification request")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--reason <text>", "Cancellation reason")
|
||||
.option("--code <code>", "Matrix cancellation code")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: MatrixCliVerificationCommandOptions & {
|
||||
reason?: string;
|
||||
code?: string;
|
||||
},
|
||||
) => {
|
||||
await runMatrixCliVerificationSummaryCommand({
|
||||
options,
|
||||
run: async (accountId, cfg) =>
|
||||
await cancelMatrixVerification(id, {
|
||||
accountId,
|
||||
cfg,
|
||||
reason: options.reason,
|
||||
code: options.code,
|
||||
}),
|
||||
errorPrefix: "Verification cancel failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
verify
|
||||
.command("status")
|
||||
.description("Check Matrix device verification status")
|
||||
@@ -1152,10 +1554,30 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
printAccountLabel(accountId);
|
||||
if (!result.success) {
|
||||
console.error(`Verification failed: ${result.error ?? "unknown error"}`);
|
||||
printVerificationIdentity(result);
|
||||
console.log(`Recovery key accepted: ${result.recoveryKeyAccepted ? "yes" : "no"}`);
|
||||
console.log(`Backup usable: ${result.backupUsable ? "yes" : "no"}`);
|
||||
console.log(`Device verified by owner: ${result.deviceOwnerVerified ? "yes" : "no"}`);
|
||||
printVerificationBackupSummary(result);
|
||||
if (verbose) {
|
||||
printVerificationTrustDiagnostics(result);
|
||||
printVerificationBackupStatus(result);
|
||||
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
|
||||
}
|
||||
printVerificationGuidance(
|
||||
{
|
||||
...result,
|
||||
pendingVerifications: 0,
|
||||
},
|
||||
accountId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log("Device verification completed successfully.");
|
||||
printVerificationIdentity(result);
|
||||
console.log(`Recovery key accepted: ${result.recoveryKeyAccepted ? "yes" : "no"}`);
|
||||
console.log(`Backup usable: ${result.backupUsable ? "yes" : "no"}`);
|
||||
console.log(`Device verified by owner: ${result.deviceOwnerVerified ? "yes" : "no"}`);
|
||||
printVerificationBackupSummary(result);
|
||||
if (verbose) {
|
||||
printVerificationTrustDiagnostics(result);
|
||||
@@ -1187,11 +1609,11 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
|
||||
const accountId = resolveMatrixCliAccountId(options.account);
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await listMatrixOwnDevices({ accountId }),
|
||||
run: async () => await listMatrixOwnDevices({ accountId, cfg }),
|
||||
onText: (result) => {
|
||||
printAccountLabel(accountId);
|
||||
printMatrixOwnDevices(result);
|
||||
@@ -1207,11 +1629,11 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
|
||||
const accountId = resolveMatrixCliAccountId(options.account);
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await pruneMatrixStaleGatewayDevices({ accountId }),
|
||||
run: async () => await pruneMatrixStaleGatewayDevices({ accountId, cfg }),
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(accountId);
|
||||
console.log(
|
||||
|
||||
@@ -35,6 +35,7 @@ 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;
|
||||
|
||||
describe("matrix verification actions", () => {
|
||||
beforeAll(async () => {
|
||||
@@ -43,6 +44,7 @@ describe("matrix verification actions", () => {
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
listMatrixVerifications,
|
||||
runMatrixSelfVerification,
|
||||
} = await import("./verification.js"));
|
||||
});
|
||||
|
||||
@@ -55,6 +57,40 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
it("points encryption guidance at the selected Matrix account", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
@@ -213,4 +249,199 @@ describe("matrix verification actions", () => {
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2);
|
||||
expect(withStartedActionClientMock).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 bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
success: true,
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ bootstrapOwnDeviceVerification, crypto, 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,
|
||||
verifyOwnIdentity: true,
|
||||
});
|
||||
expect(getOwnDeviceVerificationStatus).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 getOwnDeviceVerificationStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
|
||||
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
success: true,
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ bootstrapOwnDeviceVerification, crypto, getOwnDeviceVerificationStatus });
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
ownerVerification: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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 () => ({
|
||||
success: false,
|
||||
error: "cross-signing identity is still not trusted",
|
||||
verification: mockUnverifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
|
||||
).rejects.toThrow(
|
||||
"Matrix self-verification completed, but full Matrix identity trust is still incomplete",
|
||||
);
|
||||
|
||||
expect(crypto.cancelVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
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 { 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;
|
||||
|
||||
export type MatrixSelfVerificationResult = MatrixVerificationSummary & {
|
||||
deviceOwnerVerified: boolean;
|
||||
ownerVerification: MatrixOwnDeviceVerificationStatus;
|
||||
};
|
||||
|
||||
function requireCrypto(
|
||||
client: import("../sdk.js").MatrixClient,
|
||||
opts: MatrixActionClientOpts,
|
||||
@@ -29,6 +42,98 @@ function resolveVerificationId(input: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForMatrixVerificationSummary(params: {
|
||||
crypto: MatrixCryptoActionFacade;
|
||||
label: string;
|
||||
request: MatrixVerificationSummary;
|
||||
timeoutMs: number;
|
||||
predicate: (summary: MatrixVerificationSummary) => boolean;
|
||||
}): 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;
|
||||
}
|
||||
}
|
||||
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: 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 waitForMatrixOwnerVerificationStatus(params: {
|
||||
client: MatrixActionClient;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixOwnDeviceVerificationStatus> {
|
||||
const startedAt = Date.now();
|
||||
let last: MatrixOwnDeviceVerificationStatus | undefined;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
last = await params.client.getOwnDeviceVerificationStatus();
|
||||
if (last.verified) {
|
||||
return last;
|
||||
}
|
||||
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,
|
||||
)}). 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);
|
||||
}
|
||||
|
||||
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
@@ -56,6 +161,100 @@ 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);
|
||||
|
||||
let ready = requested;
|
||||
if (!ready.hasSas) {
|
||||
ready = await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "be accepted in another Matrix client",
|
||||
request: requested,
|
||||
timeoutMs,
|
||||
predicate: isMatrixVerificationReadyForSas,
|
||||
});
|
||||
}
|
||||
await params.onReady?.(ready);
|
||||
|
||||
const started = ready.hasSas ? ready : await crypto.startVerification(ready.id, "sas");
|
||||
let sasSummary = started;
|
||||
if (!sasSummary.hasSas) {
|
||||
sasSummary = await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "show SAS emoji or decimals",
|
||||
request: started,
|
||||
timeoutMs,
|
||||
predicate: (summary) => summary.hasSas,
|
||||
});
|
||||
}
|
||||
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) {
|
||||
handledByMismatch = true;
|
||||
await crypto.mismatchVerificationSas(sasSummary.id);
|
||||
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;
|
||||
const bootstrap = await client.bootstrapOwnDeviceVerification({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
verifyOwnIdentity: true,
|
||||
});
|
||||
if (!bootstrap.success) {
|
||||
throw new Error(
|
||||
`Matrix self-verification completed, but full Matrix identity trust is still incomplete: ${
|
||||
bootstrap.error ?? formatMatrixOwnerVerificationDiagnostics(bootstrap.verification)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const ownerVerification = await waitForMatrixOwnerVerificationStatus({ client, timeoutMs });
|
||||
return {
|
||||
...completed,
|
||||
deviceOwnerVerified: ownerVerification.verified,
|
||||
ownerVerification,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!requestCompleted && !handledByMismatch) {
|
||||
await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { logger as matrixJsSdkLogger } from "matrix-js-sdk/lib/logger.js";
|
||||
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
let matrixSdkLogMode: "default" | "quiet" = "default";
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
|
||||
type MatrixJsSdkLogger = {
|
||||
@@ -13,17 +13,16 @@ type MatrixJsSdkLogger = {
|
||||
getChild: (namespace: string) => MatrixJsSdkLogger;
|
||||
};
|
||||
|
||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
||||
if (!module.includes("MatrixHttpClient")) {
|
||||
return false;
|
||||
}
|
||||
return messageOrObject.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||
});
|
||||
}
|
||||
type MatrixJsSdkLoglevelLogger = MatrixJsSdkLogger & {
|
||||
levels?: { DEBUG?: number };
|
||||
methodFactory?: (
|
||||
methodName: string,
|
||||
logLevel: number,
|
||||
loggerName: string | symbol,
|
||||
) => (...args: unknown[]) => void;
|
||||
rebuild?: () => void;
|
||||
setLevel?: (level: number | string, persist?: boolean) => void;
|
||||
};
|
||||
|
||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
@@ -33,7 +32,7 @@ export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
}
|
||||
|
||||
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
|
||||
matrixSdkLogMode = mode;
|
||||
void mode;
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
return;
|
||||
}
|
||||
@@ -49,36 +48,48 @@ export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLog
|
||||
}
|
||||
|
||||
function applyMatrixSdkLogger(): void {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
LogService.setLogger({
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
LogService.setLogger({
|
||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||
error: (module, ...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||
},
|
||||
error: (module, ...messageOrObject) => matrixSdkBaseLogger.error(module, ...messageOrObject),
|
||||
});
|
||||
applyMatrixJsSdkLogger();
|
||||
}
|
||||
|
||||
function normalizeMatrixJsSdkLogMethod(methodName: string): keyof ConsoleLogger {
|
||||
if (methodName === "trace" || methodName === "debug" || methodName === "info") {
|
||||
return methodName;
|
||||
}
|
||||
if (methodName === "warn" || methodName === "error") {
|
||||
return methodName;
|
||||
}
|
||||
return "debug";
|
||||
}
|
||||
|
||||
function formatMatrixJsSdkLoggerName(loggerName: string | symbol): string {
|
||||
return typeof loggerName === "symbol" ? loggerName.toString() : loggerName;
|
||||
}
|
||||
|
||||
function applyMatrixJsSdkLogger(): void {
|
||||
const logger = matrixJsSdkLogger as MatrixJsSdkLoglevelLogger;
|
||||
logger.methodFactory = (methodName, _logLevel, loggerName) => {
|
||||
const method = normalizeMatrixJsSdkLogMethod(methodName);
|
||||
const module = formatMatrixJsSdkLoggerName(loggerName);
|
||||
return (...messageOrObject) => {
|
||||
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
|
||||
module,
|
||||
...messageOrObject,
|
||||
);
|
||||
};
|
||||
};
|
||||
logger.setLevel?.(logger.levels?.DEBUG ?? "debug", false);
|
||||
logger.rebuild?.();
|
||||
}
|
||||
|
||||
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
|
||||
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
return;
|
||||
}
|
||||
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
|
||||
prefix,
|
||||
...messageOrObject,
|
||||
@@ -90,12 +101,7 @@ function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
|
||||
debug: (...messageOrObject) => log("debug", ...messageOrObject),
|
||||
info: (...messageOrObject) => log("info", ...messageOrObject),
|
||||
warn: (...messageOrObject) => log("warn", ...messageOrObject),
|
||||
error: (...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
log("error", ...messageOrObject);
|
||||
},
|
||||
error: (...messageOrObject) => log("error", ...messageOrObject),
|
||||
getChild: (namespace: string) => {
|
||||
const nextNamespace = namespace.trim();
|
||||
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
|
||||
|
||||
@@ -1248,7 +1248,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 +1273,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 +1493,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(() => ({
|
||||
@@ -1591,6 +1591,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,7 +1603,46 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when the device is only locally trusted", async () => {
|
||||
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(async () => {}),
|
||||
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 encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
@@ -1621,19 +1663,35 @@ 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-usable-"));
|
||||
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
|
||||
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);
|
||||
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.error).toContain("not verified by its owner");
|
||||
expect(result.recoveryKeyStored).toBe(true);
|
||||
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
|
||||
@@ -1680,6 +1738,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 +1800,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 +2599,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 () => {
|
||||
|
||||
@@ -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,11 +163,15 @@ const MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS = {
|
||||
} satisfies MatrixCryptoBootstrapOptions;
|
||||
|
||||
function createMatrixExplicitBootstrapOptions(params?: {
|
||||
allowAutomaticCrossSigningReset?: boolean;
|
||||
forceResetCrossSigning?: boolean;
|
||||
verifyOwnIdentity?: boolean;
|
||||
}): MatrixCryptoBootstrapOptions {
|
||||
return {
|
||||
forceResetCrossSigning: params?.forceResetCrossSigning === true,
|
||||
allowAutomaticCrossSigningReset: params?.allowAutomaticCrossSigningReset !== false,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
verifyOwnIdentity: params?.verifyOwnIdentity === true,
|
||||
strict: true,
|
||||
};
|
||||
}
|
||||
@@ -1110,12 +1117,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,
|
||||
@@ -1127,11 +1132,25 @@ export class MatrixClient {
|
||||
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");
|
||||
@@ -1168,22 +1187,42 @@ 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 recoveryKeyAccepted = status.verified || backupUsable;
|
||||
if (!status.verified) {
|
||||
if (backupUsable) {
|
||||
this.recoveryKeyStore.commitStagedRecoveryKey({
|
||||
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
|
||||
});
|
||||
} 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,
|
||||
};
|
||||
@@ -1195,6 +1234,9 @@ export class MatrixClient {
|
||||
const committedStatus = await this.getOwnDeviceVerificationStatus();
|
||||
return {
|
||||
success: true,
|
||||
recoveryKeyAccepted: true,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: true,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
...committedStatus,
|
||||
};
|
||||
@@ -1419,8 +1461,10 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
async bootstrapOwnDeviceVerification(params?: {
|
||||
allowAutomaticCrossSigningReset?: boolean;
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
verifyOwnIdentity?: boolean;
|
||||
}): Promise<MatrixVerificationBootstrapResult> {
|
||||
const pendingVerifications = async (): Promise<number> =>
|
||||
this.crypto ? (await this.crypto.listVerifications()).length : 0;
|
||||
@@ -1680,12 +1724,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,
|
||||
});
|
||||
|
||||
@@ -253,6 +253,49 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("can 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,
|
||||
verifyOwnIdentity: true,
|
||||
});
|
||||
|
||||
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
|
||||
@@ -28,6 +28,7 @@ export type MatrixCryptoBootstrapOptions = {
|
||||
forceResetCrossSigning?: boolean;
|
||||
allowAutomaticCrossSigningReset?: boolean;
|
||||
allowSecretStorageRecreateWithoutRecoveryKey?: boolean;
|
||||
verifyOwnIdentity?: boolean;
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
@@ -83,7 +84,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
strict,
|
||||
});
|
||||
}
|
||||
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
|
||||
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
|
||||
strict,
|
||||
verifyOwnIdentity: options.verifyOwnIdentity === true,
|
||||
});
|
||||
return {
|
||||
crossSigningReady: crossSigning.ready,
|
||||
crossSigningPublished: crossSigning.published,
|
||||
@@ -347,9 +351,30 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
LogService.info("MatrixClientLite", "Verification request handler registered");
|
||||
}
|
||||
|
||||
private async verifyOwnIdentityTrust(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
if (typeof crypto.getOwnIdentity !== "function") {
|
||||
return;
|
||||
}
|
||||
const identity = await crypto.getOwnIdentity();
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (identity.isVerified?.() === true) {
|
||||
return;
|
||||
}
|
||||
await identity.verify?.();
|
||||
} finally {
|
||||
identity.free?.();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureOwnDeviceTrust(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
strict = false,
|
||||
options: {
|
||||
strict: boolean;
|
||||
verifyOwnIdentity: boolean;
|
||||
},
|
||||
): Promise<boolean | null> {
|
||||
const deviceId = this.deps.getDeviceId()?.trim();
|
||||
if (!deviceId) {
|
||||
@@ -367,6 +392,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.verifyOwnIdentity) {
|
||||
await this.verifyOwnIdentityTrust(crypto);
|
||||
}
|
||||
|
||||
if (typeof crypto.setDeviceVerified === "function") {
|
||||
await crypto.setDeviceVerified(userId, deviceId, true);
|
||||
}
|
||||
@@ -386,8 +415,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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -101,6 +101,7 @@ export type MatrixVerificationRequestLike = {
|
||||
|
||||
export type MatrixVerificationCryptoApi = {
|
||||
requestOwnUserVerification: () => Promise<MatrixVerificationRequestLike | null>;
|
||||
getVerificationRequestsToDeviceInProgress?: (userId: string) => MatrixVerificationRequestLike[];
|
||||
findVerificationRequestDMInProgress?: (
|
||||
roomId: string,
|
||||
userId: string,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
function formatMatrixQaCliCommand(args: string[]) {
|
||||
return `openclaw ${args.join(" ")}`;
|
||||
}
|
||||
|
||||
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${result.stderr.trim()}` : null,
|
||||
result.stdout.trim() ? `stdout:\n${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 = path.join(cwd, "dist", "index.js");
|
||||
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 = spawn(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 (${params.args.join(" ")}): ${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(
|
||||
`openclaw ${params.args.join(" ")} did not print ${label} before timeout\nstdout:\n${output.stdout.trim()}\nstderr:\n${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();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
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 +27,11 @@ import {
|
||||
hasMatrixQaExpectedColorReply,
|
||||
MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
|
||||
} from "./scenario-media-fixtures.js";
|
||||
import {
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
type MatrixQaCliRunResult,
|
||||
} from "./scenario-runtime-cli.js";
|
||||
import {
|
||||
assertThreadReplyArtifact,
|
||||
assertTopLevelReplyArtifact,
|
||||
@@ -40,8 +47,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 +81,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 +114,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 +231,218 @@ 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(
|
||||
`openclaw ${result.args.join(" ")} printed JSON with extra output\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
if (stdout) {
|
||||
try {
|
||||
return parseMatrixQaCliJsonText(stdout);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`openclaw ${result.args.join(" ")} printed invalid JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\nstdout:\n${stdout}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stderr) {
|
||||
throw new Error(`openclaw ${result.args.join(" ")} did not print JSON`);
|
||||
}
|
||||
try {
|
||||
return parseMatrixQaCliJsonText(stderr);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`openclaw ${result.args.join(" ")} printed invalid JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\nstderr:\n${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;
|
||||
}) {
|
||||
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, params.result.stdout),
|
||||
writeFile(stderrPath, params.result.stderr),
|
||||
]);
|
||||
return { stderrPath, stdoutPath };
|
||||
}
|
||||
|
||||
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 = 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 mkdir(stateDir, { recursive: true });
|
||||
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`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
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,
|
||||
run,
|
||||
rootDir,
|
||||
start,
|
||||
stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMatrixQaSasEmojiMatches(params: {
|
||||
initiator: MatrixVerificationSummary;
|
||||
recipient: MatrixVerificationSummary;
|
||||
@@ -205,20 +458,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 +644,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 +687,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 +941,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 +1002,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 +1030,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 +1043,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 +1061,300 @@ 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,
|
||||
});
|
||||
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 config path: ${cli.configPath}`,
|
||||
`cli state dir: ${cli.stateDir}`,
|
||||
`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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runMatrixQaE2eeDeviceSasVerificationScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
): Promise<MatrixQaScenarioExecution> {
|
||||
|
||||
@@ -27,6 +27,7 @@ export type MatrixQaScenarioContext = {
|
||||
observerDeviceId?: string;
|
||||
observerPassword?: string;
|
||||
observerUserId: string;
|
||||
gatewayRuntimeEnv?: NodeJS.ProcessEnv;
|
||||
gatewayStateDir?: string;
|
||||
outputDir?: string;
|
||||
restartGateway?: () => Promise<void>;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, 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,10 @@ const { createMatrixQaE2eeScenarioClient, runMatrixQaE2eeBootstrap, startMatrixQ
|
||||
runMatrixQaE2eeBootstrap: vi.fn(),
|
||||
startMatrixQaFaultProxy: vi.fn(),
|
||||
}));
|
||||
const { runMatrixQaOpenClawCli, startMatrixQaOpenClawCli } = vi.hoisted(() => ({
|
||||
runMatrixQaOpenClawCli: vi.fn(),
|
||||
startMatrixQaOpenClawCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../substrate/client.js", () => ({
|
||||
createMatrixQaClient,
|
||||
@@ -22,6 +26,10 @@ vi.mock("../../substrate/e2ee-client.js", () => ({
|
||||
vi.mock("../../substrate/fault-proxy.js", () => ({
|
||||
startMatrixQaFaultProxy,
|
||||
}));
|
||||
vi.mock("./scenario-runtime-cli.js", () => ({
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
}));
|
||||
|
||||
import {
|
||||
LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
|
||||
@@ -95,6 +103,8 @@ describe("matrix live qa scenarios", () => {
|
||||
createMatrixQaClient.mockReset();
|
||||
createMatrixQaE2eeScenarioClient.mockReset();
|
||||
runMatrixQaE2eeBootstrap.mockReset();
|
||||
runMatrixQaOpenClawCli.mockReset();
|
||||
startMatrixQaOpenClawCli.mockReset();
|
||||
startMatrixQaFaultProxy.mockReset();
|
||||
});
|
||||
|
||||
@@ -145,6 +155,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 +2595,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 +2631,7 @@ describe("matrix live qa scenarios", () => {
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
@@ -2687,7 +2701,7 @@ describe("matrix live qa scenarios", () => {
|
||||
backupRestored: true,
|
||||
recoveryDeviceId: "RECOVERYDEVICE",
|
||||
recoveryKeyUsable: true,
|
||||
recoveryVerified: false,
|
||||
recoveryVerified: true,
|
||||
restoreImported: 1,
|
||||
restoreTotal: 1,
|
||||
},
|
||||
@@ -2699,6 +2713,447 @@ 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,
|
||||
});
|
||||
runMatrixQaOpenClawCli.mockImplementation(async ({ args }) => {
|
||||
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("cli-self-verification");
|
||||
expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("cli-self-verification");
|
||||
const configPath = String(cliEnv?.OPENCLAW_CONFIG_PATH);
|
||||
const cliConfig = JSON.parse(await readFile(configPath, "utf8")) as {
|
||||
channels?: {
|
||||
matrix?: {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(cliConfig.channels?.matrix?.accounts?.cli).toMatchObject({
|
||||
accessToken: "cli-token",
|
||||
deviceId: "CLIDEVICE",
|
||||
encryption: true,
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
startupVerification: "off",
|
||||
userId: "@driver:matrix-qa.test",
|
||||
});
|
||||
expect(acceptVerification).toHaveBeenCalledWith("owner-request");
|
||||
expect(confirmVerificationSas).toHaveBeenCalledWith("owner-request");
|
||||
expect(deleteOwnDevices).not.toHaveBeenCalled();
|
||||
const [cliRunDir] = await readdir(path.join(outputDir, "cli-self-verification"));
|
||||
const cliArtifactDir = path.join(outputDir, "cli-self-verification", cliRunDir ?? "");
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"), "utf8"),
|
||||
).resolves.toContain('"success":true');
|
||||
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([
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user