mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user