mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
Require full Matrix identity trust (#70401)
Merged via squash.
Prepared head SHA: d13a729681
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
0cce4cf8f6
commit
72731a37d2
@@ -2,6 +2,12 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Matrix: require full cross-signing identity trust for self-device verification and add `openclaw matrix verify self` so operators can establish that trust from the CLI. (#70401) Thanks @gumadeiras.
|
||||
|
||||
## 2026.4.24
|
||||
|
||||
### Breaking
|
||||
@@ -218,6 +224,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex harness/hooks: fire `llm_input`, `llm_output`, and `agent_end` for native Codex app-server turns so lifecycle hooks stop drifting from Pi. Thanks @vincentkoc.
|
||||
- QA/Telegram: record per-scenario reply RTT in the live Telegram QA report and summary, starting with the canary response. (#70550) Thanks @obviyus.
|
||||
- Status: add an explicit `Runner:` field to `/status` so sessions now report whether they are running on embedded Pi, a CLI-backed provider, or an ACP harness agent/backend such as `codex (acp/acpx)` or `gemini (acp/acpx)`. (#70595)
|
||||
- Gateway/diagnostics: enable payload-free stability recording by default and add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -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,466 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("prints recovery-key and identity-trust diagnostics for device verification failures", async () => {
|
||||
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
||||
success: false,
|
||||
error:
|
||||
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
|
||||
encryptionEnabled: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "7",
|
||||
backup: {
|
||||
serverVersion: "7",
|
||||
activeVersion: "7",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: null,
|
||||
},
|
||||
verified: false,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
recoveryKeyAccepted: true,
|
||||
backupUsable: true,
|
||||
deviceOwnerVerified: false,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "device", "valid-key"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleErrorMock).toHaveBeenCalledWith(
|
||||
"Verification failed: Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Recovery key accepted: yes");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup usable: yes");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: no");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run openclaw matrix verify self and follow the prompts from another Matrix client.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- If you intend to replace the current cross-signing identity, run openclaw matrix verify bootstrap --recovery-key '<key>' --force-reset-cross-signing.",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs interactive Matrix self-verification in one CLI flow", async () => {
|
||||
runMatrixSelfVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
ownerVerification: {
|
||||
backup: {
|
||||
activeVersion: "1",
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
matchesDecryptionKey: true,
|
||||
serverVersion: "1",
|
||||
trusted: true,
|
||||
},
|
||||
backupVersion: "1",
|
||||
crossSigningVerified: true,
|
||||
deviceId: "DEVICE123",
|
||||
localVerified: true,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: null,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
verified: true,
|
||||
},
|
||||
pending: false,
|
||||
phaseName: "done",
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
["matrix", "verify", "self", "--account", "ops", "--timeout-ms", "5000"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runMatrixSelfVerificationMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: {},
|
||||
timeoutMs: 5000,
|
||||
onRequested: expect.any(Function),
|
||||
onReady: expect.any(Function),
|
||||
onSas: expect.any(Function),
|
||||
confirmSas: expect.any(Function),
|
||||
});
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Self-verification complete.");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: yes");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Cross-signing verified: yes");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Signed by owner: yes");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
});
|
||||
|
||||
it("requests Matrix self-verification and prints the follow-up SAS commands", async () => {
|
||||
requestMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
id: "self-verify-1",
|
||||
hasSas: false,
|
||||
sas: undefined,
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(requestMatrixVerificationMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: {},
|
||||
ownUser: true,
|
||||
userId: undefined,
|
||||
deviceId: undefined,
|
||||
roomId: undefined,
|
||||
});
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: self-verify-1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Transaction id: txn-1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Accept the verification request in another Matrix client for this account.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Then run openclaw matrix verify start --account ops -- txn-1 to start SAS verification.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify sas --account ops -- txn-1 to display the SAS emoji or decimals.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- txn-1.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints DM lookup details in Matrix verification follow-up commands", async () => {
|
||||
requestMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
id: "dm-verify-1",
|
||||
transactionId: "txn-dm",
|
||||
roomId: "!room-'$(x):example.org",
|
||||
otherUserId: "@alice:example.org",
|
||||
isSelfVerification: false,
|
||||
hasSas: false,
|
||||
sas: undefined,
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"verify",
|
||||
"request",
|
||||
"--user-id",
|
||||
"@alice:example.org",
|
||||
"--room-id",
|
||||
"!room-'$(x):example.org",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(requestMatrixVerificationMock).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
ownUser: undefined,
|
||||
userId: "@alice:example.org",
|
||||
deviceId: undefined,
|
||||
roomId: "!room-'$(x):example.org",
|
||||
});
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Room id: !room-'$(x):example.org");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Then run openclaw matrix verify start --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to start SAS verification.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm to display the SAS emoji or decimals.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- When the SAS matches, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!room-'\\''$(x):example.org' -- txn-dm.",
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates options before remote Matrix verification ids in follow-up commands", async () => {
|
||||
requestMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
id: "local-id",
|
||||
transactionId: "--account=evil",
|
||||
hasSas: false,
|
||||
sas: undefined,
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "request", "--own-user", "--account", "ops"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Then run openclaw matrix verify start --account ops -- --account=evil to start SAS verification.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify sas --account ops -- --account=evil to display the SAS emoji or decimals.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- When the SAS matches, run openclaw matrix verify confirm-sas --account ops -- --account=evil.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects ambiguous Matrix verification request targets", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
["matrix", "verify", "request", "--own-user", "--user-id", "@other:example.org"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(requestMatrixVerificationMock).not.toHaveBeenCalled();
|
||||
expect(consoleErrorMock).toHaveBeenCalledWith(
|
||||
"Verification request failed: --own-user cannot be combined with --user-id, --device-id, or --room-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists Matrix verification requests", async () => {
|
||||
listMatrixVerificationsMock.mockResolvedValue([
|
||||
mockMatrixVerificationSummary({ id: "incoming-1", initiatedByMe: false }),
|
||||
]);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "list"], { from: "user" });
|
||||
|
||||
expect(listMatrixVerificationsMock).toHaveBeenCalledWith({ accountId: "default", cfg: {} });
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: incoming-1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Initiated by OpenClaw: no");
|
||||
});
|
||||
|
||||
it("sanitizes remote Matrix verification metadata before printing it", async () => {
|
||||
listMatrixVerificationsMock.mockResolvedValue([
|
||||
mockMatrixVerificationSummary({
|
||||
id: "self-\u001B[31m1",
|
||||
transactionId: "txn-\n\u009B31m1",
|
||||
otherUserId: "@bot\u001B[2J\u009Dspoof\u0007:example.org",
|
||||
otherDeviceId: "PHONE\r\u009B2J123",
|
||||
phaseName: "started\u001B[0m",
|
||||
methods: ["m.sas.v1\n\u009B31mspoof"],
|
||||
chosenMethod: "m.sas.v1\u001B[1m",
|
||||
sas: {
|
||||
emoji: [
|
||||
["🐶", "Dog\u001B[31m\u009B2J"],
|
||||
["🐱", "Cat\n\u009B31mspoof"],
|
||||
],
|
||||
},
|
||||
error: "Remote\u001B[31m cancelled\n\u009B31mforged",
|
||||
}),
|
||||
]);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "list"], { from: "user" });
|
||||
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Verification id: self-1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Transaction id: txn-1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Other user: @bot:example.org");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Other device: PHONE123");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Phase: started");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Methods: m.sas.v1spoof");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Chosen method: m.sas.v1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("SAS emoji: 🐶 Dog | 🐱 Catspoof");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Verification error: Remote cancelledforged");
|
||||
});
|
||||
|
||||
it("sanitizes remote Matrix status metadata before printing diagnostics", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
userId: "@bot\u001B[2J:example.org",
|
||||
deviceId: "PHONE\r\u009B2J123",
|
||||
backupVersion: "1\u001B[31m",
|
||||
backup: {
|
||||
serverVersion: "2\u001B[31m",
|
||||
activeVersion: "1\u009B2J",
|
||||
trusted: false,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: "Remote\n\u009B31mforged",
|
||||
},
|
||||
recoveryKeyStored: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" });
|
||||
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("User: @bot:example.org");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Device: PHONE123");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup server version: 2");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup active on this device: 1");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup key load error: Remoteforged");
|
||||
});
|
||||
|
||||
it("shell-quotes Matrix verification ids in follow-up command guidance", async () => {
|
||||
requestMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
id: "self-verify-1",
|
||||
transactionId: "txn-'$(touch /tmp/pwn)",
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "request", "--own-user"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Then run openclaw matrix verify start -- 'txn-'\\''$(touch /tmp/pwn)' to start SAS verification.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify sas -- 'txn-'\\''$(touch /tmp/pwn)' to display the SAS emoji or decimals.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- When the SAS matches, run openclaw matrix verify confirm-sas -- 'txn-'\\''$(touch /tmp/pwn)'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows Matrix SAS diagnostics and confirm/mismatch guidance", async () => {
|
||||
getMatrixVerificationSasMock.mockResolvedValue({
|
||||
decimal: [1234, 5678, 9012],
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "sas", "self-1"], { from: "user" });
|
||||
|
||||
expect(getMatrixVerificationSasMock).toHaveBeenCalledWith("self-1", {
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
});
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("SAS decimals: 1234 5678 9012");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- If they match, run openclaw matrix verify confirm-sas -- self-1.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- If they do not match, run openclaw matrix verify mismatch-sas -- self-1.",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes DM lookup details through Matrix verification follow-up commands", async () => {
|
||||
startMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
id: "dm-verify-1",
|
||||
transactionId: "txn-dm",
|
||||
roomId: "!dm:example.org",
|
||||
otherUserId: "@alice:example.org",
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"verify",
|
||||
"start",
|
||||
"txn-dm",
|
||||
"--user-id",
|
||||
"@alice:example.org",
|
||||
"--room-id",
|
||||
"!dm:example.org",
|
||||
"--account",
|
||||
"ops",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(startMatrixVerificationMock).toHaveBeenCalledWith("txn-dm", {
|
||||
accountId: "ops",
|
||||
cfg: {},
|
||||
method: "sas",
|
||||
verificationDmUserId: "@alice:example.org",
|
||||
verificationDmRoomId: "!dm:example.org",
|
||||
});
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- If they match, run openclaw matrix verify confirm-sas --user-id @alice:example.org --room-id '!dm:example.org' --account ops -- txn-dm.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints stable transaction ids in follow-up commands after accepting verification", async () => {
|
||||
acceptMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({
|
||||
id: "verification-1",
|
||||
transactionId: "txn-stable",
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "accept", "verification-1"], { from: "user" });
|
||||
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify start -- txn-stable to start SAS verification.",
|
||||
);
|
||||
});
|
||||
|
||||
it("confirms, rejects, accepts, starts, and cancels Matrix verification requests", async () => {
|
||||
acceptMatrixVerificationMock.mockResolvedValue(mockMatrixVerificationSummary({ id: "in-1" }));
|
||||
startMatrixVerificationMock.mockResolvedValue(mockMatrixVerificationSummary({ id: "in-1" }));
|
||||
confirmMatrixVerificationSasMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({ id: "in-1", completed: true, pending: false }),
|
||||
);
|
||||
mismatchMatrixVerificationSasMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({ id: "in-1", phaseName: "cancelled", pending: false }),
|
||||
);
|
||||
cancelMatrixVerificationMock.mockResolvedValue(
|
||||
mockMatrixVerificationSummary({ id: "in-1", phaseName: "cancelled", pending: false }),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "accept", "in-1"], { from: "user" });
|
||||
await program.parseAsync(["matrix", "verify", "start", "in-1"], { from: "user" });
|
||||
await program.parseAsync(["matrix", "verify", "confirm-sas", "in-1"], { from: "user" });
|
||||
await program.parseAsync(["matrix", "verify", "mismatch-sas", "in-1"], { from: "user" });
|
||||
await program.parseAsync(
|
||||
["matrix", "verify", "cancel", "in-1", "--reason", "changed my mind"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(acceptMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
});
|
||||
expect(startMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
method: "sas",
|
||||
});
|
||||
expect(confirmMatrixVerificationSasMock).toHaveBeenCalledWith("in-1", {
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
});
|
||||
expect(mismatchMatrixVerificationSasMock).toHaveBeenCalledWith("in-1", {
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
});
|
||||
expect(cancelMatrixVerificationMock).toHaveBeenCalledWith("in-1", {
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
reason: "changed my mind",
|
||||
code: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: false,
|
||||
@@ -342,9 +847,9 @@ describe("matrix CLI verification commands", () => {
|
||||
it("lists matrix devices", async () => {
|
||||
listMatrixOwnDevicesMock.mockResolvedValue([
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: "127.0.0.1",
|
||||
deviceId: "A7hWr\u001B[31mQ70ea",
|
||||
displayName: "OpenClaw\u001B[2J Gateway",
|
||||
lastSeenIp: "127.0.0.1\u009B2J",
|
||||
lastSeenTs: 1_741_507_200_000,
|
||||
current: true,
|
||||
},
|
||||
@@ -360,7 +865,7 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
|
||||
|
||||
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
|
||||
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe", cfg: {} });
|
||||
expect(console.log).toHaveBeenCalledWith("Account: poe");
|
||||
expect(console.log).toHaveBeenCalledWith("- A7hWrQ70ea (current, OpenClaw Gateway)");
|
||||
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
|
||||
@@ -535,7 +1040,7 @@ describe("matrix CLI verification commands", () => {
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Backup version: 7");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run 'openclaw matrix devices prune-stale --account ops'.",
|
||||
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run openclaw matrix devices prune-stale --account ops.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -630,7 +1135,7 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const jsonOutput = consoleLogMock.mock.calls.at(-1)?.[0];
|
||||
const jsonOutput = stdoutWriteMock.mock.calls.at(-1)?.[0];
|
||||
expect(typeof jsonOutput).toBe("string");
|
||||
expect(JSON.parse(String(jsonOutput))).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -765,7 +1270,7 @@ describe("matrix CLI verification commands", () => {
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect(stdoutWriteMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"error": "Matrix requires --homeserver"'),
|
||||
);
|
||||
});
|
||||
@@ -926,7 +1431,7 @@ describe("matrix CLI verification commands", () => {
|
||||
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.",
|
||||
"- Backup key is not loaded on this device. Run openclaw matrix verify backup restore to load it and restore old room keys.",
|
||||
);
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device <key>'.",
|
||||
@@ -993,7 +1498,7 @@ describe("matrix CLI verification commands", () => {
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'. This may also repair secret storage so the new backup key can be loaded after restart.",
|
||||
"- If you want a fresh backup baseline and accept losing unrecoverable history, run openclaw matrix verify backup reset --yes. This may also repair secret storage so the new backup key can be loaded after restart.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1071,10 +1576,10 @@ describe("matrix CLI verification commands", () => {
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Account: assistant");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Run 'openclaw matrix verify device <key> --account assistant' to verify this device.",
|
||||
"- Run openclaw matrix verify device '<key>' --account assistant to verify this device.",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Run 'openclaw matrix verify bootstrap --account assistant' to create a room key backup.",
|
||||
"- Run openclaw matrix verify bootstrap --account assistant to create a room key backup.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,8 @@ let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifi
|
||||
let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus;
|
||||
let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus;
|
||||
let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus;
|
||||
let runMatrixSelfVerification: typeof import("./verification.js").runMatrixSelfVerification;
|
||||
let startMatrixVerification: typeof import("./verification.js").startMatrixVerification;
|
||||
|
||||
describe("matrix verification actions", () => {
|
||||
beforeAll(async () => {
|
||||
@@ -43,6 +45,8 @@ describe("matrix verification actions", () => {
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
listMatrixVerifications,
|
||||
runMatrixSelfVerification,
|
||||
startMatrixVerification,
|
||||
} = await import("./verification.js"));
|
||||
});
|
||||
|
||||
@@ -55,6 +59,50 @@ describe("matrix verification actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function mockVerifiedOwnerStatus() {
|
||||
return {
|
||||
backup: {
|
||||
activeVersion: "1",
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
matchesDecryptionKey: true,
|
||||
serverVersion: "1",
|
||||
trusted: true,
|
||||
},
|
||||
backupVersion: "1",
|
||||
crossSigningVerified: true,
|
||||
deviceId: "DEVICE123",
|
||||
localVerified: true,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: null,
|
||||
recoveryKeyStored: false,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mockUnverifiedOwnerStatus() {
|
||||
return {
|
||||
...mockVerifiedOwnerStatus(),
|
||||
crossSigningVerified: false,
|
||||
localVerified: false,
|
||||
signedByOwner: false,
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mockCrossSigningPublicationStatus(published = true) {
|
||||
return {
|
||||
masterKeyPublished: published,
|
||||
published,
|
||||
selfSigningKeyPublished: published,
|
||||
userId: "@bot:example.org",
|
||||
userSigningKeyPublished: published,
|
||||
};
|
||||
}
|
||||
|
||||
it("points encryption guidance at the selected Matrix account", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
@@ -213,4 +261,609 @@ describe("matrix verification actions", () => {
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(2);
|
||||
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rehydrates DM verification requests before follow-up actions", async () => {
|
||||
const tracked = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "txn-dm",
|
||||
};
|
||||
const started = {
|
||||
...tracked,
|
||||
chosenMethod: "m.sas.v1",
|
||||
phaseName: "started",
|
||||
};
|
||||
const crypto = {
|
||||
ensureVerificationDmTracked: vi.fn(async () => tracked),
|
||||
startVerification: vi.fn(async () => started),
|
||||
};
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto });
|
||||
});
|
||||
|
||||
await expect(
|
||||
startMatrixVerification("txn-dm", {
|
||||
verificationDmRoomId: "!dm:example.org",
|
||||
verificationDmUserId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
id: "verification-1",
|
||||
phaseName: "started",
|
||||
});
|
||||
|
||||
expect(crypto.ensureVerificationDmTracked).toHaveBeenCalledWith({
|
||||
roomId: "!dm:example.org",
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(crypto.startVerification).toHaveBeenCalledWith("txn-dm", "sas");
|
||||
});
|
||||
|
||||
it("requires complete DM lookup details for verification follow-up actions", async () => {
|
||||
const crypto = {
|
||||
ensureVerificationDmTracked: vi.fn(),
|
||||
startVerification: vi.fn(),
|
||||
};
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto });
|
||||
});
|
||||
|
||||
await expect(
|
||||
startMatrixVerification("txn-dm", {
|
||||
verificationDmRoomId: "!dm:example.org",
|
||||
}),
|
||||
).rejects.toThrow("--user-id and --room-id must be provided together");
|
||||
|
||||
expect(crypto.ensureVerificationDmTracked).not.toHaveBeenCalled();
|
||||
expect(crypto.startVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps self-verification in one started Matrix client session", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const ready = {
|
||||
...requested,
|
||||
phaseName: "ready",
|
||||
};
|
||||
const sas = {
|
||||
...requested,
|
||||
hasSas: true,
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
emoji: [["🐶", "Dog"]],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const listVerifications = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([ready])
|
||||
.mockResolvedValueOnce([completed]);
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(async () => sas),
|
||||
listVerifications,
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const confirmSas = vi.fn(async () => true);
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
||||
mockCrossSigningPublicationStatus(),
|
||||
);
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(),
|
||||
success: true,
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus,
|
||||
getOwnDeviceVerificationStatus,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(runMatrixSelfVerification({ confirmSas, timeoutMs: 500 })).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
id: "verification-1",
|
||||
ownerVerification: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(withStartedActionClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
|
||||
expect(crypto.startVerification).toHaveBeenCalledWith("verification-1", "sas");
|
||||
expect(confirmSas).toHaveBeenCalledWith(sas.sas, sas);
|
||||
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-1");
|
||||
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
expect(getOwnCrossSigningPublicationStatus).not.toHaveBeenCalled();
|
||||
expect(getOwnDeviceVerificationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not complete self-verification until the OpenClaw device has full Matrix identity trust", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const sas = {
|
||||
...requested,
|
||||
hasSas: true,
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(async () => completed),
|
||||
listVerifications: vi.fn(async () => [sas]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const getOwnDeviceIdentityVerificationStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
|
||||
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
||||
mockCrossSigningPublicationStatus(),
|
||||
);
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(),
|
||||
success: true,
|
||||
verification: mockUnverifiedOwnerStatus(),
|
||||
}));
|
||||
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus,
|
||||
getOwnDeviceIdentityVerificationStatus,
|
||||
getOwnDeviceVerificationStatus,
|
||||
trustOwnIdentityAfterSelfVerification,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
ownerVerification: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not complete self-verification until cross-signing keys are published", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const sas = {
|
||||
...requested,
|
||||
hasSas: true,
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(async () => completed),
|
||||
listVerifications: vi.fn(async () => [sas]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnCrossSigningPublicationStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(false))
|
||||
.mockResolvedValueOnce(mockCrossSigningPublicationStatus(true));
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(false),
|
||||
success: false,
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus,
|
||||
getOwnDeviceIdentityVerificationStatus,
|
||||
getOwnDeviceVerificationStatus,
|
||||
trustOwnIdentityAfterSelfVerification,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
ownerVerification: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for SAS data without restarting an already-started self-verification", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const started = {
|
||||
...requested,
|
||||
phaseName: "started",
|
||||
};
|
||||
const sas = {
|
||||
...started,
|
||||
hasSas: true,
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(async () => completed),
|
||||
listVerifications: vi.fn().mockResolvedValueOnce([started]).mockResolvedValueOnce([sas]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(),
|
||||
};
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(),
|
||||
success: true,
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
});
|
||||
|
||||
expect(crypto.startVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails immediately when an already-started self-verification uses a non-SAS method", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const started = {
|
||||
...requested,
|
||||
chosenMethod: "m.reciprocate.v1",
|
||||
phaseName: "started",
|
||||
};
|
||||
const cancelled = {
|
||||
...started,
|
||||
phaseName: "cancelled",
|
||||
};
|
||||
const crypto = {
|
||||
cancelVerification: vi.fn(async () => cancelled),
|
||||
listVerifications: vi.fn(async () => [started]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(),
|
||||
};
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto });
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).rejects.toThrow(
|
||||
"Matrix self-verification started without SAS while waiting to show SAS emoji or decimals (method: m.reciprocate.v1)",
|
||||
);
|
||||
|
||||
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
|
||||
expect(crypto.startVerification).not.toHaveBeenCalled();
|
||||
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
||||
code: "m.user",
|
||||
reason: "OpenClaw self-verification did not complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes completed non-SAS self-verification without waiting for SAS", async () => {
|
||||
const completed = {
|
||||
completed: true,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "done",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification: vi.fn(async () => completed),
|
||||
startVerification: vi.fn(),
|
||||
};
|
||||
const confirmSas = vi.fn(async () => true);
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(),
|
||||
success: true,
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
|
||||
await expect(runMatrixSelfVerification({ confirmSas, timeoutMs: 500 })).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
id: "verification-1",
|
||||
});
|
||||
|
||||
expect(crypto.listVerifications).not.toHaveBeenCalled();
|
||||
expect(crypto.startVerification).not.toHaveBeenCalled();
|
||||
expect(crypto.confirmVerificationSas).not.toHaveBeenCalled();
|
||||
expect(confirmSas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows completed self-verification when only backup health remains degraded", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const sas = {
|
||||
...requested,
|
||||
hasSas: true,
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(async () => completed),
|
||||
listVerifications: vi.fn(async () => [sas]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(),
|
||||
success: false,
|
||||
error: "Matrix room key backup is not trusted by this device",
|
||||
verification: mockVerifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails self-verification if SAS completes but full identity trust cannot be established", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const sas = {
|
||||
...requested,
|
||||
hasSas: true,
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const crypto = {
|
||||
cancelVerification: vi.fn(),
|
||||
confirmVerificationSas: vi.fn(async () => completed),
|
||||
listVerifications: vi.fn(async () => [sas]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(false),
|
||||
success: false,
|
||||
error: "cross-signing identity is still not trusted",
|
||||
verification: mockUnverifiedOwnerStatus(),
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
|
||||
mockCrossSigningPublicationStatus(false),
|
||||
),
|
||||
getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
|
||||
).rejects.toThrow(
|
||||
"Timed out waiting for Matrix self-verification to establish full Matrix identity trust",
|
||||
);
|
||||
|
||||
expect(crypto.cancelVerification).not.toHaveBeenCalled();
|
||||
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the pending self-verification request when acceptance times out", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const crypto = {
|
||||
cancelVerification: vi.fn(async () => requested),
|
||||
listVerifications: vi.fn(async () => []),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
};
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto });
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 30 }),
|
||||
).rejects.toThrow("Timed out waiting for Matrix self-verification to be accepted");
|
||||
|
||||
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
||||
code: "m.user",
|
||||
reason: "OpenClaw self-verification did not complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails immediately when the self-verification request is cancelled while waiting", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const cancelled = {
|
||||
...requested,
|
||||
error: "Remote cancelled",
|
||||
pending: false,
|
||||
phaseName: "cancelled",
|
||||
};
|
||||
const crypto = {
|
||||
cancelVerification: vi.fn(async () => cancelled),
|
||||
listVerifications: vi.fn(async () => [cancelled]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
};
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto });
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).rejects.toThrow("Matrix self-verification was cancelled: Remote cancelled");
|
||||
|
||||
expect(crypto.listVerifications).toHaveBeenCalledTimes(1);
|
||||
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
||||
code: "m.user",
|
||||
reason: "OpenClaw self-verification did not complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the request when SAS mismatch submission fails", async () => {
|
||||
const sas = {
|
||||
completed: false,
|
||||
hasSas: true,
|
||||
id: "verification-1",
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const crypto = {
|
||||
cancelVerification: vi.fn(async () => sas),
|
||||
listVerifications: vi.fn(async () => [sas]),
|
||||
mismatchVerificationSas: vi.fn(async () => {
|
||||
throw new Error("failed to send SAS mismatch");
|
||||
}),
|
||||
requestVerification: vi.fn(async () => sas),
|
||||
};
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto });
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => false), timeoutMs: 500 }),
|
||||
).rejects.toThrow("failed to send SAS mismatch");
|
||||
|
||||
expect(crypto.cancelVerification).toHaveBeenCalledWith("verification-1", {
|
||||
code: "m.user",
|
||||
reason: "OpenClaw self-verification did not complete",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
|
||||
import type { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
|
||||
import type { MatrixVerificationSummary } from "../sdk/verification-manager.js";
|
||||
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
const DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
type MatrixCryptoActionFacade = NonNullable<import("../sdk.js").MatrixClient["crypto"]>;
|
||||
type MatrixActionClient = import("../sdk.js").MatrixClient;
|
||||
type MatrixVerificationDmLookupOpts = {
|
||||
verificationDmRoomId?: string;
|
||||
verificationDmUserId?: string;
|
||||
};
|
||||
|
||||
export type MatrixSelfVerificationResult = MatrixVerificationSummary & {
|
||||
deviceOwnerVerified: boolean;
|
||||
ownerVerification: MatrixOwnDeviceVerificationStatus;
|
||||
};
|
||||
|
||||
function requireCrypto(
|
||||
client: import("../sdk.js").MatrixClient,
|
||||
opts: MatrixActionClientOpts,
|
||||
@@ -29,6 +46,195 @@ function resolveVerificationId(input: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function ensureMatrixVerificationDmTracked(
|
||||
crypto: MatrixCryptoActionFacade,
|
||||
opts: MatrixVerificationDmLookupOpts,
|
||||
): Promise<void> {
|
||||
const roomId = normalizeOptionalString(opts.verificationDmRoomId);
|
||||
const userId = normalizeOptionalString(opts.verificationDmUserId);
|
||||
if (Boolean(roomId) !== Boolean(userId)) {
|
||||
throw new Error("--user-id and --room-id must be provided together for Matrix DM verification");
|
||||
}
|
||||
if (!roomId || !userId) {
|
||||
return;
|
||||
}
|
||||
const tracked = await crypto.ensureVerificationDmTracked({ roomId, userId });
|
||||
if (!tracked) {
|
||||
throw new Error(
|
||||
`Matrix DM verification request not found for room ${roomId} and user ${userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isSameMatrixVerification(
|
||||
left: MatrixVerificationSummary,
|
||||
right: MatrixVerificationSummary,
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id ||
|
||||
Boolean(left.transactionId && left.transactionId === right.transactionId)
|
||||
);
|
||||
}
|
||||
|
||||
function isMatrixVerificationReadyForSas(summary: MatrixVerificationSummary): boolean {
|
||||
return (
|
||||
summary.completed ||
|
||||
summary.hasSas ||
|
||||
summary.phaseName === "ready" ||
|
||||
summary.phaseName === "started"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldStartMatrixSasVerification(summary: MatrixVerificationSummary): boolean {
|
||||
return !summary.hasSas && summary.phaseName !== "started" && !summary.completed;
|
||||
}
|
||||
|
||||
function isMatrixVerificationCancelled(summary: MatrixVerificationSummary): boolean {
|
||||
return summary.phaseName === "cancelled";
|
||||
}
|
||||
|
||||
function isMatrixSasMethod(method: string | null | undefined): boolean {
|
||||
return method === "m.sas.v1" || method === "sas";
|
||||
}
|
||||
|
||||
function getMatrixVerificationSasWaitFailure(
|
||||
summary: MatrixVerificationSummary,
|
||||
label: string,
|
||||
): string | null {
|
||||
if (summary.hasSas || summary.phaseName === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
const method = summary.chosenMethod ? ` (method: ${summary.chosenMethod})` : "";
|
||||
if (summary.completed) {
|
||||
return `Matrix self-verification completed without SAS while waiting to ${label}${method}`;
|
||||
}
|
||||
if (
|
||||
summary.phaseName === "started" &&
|
||||
summary.chosenMethod &&
|
||||
!isMatrixSasMethod(summary.chosenMethod)
|
||||
) {
|
||||
return `Matrix self-verification started without SAS while waiting to ${label}${method}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForMatrixVerificationSummary(params: {
|
||||
crypto: MatrixCryptoActionFacade;
|
||||
label: string;
|
||||
request: MatrixVerificationSummary;
|
||||
timeoutMs: number;
|
||||
predicate: (summary: MatrixVerificationSummary) => boolean;
|
||||
reject?: (summary: MatrixVerificationSummary) => string | null;
|
||||
}): Promise<MatrixVerificationSummary> {
|
||||
const startedAt = Date.now();
|
||||
let last: MatrixVerificationSummary | undefined;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const summaries = await params.crypto.listVerifications();
|
||||
const found = summaries.find((summary) => isSameMatrixVerification(summary, params.request));
|
||||
if (found) {
|
||||
last = found;
|
||||
if (params.predicate(found)) {
|
||||
return found;
|
||||
}
|
||||
if (isMatrixVerificationCancelled(found)) {
|
||||
throw new Error(
|
||||
`Matrix self-verification was cancelled${
|
||||
found.error ? `: ${found.error}` : ` while waiting to ${params.label}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const rejection = params.reject?.(found);
|
||||
if (rejection) {
|
||||
throw new Error(rejection);
|
||||
}
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Matrix self-verification to ${params.label}${
|
||||
last ? ` (last phase: ${last.phaseName})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatMatrixOwnerVerificationDiagnostics(
|
||||
status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined,
|
||||
): string {
|
||||
if (!status) {
|
||||
return "Matrix identity trust status was unavailable";
|
||||
}
|
||||
return `cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}, signed by owner: ${
|
||||
status.signedByOwner ? "yes" : "no"
|
||||
}, locally trusted: ${status.localVerified ? "yes" : "no"}`;
|
||||
}
|
||||
|
||||
async function waitForMatrixSelfVerificationTrustStatus(params: {
|
||||
client: MatrixActionClient;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixOwnDeviceVerificationStatus> {
|
||||
const startedAt = Date.now();
|
||||
let last: MatrixDeviceVerificationStatus | undefined;
|
||||
let crossSigningPublished = false;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const [status, crossSigning] = await Promise.all([
|
||||
params.client.getOwnDeviceIdentityVerificationStatus(),
|
||||
params.client.getOwnCrossSigningPublicationStatus(),
|
||||
]);
|
||||
last = status;
|
||||
crossSigningPublished = crossSigning.published;
|
||||
if (last.verified && crossSigningPublished) {
|
||||
return await params.client.getOwnDeviceVerificationStatus();
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Matrix self-verification to establish full Matrix identity trust for this device (${formatMatrixOwnerVerificationDiagnostics(
|
||||
last,
|
||||
)}, cross-signing keys published: ${crossSigningPublished ? "yes" : "no"}). Complete self-verification from another Matrix client, then check Matrix verification status for details.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function cancelMatrixSelfVerificationOnFailure(params: {
|
||||
crypto: MatrixCryptoActionFacade;
|
||||
request: MatrixVerificationSummary | undefined;
|
||||
}): Promise<void> {
|
||||
if (!params.request || typeof params.crypto.cancelVerification !== "function") {
|
||||
return;
|
||||
}
|
||||
await params.crypto
|
||||
.cancelVerification(params.request.id, {
|
||||
reason: "OpenClaw self-verification did not complete",
|
||||
code: "m.user",
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function completeMatrixSelfVerification(params: {
|
||||
client: MatrixActionClient;
|
||||
completed: MatrixVerificationSummary;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixSelfVerificationResult> {
|
||||
const bootstrap = await params.client.bootstrapOwnDeviceVerification({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
if (!bootstrap.verification.verified) {
|
||||
await params.client.trustOwnIdentityAfterSelfVerification?.();
|
||||
}
|
||||
const ownerVerification =
|
||||
bootstrap.verification.verified && bootstrap.crossSigning.published
|
||||
? bootstrap.verification
|
||||
: await waitForMatrixSelfVerificationTrustStatus({
|
||||
client: params.client,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return {
|
||||
...params.completed,
|
||||
deviceOwnerVerified: ownerVerification.verified,
|
||||
ownerVerification,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
@@ -56,22 +262,118 @@ export async function requestMatrixVerification(
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMatrixSelfVerification(
|
||||
params: MatrixActionClientOpts & {
|
||||
confirmSas: (
|
||||
sas: NonNullable<MatrixVerificationSummary["sas"]>,
|
||||
summary: MatrixVerificationSummary,
|
||||
) => Promise<boolean>;
|
||||
onReady?: (summary: MatrixVerificationSummary) => void | Promise<void>;
|
||||
onRequested?: (summary: MatrixVerificationSummary) => void | Promise<void>;
|
||||
onSas?: (summary: MatrixVerificationSummary) => void | Promise<void>;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<MatrixSelfVerificationResult> {
|
||||
return await withStartedActionClient(params, async (client) => {
|
||||
const crypto = requireCrypto(client, params);
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS;
|
||||
let requested: MatrixVerificationSummary | undefined;
|
||||
let requestCompleted = false;
|
||||
let handledByMismatch = false;
|
||||
try {
|
||||
requested = await crypto.requestVerification({ ownUser: true });
|
||||
await params.onRequested?.(requested);
|
||||
|
||||
const ready = isMatrixVerificationReadyForSas(requested)
|
||||
? requested
|
||||
: await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "be accepted in another Matrix client",
|
||||
request: requested,
|
||||
timeoutMs,
|
||||
predicate: isMatrixVerificationReadyForSas,
|
||||
});
|
||||
await params.onReady?.(ready);
|
||||
|
||||
if (ready.completed) {
|
||||
requestCompleted = true;
|
||||
return await completeMatrixSelfVerification({ client, completed: ready, timeoutMs });
|
||||
}
|
||||
|
||||
const started = shouldStartMatrixSasVerification(ready)
|
||||
? await crypto.startVerification(ready.id, "sas")
|
||||
: ready;
|
||||
let sasSummary = started;
|
||||
if (!sasSummary.hasSas) {
|
||||
const sasFailure = getMatrixVerificationSasWaitFailure(
|
||||
sasSummary,
|
||||
"show SAS emoji or decimals",
|
||||
);
|
||||
if (sasFailure) {
|
||||
throw new Error(sasFailure);
|
||||
}
|
||||
sasSummary = await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "show SAS emoji or decimals",
|
||||
request: started,
|
||||
timeoutMs,
|
||||
predicate: (summary) => summary.hasSas,
|
||||
reject: (summary) =>
|
||||
getMatrixVerificationSasWaitFailure(summary, "show SAS emoji or decimals"),
|
||||
});
|
||||
}
|
||||
if (!sasSummary.sas) {
|
||||
throw new Error("Matrix SAS data is not available for this verification request");
|
||||
}
|
||||
await params.onSas?.(sasSummary);
|
||||
|
||||
const matched = await params.confirmSas(sasSummary.sas, sasSummary);
|
||||
if (!matched) {
|
||||
await crypto.mismatchVerificationSas(sasSummary.id);
|
||||
handledByMismatch = true;
|
||||
throw new Error("Matrix SAS verification was not confirmed.");
|
||||
}
|
||||
|
||||
const confirmed = await crypto.confirmVerificationSas(sasSummary.id);
|
||||
const completed = confirmed.completed
|
||||
? confirmed
|
||||
: await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "complete",
|
||||
request: confirmed,
|
||||
timeoutMs,
|
||||
predicate: (summary) => summary.completed,
|
||||
});
|
||||
requestCompleted = true;
|
||||
return await completeMatrixSelfVerification({ client, completed, timeoutMs });
|
||||
} catch (error) {
|
||||
if (!requestCompleted && !handledByMismatch) {
|
||||
await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.acceptVerification(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
|
||||
opts: MatrixActionClientOpts &
|
||||
MatrixVerificationDmLookupOpts & { reason?: string; code?: string } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.cancelVerification(resolveVerificationId(requestId), {
|
||||
reason: normalizeOptionalString(opts.reason),
|
||||
code: normalizeOptionalString(opts.code),
|
||||
@@ -81,20 +383,22 @@ export async function cancelMatrixVerification(
|
||||
|
||||
export async function startMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { method?: "sas" } = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts & { method?: "sas" } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateMatrixVerificationQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
@@ -102,10 +406,11 @@ export async function generateMatrixVerificationQr(
|
||||
export async function scanMatrixVerificationQr(
|
||||
requestId: string,
|
||||
qrDataBase64: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
const payload = qrDataBase64.trim();
|
||||
if (!payload) {
|
||||
throw new Error("Matrix QR data is required");
|
||||
@@ -116,40 +421,44 @@ export async function scanMatrixVerificationQr(
|
||||
|
||||
export async function getMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.getVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function mismatchMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationReciprocateQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ function stubRuntimeFetch(fetchImpl: typeof fetch): void {
|
||||
};
|
||||
}
|
||||
|
||||
async function consumeMatrixSecretStorageKey(keyId = "SSSSKEY"): Promise<boolean> {
|
||||
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
|
||||
getSecretStorageKey?: (
|
||||
params: { keys: Record<string, unknown> },
|
||||
name: string,
|
||||
) => Promise<[string, Uint8Array] | null>;
|
||||
} | null;
|
||||
const result = await callbacks?.getSecretStorageKey?.(
|
||||
{ keys: { [keyId]: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
|
||||
"m.cross_signing.master",
|
||||
);
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
class FakeMatrixEvent extends EventEmitter {
|
||||
private readonly roomId: string;
|
||||
private readonly eventId: string;
|
||||
@@ -793,7 +807,7 @@ describe("MatrixClient event bridge", () => {
|
||||
cryptoListeners.set(eventName, listener);
|
||||
}),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
@@ -893,7 +907,7 @@ describe("MatrixClient event bridge", () => {
|
||||
cryptoListeners.set(eventName, listener);
|
||||
}),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
@@ -1209,6 +1223,48 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("trusts the own Matrix identity after completed self-verification", async () => {
|
||||
const verifyOwnIdentity = vi.fn(async () => ({}));
|
||||
const freeOwnIdentity = vi.fn();
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
getOwnIdentity: vi.fn(async () => ({
|
||||
free: freeOwnIdentity,
|
||||
isVerified: () => false,
|
||||
verify: verifyOwnIdentity,
|
||||
})),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
|
||||
await client.trustOwnIdentityAfterSelfVerification();
|
||||
|
||||
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fail self-verification cleanup when own identity verify is unavailable", async () => {
|
||||
const freeOwnIdentity = vi.fn();
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
getOwnIdentity: vi.fn(async () => ({
|
||||
free: freeOwnIdentity,
|
||||
isVerified: () => false,
|
||||
})),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
|
||||
await expect(client.trustOwnIdentityAfterSelfVerification()).resolves.toBeUndefined();
|
||||
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
@@ -1248,7 +1304,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not force-reset bootstrap when the device is already signed by its owner", async () => {
|
||||
it("does not force-reset bootstrap automatically when the device has an owner signature but not full trust", async () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() }));
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
@@ -1273,7 +1329,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
encryptionEnabled: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
verified: true,
|
||||
verified: false,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: true,
|
||||
@@ -1493,7 +1549,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(status.deviceId).toBe("DEVICE123");
|
||||
});
|
||||
|
||||
it("does not treat local-only trust as owner verification", async () => {
|
||||
it("does not treat local-only trust as Matrix identity trust", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
@@ -1559,7 +1615,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
const bootstrapSecretStorage = vi.fn(async () => {});
|
||||
const bootstrapSecretStorage = vi.fn(consumeMatrixSecretStorageKey);
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const checkKeyBackupAndEnable = vi.fn(async () => {});
|
||||
const getSecretStorageStatus = vi.fn(async () => ({
|
||||
@@ -1591,6 +1647,9 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.recoveryKeyAccepted).toBe(true);
|
||||
expect(result.backupUsable).toBe(false);
|
||||
expect(result.deviceOwnerVerified).toBe(true);
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.recoveryKeyStored).toBe(true);
|
||||
expect(result.deviceId).toBe("DEVICE123");
|
||||
@@ -1600,9 +1659,167 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when the device is only locally trusted", async () => {
|
||||
it("accepts a staged recovery key when it establishes identity trust and backup usability", async () => {
|
||||
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
|
||||
const encoded = encodeRecoveryKey(privateKey);
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
let backupKeyLoaded = false;
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
defaultKeyId: "SSSSKEY",
|
||||
secretStorageKeyValidityMap: {},
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
})),
|
||||
checkKeyBackupAndEnable: vi.fn(async () => {}),
|
||||
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
|
||||
backupKeyLoaded = await consumeMatrixSecretStorageKey();
|
||||
}),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "11",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-used-key-"));
|
||||
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.recoveryKeyAccepted).toBe(true);
|
||||
expect(result.backupUsable).toBe(true);
|
||||
expect(result.deviceOwnerVerified).toBe(true);
|
||||
expect(result.recoveryKeyStored).toBe(true);
|
||||
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when the device lacks full cross-signing identity trust", async () => {
|
||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
defaultKeyId: "SSSSKEY",
|
||||
secretStorageKeyValidityMap: { SSSSKEY: true },
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.recoveryKeyAccepted).toBe(false);
|
||||
expect(result.backupUsable).toBe(false);
|
||||
expect(result.deviceOwnerVerified).toBe(false);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.error).toContain("full Matrix identity trust");
|
||||
});
|
||||
|
||||
it("keeps a usable recovery key distinct from owner device verification", async () => {
|
||||
const privateKey = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
|
||||
const encoded = encodeRecoveryKey(privateKey);
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
let backupKeyLoaded = false;
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
defaultKeyId: "SSSSKEY",
|
||||
secretStorageKeyValidityMap: { SSSSKEY: true },
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
checkKeyBackupAndEnable: vi.fn(async () => {}),
|
||||
loadSessionBackupPrivateKeyFromSecretStorage: vi.fn(async () => {
|
||||
backupKeyLoaded = await consumeMatrixSecretStorageKey();
|
||||
}),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => (backupKeyLoaded ? "11" : null)),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => (backupKeyLoaded ? privateKey : null)),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "11",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-usable-"));
|
||||
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.recoveryKeyAccepted).toBe(true);
|
||||
expect(result.backupUsable).toBe(true);
|
||||
expect(result.deviceOwnerVerified).toBe(false);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.recoveryKeyStored).toBe(true);
|
||||
expect(fs.existsSync(recoveryKeyPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not persist a staged recovery key when backup usability came from existing material", async () => {
|
||||
const previousEncoded = encodeRecoveryKey(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
|
||||
);
|
||||
const attemptedEncoded = encodeRecoveryKey(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
|
||||
);
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
@@ -1621,19 +1838,122 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
checkKeyBackupAndEnable: vi.fn(async () => {}),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "11"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "11",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-cached-"));
|
||||
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSSKEY",
|
||||
encodedPrivateKey: previousEncoded,
|
||||
privateKeyBase64: Buffer.from(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
|
||||
).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"),
|
||||
recoveryKeyPath,
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.error).toContain("not verified by its owner");
|
||||
expect(result.recoveryKeyAccepted).toBe(false);
|
||||
expect(result.backupUsable).toBe(true);
|
||||
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
encodedPrivateKey?: string;
|
||||
};
|
||||
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
|
||||
});
|
||||
|
||||
it("does not persist a staged recovery key that secret storage did not validate", async () => {
|
||||
const previousEncoded = encodeRecoveryKey(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
|
||||
);
|
||||
const attemptedEncoded = encodeRecoveryKey(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)),
|
||||
);
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
defaultKeyId: "SSSSKEY",
|
||||
secretStorageKeyValidityMap: { SSSSKEY: false },
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
checkKeyBackupAndEnable: vi.fn(async () => {}),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "11"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "11",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-invalid-"));
|
||||
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSSKEY",
|
||||
encodedPrivateKey: previousEncoded,
|
||||
privateKeyBase64: Buffer.from(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)),
|
||||
).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.recoveryKeyAccepted).toBe(false);
|
||||
expect(result.backupUsable).toBe(true);
|
||||
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
encodedPrivateKey?: string;
|
||||
};
|
||||
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
|
||||
@@ -1644,7 +1964,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(consumeMatrixSecretStorageKey),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
@@ -1680,6 +2000,9 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.recoveryKeyAccepted).toBe(true);
|
||||
expect(result.backupUsable).toBe(false);
|
||||
expect(result.deviceOwnerVerified).toBe(true);
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.error).toContain("backup signature chain is not trusted");
|
||||
expect(result.recoveryKeyStored).toBe(false);
|
||||
@@ -1739,7 +2062,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
const result = await client.verifyWithRecoveryKey(attemptedEncoded as string);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not verified by its owner");
|
||||
expect(result.error).toContain("full Matrix identity trust");
|
||||
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
encodedPrivateKey?: string;
|
||||
};
|
||||
@@ -2538,7 +2861,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.verification.localVerified).toBe(true);
|
||||
expect(result.verification.signedByOwner).toBe(false);
|
||||
expect(result.error).toContain("not verified by its owner after bootstrap");
|
||||
expect(result.error).toContain("full Matrix identity trust after bootstrap");
|
||||
});
|
||||
|
||||
it("creates a key backup during bootstrap when none exists on the server", async () => {
|
||||
|
||||
@@ -72,8 +72,8 @@ export type MatrixOwnDeviceVerificationStatus = {
|
||||
encryptionEnabled: boolean;
|
||||
userId: string | null;
|
||||
deviceId: string | null;
|
||||
// "verified" is intentionally strict: other Matrix clients should trust messages
|
||||
// from this device without showing "not verified by its owner" warnings.
|
||||
// "verified" is intentionally strict: this device must be trusted through the
|
||||
// Matrix cross-signing identity chain, not merely signed by the owner key.
|
||||
verified: boolean;
|
||||
localVerified: boolean;
|
||||
crossSigningVerified: boolean;
|
||||
@@ -128,6 +128,9 @@ export type MatrixRoomKeyBackupResetResult = {
|
||||
|
||||
export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & {
|
||||
success: boolean;
|
||||
recoveryKeyAccepted: boolean;
|
||||
backupUsable: boolean;
|
||||
deviceOwnerVerified: boolean;
|
||||
verifiedAt?: string;
|
||||
error?: string;
|
||||
};
|
||||
@@ -160,12 +163,15 @@ const MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS = {
|
||||
} satisfies MatrixCryptoBootstrapOptions;
|
||||
|
||||
function createMatrixExplicitBootstrapOptions(params?: {
|
||||
allowAutomaticCrossSigningReset?: boolean;
|
||||
forceResetCrossSigning?: boolean;
|
||||
strict?: boolean;
|
||||
}): MatrixCryptoBootstrapOptions {
|
||||
return {
|
||||
forceResetCrossSigning: params?.forceResetCrossSigning === true,
|
||||
allowAutomaticCrossSigningReset: params?.allowAutomaticCrossSigningReset !== false,
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
strict: true,
|
||||
strict: params?.strict !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,7 +368,15 @@ export class MatrixClient {
|
||||
return;
|
||||
}
|
||||
|
||||
this.verificationManager ??= new runtime.MatrixVerificationManager();
|
||||
this.verificationManager ??= new runtime.MatrixVerificationManager({
|
||||
trustOwnDeviceAfterSas: async (deviceId: string) => {
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
if (typeof crypto?.crossSignDevice !== "function") {
|
||||
return;
|
||||
}
|
||||
await crypto.crossSignDevice(deviceId);
|
||||
},
|
||||
});
|
||||
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
|
||||
getUserId: () => this.getUserId(),
|
||||
getPassword: () => this.password,
|
||||
@@ -1110,12 +1124,10 @@ export class MatrixClient {
|
||||
const deviceId = this.client.getDeviceId()?.trim() || null;
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
|
||||
const ownerVerified =
|
||||
deviceVerification.crossSigningVerified || deviceVerification.signedByOwner;
|
||||
|
||||
return {
|
||||
...deviceVerification,
|
||||
verified: ownerVerified,
|
||||
verified: deviceVerification.crossSigningVerified,
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
@@ -1124,14 +1136,67 @@ export class MatrixClient {
|
||||
};
|
||||
}
|
||||
|
||||
async getOwnDeviceIdentityVerificationStatus(): Promise<MatrixDeviceVerificationStatus> {
|
||||
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
|
||||
const deviceId = this.client.getDeviceId()?.trim() || null;
|
||||
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
|
||||
return {
|
||||
...deviceVerification,
|
||||
verified: deviceVerification.crossSigningVerified,
|
||||
};
|
||||
}
|
||||
|
||||
async trustOwnIdentityAfterSelfVerification(): Promise<void> {
|
||||
if (!this.encryptionEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureStartedForCryptoControlPlane();
|
||||
await this.ensureCryptoSupportInitialized();
|
||||
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
|
||||
const ownIdentity =
|
||||
crypto && typeof crypto.getOwnIdentity === "function"
|
||||
? await crypto.getOwnIdentity().catch(() => undefined)
|
||||
: undefined;
|
||||
if (!ownIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
|
||||
return;
|
||||
}
|
||||
if (typeof ownIdentity.verify !== "function") {
|
||||
return;
|
||||
}
|
||||
await ownIdentity.verify();
|
||||
} finally {
|
||||
ownIdentity.free?.();
|
||||
}
|
||||
}
|
||||
|
||||
async verifyWithRecoveryKey(
|
||||
rawRecoveryKey: string,
|
||||
): Promise<MatrixRecoveryKeyVerificationResult> {
|
||||
const fail = async (error: string): Promise<MatrixRecoveryKeyVerificationResult> => ({
|
||||
success: false,
|
||||
error,
|
||||
...(await this.getOwnDeviceVerificationStatus()),
|
||||
});
|
||||
const fail = async (
|
||||
error: string,
|
||||
fields: Partial<
|
||||
Pick<
|
||||
MatrixRecoveryKeyVerificationResult,
|
||||
"backupUsable" | "deviceOwnerVerified" | "recoveryKeyAccepted"
|
||||
>
|
||||
> = {},
|
||||
): Promise<MatrixRecoveryKeyVerificationResult> => {
|
||||
const status = await this.getOwnDeviceVerificationStatus();
|
||||
return {
|
||||
success: false,
|
||||
recoveryKeyAccepted: fields.recoveryKeyAccepted ?? false,
|
||||
backupUsable: fields.backupUsable ?? false,
|
||||
deviceOwnerVerified: fields.deviceOwnerVerified ?? status.verified,
|
||||
error,
|
||||
...status,
|
||||
};
|
||||
};
|
||||
|
||||
if (!this.encryptionEnabled) {
|
||||
return await fail("Matrix encryption is disabled for this client");
|
||||
@@ -1144,15 +1209,21 @@ export class MatrixClient {
|
||||
return await fail("Matrix crypto is not available (start client with encryption enabled)");
|
||||
}
|
||||
|
||||
const backupUsableBeforeStagedRecovery =
|
||||
resolveMatrixRoomKeyBackupReadinessError(await this.getRoomKeyBackupStatus(), {
|
||||
requireServerBackup: true,
|
||||
}) === null;
|
||||
const trimmedRecoveryKey = rawRecoveryKey.trim();
|
||||
if (!trimmedRecoveryKey) {
|
||||
return await fail("Matrix recovery key is required");
|
||||
}
|
||||
|
||||
let stagedKeyId: string | null = null;
|
||||
try {
|
||||
stagedKeyId = (await this.resolveDefaultSecretStorageKeyId(crypto)) ?? null;
|
||||
this.recoveryKeyStore.stageEncodedRecoveryKey({
|
||||
encodedPrivateKey: trimmedRecoveryKey,
|
||||
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
|
||||
keyId: stagedKeyId,
|
||||
});
|
||||
} catch (err) {
|
||||
return await fail(formatMatrixErrorMessage(err));
|
||||
@@ -1168,33 +1239,88 @@ export class MatrixClient {
|
||||
});
|
||||
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
|
||||
const status = await this.getOwnDeviceVerificationStatus();
|
||||
if (!status.verified) {
|
||||
this.recoveryKeyStore.discardStagedRecoveryKey();
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
|
||||
...status,
|
||||
};
|
||||
}
|
||||
const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, {
|
||||
requireServerBackup: false,
|
||||
});
|
||||
const backupUsable =
|
||||
resolveMatrixRoomKeyBackupReadinessError(status.backup, {
|
||||
requireServerBackup: true,
|
||||
}) === null;
|
||||
const stagedRecoveryKeyUsed = this.recoveryKeyStore.hasStagedRecoveryKeyBeenUsed();
|
||||
const secretStorageStatus =
|
||||
typeof crypto.getSecretStorageStatus === "function"
|
||||
? await crypto.getSecretStorageStatus().catch(() => null)
|
||||
: null;
|
||||
const stagedRecoveryKeyConfirmedBySecretStorage =
|
||||
Boolean(stagedKeyId) &&
|
||||
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === true;
|
||||
const stagedRecoveryKeyRejectedBySecretStorage =
|
||||
Boolean(stagedKeyId) &&
|
||||
secretStorageStatus?.secretStorageKeyValidityMap?.[stagedKeyId ?? ""] === false;
|
||||
const stagedRecoveryKeyUnlockedBackup =
|
||||
stagedRecoveryKeyUsed &&
|
||||
!stagedRecoveryKeyRejectedBySecretStorage &&
|
||||
!stagedRecoveryKeyConfirmedBySecretStorage &&
|
||||
!backupUsableBeforeStagedRecovery &&
|
||||
backupUsable;
|
||||
const stagedRecoveryKeyValidated =
|
||||
stagedRecoveryKeyUsed &&
|
||||
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup);
|
||||
const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable);
|
||||
if (!status.verified) {
|
||||
if (backupUsable && stagedRecoveryKeyValidated) {
|
||||
this.recoveryKeyStore.commitStagedRecoveryKey({
|
||||
keyId: stagedKeyId,
|
||||
});
|
||||
} else {
|
||||
this.recoveryKeyStore.discardStagedRecoveryKey();
|
||||
}
|
||||
const committedStatus = recoveryKeyAccepted
|
||||
? await this.getOwnDeviceVerificationStatus()
|
||||
: status;
|
||||
return {
|
||||
success: false,
|
||||
recoveryKeyAccepted,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: false,
|
||||
error:
|
||||
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust. The recovery key can unlock usable backup material only when 'Backup usable' is yes; full identity trust still requires Matrix cross-signing verification.",
|
||||
...committedStatus,
|
||||
};
|
||||
}
|
||||
if (backupError) {
|
||||
this.recoveryKeyStore.discardStagedRecoveryKey();
|
||||
return {
|
||||
success: false,
|
||||
recoveryKeyAccepted,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: true,
|
||||
error: backupError,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
if (!stagedRecoveryKeyValidated) {
|
||||
this.recoveryKeyStore.discardStagedRecoveryKey();
|
||||
return {
|
||||
success: false,
|
||||
recoveryKeyAccepted: false,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: true,
|
||||
error:
|
||||
"Matrix recovery key could not be verified against active Matrix backup material; existing backup may be usable from previously loaded recovery material.",
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
this.recoveryKeyStore.commitStagedRecoveryKey({
|
||||
keyId: await this.resolveDefaultSecretStorageKeyId(crypto),
|
||||
keyId: stagedKeyId,
|
||||
});
|
||||
const committedStatus = await this.getOwnDeviceVerificationStatus();
|
||||
return {
|
||||
success: true,
|
||||
recoveryKeyAccepted: true,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: true,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
...committedStatus,
|
||||
};
|
||||
@@ -1419,8 +1545,10 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
async bootstrapOwnDeviceVerification(params?: {
|
||||
allowAutomaticCrossSigningReset?: boolean;
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
strict?: boolean;
|
||||
}): Promise<MatrixVerificationBootstrapResult> {
|
||||
const pendingVerifications = async (): Promise<number> =>
|
||||
this.crypto ? (await this.crypto.listVerifications()).length : 0;
|
||||
@@ -1680,12 +1808,15 @@ export class MatrixClient {
|
||||
"MatrixClientLite",
|
||||
"No room key backup version found on server, creating one via secret storage bootstrap",
|
||||
);
|
||||
// matrix-js-sdk 41.3.0 can log a transient PerSessionKeyBackupDownloader
|
||||
// "current backup version ... undefined" warning while setupNewKeyBackup creates
|
||||
// the backup: resetKeyBackup emits key-backup cache events before its async
|
||||
// checkKeyBackupAndEnable pass has populated active backup state. Keep the
|
||||
// explicit server re-check below and do not hide the SDK logs; if this needs
|
||||
// fixing in code, upstream a minimal Matrix SDK repro instead of patching here.
|
||||
// matrix-js-sdk 41.3.0 can log transient PerSessionKeyBackupDownloader
|
||||
// diagnostics while setupNewKeyBackup creates the first backup, including
|
||||
// "Got current backup version from server: undefined" and
|
||||
// "Unsupported algorithm undefined". This is an expected upstream
|
||||
// matrix-js-sdk race: resetKeyBackup emits key-backup cache events before
|
||||
// its async checkKeyBackupAndEnable pass has populated active backup state.
|
||||
// Keep the explicit server re-check below and do not hide the SDK logs; if
|
||||
// this needs fixing in code, upstream a minimal Matrix SDK repro instead of
|
||||
// patching here.
|
||||
await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, {
|
||||
setupNewKeyBackup: true,
|
||||
});
|
||||
|
||||
@@ -196,7 +196,8 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
userHasCrossSigningKeys: vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true),
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValue(true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
})),
|
||||
@@ -253,6 +254,48 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mark the own Matrix identity verified before cross-signing the current device", async () => {
|
||||
const verifyOwnIdentity = vi.fn(async () => undefined);
|
||||
const freeOwnIdentity = vi.fn();
|
||||
const setDeviceVerified = vi.fn(async () => {});
|
||||
const crossSignDevice = vi.fn(async () => {});
|
||||
const getDeviceVerificationStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
isVerified: () => false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
});
|
||||
const { bootstrapper, crypto } = createBootstrapperHarness({
|
||||
crossSignDevice,
|
||||
getDeviceVerificationStatus,
|
||||
getOwnIdentity: vi.fn(async () => ({
|
||||
free: freeOwnIdentity,
|
||||
isVerified: () => false,
|
||||
verify: verifyOwnIdentity,
|
||||
})),
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
setDeviceVerified,
|
||||
userHasCrossSigningKeys: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
await bootstrapper.bootstrap(crypto, {
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
});
|
||||
|
||||
expect(verifyOwnIdentity).not.toHaveBeenCalled();
|
||||
expect(freeOwnIdentity).not.toHaveBeenCalled();
|
||||
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
|
||||
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
|
||||
});
|
||||
|
||||
it("refreshes published cross-signing keys before importing private keys from secret storage", async () => {
|
||||
const bootstrapCrossSigning = vi.fn(async () => {});
|
||||
const userHasCrossSigningKeys = vi.fn(async () => true);
|
||||
@@ -419,6 +462,47 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts the fresh own identity after a forced cross-signing reset", async () => {
|
||||
const verifyOwnIdentity = vi.fn(async () => ({}));
|
||||
const freeOwnIdentity = vi.fn();
|
||||
const { crypto, bootstrapper } = createForcedResetHarness(vi.fn(async () => {}));
|
||||
crypto.getOwnIdentity = vi.fn(async () => ({
|
||||
free: freeOwnIdentity,
|
||||
isVerified: () => false,
|
||||
verify: verifyOwnIdentity,
|
||||
}));
|
||||
|
||||
await bootstrapper.bootstrap(crypto, {
|
||||
strict: true,
|
||||
forceResetCrossSigning: true,
|
||||
});
|
||||
|
||||
expect(verifyOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
expect(freeOwnIdentity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not trust an existing unpublished identity without a reset", async () => {
|
||||
const verifyOwnIdentity = vi.fn(async () => ({}));
|
||||
const { crypto, bootstrapper } = createBootstrapperHarness({
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
getDeviceVerificationStatus: vi.fn(async () => createVerifiedDeviceStatus()),
|
||||
getOwnIdentity: vi.fn(async () => ({
|
||||
isVerified: () => false,
|
||||
verify: verifyOwnIdentity,
|
||||
})),
|
||||
isCrossSigningReady: vi.fn(async () => false),
|
||||
userHasCrossSigningKeys: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
const result = await bootstrapper.bootstrap(crypto, {
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
|
||||
expect(result.crossSigningPublished).toBe(false);
|
||||
expect(verifyOwnIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const crypto = createCryptoApi({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
|
||||
import { LogService } from "./logger.js";
|
||||
@@ -37,6 +38,8 @@ export type MatrixCryptoBootstrapResult = {
|
||||
ownDeviceVerified: boolean | null;
|
||||
};
|
||||
|
||||
const CROSS_SIGNING_PUBLICATION_WAIT_MS = 5_000;
|
||||
|
||||
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
private verificationHandlerRegistered = false;
|
||||
|
||||
@@ -83,7 +86,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
strict,
|
||||
});
|
||||
}
|
||||
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
|
||||
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
|
||||
strict,
|
||||
});
|
||||
return {
|
||||
crossSigningReady: crossSigning.ready,
|
||||
crossSigningPublished: crossSigning.published,
|
||||
@@ -165,7 +170,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
|
||||
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
|
||||
const ready = await isCrossSigningReady();
|
||||
const published = await hasPublishedCrossSigningKeys();
|
||||
const published = ready
|
||||
? await waitForPublishedCrossSigningKeys()
|
||||
: await hasPublishedCrossSigningKeys();
|
||||
if (ready && published) {
|
||||
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
|
||||
return { ready, published };
|
||||
@@ -178,6 +185,17 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
return { ready, published };
|
||||
};
|
||||
|
||||
const waitForPublishedCrossSigningKeys = async (): Promise<boolean> => {
|
||||
const startedAt = Date.now();
|
||||
do {
|
||||
if (await hasPublishedCrossSigningKeys()) {
|
||||
return true;
|
||||
}
|
||||
await sleep(250);
|
||||
} while (Date.now() - startedAt < CROSS_SIGNING_PUBLICATION_WAIT_MS);
|
||||
return false;
|
||||
};
|
||||
|
||||
if (options.forceResetCrossSigning) {
|
||||
const resetCrossSigning = async (): Promise<void> => {
|
||||
await crypto.bootstrapCrossSigning({
|
||||
@@ -187,6 +205,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
};
|
||||
try {
|
||||
await resetCrossSigning();
|
||||
await this.trustFreshOwnIdentity(crypto);
|
||||
} catch (err) {
|
||||
const shouldRepairSecretStorage =
|
||||
options.allowSecretStorageRecreateWithoutRecoveryKey &&
|
||||
@@ -202,6 +221,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
forceNewSecretStorage: true,
|
||||
});
|
||||
await resetCrossSigning();
|
||||
await this.trustFreshOwnIdentity(crypto);
|
||||
} catch (repairErr) {
|
||||
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr);
|
||||
if (options.strict) {
|
||||
@@ -287,6 +307,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
setupNewCrossSigning: true,
|
||||
authUploadDeviceSigningKeys,
|
||||
});
|
||||
await this.trustFreshOwnIdentity(crypto);
|
||||
} catch (err) {
|
||||
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
|
||||
if (options.strict) {
|
||||
@@ -298,6 +319,25 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
return await finalize();
|
||||
}
|
||||
|
||||
private async trustFreshOwnIdentity(crypto: MatrixCryptoBootstrapApi): Promise<void> {
|
||||
const ownIdentity =
|
||||
typeof crypto.getOwnIdentity === "function"
|
||||
? await crypto.getOwnIdentity().catch(() => undefined)
|
||||
: undefined;
|
||||
if (!ownIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof ownIdentity.isVerified === "function" && ownIdentity.isVerified()) {
|
||||
return;
|
||||
}
|
||||
await ownIdentity.verify?.();
|
||||
} finally {
|
||||
ownIdentity.free?.();
|
||||
}
|
||||
}
|
||||
|
||||
private async bootstrapSecretStorage(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
options: {
|
||||
@@ -349,7 +389,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
|
||||
private async ensureOwnDeviceTrust(
|
||||
crypto: MatrixCryptoBootstrapApi,
|
||||
strict = false,
|
||||
options: {
|
||||
strict: boolean;
|
||||
},
|
||||
): Promise<boolean | null> {
|
||||
const deviceId = this.deps.getDeviceId()?.trim();
|
||||
if (!deviceId) {
|
||||
@@ -386,8 +428,10 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||
: null;
|
||||
const verified = isMatrixDeviceOwnerVerified(refreshedStatus);
|
||||
if (!verified && strict) {
|
||||
throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`);
|
||||
if (!verified && options.strict) {
|
||||
throw new Error(
|
||||
`Matrix own device ${deviceId} does not have full Matrix identity trust after bootstrap`,
|
||||
);
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export class MatrixRecoveryKeyStore {
|
||||
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
|
||||
>();
|
||||
private stagedRecoveryKey: MatrixStoredRecoveryKey | null = null;
|
||||
private stagedRecoveryKeyUsed = false;
|
||||
private readonly stagedCacheKeyIds = new Set<string>();
|
||||
|
||||
constructor(private readonly recoveryKeyPath?: string) {}
|
||||
@@ -46,6 +47,11 @@ export class MatrixRecoveryKeyStore {
|
||||
return null;
|
||||
}
|
||||
|
||||
const staged = this.resolveStagedSecretStorageKey(requestedKeyIds);
|
||||
if (staged) {
|
||||
return staged;
|
||||
}
|
||||
|
||||
for (const keyId of requestedKeyIds) {
|
||||
const cached = this.secretStorageKeyCache.get(keyId);
|
||||
if (cached) {
|
||||
@@ -53,22 +59,6 @@ export class MatrixRecoveryKeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
const staged = this.stagedRecoveryKey;
|
||||
if (staged?.privateKeyBase64) {
|
||||
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
|
||||
if (privateKey.length > 0) {
|
||||
const stagedKeyId =
|
||||
staged.keyId && requestedKeyIds.includes(staged.keyId)
|
||||
? staged.keyId
|
||||
: requestedKeyIds[0];
|
||||
if (stagedKeyId) {
|
||||
this.rememberSecretStorageKey(stagedKeyId, privateKey, staged.keyInfo);
|
||||
this.stagedCacheKeyIds.add(stagedKeyId);
|
||||
return [stagedKeyId, privateKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stored = this.loadStoredRecoveryKey();
|
||||
if (!stored?.privateKeyBase64) {
|
||||
return null;
|
||||
@@ -196,6 +186,10 @@ export class MatrixRecoveryKeyStore {
|
||||
};
|
||||
}
|
||||
|
||||
hasStagedRecoveryKeyBeenUsed(): boolean {
|
||||
return this.stagedRecoveryKeyUsed;
|
||||
}
|
||||
|
||||
commitStagedRecoveryKey(params?: {
|
||||
keyId?: string | null;
|
||||
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
|
||||
@@ -264,19 +258,24 @@ export class MatrixRecoveryKeyStore {
|
||||
|
||||
if (recoveryKey && status?.defaultKeyId) {
|
||||
const defaultKeyId = status.defaultKeyId;
|
||||
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
|
||||
if (!stagedRecovery && storedRecovery && storedRecovery.keyId !== defaultKeyId) {
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId: defaultKeyId,
|
||||
keyInfo: recoveryKey.keyInfo,
|
||||
privateKey: recoveryKey.privateKey,
|
||||
encodedPrivateKey: recoveryKey.encodedPrivateKey,
|
||||
});
|
||||
if (!stagedRecovery) {
|
||||
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
|
||||
if (storedRecovery && storedRecovery.keyId !== defaultKeyId) {
|
||||
this.saveRecoveryKeyToDisk({
|
||||
keyId: defaultKeyId,
|
||||
keyInfo: recoveryKey.keyInfo,
|
||||
privateKey: recoveryKey.privateKey,
|
||||
encodedPrivateKey: recoveryKey.encodedPrivateKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensureRecoveryKey = async (): Promise<MatrixGeneratedSecretStorageKey> => {
|
||||
if (recoveryKey) {
|
||||
if (stagedRecovery) {
|
||||
this.stagedRecoveryKeyUsed = true;
|
||||
}
|
||||
return recoveryKey;
|
||||
}
|
||||
if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") {
|
||||
@@ -347,9 +346,38 @@ export class MatrixRecoveryKeyStore {
|
||||
|
||||
private clearStagedRecoveryKeyTracking(): void {
|
||||
this.stagedRecoveryKey = null;
|
||||
this.stagedRecoveryKeyUsed = false;
|
||||
this.stagedCacheKeyIds.clear();
|
||||
}
|
||||
|
||||
private resolveStagedSecretStorageKey(requestedKeyIds: string[]): [string, Uint8Array] | null {
|
||||
const staged = this.stagedRecoveryKey;
|
||||
if (!staged?.privateKeyBase64) {
|
||||
return null;
|
||||
}
|
||||
const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64"));
|
||||
if (privateKey.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const keyId =
|
||||
staged.keyId && requestedKeyIds.includes(staged.keyId) ? staged.keyId : requestedKeyIds[0];
|
||||
if (!keyId) {
|
||||
return null;
|
||||
}
|
||||
this.rememberStagedSecretStorageKey(keyId, privateKey, staged.keyInfo);
|
||||
this.stagedCacheKeyIds.add(keyId);
|
||||
return [keyId, privateKey];
|
||||
}
|
||||
|
||||
private rememberStagedSecretStorageKey(
|
||||
keyId: string,
|
||||
key: Uint8Array,
|
||||
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
|
||||
): void {
|
||||
this.stagedRecoveryKeyUsed = true;
|
||||
this.rememberSecretStorageKey(keyId, key, keyInfo);
|
||||
}
|
||||
|
||||
private rememberSecretStorageKey(
|
||||
keyId: string,
|
||||
key: Uint8Array,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -188,6 +188,73 @@ describe("MatrixVerificationManager", () => {
|
||||
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
|
||||
});
|
||||
|
||||
it("reuses the tracked id when the other device id is populated later", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const first = new MockVerificationRequest({
|
||||
transactionId: "txn-device-later",
|
||||
phase: VerificationPhase.Requested,
|
||||
});
|
||||
const second = new MockVerificationRequest({
|
||||
transactionId: "txn-device-later",
|
||||
phase: VerificationPhase.Ready,
|
||||
otherDeviceId: "DEVICE_LATER",
|
||||
pending: false,
|
||||
});
|
||||
|
||||
const firstSummary = manager.trackVerificationRequest(first);
|
||||
const secondSummary = manager.trackVerificationRequest(second);
|
||||
|
||||
expect(secondSummary.id).toBe(firstSummary.id);
|
||||
expect(secondSummary.otherDeviceId).toBe("DEVICE_LATER");
|
||||
expect(manager.listVerifications()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps separate sessions when stable other device ids differ", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const first = new MockVerificationRequest({
|
||||
transactionId: "txn-different-devices",
|
||||
otherDeviceId: "DEVICE_A",
|
||||
});
|
||||
const second = new MockVerificationRequest({
|
||||
transactionId: "txn-different-devices",
|
||||
otherDeviceId: "DEVICE_B",
|
||||
});
|
||||
|
||||
const firstSummary = manager.trackVerificationRequest(first);
|
||||
const secondSummary = manager.trackVerificationRequest(second);
|
||||
|
||||
expect(secondSummary.id).not.toBe(firstSummary.id);
|
||||
expect(manager.listVerifications()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not overwrite a different verification request with a colliding transaction ID", async () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const first = new MockVerificationRequest({
|
||||
transactionId: "txn-collision",
|
||||
initiatedByMe: true,
|
||||
otherUserId: "@alice:example.org",
|
||||
otherDeviceId: "ALICE1",
|
||||
});
|
||||
const second = new MockVerificationRequest({
|
||||
transactionId: "txn-collision",
|
||||
initiatedByMe: true,
|
||||
otherUserId: "@mallory:example.org",
|
||||
otherDeviceId: "MALLORY1",
|
||||
});
|
||||
|
||||
const firstSummary = manager.trackVerificationRequest(first);
|
||||
const secondSummary = manager.trackVerificationRequest(second);
|
||||
|
||||
expect(secondSummary.id).not.toBe(firstSummary.id);
|
||||
expect(manager.listVerifications()).toHaveLength(2);
|
||||
expect(() => manager.getVerificationSas("txn-collision")).toThrow(
|
||||
"Matrix verification request id is ambiguous for transaction txn-collision",
|
||||
);
|
||||
await manager.acceptVerification(firstSummary.id);
|
||||
expect(first.accept).toHaveBeenCalledTimes(1);
|
||||
expect(second.accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
|
||||
const confirm = vi.fn(async () => {});
|
||||
const mismatch = vi.fn();
|
||||
@@ -231,6 +298,49 @@ describe("MatrixVerificationManager", () => {
|
||||
expect(mismatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cross-signs the other own device after confirmed self-verification SAS", async () => {
|
||||
const { confirm, verifier } = createSasVerifierFixture({
|
||||
decimal: [111, 222, 333],
|
||||
emoji: [["cat", "cat"]],
|
||||
});
|
||||
const trustOwnDeviceAfterSas = vi.fn(async () => {});
|
||||
const request = new MockVerificationRequest({
|
||||
isSelfVerification: true,
|
||||
otherDeviceId: "OTHERDEVICE",
|
||||
transactionId: "txn-self-sas",
|
||||
verifier,
|
||||
});
|
||||
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
|
||||
const tracked = manager.trackVerificationRequest(request);
|
||||
|
||||
await manager.startVerification(tracked.id, "sas");
|
||||
await manager.confirmVerificationSas(tracked.id);
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(trustOwnDeviceAfterSas).toHaveBeenCalledWith("OTHERDEVICE");
|
||||
});
|
||||
|
||||
it("does not cross-sign non-self SAS verifications", async () => {
|
||||
const { verifier } = createSasVerifierFixture({
|
||||
decimal: [111, 222, 333],
|
||||
emoji: [["cat", "cat"]],
|
||||
});
|
||||
const trustOwnDeviceAfterSas = vi.fn(async () => {});
|
||||
const request = new MockVerificationRequest({
|
||||
isSelfVerification: false,
|
||||
otherDeviceId: "OTHERDEVICE",
|
||||
transactionId: "txn-remote-sas",
|
||||
verifier,
|
||||
});
|
||||
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
|
||||
const tracked = manager.trackVerificationRequest(request);
|
||||
|
||||
await manager.startVerification(tracked.id, "sas");
|
||||
await manager.confirmVerificationSas(tracked.id);
|
||||
|
||||
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-starts an incoming verifier exposed via request change events", async () => {
|
||||
const { verifier, verify } = createSasVerifierFixture({
|
||||
decimal: [6158, 1986, 3513],
|
||||
@@ -410,6 +520,33 @@ describe("MatrixVerificationManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cross-sign the other own device after auto-confirmed self-verification SAS", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { confirm, verifier } = createSasVerifierFixture({
|
||||
decimal: [6158, 1986, 3513],
|
||||
emoji: [["gift", "Gift"]],
|
||||
});
|
||||
const trustOwnDeviceAfterSas = vi.fn(async () => {});
|
||||
const request = new MockVerificationRequest({
|
||||
isSelfVerification: true,
|
||||
otherDeviceId: "OTHERDEVICE",
|
||||
transactionId: "txn-auto-confirm-self",
|
||||
initiatedByMe: false,
|
||||
verifier,
|
||||
});
|
||||
try {
|
||||
const manager = new MatrixVerificationManager({ trustOwnDeviceAfterSas });
|
||||
manager.trackVerificationRequest(request);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_100);
|
||||
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(trustOwnDeviceAfterSas).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-confirm SAS for verifications initiated by this device", async () => {
|
||||
vi.useFakeTimers();
|
||||
const confirm = vi.fn(async () => {});
|
||||
|
||||
@@ -52,6 +52,7 @@ export type MatrixVerificationSummary = {
|
||||
};
|
||||
|
||||
type MatrixVerificationSummaryListener = (summary: MatrixVerificationSummary) => void;
|
||||
type MatrixVerificationOwnerTrustCallback = (deviceId: string) => Promise<void>;
|
||||
|
||||
export type MatrixShowSasCallbacks = {
|
||||
sas: {
|
||||
@@ -101,6 +102,7 @@ export type MatrixVerificationRequestLike = {
|
||||
|
||||
export type MatrixVerificationCryptoApi = {
|
||||
requestOwnUserVerification: () => Promise<MatrixVerificationRequestLike | null>;
|
||||
getVerificationRequestsToDeviceInProgress?: (userId: string) => MatrixVerificationRequestLike[];
|
||||
findVerificationRequestDMInProgress?: (
|
||||
roomId: string,
|
||||
userId: string,
|
||||
@@ -132,6 +134,15 @@ type MatrixVerificationSession = {
|
||||
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
|
||||
};
|
||||
|
||||
type MatrixVerificationRequestIdentity = {
|
||||
transactionId: string;
|
||||
roomId: string;
|
||||
otherUserId: string;
|
||||
otherDeviceId: string;
|
||||
isSelfVerification: boolean;
|
||||
initiatedByMe: boolean;
|
||||
};
|
||||
|
||||
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
|
||||
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const SAS_AUTO_CONFIRM_DELAY_MS = 30_000;
|
||||
@@ -143,6 +154,12 @@ export class MatrixVerificationManager {
|
||||
private readonly trackedVerificationVerifiers = new WeakSet<object>();
|
||||
private readonly summaryListeners = new Set<MatrixVerificationSummaryListener>();
|
||||
|
||||
constructor(
|
||||
private readonly opts: {
|
||||
trustOwnDeviceAfterSas?: MatrixVerificationOwnerTrustCallback;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
private readRequestValue<T>(
|
||||
request: MatrixVerificationRequestLike,
|
||||
reader: () => T,
|
||||
@@ -163,6 +180,40 @@ export class MatrixVerificationManager {
|
||||
return isMatrixVerificationPhase(phase) ? phase : fallback;
|
||||
}
|
||||
|
||||
private readVerificationRequestIdentity(
|
||||
request: MatrixVerificationRequestLike,
|
||||
): MatrixVerificationRequestIdentity {
|
||||
return {
|
||||
transactionId: this.readRequestValue(request, () => request.transactionId?.trim() ?? "", ""),
|
||||
roomId: this.readRequestValue(request, () => request.roomId ?? "", ""),
|
||||
otherUserId: this.readRequestValue(request, () => request.otherUserId, ""),
|
||||
otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId ?? "", ""),
|
||||
isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false),
|
||||
initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false),
|
||||
};
|
||||
}
|
||||
|
||||
private isSameLogicalVerificationRequest(
|
||||
left: MatrixVerificationRequestLike,
|
||||
right: MatrixVerificationRequestLike,
|
||||
): boolean {
|
||||
const leftIdentity = this.readVerificationRequestIdentity(left);
|
||||
const rightIdentity = this.readVerificationRequestIdentity(right);
|
||||
return (
|
||||
leftIdentity.transactionId !== "" &&
|
||||
leftIdentity.transactionId === rightIdentity.transactionId &&
|
||||
leftIdentity.roomId === rightIdentity.roomId &&
|
||||
leftIdentity.otherUserId === rightIdentity.otherUserId &&
|
||||
this.isSameOptionalIdentityValue(leftIdentity.otherDeviceId, rightIdentity.otherDeviceId) &&
|
||||
leftIdentity.isSelfVerification === rightIdentity.isSelfVerification &&
|
||||
leftIdentity.initiatedByMe === rightIdentity.initiatedByMe
|
||||
);
|
||||
}
|
||||
|
||||
private isSameOptionalIdentityValue(left: string, right: string): boolean {
|
||||
return left === "" || right === "" || left === right;
|
||||
}
|
||||
|
||||
private pruneVerificationSessions(nowMs: number): void {
|
||||
for (const [id, session] of this.verificationSessions) {
|
||||
const phase = this.readVerificationPhase(session.request, -1);
|
||||
@@ -276,11 +327,21 @@ export class MatrixVerificationManager {
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const session of this.verificationSessions.values()) {
|
||||
const txId = this.readRequestValue(session.request, () => session.request.transactionId, "");
|
||||
if (txId === id) {
|
||||
return session;
|
||||
}
|
||||
const transactionMatches = Array.from(this.verificationSessions.values()).filter((session) => {
|
||||
const txId = this.readRequestValue(
|
||||
session.request,
|
||||
() => session.request.transactionId?.trim(),
|
||||
"",
|
||||
);
|
||||
return txId === id;
|
||||
});
|
||||
if (transactionMatches.length === 1) {
|
||||
return transactionMatches[0];
|
||||
}
|
||||
if (transactionMatches.length > 1) {
|
||||
throw new Error(
|
||||
`Matrix verification request id is ambiguous for transaction ${id}; use the verification id instead`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Matrix verification request not found: ${id}`);
|
||||
}
|
||||
@@ -443,8 +504,7 @@ export class MatrixVerificationManager {
|
||||
return;
|
||||
}
|
||||
session.sasAutoConfirmStarted = true;
|
||||
void callbacks
|
||||
.confirm()
|
||||
void this.confirmSasForSession(session, callbacks, { trustOwnDevice: false })
|
||||
.then(() => {
|
||||
this.touchVerificationSession(session);
|
||||
})
|
||||
@@ -455,6 +515,17 @@ export class MatrixVerificationManager {
|
||||
}, SAS_AUTO_CONFIRM_DELAY_MS);
|
||||
}
|
||||
|
||||
private async confirmSasForSession(
|
||||
session: MatrixVerificationSession,
|
||||
callbacks: MatrixShowSasCallbacks,
|
||||
opts: { trustOwnDevice: boolean } = { trustOwnDevice: true },
|
||||
): Promise<void> {
|
||||
await callbacks.confirm();
|
||||
if (opts.trustOwnDevice) {
|
||||
await this.trustOwnDeviceAfterConfirmedSas(session);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureVerificationStarted(session: MatrixVerificationSession): void {
|
||||
if (!session.activeVerifier || session.verifyStarted) {
|
||||
return;
|
||||
@@ -472,6 +543,21 @@ export class MatrixVerificationManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async trustOwnDeviceAfterConfirmedSas(session: MatrixVerificationSession): Promise<void> {
|
||||
if (!this.readRequestValue(session.request, () => session.request.isSelfVerification, false)) {
|
||||
return;
|
||||
}
|
||||
const deviceId = this.readRequestValue(
|
||||
session.request,
|
||||
() => session.request.otherDeviceId?.trim(),
|
||||
"",
|
||||
);
|
||||
if (!deviceId || !this.opts.trustOwnDeviceAfterSas) {
|
||||
return;
|
||||
}
|
||||
await this.opts.trustOwnDeviceAfterSas(deviceId);
|
||||
}
|
||||
|
||||
onSummaryChanged(listener: MatrixVerificationSummaryListener): () => void {
|
||||
this.summaryListeners.add(listener);
|
||||
return () => {
|
||||
@@ -481,15 +567,17 @@ export class MatrixVerificationManager {
|
||||
|
||||
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
|
||||
this.pruneVerificationSessions(Date.now());
|
||||
const txId = this.readRequestValue(request, () => request.transactionId?.trim(), "");
|
||||
const requestObj = request as unknown as object;
|
||||
for (const existing of this.verificationSessions.values()) {
|
||||
if ((existing.request as unknown as object) === requestObj) {
|
||||
this.touchVerificationSession(existing);
|
||||
return this.buildVerificationSummary(existing);
|
||||
}
|
||||
}
|
||||
const txId = this.readVerificationRequestIdentity(request).transactionId;
|
||||
if (txId) {
|
||||
for (const existing of this.verificationSessions.values()) {
|
||||
const existingTxId = this.readRequestValue(
|
||||
existing.request,
|
||||
() => existing.request.transactionId,
|
||||
"",
|
||||
);
|
||||
if (existingTxId === txId) {
|
||||
if (this.isSameLogicalVerificationRequest(existing.request, request)) {
|
||||
existing.request = request;
|
||||
this.ensureVerificationRequestTracked(existing);
|
||||
const verifier = this.readRequestValue(request, () => request.verifier, null);
|
||||
@@ -643,7 +731,7 @@ export class MatrixVerificationManager {
|
||||
this.clearSasAutoConfirmTimer(session);
|
||||
session.sasCallbacks = callbacks;
|
||||
session.sasAutoConfirmStarted = true;
|
||||
await callbacks.confirm();
|
||||
await this.confirmSasForSession(session, callbacks);
|
||||
this.touchVerificationSession(session);
|
||||
return this.buildVerificationSummary(session);
|
||||
}
|
||||
|
||||
@@ -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,44 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatMatrixQaCliCommand,
|
||||
redactMatrixQaCliOutput,
|
||||
resolveMatrixQaOpenClawCliEntryPath,
|
||||
} from "./scenario-runtime-cli.js";
|
||||
|
||||
describe("Matrix QA CLI runtime", () => {
|
||||
it("redacts secret CLI arguments in diagnostic command text", () => {
|
||||
expect(
|
||||
formatMatrixQaCliCommand([
|
||||
"matrix",
|
||||
"verify",
|
||||
"backup",
|
||||
"restore",
|
||||
"--recovery-key",
|
||||
"abcdef1234567890ghij",
|
||||
]),
|
||||
).toBe("openclaw matrix verify backup restore --recovery-key [REDACTED]");
|
||||
expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe(
|
||||
"openclaw matrix account add --access-token=[REDACTED]",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts Matrix token output before diagnostics and artifacts", () => {
|
||||
expect(
|
||||
redactMatrixQaCliOutput("GET /_matrix/client/v3/sync?access_token=abcdef1234567890ghij"),
|
||||
).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij");
|
||||
});
|
||||
|
||||
it("prefers the ESM OpenClaw CLI entrypoint when present", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "matrix-qa-cli-entry-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(path.join(root, "dist", "index.mjs"), "");
|
||||
expect(resolveMatrixQaOpenClawCliEntryPath(root)).toBe(path.join(root, "dist", "index.mjs"));
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { spawn as startOpenClawCliProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
|
||||
|
||||
export type MatrixQaCliRunResult = {
|
||||
args: string[];
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
};
|
||||
|
||||
export type MatrixQaCliSession = {
|
||||
args: string[];
|
||||
output: () => { stderr: string; stdout: string };
|
||||
wait: () => Promise<MatrixQaCliRunResult>;
|
||||
waitForOutput: (
|
||||
predicate: (output: { stderr: string; stdout: string; text: string }) => boolean,
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
) => Promise<{ stderr: string; stdout: string; text: string }>;
|
||||
writeStdin: (text: string) => Promise<void>;
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]);
|
||||
|
||||
function redactMatrixQaCliArgs(args: string[]): string[] {
|
||||
return args.map((arg, index) => {
|
||||
const [flag] = arg.split("=", 1);
|
||||
if (MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(flag) && arg.includes("=")) {
|
||||
return `${flag}=[REDACTED]`;
|
||||
}
|
||||
const previous = args[index - 1];
|
||||
if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
|
||||
export function redactMatrixQaCliOutput(text: string): string {
|
||||
return redactSensitiveText(text);
|
||||
}
|
||||
|
||||
export function formatMatrixQaCliCommand(args: string[]) {
|
||||
return `openclaw ${redactMatrixQaCliArgs(args).join(" ")}`;
|
||||
}
|
||||
|
||||
export function resolveMatrixQaOpenClawCliEntryPath(cwd: string): string {
|
||||
const mjsEntryPath = path.join(cwd, "dist", "index.mjs");
|
||||
if (existsSync(mjsEntryPath)) {
|
||||
return mjsEntryPath;
|
||||
}
|
||||
return path.join(cwd, "dist", "index.js");
|
||||
}
|
||||
|
||||
function buildMatrixQaCliResult(params: {
|
||||
args: string[];
|
||||
exitCode: number;
|
||||
output: { stderr: string; stdout: string };
|
||||
}): MatrixQaCliRunResult {
|
||||
return {
|
||||
args: params.args,
|
||||
exitCode: params.exitCode,
|
||||
stderr: params.output.stderr,
|
||||
stdout: params.output.stdout,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) {
|
||||
return [
|
||||
`${formatMatrixQaCliCommand(result.args)} exited ${result.exitCode}`,
|
||||
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
|
||||
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function startMatrixQaOpenClawCli(params: {
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
}): MatrixQaCliSession {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const distEntryPath = resolveMatrixQaOpenClawCliEntryPath(cwd);
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let closed = false;
|
||||
let closeResult: MatrixQaCliRunResult | undefined;
|
||||
let settleWait:
|
||||
| {
|
||||
reject: (error: Error) => void;
|
||||
resolve: (result: MatrixQaCliRunResult) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const child = startOpenClawCliProcess(process.execPath, [distEntryPath, ...params.args], {
|
||||
cwd,
|
||||
env: params.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const readOutput = () => ({
|
||||
stderr: Buffer.concat(stderr).toString("utf8"),
|
||||
stdout: Buffer.concat(stdout).toString("utf8"),
|
||||
});
|
||||
const finish = (result: MatrixQaCliRunResult, error?: Error) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
closeResult = result;
|
||||
if (!settleWait) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
settleWait.reject(error);
|
||||
} else {
|
||||
settleWait.resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const result = buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: 1,
|
||||
output: readOutput(),
|
||||
});
|
||||
child.kill("SIGTERM");
|
||||
finish(
|
||||
result,
|
||||
new Error(`${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`),
|
||||
);
|
||||
}, params.timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
finish(
|
||||
buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: 1,
|
||||
output: readOutput(),
|
||||
}),
|
||||
error,
|
||||
);
|
||||
});
|
||||
child.on("close", (exitCode) => {
|
||||
clearTimeout(timeout);
|
||||
const result = buildMatrixQaCliResult({
|
||||
args: params.args,
|
||||
exitCode: exitCode ?? 1,
|
||||
output: readOutput(),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
finish(result, new Error(formatMatrixQaCliExitError(result)));
|
||||
return;
|
||||
}
|
||||
finish(result);
|
||||
});
|
||||
|
||||
return {
|
||||
args: params.args,
|
||||
output: readOutput,
|
||||
wait: async () =>
|
||||
await new Promise<MatrixQaCliRunResult>((resolve, reject) => {
|
||||
if (closed && closeResult) {
|
||||
if (closeResult.exitCode === 0) {
|
||||
resolve(closeResult);
|
||||
} else {
|
||||
reject(new Error(formatMatrixQaCliExitError(closeResult)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
settleWait = { reject, resolve };
|
||||
}).catch((error) => {
|
||||
throw new Error(
|
||||
`Matrix QA CLI command failed (${formatMatrixQaCliCommand(params.args)}): ${redactMatrixQaCliOutput(formatErrorMessage(error))}`,
|
||||
);
|
||||
}),
|
||||
waitForOutput: async (predicate, label, timeoutMs) => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const output = readOutput();
|
||||
const text = `${output.stdout}\n${output.stderr}`;
|
||||
if (predicate({ ...output, text })) {
|
||||
return { ...output, text };
|
||||
}
|
||||
if (closed) {
|
||||
break;
|
||||
}
|
||||
await sleep(Math.min(100, Math.max(25, timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
const output = readOutput();
|
||||
throw new Error(
|
||||
`${formatMatrixQaCliCommand(params.args)} did not print ${label} before timeout\nstdout:\n${redactMatrixQaCliOutput(output.stdout.trim())}\nstderr:\n${redactMatrixQaCliOutput(output.stderr.trim())}`,
|
||||
);
|
||||
},
|
||||
writeStdin: async (text) => {
|
||||
if (!child.stdin.write(text)) {
|
||||
await new Promise<void>((resolve) => child.stdin.once("drain", resolve));
|
||||
}
|
||||
},
|
||||
kill: () => {
|
||||
if (!closed) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runMatrixQaOpenClawCli(params: {
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixQaCliRunResult> {
|
||||
return await startMatrixQaOpenClawCli(params).wait();
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import type { MatrixVerificationSummary } from "@openclaw/matrix/test-api.js";
|
||||
import { createMatrixQaClient } from "../../substrate/client.js";
|
||||
@@ -25,6 +28,13 @@ import {
|
||||
hasMatrixQaExpectedColorReply,
|
||||
MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
|
||||
} from "./scenario-media-fixtures.js";
|
||||
import {
|
||||
formatMatrixQaCliCommand,
|
||||
redactMatrixQaCliOutput,
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
type MatrixQaCliRunResult,
|
||||
} from "./scenario-runtime-cli.js";
|
||||
import {
|
||||
assertThreadReplyArtifact,
|
||||
assertTopLevelReplyArtifact,
|
||||
@@ -40,8 +50,32 @@ import type { MatrixQaReplyArtifact, MatrixQaScenarioExecution } from "./scenari
|
||||
|
||||
const MATRIX_QA_ROOM_KEY_BACKUP_VERSION_ENDPOINT = "/_matrix/client/v3/room_keys/version";
|
||||
const MATRIX_QA_ROOM_KEY_BACKUP_FAULT_RULE_ID = "room-key-backup-version-unavailable";
|
||||
const MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID = "owner-signature-upload-blocked";
|
||||
const MATRIX_QA_KEYS_SIGNATURES_UPLOAD_ENDPOINT = "/_matrix/client/v3/keys/signatures/upload";
|
||||
|
||||
type MatrixQaE2eeBootstrapResult = Awaited<ReturnType<typeof runMatrixQaE2eeBootstrap>>;
|
||||
type MatrixQaCliVerificationStatus = {
|
||||
backup?: {
|
||||
decryptionKeyCached?: boolean | null;
|
||||
keyLoadError?: string | null;
|
||||
matchesDecryptionKey?: boolean | null;
|
||||
trusted?: boolean | null;
|
||||
};
|
||||
crossSigningVerified?: boolean;
|
||||
verified?: boolean;
|
||||
signedByOwner?: boolean;
|
||||
deviceId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
type MatrixQaCliBackupRestoreStatus = {
|
||||
success?: boolean;
|
||||
backup?: MatrixQaCliVerificationStatus["backup"];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function isMatrixQaCliBackupUsable(backup: MatrixQaCliVerificationStatus["backup"]): boolean {
|
||||
return Boolean(backup?.trusted && backup.matchesDecryptionKey && !backup.keyLoadError);
|
||||
}
|
||||
|
||||
function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) {
|
||||
if (!context.outputDir) {
|
||||
@@ -50,6 +84,13 @@ function requireMatrixQaE2eeOutputDir(context: MatrixQaScenarioContext) {
|
||||
return context.outputDir;
|
||||
}
|
||||
|
||||
function requireMatrixQaCliRuntimeEnv(context: MatrixQaScenarioContext) {
|
||||
if (!context.gatewayRuntimeEnv) {
|
||||
throw new Error("Matrix CLI QA scenarios require the gateway runtime environment");
|
||||
}
|
||||
return context.gatewayRuntimeEnv;
|
||||
}
|
||||
|
||||
function requireMatrixQaPassword(context: MatrixQaScenarioContext, actor: "driver" | "observer") {
|
||||
const password = actor === "driver" ? context.driverPassword : context.observerPassword;
|
||||
if (!password) {
|
||||
@@ -76,6 +117,9 @@ function assertMatrixQaBootstrapSucceeded(label: string, result: MatrixQaE2eeBoo
|
||||
if (!result.verification.verified || !result.verification.signedByOwner) {
|
||||
throw new Error(`${label} bootstrap did not leave the device verified by its owner`);
|
||||
}
|
||||
if (!result.verification.crossSigningVerified) {
|
||||
throw new Error(`${label} bootstrap did not establish full Matrix identity trust`);
|
||||
}
|
||||
if (!result.crossSigning.published) {
|
||||
throw new Error(`${label} bootstrap did not publish cross-signing keys`);
|
||||
}
|
||||
@@ -190,6 +234,242 @@ function formatMatrixQaSasEmoji(summary: MatrixVerificationSummary) {
|
||||
return summary.sas?.emoji?.map(([emoji, label]) => `${emoji} ${label}`) ?? [];
|
||||
}
|
||||
|
||||
function parseMatrixQaCliJsonText(text: string): unknown {
|
||||
const candidate = text.trim();
|
||||
if (!candidate) {
|
||||
throw new Error("no JSON payload found");
|
||||
}
|
||||
return JSON.parse(candidate) as unknown;
|
||||
}
|
||||
|
||||
function parseMatrixQaCliJson(result: MatrixQaCliRunResult): unknown {
|
||||
const stdout = result.stdout.trim();
|
||||
const stderr = result.stderr.trim();
|
||||
if (stdout && stderr) {
|
||||
throw new Error(
|
||||
`${formatMatrixQaCliCommand(result.args)} printed JSON with extra output\nstdout:\n${redactMatrixQaCliOutput(stdout)}\nstderr:\n${redactMatrixQaCliOutput(stderr)}`,
|
||||
);
|
||||
}
|
||||
if (stdout) {
|
||||
try {
|
||||
return parseMatrixQaCliJsonText(stdout);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\nstdout:\n${redactMatrixQaCliOutput(stdout)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!stderr) {
|
||||
throw new Error(`${formatMatrixQaCliCommand(result.args)} did not print JSON`);
|
||||
}
|
||||
try {
|
||||
return parseMatrixQaCliJsonText(stderr);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${formatMatrixQaCliCommand(result.args)} printed invalid JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\nstderr:\n${redactMatrixQaCliOutput(stderr)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMatrixQaCliSasText(
|
||||
text: string,
|
||||
label: string,
|
||||
): { kind: "emoji"; value: string } | { kind: "decimal"; value: string } {
|
||||
const emoji = text.match(/^SAS emoji:\s*(.+)$/m)?.[1]?.trim();
|
||||
if (emoji) {
|
||||
return { kind: "emoji", value: emoji };
|
||||
}
|
||||
const decimal = text.match(/^SAS decimals:\s*(.+)$/m)?.[1]?.trim();
|
||||
if (decimal) {
|
||||
return { kind: "decimal", value: decimal };
|
||||
}
|
||||
throw new Error(`${label} did not print SAS emoji or decimals`);
|
||||
}
|
||||
|
||||
function parseMatrixQaCliSummaryField(text: string, field: string): string | null {
|
||||
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return text.match(new RegExp(`^${escaped}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
async function writeMatrixQaCliOutputArtifacts(params: {
|
||||
label: string;
|
||||
result: MatrixQaCliRunResult;
|
||||
rootDir: string;
|
||||
}) {
|
||||
await mkdir(params.rootDir, { mode: 0o700, recursive: true });
|
||||
await chmod(params.rootDir, 0o700).catch(() => undefined);
|
||||
const prefix = params.label.replace(/[^A-Za-z0-9_-]/g, "-");
|
||||
const stdoutPath = path.join(params.rootDir, `${prefix}.stdout.txt`);
|
||||
const stderrPath = path.join(params.rootDir, `${prefix}.stderr.txt`);
|
||||
await Promise.all([
|
||||
writeFile(stdoutPath, redactMatrixQaCliOutput(params.result.stdout), { mode: 0o600 }),
|
||||
writeFile(stderrPath, redactMatrixQaCliOutput(params.result.stderr), { mode: 0o600 }),
|
||||
]);
|
||||
return { stderrPath, stdoutPath };
|
||||
}
|
||||
|
||||
async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const mode = (await stat(pathToCheck)).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatrixQaCliSasMatches(params: {
|
||||
cliSas: ReturnType<typeof parseMatrixQaCliSasText>;
|
||||
owner: MatrixVerificationSummary;
|
||||
}) {
|
||||
if (params.cliSas.kind === "emoji") {
|
||||
const ownerEmoji = formatMatrixQaSasEmoji(params.owner).join(" | ");
|
||||
if (!ownerEmoji) {
|
||||
throw new Error("Matrix owner client did not expose SAS emoji");
|
||||
}
|
||||
if (params.cliSas.value !== ownerEmoji) {
|
||||
throw new Error("Matrix CLI SAS emoji did not match the owner client");
|
||||
}
|
||||
return ownerEmoji.split(" | ");
|
||||
}
|
||||
|
||||
const ownerDecimal = params.owner.sas?.decimal?.join(" ");
|
||||
if (!ownerDecimal) {
|
||||
throw new Error("Matrix owner client did not expose SAS decimals");
|
||||
}
|
||||
if (params.cliSas.value !== ownerDecimal) {
|
||||
throw new Error("Matrix CLI SAS decimals did not match the owner client");
|
||||
}
|
||||
return [ownerDecimal];
|
||||
}
|
||||
|
||||
function isMatrixQaCliOwnerSelfVerification(params: {
|
||||
cliDeviceId?: string;
|
||||
driverUserId: string;
|
||||
requireCompleted?: boolean;
|
||||
requirePending?: boolean;
|
||||
requireSas?: boolean;
|
||||
summary: MatrixVerificationSummary;
|
||||
transactionId?: string;
|
||||
}) {
|
||||
const summary = params.summary;
|
||||
if (
|
||||
!summary.isSelfVerification ||
|
||||
summary.initiatedByMe ||
|
||||
summary.otherUserId !== params.driverUserId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (params.transactionId) {
|
||||
if (summary.transactionId !== params.transactionId) {
|
||||
return false;
|
||||
}
|
||||
} else if (params.cliDeviceId && summary.otherDeviceId !== params.cliDeviceId) {
|
||||
return false;
|
||||
}
|
||||
if (params.requirePending === true && !summary.pending) {
|
||||
return false;
|
||||
}
|
||||
if (params.requireSas === true && !summary.hasSas) {
|
||||
return false;
|
||||
}
|
||||
return params.requireCompleted !== true || summary.completed;
|
||||
}
|
||||
|
||||
async function createMatrixQaCliSelfVerificationRuntime(params: {
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const outputDir = requireMatrixQaE2eeOutputDir(params.context);
|
||||
const rootDir = await mkdtemp(path.join(tmpdir(), "openclaw-matrix-cli-qa-"));
|
||||
const artifactDir = path.join(
|
||||
outputDir,
|
||||
"cli-self-verification",
|
||||
randomUUID().replaceAll("-", "").slice(0, 12),
|
||||
);
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const configPath = path.join(rootDir, "config.json");
|
||||
await chmod(rootDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory");
|
||||
await mkdir(artifactDir, { mode: 0o700, recursive: true });
|
||||
await chmod(artifactDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory");
|
||||
await mkdir(stateDir, { mode: 0o700, recursive: true });
|
||||
await chmod(stateDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: params.accountId,
|
||||
accounts: {
|
||||
[params.accountId]: {
|
||||
accessToken: params.accessToken,
|
||||
deviceId: params.deviceId,
|
||||
encryption: true,
|
||||
homeserver: params.context.baseUrl,
|
||||
initialSyncLimit: 1,
|
||||
name: "Matrix QA CLI self-verification",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
startupVerification: "off",
|
||||
userId: params.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file");
|
||||
const env = {
|
||||
...requireMatrixQaCliRuntimeEnv(params.context),
|
||||
FORCE_COLOR: "0",
|
||||
NO_COLOR: "1",
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
const run = async (args: string[], timeoutMs = params.context.timeoutMs) =>
|
||||
await runMatrixQaOpenClawCli({
|
||||
args,
|
||||
env,
|
||||
timeoutMs,
|
||||
});
|
||||
const start = (args: string[], timeoutMs = params.context.timeoutMs) =>
|
||||
startMatrixQaOpenClawCli({
|
||||
args,
|
||||
env,
|
||||
timeoutMs,
|
||||
});
|
||||
return {
|
||||
configPath,
|
||||
dispose: async () => {
|
||||
await rm(rootDir, { force: true, recursive: true });
|
||||
},
|
||||
run,
|
||||
rootDir: artifactDir,
|
||||
start,
|
||||
stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMatrixQaSasEmojiMatches(params: {
|
||||
initiator: MatrixVerificationSummary;
|
||||
recipient: MatrixVerificationSummary;
|
||||
@@ -205,20 +485,6 @@ function assertMatrixQaSasEmojiMatches(params: {
|
||||
return initiatorEmoji;
|
||||
}
|
||||
|
||||
function isMatrixQaOwnerVerificationOnlyRecoveryError(error: string | undefined) {
|
||||
return error?.toLowerCase().includes("device is still not verified by its owner") === true;
|
||||
}
|
||||
|
||||
function hasMatrixQaUsableRecoveryBackup(
|
||||
result: Awaited<ReturnType<MatrixQaE2eeScenarioClient["verifyWithRecoveryKey"]>>,
|
||||
) {
|
||||
return (
|
||||
Boolean(result.backup.serverVersion) &&
|
||||
result.backup.decryptionKeyCached !== false &&
|
||||
result.backup.keyLoadError === null
|
||||
);
|
||||
}
|
||||
|
||||
function isMatrixQaE2eeNoticeTriggeredSutReply(params: {
|
||||
event: MatrixQaObservedEvent;
|
||||
noticeEventId: string;
|
||||
@@ -405,6 +671,20 @@ function buildRoomKeyBackupUnavailableFaultRule(accessToken: string): MatrixQaFa
|
||||
};
|
||||
}
|
||||
|
||||
function buildOwnerSignatureUploadBlockedFaultRule(accessToken: string): MatrixQaFaultProxyRule {
|
||||
return {
|
||||
id: MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID,
|
||||
match: (request) =>
|
||||
request.method === "POST" &&
|
||||
request.path === MATRIX_QA_KEYS_SIGNATURES_UPLOAD_ENDPOINT &&
|
||||
request.bearerToken === accessToken,
|
||||
response: () => ({
|
||||
body: {},
|
||||
status: 200,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext): Promise<{
|
||||
faultHits: MatrixQaFaultProxyHit[];
|
||||
result: MatrixQaE2eeBootstrapResult;
|
||||
@@ -434,6 +714,77 @@ async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext)
|
||||
}
|
||||
}
|
||||
|
||||
async function runMatrixQaFaultedRecoveryOwnerVerification(params: {
|
||||
accessToken: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
deviceId: string;
|
||||
encodedRecoveryKey: string;
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
faultHits: MatrixQaFaultProxyHit[];
|
||||
restore: Awaited<ReturnType<MatrixQaE2eeScenarioClient["restoreRoomKeyBackup"]>>;
|
||||
verification: Awaited<ReturnType<MatrixQaE2eeScenarioClient["verifyWithRecoveryKey"]>>;
|
||||
}> {
|
||||
const proxy = await startMatrixQaFaultProxy({
|
||||
targetBaseUrl: params.context.baseUrl,
|
||||
rules: [buildOwnerSignatureUploadBlockedFaultRule(params.accessToken)],
|
||||
});
|
||||
const recoveryClient = await createMatrixQaE2eeScenarioClient({
|
||||
accessToken: params.accessToken,
|
||||
actorId: `driver-recovery-${randomUUID().slice(0, 8)}`,
|
||||
baseUrl: proxy.baseUrl,
|
||||
deviceId: params.deviceId,
|
||||
observedEvents: params.context.observedEvents,
|
||||
outputDir: requireMatrixQaE2eeOutputDir(params.context),
|
||||
scenarioId: "matrix-e2ee-recovery-owner-verification-required",
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
userId: params.userId,
|
||||
});
|
||||
try {
|
||||
const verification = await recoveryClient.verifyWithRecoveryKey(params.encodedRecoveryKey);
|
||||
const restore = await waitForMatrixQaNonEmptyRoomKeyRestore({
|
||||
client: recoveryClient,
|
||||
recoveryKey: params.encodedRecoveryKey,
|
||||
timeoutMs: params.context.timeoutMs,
|
||||
});
|
||||
return {
|
||||
faultHits: proxy.hits(),
|
||||
restore,
|
||||
verification,
|
||||
};
|
||||
} finally {
|
||||
await recoveryClient.stop().catch(() => undefined);
|
||||
await proxy.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatrixQaFaultedRecoveryOwnerVerificationRequired(
|
||||
faulted: Awaited<ReturnType<typeof runMatrixQaFaultedRecoveryOwnerVerification>>,
|
||||
) {
|
||||
if (faulted.faultHits.length === 0) {
|
||||
throw new Error("Matrix E2EE owner signature fault proxy was not exercised");
|
||||
}
|
||||
if (faulted.verification.success) {
|
||||
throw new Error(
|
||||
"Matrix E2EE recovery verification unexpectedly succeeded while owner signature upload was blocked",
|
||||
);
|
||||
}
|
||||
if (!faulted.verification.recoveryKeyAccepted) {
|
||||
throw new Error("Matrix E2EE recovery key was not accepted");
|
||||
}
|
||||
if (!faulted.verification.backupUsable) {
|
||||
throw new Error("Matrix E2EE recovery key did not leave room-key backup usable");
|
||||
}
|
||||
if (faulted.verification.deviceOwnerVerified) {
|
||||
throw new Error("Matrix E2EE recovery device should still require Matrix identity trust");
|
||||
}
|
||||
if (!faulted.restore.success) {
|
||||
throw new Error(
|
||||
`Matrix E2EE room-key backup restore failed after owner-verification fault: ${faulted.restore.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatrixQaExpectedBootstrapFailure(params: {
|
||||
faultHits: MatrixQaFaultProxyHit[];
|
||||
result: MatrixQaE2eeBootstrapResult;
|
||||
@@ -617,6 +968,7 @@ export async function runMatrixQaE2eeBootstrapSuccessScenario(
|
||||
details: [
|
||||
"driver bootstrap succeeded through real Matrix crypto bootstrap",
|
||||
`device verified: ${result.verification.verified ? "yes" : "no"}`,
|
||||
`cross-signing verified: ${result.verification.crossSigningVerified ? "yes" : "no"}`,
|
||||
`signed by owner: ${result.verification.signedByOwner ? "yes" : "no"}`,
|
||||
`cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`,
|
||||
`room-key backup version: ${result.verification.backupVersion ?? "<none>"}`,
|
||||
@@ -677,11 +1029,7 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
|
||||
let cleanupRecoveryDevice = true;
|
||||
try {
|
||||
const recoveryVerification = await recoveryClient.verifyWithRecoveryKey(encodedRecoveryKey);
|
||||
const recoveryKeyUsable =
|
||||
recoveryVerification.success ||
|
||||
isMatrixQaOwnerVerificationOnlyRecoveryError(recoveryVerification.error) ||
|
||||
hasMatrixQaUsableRecoveryBackup(recoveryVerification);
|
||||
if (!recoveryVerification.success && !recoveryKeyUsable) {
|
||||
if (!recoveryVerification.success) {
|
||||
throw new Error(
|
||||
`Matrix E2EE recovery device verification failed: ${recoveryVerification.error ?? "unknown error"}`,
|
||||
);
|
||||
@@ -709,9 +1057,10 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
|
||||
bootstrapSuccess: ready.bootstrap?.success ?? true,
|
||||
recoveryDeviceId: recoveryDevice.deviceId,
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
recoveryKeyUsable,
|
||||
recoveryKeyUsable:
|
||||
recoveryVerification.recoveryKeyAccepted && recoveryVerification.backupUsable,
|
||||
recoveryKeyStored: true,
|
||||
recoveryVerified: recoveryVerification.success,
|
||||
recoveryVerified: recoveryVerification.deviceOwnerVerified,
|
||||
restoreImported: restored.imported,
|
||||
restoreTotal: restored.total,
|
||||
seededEventId,
|
||||
@@ -721,8 +1070,8 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
|
||||
`bootstrap backup version: ${ready.verification.backupVersion ?? "<none>"}`,
|
||||
`seeded encrypted event: ${seededEventId}`,
|
||||
`recovery device: ${recoveryDevice.deviceId}`,
|
||||
`recovery key usable: ${recoveryKeyUsable ? "yes" : "no"}`,
|
||||
`recovery device verified: ${recoveryVerification.success ? "yes" : "no"}`,
|
||||
`recovery key usable: ${recoveryVerification.backupUsable ? "yes" : "no"}`,
|
||||
`recovery device verified: ${recoveryVerification.deviceOwnerVerified ? "yes" : "no"}`,
|
||||
`restore imported/total: ${restored.imported}/${restored.total}`,
|
||||
`restore loaded from secret storage: ${restored.loadedFromSecretStorage ? "yes" : "no"}`,
|
||||
`reset previous version: ${reset.previousVersion ?? "<none>"}`,
|
||||
@@ -739,6 +1088,310 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
|
||||
);
|
||||
}
|
||||
|
||||
export async function runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
): Promise<MatrixQaScenarioExecution> {
|
||||
const driverPassword = requireMatrixQaPassword(context, "driver");
|
||||
return await withMatrixQaE2eeDriver(
|
||||
context,
|
||||
"matrix-e2ee-recovery-owner-verification-required",
|
||||
async (client) => {
|
||||
const { roomId } = resolveMatrixQaE2eeScenarioGroupRoom(
|
||||
context,
|
||||
"matrix-e2ee-recovery-owner-verification-required",
|
||||
);
|
||||
const ready = await ensureMatrixQaE2eeOwnDeviceVerified({
|
||||
client,
|
||||
label: "driver",
|
||||
});
|
||||
const recoveryKey = ready.recoveryKey;
|
||||
const encodedRecoveryKey = recoveryKey?.encodedPrivateKey?.trim();
|
||||
if (!encodedRecoveryKey) {
|
||||
throw new Error("Matrix E2EE bootstrap did not expose an encoded recovery key");
|
||||
}
|
||||
const seededEventId = await client.sendTextMessage({
|
||||
body: `E2EE recovery owner-verification seed ${randomUUID().slice(0, 8)}`,
|
||||
roomId,
|
||||
});
|
||||
const loginClient = createMatrixQaClient({
|
||||
baseUrl: context.baseUrl,
|
||||
});
|
||||
const recoveryDevice = await loginClient.loginWithPassword({
|
||||
deviceName: "OpenClaw Matrix QA Owner Verification Required Device",
|
||||
password: driverPassword,
|
||||
userId: context.driverUserId,
|
||||
});
|
||||
if (!recoveryDevice.deviceId) {
|
||||
throw new Error("Matrix E2EE recovery login did not return a secondary device id");
|
||||
}
|
||||
try {
|
||||
const faulted = await runMatrixQaFaultedRecoveryOwnerVerification({
|
||||
accessToken: recoveryDevice.accessToken,
|
||||
context,
|
||||
deviceId: recoveryDevice.deviceId,
|
||||
encodedRecoveryKey,
|
||||
userId: recoveryDevice.userId,
|
||||
});
|
||||
assertMatrixQaFaultedRecoveryOwnerVerificationRequired(faulted);
|
||||
return {
|
||||
artifacts: {
|
||||
backupRestored: faulted.restore.success,
|
||||
backupUsable: faulted.verification.backupUsable,
|
||||
faultHitCount: faulted.faultHits.length,
|
||||
faultedEndpoints: faulted.faultHits.map((hit) => hit.path),
|
||||
faultRuleId: MATRIX_QA_OWNER_SIGNATURE_UPLOAD_BLOCKED_RULE_ID,
|
||||
recoveryDeviceId: recoveryDevice.deviceId,
|
||||
recoveryKeyAccepted: faulted.verification.recoveryKeyAccepted,
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
recoveryVerified: faulted.verification.deviceOwnerVerified,
|
||||
restoreImported: faulted.restore.imported,
|
||||
restoreTotal: faulted.restore.total,
|
||||
verificationSuccess: faulted.verification.success,
|
||||
},
|
||||
details: [
|
||||
"driver recovery key unlocked backup while owner signature upload was blocked",
|
||||
`seeded encrypted event: ${seededEventId}`,
|
||||
`recovery device: ${recoveryDevice.deviceId}`,
|
||||
`fault hits: ${faulted.faultHits.length}`,
|
||||
`recovery key accepted: ${faulted.verification.recoveryKeyAccepted ? "yes" : "no"}`,
|
||||
`backup usable: ${faulted.verification.backupUsable ? "yes" : "no"}`,
|
||||
`device owner verified: ${faulted.verification.deviceOwnerVerified ? "yes" : "no"}`,
|
||||
`restore imported/total: ${faulted.restore.imported}/${faulted.restore.total}`,
|
||||
].join("\n"),
|
||||
};
|
||||
} finally {
|
||||
await client.deleteOwnDevices([recoveryDevice.deviceId]).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runMatrixQaE2eeCliSelfVerificationScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
): Promise<MatrixQaScenarioExecution> {
|
||||
const driverPassword = requireMatrixQaPassword(context, "driver");
|
||||
const accountId = "cli";
|
||||
return await withMatrixQaE2eeDriver(
|
||||
context,
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
async (owner) => {
|
||||
const ownerReady = await ensureMatrixQaE2eeOwnDeviceVerified({
|
||||
client: owner,
|
||||
label: "driver",
|
||||
});
|
||||
const encodedRecoveryKey = ownerReady.recoveryKey?.encodedPrivateKey?.trim();
|
||||
if (!encodedRecoveryKey) {
|
||||
throw new Error("Matrix E2EE self-verification scenario did not expose a recovery key");
|
||||
}
|
||||
const loginClient = createMatrixQaClient({
|
||||
baseUrl: context.baseUrl,
|
||||
});
|
||||
const cliDevice = await loginClient.loginWithPassword({
|
||||
deviceName: "OpenClaw Matrix QA CLI Self Verification Device",
|
||||
password: driverPassword,
|
||||
userId: context.driverUserId,
|
||||
});
|
||||
if (!cliDevice.deviceId) {
|
||||
throw new Error("Matrix E2EE CLI verification login did not return a device id");
|
||||
}
|
||||
|
||||
const cli = await createMatrixQaCliSelfVerificationRuntime({
|
||||
accountId,
|
||||
accessToken: cliDevice.accessToken,
|
||||
context,
|
||||
deviceId: cliDevice.deviceId,
|
||||
userId: cliDevice.userId,
|
||||
});
|
||||
try {
|
||||
const restoreResult = await cli.run([
|
||||
"matrix",
|
||||
"verify",
|
||||
"backup",
|
||||
"restore",
|
||||
"--account",
|
||||
accountId,
|
||||
"--recovery-key",
|
||||
encodedRecoveryKey,
|
||||
"--json",
|
||||
]);
|
||||
const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({
|
||||
label: "verify-backup-restore",
|
||||
result: restoreResult,
|
||||
rootDir: cli.rootDir,
|
||||
});
|
||||
const restored = parseMatrixQaCliJson(restoreResult) as MatrixQaCliBackupRestoreStatus;
|
||||
if (
|
||||
restored.success !== true ||
|
||||
restored.backup?.decryptionKeyCached !== true ||
|
||||
restored.backup?.matchesDecryptionKey !== true ||
|
||||
restored.backup?.keyLoadError
|
||||
) {
|
||||
throw new Error(
|
||||
`Matrix CLI recovery key did not load matching room-key backup material before self-verification: ${
|
||||
restored.error ?? restored.backup?.keyLoadError ?? "unknown backup state"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const session = cli.start(["matrix", "verify", "self", "--account", accountId]);
|
||||
try {
|
||||
const requestOutput = await session.waitForOutput(
|
||||
(output) => output.text.includes("Accept this verification request"),
|
||||
"self-verification request guidance",
|
||||
context.timeoutMs,
|
||||
);
|
||||
const cliTransactionId = parseMatrixQaCliSummaryField(
|
||||
requestOutput.text,
|
||||
"Transaction id",
|
||||
);
|
||||
const ownerRequested = await waitForMatrixQaVerificationSummary({
|
||||
client: owner,
|
||||
label: "owner received CLI self-verification request",
|
||||
predicate: (summary) =>
|
||||
isMatrixQaCliOwnerSelfVerification({
|
||||
cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId,
|
||||
driverUserId: context.driverUserId,
|
||||
requirePending: true,
|
||||
summary,
|
||||
transactionId: cliTransactionId ?? undefined,
|
||||
}),
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
if (ownerRequested.canAccept) {
|
||||
await owner.acceptVerification(ownerRequested.id);
|
||||
}
|
||||
|
||||
const sasOutput = await session.waitForOutput(
|
||||
(output) => /^SAS (?:emoji|decimals):/m.test(output.text),
|
||||
"SAS emoji or decimals",
|
||||
context.timeoutMs,
|
||||
);
|
||||
const cliSas = parseMatrixQaCliSasText(
|
||||
sasOutput.text,
|
||||
"interactive openclaw matrix verify self",
|
||||
);
|
||||
const ownerSas = await waitForMatrixQaVerificationSummary({
|
||||
client: owner,
|
||||
label: "owner SAS for CLI self-verification",
|
||||
predicate: (summary) =>
|
||||
isMatrixQaCliOwnerSelfVerification({
|
||||
cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId,
|
||||
driverUserId: context.driverUserId,
|
||||
requireSas: true,
|
||||
summary,
|
||||
transactionId: cliTransactionId ?? undefined,
|
||||
}),
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
const sasArtifact = assertMatrixQaCliSasMatches({
|
||||
cliSas,
|
||||
owner: ownerSas,
|
||||
});
|
||||
await session.writeStdin("yes\n");
|
||||
await owner.confirmVerificationSas(ownerSas.id);
|
||||
const completedCli = await session.wait();
|
||||
const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({
|
||||
label: "verify-self",
|
||||
result: completedCli,
|
||||
rootDir: cli.rootDir,
|
||||
});
|
||||
if (!/^Device verified by owner:\s*yes$/m.test(completedCli.stdout)) {
|
||||
throw new Error(
|
||||
"Interactive Matrix CLI self-verification did not report final device verification",
|
||||
);
|
||||
}
|
||||
if (!/^Cross-signing verified:\s*yes$/m.test(completedCli.stdout)) {
|
||||
throw new Error(
|
||||
"Interactive Matrix CLI self-verification did not report full Matrix identity trust",
|
||||
);
|
||||
}
|
||||
const completedOwner = await waitForMatrixQaVerificationSummary({
|
||||
client: owner,
|
||||
label: "owner completed CLI self-verification",
|
||||
predicate: (summary) =>
|
||||
isMatrixQaCliOwnerSelfVerification({
|
||||
cliDeviceId: cliTransactionId ? undefined : cliDevice.deviceId,
|
||||
driverUserId: context.driverUserId,
|
||||
requireCompleted: true,
|
||||
summary,
|
||||
transactionId: cliTransactionId ?? undefined,
|
||||
}),
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
const cliVerificationId =
|
||||
completedCli.stdout.match(/^Verification id:\s*(\S+)/m)?.[1] ?? "interactive-cli";
|
||||
const statusResult = await cli.run([
|
||||
"matrix",
|
||||
"verify",
|
||||
"status",
|
||||
"--account",
|
||||
accountId,
|
||||
"--json",
|
||||
]);
|
||||
const statusArtifacts = await writeMatrixQaCliOutputArtifacts({
|
||||
label: "verify-status",
|
||||
result: statusResult,
|
||||
rootDir: cli.rootDir,
|
||||
});
|
||||
const status = parseMatrixQaCliJson(statusResult) as MatrixQaCliVerificationStatus;
|
||||
if (
|
||||
status.verified !== true ||
|
||||
status.crossSigningVerified !== true ||
|
||||
status.signedByOwner !== true ||
|
||||
status.backup?.trusted !== true ||
|
||||
status.backup?.matchesDecryptionKey !== true ||
|
||||
status.backup?.keyLoadError
|
||||
) {
|
||||
throw new Error(
|
||||
`Matrix CLI device was not fully usable after SAS completion: ownerVerified=${
|
||||
status.verified === true &&
|
||||
status.crossSigningVerified === true &&
|
||||
status.signedByOwner === true
|
||||
? "yes"
|
||||
: "no"
|
||||
}, backupUsable=${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}${
|
||||
status.backup?.keyLoadError ? `, backupError=${status.backup.keyLoadError}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
artifacts: {
|
||||
completedVerificationIds: [cliVerificationId, completedOwner.id],
|
||||
currentDeviceId: status.deviceId ?? cliDevice.deviceId,
|
||||
...(cliSas.kind === "emoji" ? { sasEmoji: sasArtifact } : {}),
|
||||
secondaryDeviceId: cliDevice.deviceId,
|
||||
},
|
||||
details: [
|
||||
"Matrix CLI self-verification established full Matrix identity trust through interactive openclaw matrix verify self",
|
||||
"cli secret config cleaned after run: yes",
|
||||
`cli backup restore stdout: ${restoreArtifacts.stdoutPath}`,
|
||||
`cli backup restore stderr: ${restoreArtifacts.stderrPath}`,
|
||||
`cli verify self stdout: ${selfVerificationArtifacts.stdoutPath}`,
|
||||
`cli verify self stderr: ${selfVerificationArtifacts.stderrPath}`,
|
||||
`cli verify status stdout: ${statusArtifacts.stdoutPath}`,
|
||||
`cli verify status stderr: ${statusArtifacts.stderrPath}`,
|
||||
`cli device: ${cliDevice.deviceId}`,
|
||||
`cli verification id: ${cliVerificationId}`,
|
||||
`owner-side verification id: ${completedOwner.id}`,
|
||||
`transaction: ${completedOwner.transactionId ?? "<none>"}`,
|
||||
`cli verified by owner: ${status.verified ? "yes" : "no"}`,
|
||||
`cli cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`,
|
||||
`cli backup usable: ${isMatrixQaCliBackupUsable(status.backup) ? "yes" : "no"}`,
|
||||
].join("\n"),
|
||||
};
|
||||
} finally {
|
||||
session.kill();
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await cli.dispose();
|
||||
} finally {
|
||||
await owner.deleteOwnDevices([cliDevice.deviceId]).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runMatrixQaE2eeDeviceSasVerificationScenario(
|
||||
context: MatrixQaScenarioContext,
|
||||
): Promise<MatrixQaScenarioExecution> {
|
||||
|
||||
@@ -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, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
@@ -11,6 +11,19 @@ const { createMatrixQaE2eeScenarioClient, runMatrixQaE2eeBootstrap, startMatrixQ
|
||||
runMatrixQaE2eeBootstrap: vi.fn(),
|
||||
startMatrixQaFaultProxy: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
formatMatrixQaCliCommand,
|
||||
redactMatrixQaCliOutput,
|
||||
resolveMatrixQaOpenClawCliEntryPath,
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
} = vi.hoisted(() => ({
|
||||
formatMatrixQaCliCommand: (args: string[]) => `openclaw ${args.join(" ")}`,
|
||||
redactMatrixQaCliOutput: (text: string) => text,
|
||||
resolveMatrixQaOpenClawCliEntryPath: (cwd: string) => `${cwd}/dist/index.js`,
|
||||
runMatrixQaOpenClawCli: vi.fn(),
|
||||
startMatrixQaOpenClawCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../substrate/client.js", () => ({
|
||||
createMatrixQaClient,
|
||||
@@ -22,6 +35,13 @@ vi.mock("../../substrate/e2ee-client.js", () => ({
|
||||
vi.mock("../../substrate/fault-proxy.js", () => ({
|
||||
startMatrixQaFaultProxy,
|
||||
}));
|
||||
vi.mock("./scenario-runtime-cli.js", () => ({
|
||||
formatMatrixQaCliCommand,
|
||||
redactMatrixQaCliOutput,
|
||||
resolveMatrixQaOpenClawCliEntryPath,
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
}));
|
||||
|
||||
import {
|
||||
LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
|
||||
@@ -95,6 +115,8 @@ describe("matrix live qa scenarios", () => {
|
||||
createMatrixQaClient.mockReset();
|
||||
createMatrixQaE2eeScenarioClient.mockReset();
|
||||
runMatrixQaE2eeBootstrap.mockReset();
|
||||
runMatrixQaOpenClawCli.mockReset();
|
||||
startMatrixQaOpenClawCli.mockReset();
|
||||
startMatrixQaFaultProxy.mockReset();
|
||||
});
|
||||
|
||||
@@ -145,6 +167,8 @@ describe("matrix live qa scenarios", () => {
|
||||
"matrix-e2ee-thread-follow-up",
|
||||
"matrix-e2ee-bootstrap-success",
|
||||
"matrix-e2ee-recovery-key-lifecycle",
|
||||
"matrix-e2ee-recovery-owner-verification-required",
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
"matrix-e2ee-device-sas-verification",
|
||||
"matrix-e2ee-qr-verification",
|
||||
"matrix-e2ee-stale-device-hygiene",
|
||||
@@ -2583,9 +2607,10 @@ describe("matrix live qa scenarios", () => {
|
||||
serverVersion: "backup-v1",
|
||||
trusted: true,
|
||||
},
|
||||
error:
|
||||
"Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.",
|
||||
success: false,
|
||||
backupUsable: true,
|
||||
deviceOwnerVerified: true,
|
||||
recoveryKeyAccepted: true,
|
||||
success: true,
|
||||
});
|
||||
const restoreRoomKeyBackup = vi.fn().mockResolvedValue({
|
||||
imported: 1,
|
||||
@@ -2618,6 +2643,7 @@ describe("matrix live qa scenarios", () => {
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
@@ -2687,7 +2713,7 @@ describe("matrix live qa scenarios", () => {
|
||||
backupRestored: true,
|
||||
recoveryDeviceId: "RECOVERYDEVICE",
|
||||
recoveryKeyUsable: true,
|
||||
recoveryVerified: false,
|
||||
recoveryVerified: true,
|
||||
restoreImported: 1,
|
||||
restoreTotal: 1,
|
||||
},
|
||||
@@ -2699,6 +2725,460 @@ describe("matrix live qa scenarios", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps recovery-key backup access distinct from Matrix identity trust in Matrix E2EE QA", async () => {
|
||||
const verifyWithRecoveryKey = vi.fn().mockResolvedValue({
|
||||
backupUsable: true,
|
||||
deviceOwnerVerified: false,
|
||||
error:
|
||||
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust.",
|
||||
recoveryKeyAccepted: true,
|
||||
success: false,
|
||||
});
|
||||
const restoreRoomKeyBackup = vi.fn().mockResolvedValue({
|
||||
imported: 1,
|
||||
loadedFromSecretStorage: true,
|
||||
success: true,
|
||||
total: 1,
|
||||
});
|
||||
const driverDeleteOwnDevices = vi.fn().mockResolvedValue(undefined);
|
||||
const driverStop = vi.fn().mockResolvedValue(undefined);
|
||||
const recoveryStop = vi.fn().mockResolvedValue(undefined);
|
||||
const proxyStop = vi.fn().mockResolvedValue(undefined);
|
||||
const proxyHits = vi.fn().mockReturnValue([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/_matrix/client/v3/keys/signatures/upload",
|
||||
ruleId: "owner-signature-upload-blocked",
|
||||
},
|
||||
]);
|
||||
startMatrixQaFaultProxy.mockResolvedValue({
|
||||
baseUrl: "http://127.0.0.1:39877",
|
||||
hits: proxyHits,
|
||||
stop: proxyStop,
|
||||
});
|
||||
createMatrixQaClient.mockReturnValue({
|
||||
loginWithPassword: vi.fn().mockResolvedValue({
|
||||
accessToken: "recovery-token",
|
||||
deviceId: "RECOVERYDEVICE",
|
||||
password: "driver-password",
|
||||
userId: "@driver:matrix-qa.test",
|
||||
}),
|
||||
});
|
||||
createMatrixQaE2eeScenarioClient
|
||||
.mockResolvedValueOnce({
|
||||
bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({
|
||||
crossSigning: {
|
||||
published: true,
|
||||
},
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
},
|
||||
}),
|
||||
deleteOwnDevices: driverDeleteOwnDevices,
|
||||
getRecoveryKey: vi.fn().mockResolvedValue({
|
||||
encodedPrivateKey: "encoded-recovery-key",
|
||||
keyId: "SSSS",
|
||||
}),
|
||||
sendTextMessage: vi.fn().mockResolvedValue("$seeded-event"),
|
||||
stop: driverStop,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
restoreRoomKeyBackup,
|
||||
stop: recoveryStop,
|
||||
verifyWithRecoveryKey,
|
||||
});
|
||||
|
||||
const scenario = MATRIX_QA_SCENARIOS.find(
|
||||
(entry) => entry.id === "matrix-e2ee-recovery-owner-verification-required",
|
||||
);
|
||||
expect(scenario).toBeDefined();
|
||||
|
||||
await expect(
|
||||
runMatrixQaScenario(scenario!, {
|
||||
baseUrl: "http://127.0.0.1:28008/",
|
||||
canary: undefined,
|
||||
driverAccessToken: "driver-token",
|
||||
driverDeviceId: "DRIVERDEVICE",
|
||||
driverPassword: "driver-password",
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
observedEvents: [],
|
||||
observerAccessToken: "observer-token",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
outputDir: "/tmp/matrix-qa",
|
||||
roomId: "!main:matrix-qa.test",
|
||||
restartGateway: undefined,
|
||||
syncState: {},
|
||||
sutAccessToken: "sut-token",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
timeoutMs: 8_000,
|
||||
topology: {
|
||||
defaultRoomId: "!main:matrix-qa.test",
|
||||
defaultRoomKey: "main",
|
||||
rooms: [
|
||||
{
|
||||
encrypted: true,
|
||||
key: matrixQaE2eeRoomKey("matrix-e2ee-recovery-owner-verification-required"),
|
||||
kind: "group",
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@driver:matrix-qa.test",
|
||||
"@observer:matrix-qa.test",
|
||||
"@sut:matrix-qa.test",
|
||||
],
|
||||
name: "E2EE",
|
||||
requireMention: true,
|
||||
roomId: "!e2ee:matrix-qa.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
artifacts: {
|
||||
backupRestored: true,
|
||||
backupUsable: true,
|
||||
faultHitCount: 1,
|
||||
faultRuleId: "owner-signature-upload-blocked",
|
||||
recoveryDeviceId: "RECOVERYDEVICE",
|
||||
recoveryKeyAccepted: true,
|
||||
recoveryVerified: false,
|
||||
restoreImported: 1,
|
||||
restoreTotal: 1,
|
||||
verificationSuccess: false,
|
||||
},
|
||||
});
|
||||
|
||||
const proxyArgs = startMatrixQaFaultProxy.mock.calls[0]?.[0];
|
||||
expect(proxyArgs).toBeDefined();
|
||||
if (!proxyArgs) {
|
||||
throw new Error("expected Matrix QA fault proxy to start");
|
||||
}
|
||||
const [faultRule] = proxyArgs.rules;
|
||||
expect(faultRule).toBeDefined();
|
||||
if (!faultRule) {
|
||||
throw new Error("expected Matrix QA fault proxy rule");
|
||||
}
|
||||
expect(proxyArgs.targetBaseUrl).toBe("http://127.0.0.1:28008/");
|
||||
expect(
|
||||
faultRule.match({
|
||||
bearerToken: "recovery-token",
|
||||
headers: {},
|
||||
method: "POST",
|
||||
path: "/_matrix/client/v3/keys/signatures/upload",
|
||||
search: "",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
faultRule.match({
|
||||
bearerToken: "recovery-token",
|
||||
headers: {},
|
||||
method: "GET",
|
||||
path: "/_matrix/client/v3/user/%40driver%3Amatrix-qa.test/account_data/m.megolm_backup.v1",
|
||||
search: "",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(createMatrixQaE2eeScenarioClient).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "recovery-token",
|
||||
baseUrl: "http://127.0.0.1:39877",
|
||||
deviceId: "RECOVERYDEVICE",
|
||||
scenarioId: "matrix-e2ee-recovery-owner-verification-required",
|
||||
}),
|
||||
);
|
||||
expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key");
|
||||
expect(restoreRoomKeyBackup).toHaveBeenCalledWith({
|
||||
recoveryKey: "encoded-recovery-key",
|
||||
});
|
||||
expect(driverDeleteOwnDevices).toHaveBeenCalledWith(["RECOVERYDEVICE"]);
|
||||
expect(recoveryStop).toHaveBeenCalledTimes(1);
|
||||
expect(proxyStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs Matrix self-verification through the interactive CLI command", async () => {
|
||||
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-cli-self-verification-"));
|
||||
try {
|
||||
const acceptVerification = vi.fn().mockResolvedValue(undefined);
|
||||
const confirmVerificationSas = vi.fn().mockResolvedValue(undefined);
|
||||
const deleteOwnDevices = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const bootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({
|
||||
crossSigning: {
|
||||
published: true,
|
||||
},
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
const baseSummary = {
|
||||
canAccept: false,
|
||||
chosenMethod: "m.sas.v1",
|
||||
completed: false,
|
||||
createdAt: "2026-04-22T12:00:00.000Z",
|
||||
error: undefined,
|
||||
hasReciprocateQr: false,
|
||||
methods: ["m.sas.v1"],
|
||||
otherDeviceId: "CLIDEVICE",
|
||||
otherUserId: "@driver:matrix-qa.test",
|
||||
pending: true,
|
||||
phase: 2,
|
||||
phaseName: "ready",
|
||||
roomId: undefined,
|
||||
transactionId: "tx-cli-self",
|
||||
updatedAt: "2026-04-22T12:00:00.000Z",
|
||||
};
|
||||
const listVerifications = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
...baseSummary,
|
||||
canAccept: true,
|
||||
hasSas: false,
|
||||
id: "owner-request",
|
||||
initiatedByMe: false,
|
||||
isSelfVerification: true,
|
||||
phaseName: "requested",
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
...baseSummary,
|
||||
hasSas: true,
|
||||
id: "owner-request",
|
||||
initiatedByMe: false,
|
||||
isSelfVerification: true,
|
||||
sas: {
|
||||
emoji: [["🐶", "Dog"]],
|
||||
},
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
...baseSummary,
|
||||
completed: true,
|
||||
hasSas: true,
|
||||
id: "owner-request",
|
||||
initiatedByMe: false,
|
||||
isSelfVerification: true,
|
||||
pending: false,
|
||||
phaseName: "done",
|
||||
sas: {
|
||||
emoji: [["🐶", "Dog"]],
|
||||
},
|
||||
},
|
||||
]);
|
||||
createMatrixQaClient.mockReturnValue({
|
||||
loginWithPassword: vi.fn().mockResolvedValue({
|
||||
accessToken: "cli-token",
|
||||
deviceId: "CLIDEVICE",
|
||||
password: "driver-password",
|
||||
userId: "@driver:matrix-qa.test",
|
||||
}),
|
||||
});
|
||||
createMatrixQaE2eeScenarioClient.mockResolvedValueOnce({
|
||||
acceptVerification,
|
||||
bootstrapOwnDeviceVerification,
|
||||
confirmVerificationSas,
|
||||
deleteOwnDevices,
|
||||
getRecoveryKey: vi.fn().mockResolvedValue({
|
||||
encodedPrivateKey: "encoded-recovery-key",
|
||||
keyId: "SSSS",
|
||||
}),
|
||||
listVerifications,
|
||||
stop,
|
||||
});
|
||||
const waitForOutput = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stderr: "",
|
||||
stdout:
|
||||
"Verification id: verification-1\nTransaction id: tx-cli-self\nAccept this verification request in another Matrix client.\n",
|
||||
text: "Verification id: verification-1\nTransaction id: tx-cli-self\nAccept this verification request in another Matrix client.\n",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stderr: "",
|
||||
stdout: "Verification id: verification-1\nSAS emoji: 🐶 Dog\n",
|
||||
text: "Verification id: verification-1\nSAS emoji: 🐶 Dog\n",
|
||||
});
|
||||
const writeStdin = vi.fn().mockResolvedValue(undefined);
|
||||
const wait = vi.fn().mockResolvedValue({
|
||||
args: ["matrix", "verify", "self", "--account", "cli"],
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout:
|
||||
"Verification id: verification-1\nCompleted: yes\nDevice verified by owner: yes\nCross-signing verified: yes\n",
|
||||
});
|
||||
const kill = vi.fn();
|
||||
startMatrixQaOpenClawCli.mockReturnValue({
|
||||
args: ["matrix", "verify", "self", "--account", "cli"],
|
||||
kill,
|
||||
output: vi.fn(() => ({ stderr: "", stdout: "" })),
|
||||
wait,
|
||||
waitForOutput,
|
||||
writeStdin,
|
||||
});
|
||||
let cliAccountConfigDuringRun: Record<string, unknown> | null = null;
|
||||
runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => {
|
||||
if (!cliAccountConfigDuringRun && env.OPENCLAW_CONFIG_PATH) {
|
||||
const cliConfig = JSON.parse(
|
||||
await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"),
|
||||
) as {
|
||||
channels?: {
|
||||
matrix?: {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
cliAccountConfigDuringRun = cliConfig.channels?.matrix?.accounts?.cli ?? null;
|
||||
}
|
||||
const joined = args.join(" ");
|
||||
if (joined === "matrix verify status --account cli --json") {
|
||||
return {
|
||||
args,
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: JSON.stringify({
|
||||
backup: {
|
||||
decryptionKeyCached: true,
|
||||
keyLoadError: null,
|
||||
matchesDecryptionKey: true,
|
||||
trusted: true,
|
||||
},
|
||||
crossSigningVerified: true,
|
||||
deviceId: "CLIDEVICE",
|
||||
signedByOwner: true,
|
||||
userId: "@driver:matrix-qa.test",
|
||||
verified: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
joined ===
|
||||
"matrix verify backup restore --account cli --recovery-key encoded-recovery-key --json"
|
||||
) {
|
||||
return {
|
||||
args,
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: JSON.stringify({
|
||||
backup: {
|
||||
decryptionKeyCached: true,
|
||||
keyLoadError: null,
|
||||
matchesDecryptionKey: true,
|
||||
trusted: false,
|
||||
},
|
||||
success: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected CLI command: ${joined}`);
|
||||
});
|
||||
|
||||
const scenario = MATRIX_QA_SCENARIOS.find(
|
||||
(entry) => entry.id === "matrix-e2ee-cli-self-verification",
|
||||
);
|
||||
expect(scenario).toBeDefined();
|
||||
|
||||
await expect(
|
||||
runMatrixQaScenario(scenario!, {
|
||||
...matrixQaScenarioContext(),
|
||||
driverDeviceId: "DRIVERDEVICE",
|
||||
driverPassword: "driver-password",
|
||||
gatewayRuntimeEnv: {
|
||||
OPENCLAW_CONFIG_PATH: "/tmp/gateway-config.json",
|
||||
OPENCLAW_STATE_DIR: "/tmp/gateway-state",
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
outputDir,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
artifacts: {
|
||||
completedVerificationIds: ["verification-1", "owner-request"],
|
||||
currentDeviceId: "CLIDEVICE",
|
||||
sasEmoji: ["🐶 Dog"],
|
||||
secondaryDeviceId: "CLIDEVICE",
|
||||
},
|
||||
});
|
||||
|
||||
expect(startMatrixQaOpenClawCli).toHaveBeenCalledTimes(1);
|
||||
expect(startMatrixQaOpenClawCli.mock.calls[0]?.[0].args).toEqual([
|
||||
"matrix",
|
||||
"verify",
|
||||
"self",
|
||||
"--account",
|
||||
"cli",
|
||||
]);
|
||||
expect(waitForOutput).toHaveBeenCalledTimes(2);
|
||||
expect(writeStdin).toHaveBeenCalledWith("yes\n");
|
||||
expect(wait).toHaveBeenCalledTimes(1);
|
||||
expect(kill).toHaveBeenCalledTimes(1);
|
||||
expect(runMatrixQaOpenClawCli).toHaveBeenCalledTimes(2);
|
||||
expect(runMatrixQaOpenClawCli.mock.calls.map(([params]) => params.args)).toEqual([
|
||||
[
|
||||
"matrix",
|
||||
"verify",
|
||||
"backup",
|
||||
"restore",
|
||||
"--account",
|
||||
"cli",
|
||||
"--recovery-key",
|
||||
"encoded-recovery-key",
|
||||
"--json",
|
||||
],
|
||||
["matrix", "verify", "status", "--account", "cli", "--json"],
|
||||
]);
|
||||
const cliEnv = startMatrixQaOpenClawCli.mock.calls[0]?.[0].env;
|
||||
expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("openclaw-matrix-cli-qa-");
|
||||
expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("openclaw-matrix-cli-qa-");
|
||||
const configPath = String(cliEnv?.OPENCLAW_CONFIG_PATH);
|
||||
expect(cliAccountConfigDuringRun).toMatchObject({
|
||||
accessToken: "cli-token",
|
||||
deviceId: "CLIDEVICE",
|
||||
encryption: true,
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
startupVerification: "off",
|
||||
userId: "@driver:matrix-qa.test",
|
||||
});
|
||||
await expect(readFile(configPath, "utf8")).rejects.toThrow();
|
||||
await expect(readdir(String(cliEnv?.OPENCLAW_STATE_DIR))).rejects.toThrow();
|
||||
expect(acceptVerification).toHaveBeenCalledWith("owner-request");
|
||||
expect(confirmVerificationSas).toHaveBeenCalledWith("owner-request");
|
||||
expect(deleteOwnDevices).toHaveBeenCalledWith(["CLIDEVICE"]);
|
||||
const [cliRunDir] = await readdir(path.join(outputDir, "cli-self-verification"));
|
||||
const cliArtifactDir = path.join(outputDir, "cli-self-verification", cliRunDir ?? "");
|
||||
await expect(stat(cliArtifactDir)).resolves.toMatchObject({ mode: expect.any(Number) });
|
||||
expect((await stat(cliArtifactDir)).mode & 0o777).toBe(0o700);
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"), "utf8"),
|
||||
).resolves.toContain('"success":true');
|
||||
expect(
|
||||
(await stat(path.join(cliArtifactDir, "verify-backup-restore.stdout.txt"))).mode & 0o777,
|
||||
).toBe(0o600);
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-self.stdout.txt"), "utf8"),
|
||||
).resolves.toContain("Device verified by owner: yes");
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-self.stdout.txt"), "utf8"),
|
||||
).resolves.toContain("Cross-signing verified: yes");
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"),
|
||||
).resolves.toContain('"verified":true');
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"),
|
||||
).resolves.toContain('"crossSigningVerified":true');
|
||||
} finally {
|
||||
await rm(outputDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Matrix E2EE bootstrap failure through a real faulted homeserver endpoint", async () => {
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const hits = vi.fn().mockReturnValue([
|
||||
|
||||
@@ -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