Skill: mlumeau/verify-matrix-device (version 0.1.0)

--- name: verify-matrix-device description: Verify and cross-sign the active Matrix device for one OpenClaw-managed account. Use when a user needs to repair trust for an OpenClaw Matrix device, confirm the current device is self-signed, or recover cross-signing with a recovery key. metadata: {"openclaw":{"requires":{"bins":["node","npm"]},"emoji":"🔐"}} --- # OpenClaw Matrix Device Verification Use this skill when the user wants to verify the currently active Matrix device for an OpenClaw-managed account. ## Required Inputs - `homeserver`: Matrix homeserver base URL. - `username`: OpenClaw account id, full Matrix user id, or unique localpart. - `recovery key`: the Matrix recovery key. Treat this as a secret. - Optional: path to `openclaw.json` when it is not in the default location. ## Workflow 1. If any required input is missing, ask the user for it. 2. Ensure dependencies are installed in this skill folder. If `{baseDir}/node_modules/matrix-js-sdk` is missing, run `npm install --prefix {baseDir}`. 3. Run the verifier from this skill folder: `node {baseDir}/scripts/verify_matrix_device.mjs --homeserver "<homeserver>" --username "<username>"` 4. If the user gave a non-default config path, append: `--openclaw-json "<path>"` 5. Let the script prompt for the recovery key in the terminal. Do not pass the recovery key on the command line or in environment variables. 6. Report whether the device was already cross-signed or was signed successfully. ## Notes - The skill uses the existing OpenClaw access token from `openclaw.json`; it does not create a helper Matrix device. - The verifier signs the active device directly and confirms the signature server-side. - Use a real TTY so the hidden recovery-key prompt works correctly. - For local testing outside OpenClaw, `--access-token` bypasses `openclaw.json` and prompts for the Matrix user ID plus access token (`--direct` and `-t` are compatibility aliases). - If the access token is missing, `--password` (or `-p`) can log in with the Matrix password and sign a specific target `device_id`, then log out the temporary helper session.

path: agents/openai.yaml
content: interface:
  display_name: "Verify Matrix Device"
  short_description: "Verify one Matrix device"
  default_prompt: "Use $verify-matrix-device to verify and cross-sign one Matrix device with a homeserver, username, and recovery key."

policy:
  allow_implicit_invocation: true


path: README.md
content: # verify-matrix-device

OpenClaw skill and scripts to verify and cross-sign active Matrix devices for OpenClaw-managed accounts.

## Purpose

- Resolve one OpenClaw-managed Matrix account from `openclaw.json`
- Resolve the active `device_id` via `/whoami` using the stored OpenClaw access token
- Restore the self-signing key from secret storage using the recovery key
- Sign the active device directly and confirm the signature server-side

## Requirements

- Node.js 20+
- Access to an `openclaw.json` file, or use direct test mode
- An interactive terminal (the script prompts for secrets securely)

## Installation

```bash
npm install
```

## Usage

```bash
npm run verify-account
```

Direct test mode:

```bash
npm run verify-direct
```

Password-only mode:

```bash
npm run verify-password
```

The script prompts for:

- `homeserver`
- `recovery key` (hidden)

Default mode prompts:

- `username` (OpenClaw account id, full Matrix user id, or unique localpart)

Access-token mode prompts:

- `Matrix user ID`
- `access token` (hidden)

Password mode prompts:

- `Matrix user ID`
- `password` (hidden)
- `target device ID`

Optional flags:

- `--homeserver` pre-fills the homeserver prompt
- `--username` pre-fills the username prompt
- `--user-id` pre-fills the Matrix user ID in access-token mode or password mode
- `--device-id` pre-fills the target device ID in password mode
- `--openclaw-json` overrides the default config path
- `--access-token` bypasses `openclaw.json` and uses a Matrix user + access token flow
- `--direct` and `-t` are compatibility aliases for `--access-token`
- `--password` or `-p` logs in with the Matrix password, signs the specified target device ID, then logs out the temporary helper session

Test without `openclaw.json`:

```bash
npm run verify-direct
```

If you do not have an access token but you know the device to sign:

```bash
npm run verify-password
```

Default config path:

- `OPENCLAW_JSON` if set
- otherwise `~/.openclaw/openclaw.json`

## Security

- No secrets are embedded in the repository
- No default homeserver is hardcoded
- Access tokens, passwords, and recovery keys are hidden while typed
- `verify-account` and `verify-direct` run through `scripts/verify_matrix_device.mjs` and do not create a helper login or temporary Matrix device
- `verify-direct` now runs access-token mode
- `verify-password` runs through `scripts/verify_matrix_device.mjs`, creates a temporary helper session only to fetch account data and upload the signature, then logs it out
- Tokens are not printed in clear text

## License

MIT (see `LICENSE`)


path: package-lock.json
content: {
  "name": "openclaw-matrix-verify-device",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "openclaw-matrix-verify-device",
      "dependencies": {
        "matrix-js-sdk": "^34.3.0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
      "version": "9.1.0",
      "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-9.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@matrix-org/olm": {
      "version": "3.2.15",
      "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0"
    },
    "node_modules/@types/events": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/retry": {
      "version": "0.12.0",
      "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/another-json": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0"
    },
    "node_modules/base-x": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/bs58": {
      "version": "6.0.0",
      "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "base-x": "^5.0.0"
      }
    },
    "node_modules/content-type": {
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/events": {
      "version": "3.3.0",
      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.8.x"
      }
    },
    "node_modules/jwt-decode": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/loglevel": {
      "version": "1.9.2",
      "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6.0"
      },
      "funding": {
        "type": "tidelift",
        "url": "https://tidelift.com/funding/github/npm/loglevel"
      }
    },
    "node_modules/matrix-events-sdk": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0"
    },
    "node_modules/matrix-js-sdk": {
      "version": "34.13.0",
      "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-34.13.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "@babel/runtime": "^7.12.5",
        "@matrix-org/matrix-sdk-crypto-wasm": "^9.0.0",
        "@matrix-org/olm": "3.2.15",
        "another-json": "^0.2.0",
        "bs58": "^6.0.0",
        "content-type": "^1.0.4",
        "jwt-decode": "^4.0.0",
        "loglevel": "^1.7.1",
        "matrix-events-sdk": "0.0.1",
        "matrix-widget-api": "^1.10.0",
        "oidc-client-ts": "^3.0.1",
        "p-retry": "4",
        "sdp-transform": "^2.14.1",
        "unhomoglyph": "^1.0.6",
        "uuid": "11"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/matrix-widget-api": {
      "version": "1.17.0",
      "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "@types/events": "^3.0.0",
        "events": "^3.2.0"
      }
    },
    "node_modules/oidc-client-ts": {
      "version": "3.4.1",
      "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "jwt-decode": "^4.0.0"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/p-retry": {
      "version": "4.6.2",
      "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "@types/retry": "0.12.0",
        "retry": "^0.13.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/retry": {
      "version": "0.13.1",
      "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 4"
      }
    },
    "node_modules/sdp-transform": {
      "version": "2.15.0",
      "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "bin": {
        "sdp-verify": "checker.js"
      }
    },
    "node_modules/unhomoglyph": {
      "version": "1.0.6",
      "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/uuid": {
      "version": "11.1.0",
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        "https://github.com/sponsors/broofa",
        "https://github.com/sponsors/ctavan"
      ],
      "license": "MIT",
      "bin": {
        "uuid": "dist/esm/bin/uuid"
      }
    }
  }
}


path: package.json
content: {
  "name": "verify-matrix-device",
  "private": true,
  "type": "module",
  "scripts": {
    "verify-account": "node scripts/verify_matrix_device.mjs",
    "verify-direct": "node scripts/verify_matrix_device.mjs --access-token",
    "verify-password": "node scripts/verify_matrix_device.mjs --password"
  },
  "dependencies": {
    "matrix-js-sdk": "^34.3.0"
  }
}


path: scripts/verify_matrix_device_sdk.mjs
content: #!/usr/bin/env node
/*
Direct verifier for the active OpenClaw Matrix device.
- Resolves the active device_id via /whoami using the existing access token.
- Restores the self-signing private key from SSSS using the recovery key.
- Signs the active device directly via /keys/signatures/upload.
- Does not print secrets or create a helper device.
*/

import {
  createCipheriv,
  createDecipheriv,
  createHmac,
  createPrivateKey,
  hkdfSync,
  sign as signMessage,
  timingSafeEqual,
} from "node:crypto";

import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/index.js";

import {
  isDeviceCrossSignedOnServer,
  normalizeRecoveryKey,
  pickSigningKeyId,
} from "./matrix_sdk_helpers.mjs";

const DEFAULT_TIMEOUT_MS = 30000;
const SECRET_STORAGE_ALGORITHM = "m.secret_storage.v1.aes-hmac-sha2";
const SECRET_STORAGE_DEFAULT_KEY_EVENT = "m.secret_storage.default_key";
const SELF_SIGNING_SECRET_EVENT = "m.cross_signing.self_signing";
const SERVER_CONFIRMATION_POLL_MS = 500;

function baseUrl(homeserver) {
  return homeserver.replace(/\/$/, "");
}

function extractLocalpart(userId) {
  if (typeof userId !== "string") return "";
  if (userId.startsWith("@")) {
    return userId.split(":")[0].slice(1);
  }
  return userId;
}

function encodeUnpaddedBase64(value) {
  return Buffer.from(value).toString("base64").replace(/=+$/g, "");
}

function decodeUnpaddedBase64(value) {
  const normalized = `${value}`.trim();
  const paddingLength = (4 - (normalized.length % 4 || 4)) % 4;
  return Buffer.from(`${normalized}${"=".repeat(paddingLength)}`, "base64");
}

function toBase64Url(value) {
  return encodeUnpaddedBase64(value).replace(/\+/g, "-").replace(/\//g, "_");
}

function matrixBase64ToBase64Url(value) {
  return `${value}`.trim().replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

function safeEqual(a, b) {
  return a.length === b.length && timingSafeEqual(a, b);
}

function deriveSecretStorageKeys(secretStorageKey, name) {
  const derived = Buffer.from(
    hkdfSync(
      "sha256",
      Buffer.from(secretStorageKey),
      Buffer.alloc(32),
      Buffer.from(name, "utf8"),
      64,
    ),
  );
  return {
    aesKey: derived.subarray(0, 32),
    macKey: derived.subarray(32),
  };
}

function encryptAesCtr(aesKey, iv, plaintext) {
  const cipher = createCipheriv("aes-256-ctr", aesKey, iv);
  return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}

function decryptAesCtr(aesKey, iv, ciphertext) {
  const decipher = createDecipheriv("aes-256-ctr", aesKey, iv);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}

function computeSecretStorageMac(macKey, ciphertext) {
  return createHmac("sha256", macKey).update(ciphertext).digest();
}

function assertSupportedSecretStorageKey(keyId, keyInfo) {
  if (!keyInfo) {
    throw new Error(`Missing account data for m.secret_storage.key.${keyId}`);
  }
  if (keyInfo.algorithm !== SECRET_STORAGE_ALGORITHM) {
    throw new Error(`Unsupported secret storage algorithm for ${keyId}: ${keyInfo.algorithm || "unknown"}`);
  }
  if (!keyInfo.iv || !keyInfo.mac) {
    throw new Error(`Secret storage key ${keyId} is missing checksum data (iv/mac).`);
  }
}

function verifySecretStorageKey(secretStorageKey, keyInfo) {
  const { aesKey, macKey } = deriveSecretStorageKeys(secretStorageKey, "");
  const iv = decodeUnpaddedBase64(keyInfo.iv);
  const ciphertext = encryptAesCtr(aesKey, iv, Buffer.alloc(32));
  const mac = computeSecretStorageMac(macKey, ciphertext);
  const expected = decodeUnpaddedBase64(keyInfo.mac);
  return safeEqual(mac, expected);
}

function decryptSecretStorageItem({ encrypted, secretStorageKey, secretName }) {
  const { iv, ciphertext, mac } = encrypted || {};
  if (!iv || !ciphertext || !mac) {
    throw new Error(`Secret ${secretName} is missing iv/ciphertext/mac.`);
  }

  const { aesKey, macKey } = deriveSecretStorageKeys(secretStorageKey, secretName);
  const ciphertextBytes = decodeUnpaddedBase64(ciphertext);
  const actualMac = computeSecretStorageMac(macKey, ciphertextBytes);
  const expectedMac = decodeUnpaddedBase64(mac);
  if (!safeEqual(actualMac, expectedMac)) {
    throw new Error(`Secret ${secretName} failed MAC validation.`);
  }

  return decryptAesCtr(aesKey, decodeUnpaddedBase64(iv), ciphertextBytes).toString("utf8");
}

function deepCloneJson(value) {
  return JSON.parse(JSON.stringify(value));
}

function canonicalJson(value) {
  if (Array.isArray(value)) {
    return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
  }
  if (value && typeof value === "object") {
    const entries = Object.keys(value)
      .sort()
      .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
    return `{${entries.join(",")}}`;
  }
  return JSON.stringify(value);
}

function buildSignedDevicePayload(device, userId, keyId, signature) {
  const signedDevice = deepCloneJson(device);
  delete signedDevice.unsigned;
  const existingSignatures = signedDevice.signatures && typeof signedDevice.signatures === "object"
    ? deepCloneJson(signedDevice.signatures)
    : {};
  const userSignatures = existingSignatures[userId] && typeof existingSignatures[userId] === "object"
    ? existingSignatures[userId]
    : {};

  existingSignatures[userId] = {
    ...userSignatures,
    [keyId]: signature,
  };
  signedDevice.signatures = existingSignatures;
  return signedDevice;
}

function signObjectWithKey({ object, privateKey }) {
  const signingTarget = deepCloneJson(object);
  delete signingTarget.unsigned;
  delete signingTarget.signatures;
  const payload = Buffer.from(canonicalJson(signingTarget), "utf8");
  return encodeUnpaddedBase64(signMessage(null, payload, privateKey));
}

function createEd25519PrivateKey({ seed, publicKey }) {
  if (seed.length !== 32) {
    throw new Error(`Expected a 32-byte self-signing seed, received ${seed.length} bytes.`);
  }

  return createPrivateKey({
    key: {
      kty: "OKP",
      crv: "Ed25519",
      d: toBase64Url(seed),
      x: matrixBase64ToBase64Url(publicKey),
    },
    format: "jwk",
  });
}

async function matrixRequest({ homeserver, accessToken, path, method = "GET", body }) {
  const headers = {};
  if (accessToken) {
    headers.Authorization = `Bearer ${accessToken}`;
  }
  if (body !== undefined) {
    headers["Content-Type"] = "application/json";
  }

  const response = await fetch(`${baseUrl(homeserver)}${path}`, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  const rawBody = await response.text();
  if (!response.ok) {
    const error = new Error(`${method} ${path} failed (${response.status}): ${rawBody}`);
    error.status = response.status;
    throw error;
  }

  if (!rawBody) {
    return null;
  }

  return JSON.parse(rawBody);
}

async function fetchWhoami(homeserver, accessToken) {
  return matrixRequest({
    homeserver,
    accessToken,
    path: "/_matrix/client/v3/account/whoami",
  });
}

async function loginWithPassword(homeserver, userId, password) {
  const localpart = extractLocalpart(userId);
  return matrixRequest({
    homeserver,
    accessToken: "",
    path: "/_matrix/client/v3/login",
    method: "POST",
    body: {
      type: "m.login.password",
      identifier: {
        type: "m.id.user",
        user: localpart,
      },
      password,
    },
  });
}

async function logoutSession(homeserver, accessToken) {
  return matrixRequest({
    homeserver,
    accessToken,
    path: "/_matrix/client/v3/logout",
    method: "POST",
    body: {},
  });
}

async function fetchAccountData(homeserver, accessToken, userId, eventType) {
  try {
    return await matrixRequest({
      homeserver,
      accessToken,
      path: `/_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${encodeURIComponent(eventType)}`,
    });
  } catch (error) {
    if (error?.status === 404) {
      return null;
    }
    throw error;
  }
}

async function fetchServerKeyQuery(homeserver, accessToken, userId, deviceId) {
  return matrixRequest({
    homeserver,
    accessToken,
    path: "/_matrix/client/v3/keys/query",
    method: "POST",
    body: { device_keys: { [userId]: [deviceId] } },
  });
}

async function uploadDeviceSignature(homeserver, accessToken, userId, deviceId, signedDevice) {
  return matrixRequest({
    homeserver,
    accessToken,
    path: "/_matrix/client/v3/keys/signatures/upload",
    method: "POST",
    body: {
      [userId]: {
        [deviceId]: signedDevice,
      },
    },
  });
}

async function waitForServerCrossSigning(homeserver, accessToken, userId, deviceId, timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const serverData = await fetchServerKeyQuery(homeserver, accessToken, userId, deviceId);
    if (isDeviceCrossSignedOnServer(serverData, userId, deviceId)) return true;
    await new Promise((resolve) => setTimeout(resolve, SERVER_CONFIRMATION_POLL_MS));
  }
  return false;
}

async function restoreSelfSigningSeed({ homeserver, accessToken, userId, recoveryKey }) {
  const normalizedRecoveryKey = normalizeRecoveryKey(recoveryKey);
  const secretStorageKey = Buffer.from(decodeRecoveryKey(normalizedRecoveryKey));

  const defaultKey = await fetchAccountData(homeserver, accessToken, userId, SECRET_STORAGE_DEFAULT_KEY_EVENT);
  const secretEvent = await fetchAccountData(homeserver, accessToken, userId, SELF_SIGNING_SECRET_EVENT);
  if (!secretEvent?.encrypted || typeof secretEvent.encrypted !== "object") {
    throw new Error(`Account data ${SELF_SIGNING_SECRET_EVENT} is missing encrypted secret content.`);
  }

  const candidateIds = [];
  if (defaultKey?.key) {
    candidateIds.push(defaultKey.key);
  }
  for (const keyId of Object.keys(secretEvent.encrypted)) {
    if (!candidateIds.includes(keyId)) {
      candidateIds.push(keyId);
    }
  }

  if (candidateIds.length === 0) {
    throw new Error(`Account data ${SELF_SIGNING_SECRET_EVENT} does not reference any secret storage keys.`);
  }

  for (const keyId of candidateIds) {
    const encrypted = secretEvent.encrypted[keyId];
    if (!encrypted) {
      continue;
    }

    const keyInfo = await fetchAccountData(homeserver, accessToken, userId, `m.secret_storage.key.${keyId}`);
    try {
      assertSupportedSecretStorageKey(keyId, keyInfo);
    } catch {
      continue;
    }

    if (!verifySecretStorageKey(secretStorageKey, keyInfo)) {
      continue;
    }

    const encodedSeed = decryptSecretStorageItem({
      encrypted,
      secretStorageKey,
      secretName: SELF_SIGNING_SECRET_EVENT,
    }).trim();

    return decodeUnpaddedBase64(encodedSeed);
  }

  throw new Error("Recovery key does not match any secret storage key for the self-signing secret.");
}

export async function verifyActiveDevice({
  homeserver,
  userId,
  targetAccessToken,
  recoveryKey,
  timeoutMs = DEFAULT_TIMEOUT_MS,
}) {
  const whoamiTarget = await fetchWhoami(homeserver, targetAccessToken);
  if (whoamiTarget.user_id !== userId) {
    throw new Error(`Target token belongs to ${whoamiTarget.user_id}, expected ${userId}`);
  }
  if (!whoamiTarget.device_id) {
    throw new Error("Target token did not return a device_id from /whoami.");
  }

  return verifyDeviceWithAccessToken({
    homeserver,
    userId,
    authAccessToken: targetAccessToken,
    targetDeviceId: whoamiTarget.device_id,
    recoveryKey,
    timeoutMs,
    label: "Active OpenClaw",
  });
}

async function verifyDeviceWithAccessToken({
  homeserver,
  userId,
  authAccessToken,
  targetDeviceId,
  recoveryKey,
  timeoutMs = DEFAULT_TIMEOUT_MS,
  label = "Target",
}) {
  console.log(`[+] ${label} device id: ${targetDeviceId}`);

  const serverData = await fetchServerKeyQuery(homeserver, authAccessToken, userId, targetDeviceId);
  const device = serverData?.device_keys?.[userId]?.[targetDeviceId];
  if (!device) {
    throw new Error(`Device ${targetDeviceId} was not returned by /keys/query.`);
  }

  const beforeSigned = isDeviceCrossSignedOnServer(serverData, userId, targetDeviceId);
  console.log(`[*] Before: crossSignedOnServer=${beforeSigned}`);
  if (beforeSigned) {
    console.log("[+] Device is already cross-signed on the server.");
    return;
  }

  const selfSigning = serverData?.self_signing_keys?.[userId];
  if (!selfSigning?.keys || typeof selfSigning.keys !== "object") {
    throw new Error(`Self-signing public key for ${userId} was not returned by /keys/query.`);
  }

  const selfSigningKeyId = pickSigningKeyId(selfSigning.keys);
  const selfSigningPublicKey = selfSigning.keys[selfSigningKeyId];
  if (!selfSigningPublicKey) {
    throw new Error(`Self-signing key ${selfSigningKeyId} is missing its public key value.`);
  }

  const selfSigningSeed = await restoreSelfSigningSeed({
    homeserver,
    accessToken: authAccessToken,
    userId,
    recoveryKey,
  });

  const privateKey = createEd25519PrivateKey({
    seed: selfSigningSeed,
    publicKey: selfSigningPublicKey,
  });

  const signature = signObjectWithKey({
    object: device,
    privateKey,
  });
  const signedDevice = buildSignedDevicePayload(device, userId, selfSigningKeyId, signature);
  const uploadResult = await uploadDeviceSignature(
    homeserver,
    authAccessToken,
    userId,
    targetDeviceId,
    signedDevice,
  );

  if (uploadResult?.failures && Object.keys(uploadResult.failures).length > 0) {
    throw new Error(`Signature upload returned failures: ${JSON.stringify(uploadResult.failures)}`);
  }

  const confirmed = await waitForServerCrossSigning(
    homeserver,
    authAccessToken,
    userId,
    targetDeviceId,
    timeoutMs,
  );
  if (!confirmed) {
    throw new Error("Server confirmation failed (signature not observed within timeout).");
  }

  console.log("[*] After: crossSignedOnServer=true");
  console.log("[+] Server confirmation: self-signing signature is present.");
}

export async function verifyDeviceByPassword({
  homeserver,
  userId,
  password,
  targetDeviceId,
  recoveryKey,
  timeoutMs = DEFAULT_TIMEOUT_MS,
}) {
  const helperLogin = await loginWithPassword(homeserver, userId, password);
  const helperAccessToken = helperLogin?.access_token;
  if (!helperAccessToken) {
    throw new Error("Password login did not return an access token.");
  }

  const resolvedTargetDeviceId = targetDeviceId || helperLogin.device_id;
  try {
    return await verifyDeviceWithAccessToken({
      homeserver,
      userId,
      authAccessToken: helperAccessToken,
      targetDeviceId: resolvedTargetDeviceId,
      recoveryKey,
      timeoutMs,
      label: "Target",
    });
  } finally {
    try {
      await logoutSession(homeserver, helperAccessToken);
      console.log("[+] Temporary helper session logged out.");
    } catch (error) {
      console.warn(`[!] Failed to log out temporary helper session: ${error?.message || String(error)}`);
    }
  }
}


path: scripts/verify_matrix_device.mjs
content: #!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline/promises";

const DEFAULT_OPENCLAW_JSON = process.env.OPENCLAW_JSON || path.join(os.homedir(), ".openclaw", "openclaw.json");
const BOOLEAN_FLAGS = new Set(["help", "direct", "access-token", "password", "password-only"]);
const SHORT_FLAG_ALIASES = {
  "-p": "password",
  "-t": "access-token",
};

function parseArgs(argv) {
  const args = {};
  for (let i = 2; i < argv.length; i += 1) {
    const rawFlag = argv[i];
    const flag = SHORT_FLAG_ALIASES[rawFlag] ? `--${SHORT_FLAG_ALIASES[rawFlag]}` : rawFlag;
    if (!flag.startsWith("--")) {
      throw new Error(`Unexpected arg: ${flag}`);
    }
    const flagName = flag.slice(2);
    if (BOOLEAN_FLAGS.has(flagName)) {
      if (flagName === "password-only") {
        args.password = true;
      } else if (flagName === "direct") {
        args["access-token"] = true;
      } else {
        args[flagName] = true;
      }
      continue;
    }
    const value = argv[i + 1];
    if (!value || value.startsWith("--")) {
      throw new Error(`Missing value for ${flag}`);
    }
    args[flagName] = value;
    i += 1;
  }
  return args;
}

function usage() {
  console.log(`Usage:
npm run verify-account
npm run verify-direct
npm run verify-password
node scripts/verify_matrix_device.mjs [--homeserver https://matrix.example.org] [--username alice] [--user-id @alice:example.org] [--device-id ABCDEF1234] [--openclaw-json /path/to/openclaw.json] [--access-token|--direct|-t|--password|-p]

Prompts:
  default mode:
    username (OpenClaw account id, Matrix user id, or unique localpart)
  access-token mode (--access-token, --direct, or -t):
    matrix user ID
    access token (hidden input)
  password mode (--password or -p):
    matrix user ID
    password (hidden input)
    target device ID
  homeserver
  recovery key (hidden input)

Defaults:
  OPENCLAW_JSON=${DEFAULT_OPENCLAW_JSON}
`);
}

function readJson(p) {
  return JSON.parse(fs.readFileSync(p, "utf8"));
}

function extractLocalpart(userId) {
  if (typeof userId !== "string") return "";
  if (userId.startsWith("@")) {
    return userId.split(":")[0].slice(1);
  }
  return userId;
}

function buildResolvedAccount(accountId, account, openclawJsonPath) {
  if (!account?.accessToken || !account?.userId) {
    throw new Error(`Missing channels.matrix.accounts.${accountId}.accessToken/userId in ${openclawJsonPath}`);
  }
  return { accountId, userId: account.userId, targetAccessToken: account.accessToken };
}

function ensureTty(label) {
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
    throw new Error(`${label} requires an interactive terminal.`);
  }
}

async function promptText(label, defaultValue) {
  ensureTty(label);
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    const suffix = defaultValue ? ` [${defaultValue}]` : "";
    const answer = (await rl.question(`${label}${suffix}: `)).trim();
    return answer || defaultValue || "";
  } finally {
    rl.close();
  }
}

async function promptRequiredText(label, defaultValue) {
  while (true) {
    const value = await promptText(label, defaultValue);
    if (value) return value;
    console.error(`${label} cannot be empty.`);
  }
}

async function promptSecret(label) {
  ensureTty(label);
  return new Promise((resolve, reject) => {
    const stdin = process.stdin;
    const stdout = process.stdout;
    const previousRawMode = Boolean(stdin.isRaw);
    let value = "";

    const cleanup = () => {
      stdin.removeListener("data", onData);
      stdin.setRawMode(previousRawMode);
      stdout.write("\n");
    };

    const onData = (chunk) => {
      const text = chunk.toString("utf8");
      if (text.startsWith("\u001b")) {
        return;
      }

      for (const ch of text) {
        if (ch === "\r" || ch === "\n") {
          cleanup();
          resolve(value);
          return;
        }
        if (ch === "\u0003") {
          cleanup();
          reject(new Error("Prompt aborted by user."));
          return;
        }
        if (ch === "\u0008" || ch === "\u007f") {
          value = value.slice(0, -1);
          continue;
        }
        if (ch >= " " && ch !== "\u007f") {
          value += ch;
        }
      }
    };

    stdout.write(`${label}: `);
    stdin.resume();
    stdin.setRawMode(true);
    stdin.on("data", onData);
  });
}

async function promptRequiredSecret(label) {
  while (true) {
    const value = await promptSecret(label);
    if (value) return value;
    console.error(`${label} cannot be empty.`);
  }
}

function resolveOpenClawAccount(oc, username, openclawJsonPath) {
  const accounts = oc?.channels?.matrix?.accounts;
  if (!accounts || typeof accounts !== "object") {
    throw new Error(`Missing channels.matrix.accounts in ${openclawJsonPath}`);
  }

  const direct = accounts[username];
  if (direct) {
    return buildResolvedAccount(username, direct, openclawJsonPath);
  }

  const exactMatches = Object.entries(accounts).filter(([, account]) => account?.userId === username);
  if (exactMatches.length === 1) {
    const [accountId, account] = exactMatches[0];
    return buildResolvedAccount(accountId, account, openclawJsonPath);
  }

  if (exactMatches.length > 1) {
    const accountIds = exactMatches.map(([accountId]) => accountId).join(", ");
    throw new Error(`Username "${username}" matches multiple Matrix user ids in ${openclawJsonPath}: ${accountIds}. Use the OpenClaw account id.`);
  }

  const localpart = extractLocalpart(username);
  const localpartMatches = Object.entries(accounts).filter(([, account]) => extractLocalpart(account?.userId) === localpart);
  if (localpartMatches.length === 1) {
    const [accountId, account] = localpartMatches[0];
    return buildResolvedAccount(accountId, account, openclawJsonPath);
  }

  if (localpartMatches.length > 1) {
    const accountIds = localpartMatches.map(([accountId]) => accountId).join(", ");
    throw new Error(`Username "${username}" is ambiguous in ${openclawJsonPath}; matches: ${accountIds}. Use the OpenClaw account id or full Matrix user id.`);
  }

  throw new Error(`No OpenClaw Matrix account matched "${username}" in ${openclawJsonPath}`);
}

async function runVerify({ homeserver, userId, targetAccessToken, password, targetDeviceId, recoveryKey }) {
  const mod = await import("./verify_matrix_device_sdk.mjs");
  if (password) {
    return mod.verifyDeviceByPassword({ homeserver, userId, password, targetDeviceId, recoveryKey });
  }
  return mod.verifyActiveDevice({ homeserver, userId, targetAccessToken, recoveryKey });
}

async function main() {
  const args = parseArgs(process.argv);
  if (args.help) {
    usage();
    return;
  }

  const homeserver = await promptRequiredText("Homeserver URL", args.homeserver);
  let account;

  if (args["access-token"]) {
    const userId = await promptRequiredText("Matrix user ID", args["user-id"] || args.username);
    const targetAccessToken = await promptRequiredSecret("Access token (hidden)");
    account = { accountId: null, userId, targetAccessToken };
    console.log(`[+] Access-token mode target: ${account.userId}`);
  } else if (args.password) {
    const userId = await promptRequiredText("Matrix user ID", args["user-id"] || args.username);
    const password = await promptRequiredSecret("Password (hidden)");
    const targetDeviceId = await promptRequiredText("Target device ID", args["device-id"]);
    account = { accountId: null, userId, password, targetDeviceId };
    console.log(`[+] Password mode target: ${account.userId} / ${account.targetDeviceId}`);
  } else {
    const openclawJsonPath = args["openclaw-json"] || DEFAULT_OPENCLAW_JSON;
    if (!fs.existsSync(openclawJsonPath)) {
      throw new Error(`OpenClaw config not found at ${openclawJsonPath}. Use --access-token or --password to test without openclaw.json.`);
    }
    const oc = readJson(openclawJsonPath);
    const username = await promptRequiredText("Username", args.username);
    account = resolveOpenClawAccount(oc, username, openclawJsonPath);
    console.log(`[+] OpenClaw account: ${account.accountId} (${account.userId})`);
  }

  const recoveryKey = await promptRequiredSecret("Recovery key (hidden)");

  await runVerify({
    homeserver,
    userId: account.userId,
    targetAccessToken: account.targetAccessToken,
    [REDACTED_SECRET],
    targetDeviceId: account.targetDeviceId,
    recoveryKey,
  });
}

main().catch((e) => {
  console.error(e?.stack || String(e));
  process.exit(1);
});


path: scripts/matrix_sdk_helpers.mjs
content: export function normalizeRecoveryKey(value) {
  return value.replace(/[^A-Za-z0-9]/g, "");
}

export function pickSigningKeyId(keys) {
  const keyIds = Object.keys(keys || {});
  if (keyIds.length === 0) {
    throw new Error("No signing keys found.");
  }
  return keyIds[0];
}

export function isDeviceCrossSignedOnServer(serverData, userId, deviceId) {
  const selfSigning = serverData?.self_signing_keys?.[userId];
  const device = serverData?.device_keys?.[userId]?.[deviceId];
  if (!selfSigning || !device) {
    return false;
  }

  const selfSigningKeyId = pickSigningKeyId(selfSigning.keys);
  const signatures = device.signatures?.[userId] || {};
  return Boolean(signatures[selfSigningKeyId]);
}


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)