diff --git a/CHANGELOG.md b/CHANGELOG.md index 411848441..73e2a3173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 75addf3fa..a36c93313 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -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. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 85a69aca6..e80263ab4 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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 diff --git a/src/gateway/client.ts b/src/gateway/client.ts index c95bbbcc3..b9e7dd248 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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, }, diff --git a/src/gateway/device-auth.ts b/src/gateway/device-auth.ts index 2e5b9e6fa..9172da22a 100644 --- a/src/gateway/device-auth.ts +++ b/src/gateway/device-auth.ts @@ -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("|"); +} diff --git a/src/gateway/protocol/schema/devices.ts b/src/gateway/protocol/schema/devices.ts index 752347a09..813390775 100644 --- a/src/gateway/protocol/schema/devices.ts +++ b/src/gateway/protocol/schema/devices.ts @@ -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), diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index a0cbf5d9c..71b435d13 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -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(); diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 7cc84b5b8..26dc29378 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -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") { diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index fceb71a0b..8b78ced9b 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -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(); + } + }); }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 261e9f69d..d78066e85 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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,9 +692,18 @@ 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, @@ -678,7 +711,7 @@ export function attachGatewayWsMessageHandler(params: { 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 ?? ""} pinnedPlatform=${pairedPlatform ?? ""} claimedDeviceFamily=${claimedDeviceFamily ?? ""} pinnedDeviceFamily=${pairedDeviceFamily ?? ""} 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); } } diff --git a/src/gateway/test-helpers.e2e.ts b/src/gateway/test-helpers.e2e.ts index e267921c0..34afd6614 100644 --- a/src/gateway/test-helpers.e2e.ts +++ b/src/gateway/test-helpers.e2e.ts @@ -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>((resolve, reject) => { let settled = false; const stop = (err?: Error, client?: InstanceType) => { @@ -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: [], diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e923a3bbb..d6afcc82d 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -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, diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 1d18efed1..591a9d708 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -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,