VYPR
High severityNVD Advisory· Published Mar 19, 2026· Updated Mar 20, 2026

OpenClaw < 2026.2.26 - Node Reconnect Metadata Spoofing via Unsigned Platform Fields

CVE-2026-32014

Description

OpenClaw versions prior to 2026.2.26 contain a metadata spoofing vulnerability where reconnect platform and deviceFamily fields are accepted from the client without being bound into the device-auth signature. An attacker with a paired node identity on the trusted network can spoof reconnect metadata to bypass platform-based node command policies and gain access to restricted commands.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.262026.2.26

Affected products

1

Patches

1
7d8aeaaf06e2

fix(gateway): pin paired reconnect metadata for node policy

https://github.com/openclaw/openclawPeter SteinbergerFeb 26, 2026via ghsa
13 files changed · +282 39
  • CHANGELOG.md+1 0 modified
    @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
     - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth.
     - Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
     - Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
    +- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
     - Onboarding/Gateway: seed default Control UI `allowedOrigins` for non-loopback binds during onboarding (`localhost`/`127.0.0.1` plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.
     - CLI/Gateway status: force local `gateway status` probe host to `127.0.0.1` for `bind=lan` so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.
     - Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.
    
  • docs/concepts/architecture.md+3 0 modified
    @@ -98,6 +98,9 @@ sequenceDiagram
     - **Local** connects (loopback or the gateway host’s own tailnet address) can be
       auto‑approved to keep same‑host UX smooth.
     - All connects must sign the `connect.challenge` nonce.
    +- Signature payload `v3` also binds `platform` + `deviceFamily`; the gateway
    +  pins paired metadata on reconnect and requires repair pairing for metadata
    +  changes.
     - **Non‑local** connects still require explicit approval.
     - Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
       remote.
    
  • docs/gateway/protocol.md+4 0 modified
    @@ -215,6 +215,10 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
       Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
       is enabled for break-glass use.
     - All connections must sign the server-provided `connect.challenge` nonce.
    +- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
    +  in addition to device/client/role/scopes/token/nonce fields.
    +- Legacy `v2` signatures remain accepted for compatibility, but paired-device
    +  metadata pinning still controls command policy on reconnect.
     
     ## TLS + pinning
     
    
  • src/gateway/client.ts+8 3 modified
    @@ -21,7 +21,7 @@ import {
       type GatewayClientMode,
       type GatewayClientName,
     } from "../utils/message-channel.js";
    -import { buildDeviceAuthPayload } from "./device-auth.js";
    +import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
     import { isSecureWebSocketUrl } from "./net.js";
     import {
       type ConnectParams,
    @@ -52,6 +52,7 @@ export type GatewayClientOptions = {
       clientDisplayName?: string;
       clientVersion?: string;
       platform?: string;
    +  deviceFamily?: string;
       mode?: GatewayClientMode;
       role?: string;
       scopes?: string[];
    @@ -265,11 +266,12 @@ export class GatewayClient {
             : undefined;
         const signedAtMs = Date.now();
         const scopes = this.opts.scopes ?? ["operator.admin"];
    +    const platform = this.opts.platform ?? process.platform;
         const device = (() => {
           if (!this.opts.deviceIdentity) {
             return undefined;
           }
    -      const payload = buildDeviceAuthPayload({
    +      const payload = buildDeviceAuthPayloadV3({
             deviceId: this.opts.deviceIdentity.deviceId,
             clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
             clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
    @@ -278,6 +280,8 @@ export class GatewayClient {
             signedAtMs,
             token: authToken ?? null,
             nonce,
    +        platform,
    +        deviceFamily: this.opts.deviceFamily,
           });
           const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
           return {
    @@ -295,7 +299,8 @@ export class GatewayClient {
             id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
             displayName: this.opts.clientDisplayName,
             version: this.opts.clientVersion ?? "dev",
    -        platform: this.opts.platform ?? process.platform,
    +        platform,
    +        deviceFamily: this.opts.deviceFamily,
             mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
             instanceId: this.opts.instanceId,
           },
    
  • src/gateway/device-auth.ts+32 0 modified
    @@ -9,6 +9,18 @@ export type DeviceAuthPayloadParams = {
       nonce: string;
     };
     
    +export type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
    +  platform?: string | null;
    +  deviceFamily?: string | null;
    +};
    +
    +function normalizeMetadataField(value?: string | null): string {
    +  if (typeof value !== "string") {
    +    return "";
    +  }
    +  return value.trim().toLowerCase();
    +}
    +
     export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
       const scopes = params.scopes.join(",");
       const token = params.token ?? "";
    @@ -24,3 +36,23 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
         params.nonce,
       ].join("|");
     }
    +
    +export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
    +  const scopes = params.scopes.join(",");
    +  const token = params.token ?? "";
    +  const platform = normalizeMetadataField(params.platform);
    +  const deviceFamily = normalizeMetadataField(params.deviceFamily);
    +  return [
    +    "v3",
    +    params.deviceId,
    +    params.clientId,
    +    params.clientMode,
    +    params.role,
    +    scopes,
    +    String(params.signedAtMs),
    +    token,
    +    params.nonce,
    +    platform,
    +    deviceFamily,
    +  ].join("|");
    +}
    
  • src/gateway/protocol/schema/devices.ts+1 0 modified
    @@ -42,6 +42,7 @@ export const DevicePairRequestedEventSchema = Type.Object(
         publicKey: NonEmptyString,
         displayName: Type.Optional(NonEmptyString),
         platform: Type.Optional(NonEmptyString),
    +    deviceFamily: Type.Optional(NonEmptyString),
         clientId: Type.Optional(NonEmptyString),
         clientMode: Type.Optional(NonEmptyString),
         role: Type.Optional(NonEmptyString),
    
  • src/gateway/server.auth.test.ts+42 17 modified
    @@ -1,3 +1,5 @@
    +import os from "node:os";
    +import path from "node:path";
     import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
     import { WebSocket } from "ws";
     import { withEnvAsync } from "../test-utils/env.js";
    @@ -267,19 +269,24 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
       } as any;
     
       const { server, ws, port, prevToken } = await startServerWithClient();
    +  const deviceIdentityPath = path.join(
    +    os.tmpdir(),
    +    `openclaw-auth-rate-limit-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
    +  );
       try {
    -    const initial = await connectReq(ws, { token: "secret" });
    +    const initial = await connectReq(ws, { token: "secret", deviceIdentityPath });
         if (!initial.ok) {
           await approvePendingPairingIfNeeded();
         }
     
    -    const identity = loadOrCreateDeviceIdentity();
    +    const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
         const paired = await getPairedDevice(identity.deviceId);
         const deviceToken = paired?.tokens?.operator?.token;
    +    expect(paired?.deviceId).toBe(identity.deviceId);
         expect(deviceToken).toBeDefined();
     
         ws.close();
    -    return { server, port, prevToken, deviceToken: String(deviceToken ?? "") };
    +    return { server, port, prevToken, deviceToken: String(deviceToken ?? ""), deviceIdentityPath };
       } catch (err) {
         ws.close();
         await server.close();
    @@ -291,20 +298,31 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
     async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise<{
       identity: { deviceId: string };
       deviceToken: string;
    +  deviceIdentityPath: string;
     }> {
       const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
       const { getPairedDevice } = await import("../infra/device-pairing.js");
     
    -  const res = await connectReq(ws, { token: "secret" });
    +  const deviceIdentityPath = path.join(
    +    os.tmpdir(),
    +    `openclaw-auth-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
    +  );
    +
    +  const res = await connectReq(ws, { token: "secret", deviceIdentityPath });
       if (!res.ok) {
         await approvePendingPairingIfNeeded();
       }
     
    -  const identity = loadOrCreateDeviceIdentity();
    +  const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
       const paired = await getPairedDevice(identity.deviceId);
       const deviceToken = paired?.tokens?.operator?.token;
    +  expect(paired?.deviceId).toBe(identity.deviceId);
       expect(deviceToken).toBeDefined();
    -  return { identity: { deviceId: identity.deviceId }, deviceToken: String(deviceToken ?? "") };
    +  return {
    +    identity: { deviceId: identity.deviceId },
    +    deviceToken: String(deviceToken ?? ""),
    +    deviceIdentityPath,
    +  };
     }
     
     describe("gateway server auth/connect", () => {
    @@ -328,7 +346,7 @@ describe("gateway server auth/connect", () => {
           try {
             const ws = await openWs(port);
             const handshakeTimeoutMs = getHandshakeTimeoutMs();
    -        const closed = await waitForWsClose(ws, handshakeTimeoutMs + 60);
    +        const closed = await waitForWsClose(ws, handshakeTimeoutMs + 500);
             expect(closed).toBe(true);
           } finally {
             if (prevHandshakeTimeout === undefined) {
    @@ -1042,7 +1060,7 @@ describe("gateway server auth/connect", () => {
     
       test("device token auth matrix", async () => {
         const { server, ws, port, prevToken } = await startServerWithClient("secret");
    -    const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
    +    const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
         ws.close();
     
         const scenarios: Array<{
    @@ -1109,7 +1127,10 @@ describe("gateway server auth/connect", () => {
           for (const scenario of scenarios) {
             const ws2 = await openWs(port);
             try {
    -          const res = await connectReq(ws2, scenario.opts);
    +          const res = await connectReq(ws2, {
    +            ...scenario.opts,
    +            deviceIdentityPath,
    +          });
               scenario.assert(res);
             } finally {
               ws2.close();
    @@ -1122,7 +1143,7 @@ describe("gateway server auth/connect", () => {
       });
     
       test("keeps shared-secret lockout separate from device-token auth", async () => {
    -    const { server, port, prevToken, deviceToken } =
    +    const { server, port, prevToken, deviceToken, deviceIdentityPath } =
           await startRateLimitedTokenServerWithPairedDeviceToken();
         try {
           const wsBadShared = await openWs(port);
    @@ -1137,7 +1158,7 @@ describe("gateway server auth/connect", () => {
           wsSharedLocked.close();
     
           const wsDevice = await openWs(port);
    -      const deviceOk = await connectReq(wsDevice, { token: deviceToken });
    +      const deviceOk = await connectReq(wsDevice, { token: deviceToken, deviceIdentityPath });
           expect(deviceOk.ok).toBe(true);
           wsDevice.close();
         } finally {
    @@ -1147,16 +1168,16 @@ describe("gateway server auth/connect", () => {
       });
     
       test("keeps device-token lockout separate from shared-secret auth", async () => {
    -    const { server, port, prevToken, deviceToken } =
    +    const { server, port, prevToken, deviceToken, deviceIdentityPath } =
           await startRateLimitedTokenServerWithPairedDeviceToken();
         try {
           const wsBadDevice = await openWs(port);
    -      const badDevice = await connectReq(wsBadDevice, { token: "wrong" });
    +      const badDevice = await connectReq(wsBadDevice, { token: "wrong", deviceIdentityPath });
           expect(badDevice.ok).toBe(false);
           wsBadDevice.close();
     
           const wsDeviceLocked = await openWs(port);
    -      const deviceLocked = await connectReq(wsDeviceLocked, { token: "wrong" });
    +      const deviceLocked = await connectReq(wsDeviceLocked, { token: "wrong", deviceIdentityPath });
           expect(deviceLocked.ok).toBe(false);
           expect(deviceLocked.error?.message ?? "").toContain("retry later");
           wsDeviceLocked.close();
    @@ -1167,7 +1188,10 @@ describe("gateway server auth/connect", () => {
           wsShared.close();
     
           const wsDeviceReal = await openWs(port);
    -      const deviceStillLocked = await connectReq(wsDeviceReal, { token: deviceToken });
    +      const deviceStillLocked = await connectReq(wsDeviceReal, {
    +        token: deviceToken,
    +        deviceIdentityPath,
    +      });
           expect(deviceStillLocked.ok).toBe(false);
           expect(deviceStillLocked.error?.message ?? "").toContain("retry later");
           wsDeviceReal.close();
    @@ -1686,14 +1710,15 @@ describe("gateway server auth/connect", () => {
       test("rejects revoked device token", async () => {
         const { revokeDeviceToken } = await import("../infra/device-pairing.js");
         const { server, ws, port, prevToken } = await startServerWithClient("secret");
    -    const { identity, deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
    +    const { identity, deviceToken, deviceIdentityPath } =
    +      await ensurePairedDeviceTokenForCurrentIdentity(ws);
     
         await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
     
         ws.close();
     
         const ws2 = await openWs(port);
    -    const res2 = await connectReq(ws2, { token: deviceToken });
    +    const res2 = await connectReq(ws2, { token: deviceToken, deviceIdentityPath });
         expect(res2.ok).toBe(false);
     
         ws2.close();
    
  • src/gateway/server.node-invoke-approval-bypass.test.ts+2 1 modified
    @@ -202,6 +202,7 @@ describe("node.invoke approval bypass", () => {
           readyResolve = resolve;
         });
     
    +    const resolvedDeviceIdentity = deviceIdentity ?? createDeviceIdentity();
         const client = new GatewayClient({
           url: `ws://127.0.0.1:${port}`,
           // Keep challenge timeout realistic in tests; 0 maps to a 250ms timeout and can
    @@ -215,7 +216,7 @@ describe("node.invoke approval bypass", () => {
           mode: GATEWAY_CLIENT_MODES.NODE,
           scopes: [],
           commands: ["system.run"],
    -      deviceIdentity,
    +      deviceIdentity: resolvedDeviceIdentity,
           onHelloOk: () => readyResolve?.(),
           onEvent: (evt) => {
             if (evt.event !== "node.invoke.request") {
    
  • src/gateway/server.roles-allowlist-update.test.ts+54 1 modified
    @@ -4,6 +4,7 @@ import path from "node:path";
     import { describe, expect, test, vi } from "vitest";
     import { WebSocket } from "ws";
     import { CONFIG_PATH } from "../config/config.js";
    +import type { DeviceIdentity } from "../infra/device-identity.js";
     import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
     import type { GatewayClient } from "./client.js";
     
    @@ -36,6 +37,9 @@ installConnectedControlUiServerSuite((started) => {
     const connectNodeClient = async (params: {
       port: number;
       commands: string[];
    +  platform?: string;
    +  deviceFamily?: string;
    +  deviceIdentity?: DeviceIdentity;
       instanceId?: string;
       displayName?: string;
       onEvent?: (evt: { event?: string; payload?: unknown }) => void;
    @@ -51,11 +55,13 @@ const connectNodeClient = async (params: {
         clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
         clientVersion: "1.0.0",
         clientDisplayName: params.displayName,
    -    platform: "ios",
    +    platform: params.platform ?? "ios",
    +    deviceFamily: params.deviceFamily,
         mode: GATEWAY_CLIENT_MODES.NODE,
         instanceId: params.instanceId,
         scopes: [],
         commands: params.commands,
    +    deviceIdentity: params.deviceIdentity,
         onEvent: params.onEvent,
         timeoutMessage: "timeout waiting for node to connect",
       });
    @@ -313,4 +319,51 @@ describe("gateway node command allowlist", () => {
           allowedClient?.stop();
         }
       });
    +
    +  test("rejects reconnect metadata spoof for paired node devices", async () => {
    +    const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
    +    const deviceIdentityPath = path.join(
    +      os.tmpdir(),
    +      `openclaw-spoof-test-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
    +    );
    +    const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath);
    +
    +    let iosClient: GatewayClient | undefined;
    +    try {
    +      iosClient = await connectNodeClientWithPairing({
    +        port,
    +        commands: ["canvas.snapshot"],
    +        platform: "ios",
    +        deviceFamily: "iPhone",
    +        instanceId: "node-platform-pin",
    +        displayName: "node-platform-pin",
    +        deviceIdentity,
    +      });
    +      iosClient.stop();
    +      await expect
    +        .poll(async () => {
    +          const listRes = await rpcReq<{ nodes?: Array<{ connected?: boolean }> }>(
    +            ws,
    +            "node.list",
    +            {},
    +          );
    +          return (listRes.payload?.nodes ?? []).filter((node) => node.connected).length;
    +        }, FAST_WAIT_OPTS)
    +        .toBe(0);
    +
    +      await expect(
    +        connectNodeClient({
    +          port,
    +          commands: ["system.run"],
    +          platform: "linux",
    +          deviceFamily: "linux",
    +          instanceId: "node-platform-pin",
    +          displayName: "node-platform-pin",
    +          deviceIdentity,
    +        }),
    +      ).rejects.toThrow(/pairing required/i);
    +    } finally {
    +      iosClient?.stop();
    +    }
    +  });
     });
    
  • src/gateway/server/ws-connection/message-handler.ts+74 8 modified
    @@ -32,7 +32,7 @@ import {
       CANVAS_CAPABILITY_TTL_MS,
       mintCanvasCapabilityToken,
     } from "../../canvas-capability.js";
    -import { buildDeviceAuthPayload } from "../../device-auth.js";
    +import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
     import {
       isLocalishHost,
       isLoopbackAddress,
    @@ -122,7 +122,7 @@ function shouldAllowSilentLocalPairing(params: {
       hasBrowserOriginHeader: boolean;
       isControlUi: boolean;
       isWebchat: boolean;
    -  reason: "not-paired" | "role-upgrade" | "scope-upgrade";
    +  reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
     }): boolean {
       return (
         params.isLocalClient &&
    @@ -131,6 +131,10 @@ function shouldAllowSilentLocalPairing(params: {
       );
     }
     
    +function normalizeClientMetadataForComparison(value: string | undefined): string {
    +  return typeof value === "string" ? value.trim().toLowerCase() : "";
    +}
    +
     export function attachGatewayWsMessageHandler(params: {
       socket: WebSocket;
       upgradeReq: IncomingMessage;
    @@ -416,6 +420,7 @@ export function attachGatewayWsMessageHandler(params: {
     
             const deviceRaw = connectParams.device;
             let devicePublicKey: string | null = null;
    +        let deviceAuthPayloadVersion: "v2" | "v3" | null = null;
             const hasTokenAuth = Boolean(connectParams.auth?.token);
             const hasPasswordAuth = Boolean(connectParams.auth?.password);
             const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
    @@ -583,7 +588,19 @@ export function attachGatewayWsMessageHandler(params: {
                 rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
                 return;
               }
    -          const payload = buildDeviceAuthPayload({
    +          const payloadV3 = buildDeviceAuthPayloadV3({
    +            deviceId: device.id,
    +            clientId: connectParams.client.id,
    +            clientMode: connectParams.client.mode,
    +            role,
    +            scopes,
    +            signedAtMs: signedAt,
    +            token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null,
    +            nonce: providedNonce,
    +            platform: connectParams.client.platform,
    +            deviceFamily: connectParams.client.deviceFamily,
    +          });
    +          const payloadV2 = buildDeviceAuthPayload({
                 deviceId: device.id,
                 clientId: connectParams.client.id,
                 clientMode: connectParams.client.mode,
    @@ -595,11 +612,18 @@ export function attachGatewayWsMessageHandler(params: {
               });
               const rejectDeviceSignatureInvalid = () =>
                 rejectDeviceAuthInvalid("device-signature", "device signature invalid");
    -          const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature);
    -          if (!signatureOk) {
    +          const signatureOkV3 = verifyDeviceSignature(
    +            device.publicKey,
    +            payloadV3,
    +            device.signature,
    +          );
    +          const signatureOkV2 =
    +            !signatureOkV3 && verifyDeviceSignature(device.publicKey, payloadV2, device.signature);
    +          if (!signatureOkV3 && !signatureOkV2) {
                 rejectDeviceSignatureInvalid();
                 return;
               }
    +          deviceAuthPayloadVersion = signatureOkV3 ? "v3" : "v2";
               devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
               if (!devicePublicKey) {
                 rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
    @@ -668,17 +692,26 @@ export function attachGatewayWsMessageHandler(params: {
                   `security audit: device access upgrade requested reason=${reason} device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} roleFrom=${formatAuditList(currentRoles)} roleTo=${role} scopesFrom=${formatAuditList(currentScopes)} scopesTo=${formatAuditList(scopes)} client=${connectParams.client.id} conn=${connId}`,
                 );
               };
    -          const clientAccessMetadata = {
    +          const clientPairingMetadata = {
                 displayName: connectParams.client.displayName,
                 platform: connectParams.client.platform,
    +            deviceFamily: connectParams.client.deviceFamily,
    +            clientId: connectParams.client.id,
    +            clientMode: connectParams.client.mode,
    +            role,
    +            scopes,
    +            remoteIp: reportedClientIp,
    +          };
    +          const clientAccessMetadata = {
    +            displayName: connectParams.client.displayName,
                 clientId: connectParams.client.id,
                 clientMode: connectParams.client.mode,
                 role,
                 scopes,
                 remoteIp: reportedClientIp,
               };
               const requirePairing = async (
    -            reason: "not-paired" | "role-upgrade" | "scope-upgrade",
    +            reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
               ) => {
                 const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
                   isLocalClient,
    @@ -690,7 +723,7 @@ export function attachGatewayWsMessageHandler(params: {
                 const pairing = await requestDevicePairing({
                   deviceId: device.id,
                   publicKey: devicePublicKey,
    -              ...clientAccessMetadata,
    +              ...clientPairingMetadata,
                   silent: allowSilentLocalPairing,
                 });
                 const context = buildRequestContext();
    @@ -747,6 +780,37 @@ export function attachGatewayWsMessageHandler(params: {
                   return;
                 }
               } else {
    +            const claimedPlatform = connectParams.client.platform;
    +            const pairedPlatform = paired.platform;
    +            const claimedDeviceFamily = connectParams.client.deviceFamily;
    +            const pairedDeviceFamily = paired.deviceFamily;
    +            const hasPinnedPlatform = normalizeClientMetadataForComparison(pairedPlatform) !== "";
    +            const hasPinnedDeviceFamily =
    +              normalizeClientMetadataForComparison(pairedDeviceFamily) !== "";
    +            const platformMismatch =
    +              hasPinnedPlatform &&
    +              normalizeClientMetadataForComparison(claimedPlatform) !==
    +                normalizeClientMetadataForComparison(pairedPlatform);
    +            const deviceFamilyMismatch =
    +              hasPinnedDeviceFamily &&
    +              normalizeClientMetadataForComparison(claimedDeviceFamily) !==
    +                normalizeClientMetadataForComparison(pairedDeviceFamily);
    +            if (platformMismatch || deviceFamilyMismatch) {
    +              logGateway.warn(
    +                `security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
    +              );
    +              const ok = await requirePairing("metadata-upgrade");
    +              if (!ok) {
    +                return;
    +              }
    +            } else {
    +              if (hasPinnedPlatform && pairedPlatform) {
    +                connectParams.client.platform = pairedPlatform;
    +              }
    +              if (hasPinnedDeviceFamily) {
    +                connectParams.client.deviceFamily = pairedDeviceFamily;
    +              }
    +            }
                 const pairedRoles = Array.isArray(paired.roles)
                   ? paired.roles
                   : paired.role
    @@ -795,6 +859,8 @@ export function attachGatewayWsMessageHandler(params: {
                   }
                 }
     
    +            // Metadata pinning is approval-bound. Reconnects can update access metadata,
    +            // but platform/device family must stay on the approved pairing record.
                 await updatePairedDeviceMetadata(device.id, clientAccessMetadata);
               }
             }
    
  • src/gateway/test-helpers.e2e.ts+26 6 modified
    @@ -1,4 +1,6 @@
     import { writeFile } from "node:fs/promises";
    +import os from "node:os";
    +import path from "node:path";
     import { WebSocket } from "ws";
     import {
       type DeviceIdentity,
    @@ -15,7 +17,7 @@ import {
       type GatewayClientName,
     } from "../utils/message-channel.js";
     import { GatewayClient } from "./client.js";
    -import { buildDeviceAuthPayload } from "./device-auth.js";
    +import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
     import { PROTOCOL_VERSION } from "./protocol/index.js";
     import { startGatewayServer } from "./server.js";
     
    @@ -31,6 +33,7 @@ export async function connectGatewayClient(params: {
       clientVersion?: string;
       mode?: GatewayClientMode;
       platform?: string;
    +  deviceFamily?: string;
       role?: "operator" | "node";
       scopes?: string[];
       caps?: string[];
    @@ -42,6 +45,20 @@ export async function connectGatewayClient(params: {
       timeoutMs?: number;
       timeoutMessage?: string;
     }) {
    +  const role = params.role ?? "operator";
    +  const platform = params.platform ?? process.platform;
    +  const identityRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
    +  const deviceIdentity =
    +    params.deviceIdentity ??
    +    loadOrCreateDeviceIdentity(
    +      (() => {
    +        const safe =
    +          `${params.clientName ?? GATEWAY_CLIENT_NAMES.TEST}-${params.mode ?? GATEWAY_CLIENT_MODES.TEST}-${platform}-${params.deviceFamily ?? "none"}-${role}`
    +            .replace(/[^a-zA-Z0-9._-]+/g, "_")
    +            .toLowerCase();
    +        return path.join(identityRoot, "test-device-identities", `${safe}.json`);
    +      })(),
    +    );
       return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
         let settled = false;
         const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
    @@ -63,14 +80,15 @@ export async function connectGatewayClient(params: {
           clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST,
           clientDisplayName: params.clientDisplayName ?? "vitest",
           clientVersion: params.clientVersion ?? "dev",
    -      platform: params.platform,
    +      platform,
    +      deviceFamily: params.deviceFamily,
           mode: params.mode ?? GATEWAY_CLIENT_MODES.TEST,
    -      role: params.role,
    +      role,
           scopes: params.scopes,
           caps: params.caps,
           commands: params.commands,
           instanceId: params.instanceId,
    -      deviceIdentity: params.deviceIdentity,
    +      deviceIdentity,
           onEvent: params.onEvent,
           onHelloOk: () => stop(undefined, client),
           onConnectError: (err) => stop(err),
    @@ -127,7 +145,8 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
       const connectNonce = await connectNoncePromise;
       const identity = loadOrCreateDeviceIdentity();
       const signedAtMs = Date.now();
    -  const payload = buildDeviceAuthPayload({
    +  const platform = process.platform;
    +  const payload = buildDeviceAuthPayloadV3({
         deviceId: identity.deviceId,
         clientId: GATEWAY_CLIENT_NAMES.TEST,
         clientMode: GATEWAY_CLIENT_MODES.TEST,
    @@ -136,6 +155,7 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
         signedAtMs,
         token: params.token ?? null,
         nonce: connectNonce,
    +    platform,
       });
       const device = {
         id: identity.deviceId,
    @@ -156,7 +176,7 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
               id: GATEWAY_CLIENT_NAMES.TEST,
               displayName: "vitest",
               version: "dev",
    -          platform: process.platform,
    +          platform,
               mode: GATEWAY_CLIENT_MODES.TEST,
             },
             caps: [],
    
  • src/gateway/test-helpers.server.ts+30 3 modified
    @@ -18,7 +18,7 @@ import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key
     import { captureEnv } from "../test-utils/env.js";
     import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
     import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
    -import { buildDeviceAuthPayload } from "./device-auth.js";
    +import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
     import { PROTOCOL_VERSION } from "./protocol/index.js";
     import type { GatewayServerOptions } from "./server.js";
     import {
    @@ -421,6 +421,21 @@ type ConnectResponse = {
       error?: { message?: string; code?: string; details?: unknown };
     };
     
    +function resolveDefaultTestDeviceIdentityPath(params: {
    +  clientId: string;
    +  clientMode: string;
    +  platform: string;
    +  deviceFamily?: string;
    +  role: string;
    +}) {
    +  const safe =
    +    `${params.clientId}-${params.clientMode}-${params.platform}-${params.deviceFamily ?? "none"}-${params.role}`
    +      .replace(/[^a-zA-Z0-9._-]+/g, "_")
    +      .toLowerCase();
    +  const suiteRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
    +  return path.join(suiteRoot, "test-device-identities", `${safe}.json`);
    +}
    +
     export async function readConnectChallengeNonce(
       ws: WebSocket,
       timeoutMs = 2_000,
    @@ -478,6 +493,7 @@ export async function connectReq(
           signedAt: number;
           nonce?: string;
         } | null;
    +    deviceIdentityPath?: string;
         skipConnectChallengeNonce?: boolean;
         timeoutMs?: number;
       },
    @@ -527,9 +543,18 @@ export async function connectReq(
         if (!connectChallengeNonce) {
           throw new Error("missing connect.challenge nonce");
         }
    -    const identity = loadOrCreateDeviceIdentity();
    +    const identityPath =
    +      opts?.deviceIdentityPath ??
    +      resolveDefaultTestDeviceIdentityPath({
    +        clientId: client.id,
    +        clientMode: client.mode,
    +        platform: client.platform,
    +        deviceFamily: client.deviceFamily,
    +        role,
    +      });
    +    const identity = loadOrCreateDeviceIdentity(identityPath);
         const signedAtMs = Date.now();
    -    const payload = buildDeviceAuthPayload({
    +    const payload = buildDeviceAuthPayloadV3({
           deviceId: identity.deviceId,
           clientId: client.id,
           clientMode: client.mode,
    @@ -538,6 +563,8 @@ export async function connectReq(
           signedAtMs,
           token: authTokenForSignature ?? null,
           nonce: connectChallengeNonce,
    +      platform: client.platform,
    +      deviceFamily: client.deviceFamily,
         });
         return {
           id: identity.deviceId,
    
  • src/infra/device-pairing.ts+5 0 modified
    @@ -17,6 +17,7 @@ export type DevicePairingPendingRequest = {
       publicKey: string;
       displayName?: string;
       platform?: string;
    +  deviceFamily?: string;
       clientId?: string;
       clientMode?: string;
       role?: string;
    @@ -52,6 +53,7 @@ export type PairedDevice = {
       publicKey: string;
       displayName?: string;
       platform?: string;
    +  deviceFamily?: string;
       clientId?: string;
       clientMode?: string;
       role?: string;
    @@ -165,6 +167,7 @@ function mergePendingDevicePairingRequest(
         ...existing,
         displayName: incoming.displayName ?? existing.displayName,
         platform: incoming.platform ?? existing.platform,
    +    deviceFamily: incoming.deviceFamily ?? existing.deviceFamily,
         clientId: incoming.clientId ?? existing.clientId,
         clientMode: incoming.clientMode ?? existing.clientMode,
         role: existingRole ?? incomingRole ?? undefined,
    @@ -297,6 +300,7 @@ export async function requestDevicePairing(
           publicKey: req.publicKey,
           displayName: req.displayName,
           platform: req.platform,
    +      deviceFamily: req.deviceFamily,
           clientId: req.clientId,
           clientMode: req.clientMode,
           role: req.role,
    @@ -360,6 +364,7 @@ export async function approveDevicePairing(
           publicKey: pending.publicKey,
           displayName: pending.displayName,
           platform: pending.platform,
    +      deviceFamily: pending.deviceFamily,
           clientId: pending.clientId,
           clientMode: pending.clientMode,
           role: pending.role,
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.