mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: tighten verification trust and expose profile updates
This commit is contained in:
@@ -82,4 +82,27 @@ describe("matrixMessageActions account propagation", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards accountId for self-profile updates", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "set-profile",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "setProfile",
|
||||
accountId: "ops",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,4 +66,16 @@ describe("matrixMessageActions", () => {
|
||||
expect(supportsAction!({ action: "poll" } as never)).toBe(false);
|
||||
expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes and handles self-profile updates", () => {
|
||||
const listActions = matrixMessageActions.listActions;
|
||||
const supportsAction = matrixMessageActions.supportsAction;
|
||||
|
||||
const actions = listActions!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
|
||||
expect(actions).toContain("set-profile");
|
||||
expect(supportsAction!({ action: "set-profile" } as never)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"set-profile",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"permissions",
|
||||
@@ -53,6 +54,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
actions.add("unpin");
|
||||
actions.add("list-pins");
|
||||
}
|
||||
if (gate("profile")) {
|
||||
actions.add("set-profile");
|
||||
}
|
||||
if (gate("memberInfo")) {
|
||||
actions.add("member-info");
|
||||
}
|
||||
@@ -184,6 +188,14 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "set-profile") {
|
||||
return await dispatch({
|
||||
action: "setProfile",
|
||||
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
||||
avatarUrl: readStringParam(params, "avatarUrl"),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await dispatch({
|
||||
|
||||
@@ -310,6 +310,9 @@ describe("matrix CLI verification commands", () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
@@ -332,6 +335,8 @@ describe("matrix CLI verification commands", () => {
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
|
||||
expect(console.log).toHaveBeenCalledWith("Locally trusted: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Signed by owner: yes");
|
||||
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
|
||||
});
|
||||
|
||||
@@ -413,6 +418,9 @@ describe("matrix CLI verification commands", () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
@@ -444,6 +452,9 @@ describe("matrix CLI verification commands", () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "5256",
|
||||
@@ -479,6 +490,9 @@ describe("matrix CLI verification commands", () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "5256",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./matrix/actions/verification.js";
|
||||
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
|
||||
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -200,60 +201,18 @@ async function addMatrixAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
type MatrixCliProfileSetResult = {
|
||||
accountId: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
profile: {
|
||||
displayNameUpdated: boolean;
|
||||
avatarUpdated: boolean;
|
||||
resolvedAvatarUrl: string | null;
|
||||
convertedAvatarFromHttp: boolean;
|
||||
};
|
||||
configPath: string;
|
||||
};
|
||||
type MatrixCliProfileSetResult = MatrixProfileUpdateResult;
|
||||
|
||||
async function setMatrixProfile(params: {
|
||||
account?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
}): Promise<MatrixCliProfileSetResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const accountId = normalizeAccountId(params.account);
|
||||
const displayName = params.name?.trim() || null;
|
||||
const avatarUrl = params.avatarUrl?.trim() || null;
|
||||
if (!displayName && !avatarUrl) {
|
||||
throw new Error("Provide --name and/or --avatar-url.");
|
||||
}
|
||||
|
||||
const synced = await updateMatrixOwnProfile({
|
||||
accountId,
|
||||
displayName: displayName ?? undefined,
|
||||
avatarUrl: avatarUrl ?? undefined,
|
||||
return await applyMatrixProfileUpdate({
|
||||
account: params.account,
|
||||
displayName: params.name,
|
||||
avatarUrl: params.avatarUrl,
|
||||
});
|
||||
const persistedAvatarUrl =
|
||||
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
|
||||
? synced.resolvedAvatarUrl
|
||||
: avatarUrl;
|
||||
const updated = updateMatrixAccountConfig(cfg, accountId, {
|
||||
name: displayName ?? undefined,
|
||||
avatarUrl: persistedAvatarUrl ?? undefined,
|
||||
});
|
||||
await runtime.config.writeConfigFile(updated as never);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
displayName,
|
||||
avatarUrl: persistedAvatarUrl ?? null,
|
||||
profile: {
|
||||
displayNameUpdated: synced.displayNameUpdated,
|
||||
avatarUpdated: synced.avatarUpdated,
|
||||
resolvedAvatarUrl: synced.resolvedAvatarUrl,
|
||||
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
|
||||
},
|
||||
configPath: resolveMatrixConfigPath(updated, accountId),
|
||||
};
|
||||
}
|
||||
|
||||
type MatrixCliCommandConfig<TResult> = {
|
||||
@@ -309,6 +268,9 @@ type MatrixCliVerificationStatus = {
|
||||
verified: boolean;
|
||||
userId: string | null;
|
||||
deviceId: string | null;
|
||||
localVerified: boolean;
|
||||
crossSigningVerified: boolean;
|
||||
signedByOwner: boolean;
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
recoveryKeyStored: boolean;
|
||||
@@ -391,6 +353,16 @@ function printVerificationBackupStatus(status: {
|
||||
printBackupStatus(resolveBackupStatus(status));
|
||||
}
|
||||
|
||||
function printVerificationTrustDiagnostics(status: {
|
||||
localVerified: boolean;
|
||||
crossSigningVerified: boolean;
|
||||
signedByOwner: boolean;
|
||||
}): void {
|
||||
console.log(`Locally trusted: ${status.localVerified ? "yes" : "no"}`);
|
||||
console.log(`Cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`);
|
||||
console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`);
|
||||
}
|
||||
|
||||
function printVerificationGuidance(status: MatrixCliVerificationStatus): void {
|
||||
printGuidance(buildVerificationGuidance(status));
|
||||
}
|
||||
@@ -525,7 +497,7 @@ function printGuidance(lines: string[]): void {
|
||||
}
|
||||
|
||||
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void {
|
||||
console.log(`Verified: ${status.verified ? "yes" : "no"}`);
|
||||
console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`);
|
||||
const backup = resolveBackupStatus(status);
|
||||
const backupIssue = resolveBackupIssue(backup);
|
||||
printVerificationBackupSummary(status);
|
||||
@@ -535,6 +507,7 @@ function printVerificationStatus(status: MatrixCliVerificationStatus, verbose =
|
||||
if (verbose) {
|
||||
console.log("Diagnostics:");
|
||||
printVerificationIdentity(status);
|
||||
printVerificationTrustDiagnostics(status);
|
||||
printVerificationBackupStatus(status);
|
||||
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
||||
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
|
||||
@@ -804,9 +777,10 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
if (result.error) {
|
||||
console.log(`Error: ${result.error}`);
|
||||
}
|
||||
console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`);
|
||||
console.log(`Verified by owner: ${result.verification.verified ? "yes" : "no"}`);
|
||||
printVerificationIdentity(result.verification);
|
||||
if (verbose) {
|
||||
printVerificationTrustDiagnostics(result.verification);
|
||||
console.log(
|
||||
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
|
||||
);
|
||||
@@ -853,6 +827,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
printVerificationIdentity(result);
|
||||
printVerificationBackupSummary(result);
|
||||
if (verbose) {
|
||||
printVerificationTrustDiagnostics(result);
|
||||
printVerificationBackupStatus(result);
|
||||
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
|
||||
printTimestamp("Verified at", result.verifiedAt);
|
||||
|
||||
@@ -13,6 +13,7 @@ const matrixActionSchema = z
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
profile: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
verification: z.boolean().optional(),
|
||||
|
||||
@@ -402,7 +402,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
env: process.env,
|
||||
});
|
||||
if (startupVerification.kind === "verified") {
|
||||
logger.info("matrix: device is verified and ready for encrypted rooms");
|
||||
logger.info("matrix: device is verified by its owner and ready for encrypted rooms");
|
||||
} else if (
|
||||
startupVerification.kind === "disabled" ||
|
||||
startupVerification.kind === "cooldown" ||
|
||||
|
||||
@@ -22,6 +22,9 @@ type VerificationSummaryLike = {
|
||||
|
||||
function createHarness(params?: {
|
||||
verified?: boolean;
|
||||
localVerified?: boolean;
|
||||
crossSigningVerified?: boolean;
|
||||
signedByOwner?: boolean;
|
||||
requestVerification?: () => Promise<{ id: string; transactionId?: string }>;
|
||||
listVerifications?: () => Promise<VerificationSummaryLike[]>;
|
||||
}) {
|
||||
@@ -37,9 +40,9 @@ function createHarness(params?: {
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
verified: params?.verified === true,
|
||||
localVerified: params?.verified === true,
|
||||
crossSigningVerified: params?.verified === true,
|
||||
signedByOwner: params?.verified === true,
|
||||
localVerified: params?.localVerified ?? params?.verified === true,
|
||||
crossSigningVerified: params?.crossSigningVerified ?? params?.verified === true,
|
||||
signedByOwner: params?.signedByOwner ?? params?.verified === true,
|
||||
recoveryKeyStored: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: null,
|
||||
@@ -91,6 +94,31 @@ describe("ensureMatrixStartupVerification", () => {
|
||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still requests startup verification when trust is only local", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({
|
||||
verified: false,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
});
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
accountConfig: {},
|
||||
stateFilePath: createStateFilePath(tempHome),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("requested");
|
||||
expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true });
|
||||
});
|
||||
|
||||
it("skips automatic requests when a self verification is already pending", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({
|
||||
|
||||
@@ -843,6 +843,34 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(status.deviceId).toBe("DEVICE123");
|
||||
});
|
||||
|
||||
it("does not treat local-only trust as owner verification", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
expect(status.localVerified).toBe(true);
|
||||
expect(status.crossSigningVerified).toBe(false);
|
||||
expect(status.signedByOwner).toBe(false);
|
||||
expect(status.verified).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies with a provided recovery key and reports success", async () => {
|
||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
expect(encoded).toBeTypeOf("string");
|
||||
@@ -887,6 +915,42 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(bootstrapCrossSigning).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when the device is only locally trusted", async () => {
|
||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
defaultKeyId: "SSSSKEY",
|
||||
secretStorageKeyValidityMap: { SSSSKEY: true },
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-"));
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
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.verified).toBe(false);
|
||||
expect(result.error).toContain("not verified by its owner");
|
||||
});
|
||||
|
||||
it("reports detailed room-key backup health", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
@@ -1140,6 +1204,42 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(result.cryptoBootstrap).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reports bootstrap failure when the device is only locally trusted", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
userHasCrossSigningKeys: vi.fn(async () => true),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
|
||||
encryption: true,
|
||||
});
|
||||
vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({
|
||||
userId: "@bot:example.org",
|
||||
masterKeyPublished: true,
|
||||
selfSigningKeyPublished: true,
|
||||
userSigningKeyPublished: true,
|
||||
published: true,
|
||||
});
|
||||
|
||||
const result = await client.bootstrapOwnDeviceVerification();
|
||||
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");
|
||||
});
|
||||
|
||||
it("creates a key backup during bootstrap when none exists on the server", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
MessageEventContent,
|
||||
} from "./sdk/types.js";
|
||||
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
|
||||
import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js";
|
||||
|
||||
export { ConsoleLogger, LogService };
|
||||
export type {
|
||||
@@ -47,6 +48,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: boolean;
|
||||
localVerified: boolean;
|
||||
crossSigningVerified: boolean;
|
||||
@@ -102,17 +105,6 @@ export type MatrixVerificationBootstrapResult = {
|
||||
cryptoBootstrap: MatrixCryptoBootstrapResult | null;
|
||||
};
|
||||
|
||||
function isMatrixDeviceVerified(
|
||||
status: MatrixDeviceVerificationStatusLike | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
status?.isVerified?.() === true ||
|
||||
status?.localVerified === true ||
|
||||
status?.crossSigningVerified === true ||
|
||||
status?.signedByOwner === true
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
@@ -659,7 +651,7 @@ export class MatrixClient {
|
||||
encryptionEnabled: true,
|
||||
userId,
|
||||
deviceId,
|
||||
verified: isMatrixDeviceVerified(deviceStatus),
|
||||
verified: isMatrixDeviceOwnerVerified(deviceStatus),
|
||||
localVerified: deviceStatus?.localVerified === true,
|
||||
crossSigningVerified: deviceStatus?.crossSigningVerified === true,
|
||||
signedByOwner: deviceStatus?.signedByOwner === true,
|
||||
@@ -715,7 +707,7 @@ export class MatrixClient {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.",
|
||||
"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,
|
||||
};
|
||||
}
|
||||
@@ -901,7 +893,7 @@ export class MatrixClient {
|
||||
const error = success
|
||||
? undefined
|
||||
: (bootstrapError ??
|
||||
"Matrix verification bootstrap did not produce a verified device with published cross-signing keys");
|
||||
"Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys");
|
||||
return {
|
||||
success,
|
||||
error,
|
||||
|
||||
@@ -230,6 +230,48 @@ describe("MatrixCryptoBootstrapper", () => {
|
||||
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
|
||||
});
|
||||
|
||||
it("does not treat local-only trust as sufficient for own-device bootstrap", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const setDeviceVerified = vi.fn(async () => {});
|
||||
const crossSignDevice = vi.fn(async () => {});
|
||||
const getDeviceVerificationStatus = vi
|
||||
.fn<
|
||||
() => Promise<{
|
||||
isVerified: () => boolean;
|
||||
localVerified: boolean;
|
||||
crossSigningVerified: boolean;
|
||||
signedByOwner: boolean;
|
||||
}>
|
||||
>()
|
||||
.mockResolvedValueOnce({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
});
|
||||
const crypto = createCryptoApi({
|
||||
getDeviceVerificationStatus,
|
||||
setDeviceVerified,
|
||||
crossSignDevice,
|
||||
isCrossSigningReady: vi.fn(async () => true),
|
||||
});
|
||||
const bootstrapper = new MatrixCryptoBootstrapper(
|
||||
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
|
||||
);
|
||||
|
||||
await bootstrapper.bootstrap(crypto);
|
||||
|
||||
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
|
||||
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
|
||||
expect(getDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("auto-accepts incoming verification requests from other users", async () => {
|
||||
const deps = createBootstrapperDeps();
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
MatrixVerificationManager,
|
||||
MatrixVerificationRequestLike,
|
||||
} from "./verification-manager.js";
|
||||
import { isMatrixDeviceOwnerVerified } from "./verification-status.js";
|
||||
|
||||
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
|
||||
getUserId: () => Promise<string>;
|
||||
@@ -293,11 +294,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
typeof crypto.getDeviceVerificationStatus === "function"
|
||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||
: null;
|
||||
const alreadyVerified =
|
||||
deviceStatus?.isVerified?.() === true ||
|
||||
deviceStatus?.localVerified === true ||
|
||||
deviceStatus?.crossSigningVerified === true ||
|
||||
deviceStatus?.signedByOwner === true;
|
||||
const alreadyVerified = isMatrixDeviceOwnerVerified(deviceStatus);
|
||||
|
||||
if (alreadyVerified) {
|
||||
return true;
|
||||
@@ -321,13 +318,9 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
|
||||
typeof crypto.getDeviceVerificationStatus === "function"
|
||||
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
|
||||
: null;
|
||||
const verified =
|
||||
refreshedStatus?.isVerified?.() === true ||
|
||||
refreshedStatus?.localVerified === true ||
|
||||
refreshedStatus?.crossSigningVerified === true ||
|
||||
refreshedStatus?.signedByOwner === true;
|
||||
const verified = isMatrixDeviceOwnerVerified(refreshedStatus);
|
||||
if (!verified && strict) {
|
||||
throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`);
|
||||
throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`);
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
|
||||
23
extensions/matrix/src/matrix/sdk/verification-status.ts
Normal file
23
extensions/matrix/src/matrix/sdk/verification-status.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { MatrixDeviceVerificationStatusLike } from "./types.js";
|
||||
|
||||
export function isMatrixDeviceLocallyVerified(
|
||||
status: MatrixDeviceVerificationStatusLike | null | undefined,
|
||||
): boolean {
|
||||
return status?.localVerified === true;
|
||||
}
|
||||
|
||||
export function isMatrixDeviceOwnerVerified(
|
||||
status: MatrixDeviceVerificationStatusLike | null | undefined,
|
||||
): boolean {
|
||||
return status?.crossSigningVerified === true || status?.signedByOwner === true;
|
||||
}
|
||||
|
||||
export function isMatrixDeviceVerifiedInCurrentClient(
|
||||
status: MatrixDeviceVerificationStatusLike | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
status?.isVerified?.() === true ||
|
||||
isMatrixDeviceLocallyVerified(status) ||
|
||||
isMatrixDeviceOwnerVerified(status)
|
||||
);
|
||||
}
|
||||
61
extensions/matrix/src/profile-update.ts
Normal file
61
extensions/matrix/src/profile-update.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix";
|
||||
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
|
||||
import { updateMatrixAccountConfig, resolveMatrixConfigPath } from "./matrix/config-update.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export type MatrixProfileUpdateResult = {
|
||||
accountId: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
profile: {
|
||||
displayNameUpdated: boolean;
|
||||
avatarUpdated: boolean;
|
||||
resolvedAvatarUrl: string | null;
|
||||
convertedAvatarFromHttp: boolean;
|
||||
};
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
export async function applyMatrixProfileUpdate(params: {
|
||||
account?: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
}): Promise<MatrixProfileUpdateResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const accountId = normalizeAccountId(params.account);
|
||||
const displayName = params.displayName?.trim() || null;
|
||||
const avatarUrl = params.avatarUrl?.trim() || null;
|
||||
if (!displayName && !avatarUrl) {
|
||||
throw new Error("Provide name/displayName and/or avatarUrl.");
|
||||
}
|
||||
|
||||
const synced = await updateMatrixOwnProfile({
|
||||
accountId,
|
||||
displayName: displayName ?? undefined,
|
||||
avatarUrl: avatarUrl ?? undefined,
|
||||
});
|
||||
const persistedAvatarUrl =
|
||||
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
|
||||
? synced.resolvedAvatarUrl
|
||||
: avatarUrl;
|
||||
const updated = updateMatrixAccountConfig(cfg, accountId, {
|
||||
name: displayName ?? undefined,
|
||||
avatarUrl: persistedAvatarUrl ?? undefined,
|
||||
});
|
||||
await runtime.config.writeConfigFile(updated as never);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
displayName,
|
||||
avatarUrl: persistedAvatarUrl ?? null,
|
||||
profile: {
|
||||
displayNameUpdated: synced.displayNameUpdated,
|
||||
avatarUpdated: synced.avatarUpdated,
|
||||
resolvedAvatarUrl: synced.resolvedAvatarUrl,
|
||||
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
|
||||
},
|
||||
configPath: resolveMatrixConfigPath(updated, accountId),
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
listMatrixPins: vi.fn(),
|
||||
getMatrixMemberInfo: vi.fn(),
|
||||
getMatrixRoomInfo: vi.fn(),
|
||||
applyMatrixProfileUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions.js", async () => {
|
||||
@@ -35,6 +36,10 @@ vi.mock("./matrix/send.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./profile-update.js", () => ({
|
||||
applyMatrixProfileUpdate: (...args: unknown[]) => mocks.applyMatrixProfileUpdate(...args),
|
||||
}));
|
||||
|
||||
describe("handleMatrixAction pollVote", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -55,6 +60,18 @@ describe("handleMatrixAction pollVote", () => {
|
||||
});
|
||||
mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" });
|
||||
mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room:example" });
|
||||
mocks.applyMatrixProfileUpdate.mockResolvedValue({
|
||||
accountId: "ops",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
profile: {
|
||||
displayNameUpdated: true,
|
||||
avatarUpdated: true,
|
||||
resolvedAvatarUrl: "mxc://example/avatar",
|
||||
convertedAvatarFromHttp: false,
|
||||
},
|
||||
configPath: "channels.matrix.accounts.ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses snake_case vote params and forwards normalized selectors", async () => {
|
||||
@@ -219,4 +236,30 @@ describe("handleMatrixAction pollVote", () => {
|
||||
accountId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists self-profile updates through the shared profile helper", async () => {
|
||||
const result = await handleMatrixAction(
|
||||
{
|
||||
action: "setProfile",
|
||||
account_id: "ops",
|
||||
display_name: "Ops Bot",
|
||||
avatar_url: "mxc://example/avatar",
|
||||
},
|
||||
{ channels: { matrix: { actions: { profile: true } } } } as CoreConfig,
|
||||
);
|
||||
|
||||
expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({
|
||||
account: "ops",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
accountId: "ops",
|
||||
profile: {
|
||||
displayNameUpdated: true,
|
||||
avatarUpdated: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,12 +39,14 @@ import {
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./matrix/actions.js";
|
||||
import { reactMatrixMessage } from "./matrix/send.js";
|
||||
import { applyMatrixProfileUpdate } from "./profile-update.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
|
||||
const reactionActions = new Set(["react", "reactions"]);
|
||||
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
||||
const pollActions = new Set(["pollVote"]);
|
||||
const profileActions = new Set(["setProfile"]);
|
||||
const verificationActions = new Set([
|
||||
"encryptionStatus",
|
||||
"verificationList",
|
||||
@@ -258,6 +260,18 @@ export async function handleMatrixAction(
|
||||
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
|
||||
}
|
||||
|
||||
if (profileActions.has(action)) {
|
||||
if (!isActionEnabled("profile")) {
|
||||
throw new Error("Matrix profile updates are disabled.");
|
||||
}
|
||||
const result = await applyMatrixProfileUpdate({
|
||||
account: accountId,
|
||||
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
||||
avatarUrl: readStringParam(params, "avatarUrl"),
|
||||
});
|
||||
return jsonResult({ ok: true, ...result });
|
||||
}
|
||||
|
||||
if (action === "memberInfo") {
|
||||
if (!isActionEnabled("memberInfo")) {
|
||||
throw new Error("Matrix member info is disabled.");
|
||||
|
||||
@@ -35,6 +35,7 @@ export type MatrixActionConfig = {
|
||||
reactions?: boolean;
|
||||
messages?: boolean;
|
||||
pins?: boolean;
|
||||
profile?: boolean;
|
||||
memberInfo?: boolean;
|
||||
channelInfo?: boolean;
|
||||
verification?: boolean;
|
||||
|
||||
@@ -156,6 +156,14 @@ describe("message tool schema scoping", () => {
|
||||
actions: ["send", "poll", "poll-vote"],
|
||||
});
|
||||
|
||||
const matrixPlugin = createChannelPlugin({
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
docsPath: "/channels/matrix",
|
||||
blurb: "Matrix test plugin.",
|
||||
actions: ["send", "set-profile"],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
@@ -191,6 +199,7 @@ describe("message tool schema scoping", () => {
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
||||
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
||||
{ pluginId: "matrix", source: "test", plugin: matrixPlugin },
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -235,6 +244,8 @@ describe("message tool schema scoping", () => {
|
||||
expect(properties.pollId).toBeDefined();
|
||||
expect(properties.pollOptionIndex).toBeDefined();
|
||||
expect(properties.pollOptionId).toBeDefined();
|
||||
expect(properties.avatarUrl).toBeDefined();
|
||||
expect(properties.displayName).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -421,6 +421,33 @@ function buildPresenceSchema() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfileSchema() {
|
||||
return {
|
||||
displayName: Type.Optional(
|
||||
Type.String({
|
||||
description: "Profile display name for self-profile update actions.",
|
||||
}),
|
||||
),
|
||||
display_name: Type.Optional(
|
||||
Type.String({
|
||||
description: "snake_case alias of displayName for self-profile update actions.",
|
||||
}),
|
||||
),
|
||||
avatarUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Profile avatar URL for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
||||
}),
|
||||
),
|
||||
avatar_url: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"snake_case alias of avatarUrl for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildChannelManagementSchema() {
|
||||
return {
|
||||
name: Type.Optional(Type.String()),
|
||||
@@ -459,6 +486,7 @@ function buildMessageToolSchemaProps(options: {
|
||||
...buildGatewaySchema(),
|
||||
...buildChannelManagementSchema(),
|
||||
...buildPresenceSchema(),
|
||||
...buildProfileSchema(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
||||
"kick",
|
||||
"ban",
|
||||
"set-presence",
|
||||
"set-profile",
|
||||
"download-file",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
kick: "none",
|
||||
ban: "none",
|
||||
"set-presence": "none",
|
||||
"set-profile": "none",
|
||||
"download-file": "none",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user