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

This commit is contained in:
Peter Steinberger
2026-02-26 14:10:00 +01:00
parent cf311978ea
commit 7d8aeaaf06
13 changed files with 282 additions and 39 deletions

View File

@@ -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/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/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/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. - 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. - 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. - 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.

View File

@@ -98,6 +98,9 @@ sequenceDiagram
- **Local** connects (loopback or the gateway hosts own tailnet address) can be - **Local** connects (loopback or the gateway hosts own tailnet address) can be
autoapproved to keep samehost UX smooth. autoapproved to keep samehost UX smooth.
- All connects must sign the `connect.challenge` nonce. - 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.
- **Nonlocal** connects still require explicit approval. - **Nonlocal** connects still require explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or - Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote. remote.

View File

@@ -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` Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use. is enabled for break-glass use.
- All connections must sign the server-provided `connect.challenge` nonce. - 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 ## TLS + pinning

View File

@@ -21,7 +21,7 @@ import {
type GatewayClientMode, type GatewayClientMode,
type GatewayClientName, type GatewayClientName,
} from "../utils/message-channel.js"; } from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js"; import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { isSecureWebSocketUrl } from "./net.js"; import { isSecureWebSocketUrl } from "./net.js";
import { import {
type ConnectParams, type ConnectParams,
@@ -52,6 +52,7 @@ export type GatewayClientOptions = {
clientDisplayName?: string; clientDisplayName?: string;
clientVersion?: string; clientVersion?: string;
platform?: string; platform?: string;
deviceFamily?: string;
mode?: GatewayClientMode; mode?: GatewayClientMode;
role?: string; role?: string;
scopes?: string[]; scopes?: string[];
@@ -265,11 +266,12 @@ export class GatewayClient {
: undefined; : undefined;
const signedAtMs = Date.now(); const signedAtMs = Date.now();
const scopes = this.opts.scopes ?? ["operator.admin"]; const scopes = this.opts.scopes ?? ["operator.admin"];
const platform = this.opts.platform ?? process.platform;
const device = (() => { const device = (() => {
if (!this.opts.deviceIdentity) { if (!this.opts.deviceIdentity) {
return undefined; return undefined;
} }
const payload = buildDeviceAuthPayload({ const payload = buildDeviceAuthPayloadV3({
deviceId: this.opts.deviceIdentity.deviceId, deviceId: this.opts.deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
@@ -278,6 +280,8 @@ export class GatewayClient {
signedAtMs, signedAtMs,
token: authToken ?? null, token: authToken ?? null,
nonce, nonce,
platform,
deviceFamily: this.opts.deviceFamily,
}); });
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload); const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
return { return {
@@ -295,7 +299,8 @@ export class GatewayClient {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
displayName: this.opts.clientDisplayName, displayName: this.opts.clientDisplayName,
version: this.opts.clientVersion ?? "dev", version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? process.platform, platform,
deviceFamily: this.opts.deviceFamily,
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
instanceId: this.opts.instanceId, instanceId: this.opts.instanceId,
}, },

View File

@@ -9,6 +9,18 @@ export type DeviceAuthPayloadParams = {
nonce: string; 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 { export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const scopes = params.scopes.join(","); const scopes = params.scopes.join(",");
const token = params.token ?? ""; const token = params.token ?? "";
@@ -24,3 +36,23 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
params.nonce, params.nonce,
].join("|"); ].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("|");
}

View File

@@ -42,6 +42,7 @@ export const DevicePairRequestedEventSchema = Type.Object(
publicKey: NonEmptyString, publicKey: NonEmptyString,
displayName: Type.Optional(NonEmptyString), displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
clientId: Type.Optional(NonEmptyString), clientId: Type.Optional(NonEmptyString),
clientMode: Type.Optional(NonEmptyString), clientMode: Type.Optional(NonEmptyString),
role: Type.Optional(NonEmptyString), role: Type.Optional(NonEmptyString),

View File

@@ -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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { withEnvAsync } from "../test-utils/env.js"; import { withEnvAsync } from "../test-utils/env.js";
@@ -267,19 +269,24 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
} as any; } as any;
const { server, ws, port, prevToken } = await startServerWithClient(); 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 { try {
const initial = await connectReq(ws, { token: "secret" }); const initial = await connectReq(ws, { token: "secret", deviceIdentityPath });
if (!initial.ok) { if (!initial.ok) {
await approvePendingPairingIfNeeded(); await approvePendingPairingIfNeeded();
} }
const identity = loadOrCreateDeviceIdentity(); const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
const paired = await getPairedDevice(identity.deviceId); const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token; const deviceToken = paired?.tokens?.operator?.token;
expect(paired?.deviceId).toBe(identity.deviceId);
expect(deviceToken).toBeDefined(); expect(deviceToken).toBeDefined();
ws.close(); ws.close();
return { server, port, prevToken, deviceToken: String(deviceToken ?? "") }; return { server, port, prevToken, deviceToken: String(deviceToken ?? ""), deviceIdentityPath };
} catch (err) { } catch (err) {
ws.close(); ws.close();
await server.close(); await server.close();
@@ -291,20 +298,31 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise<{ async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise<{
identity: { deviceId: string }; identity: { deviceId: string };
deviceToken: string; deviceToken: string;
deviceIdentityPath: string;
}> { }> {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { getPairedDevice } = await import("../infra/device-pairing.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) { if (!res.ok) {
await approvePendingPairingIfNeeded(); await approvePendingPairingIfNeeded();
} }
const identity = loadOrCreateDeviceIdentity(); const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
const paired = await getPairedDevice(identity.deviceId); const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token; const deviceToken = paired?.tokens?.operator?.token;
expect(paired?.deviceId).toBe(identity.deviceId);
expect(deviceToken).toBeDefined(); 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", () => { describe("gateway server auth/connect", () => {
@@ -328,7 +346,7 @@ describe("gateway server auth/connect", () => {
try { try {
const ws = await openWs(port); const ws = await openWs(port);
const handshakeTimeoutMs = getHandshakeTimeoutMs(); const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 60); const closed = await waitForWsClose(ws, handshakeTimeoutMs + 500);
expect(closed).toBe(true); expect(closed).toBe(true);
} finally { } finally {
if (prevHandshakeTimeout === undefined) { if (prevHandshakeTimeout === undefined) {
@@ -1042,7 +1060,7 @@ describe("gateway server auth/connect", () => {
test("device token auth matrix", async () => { test("device token auth matrix", async () => {
const { server, ws, port, prevToken } = await startServerWithClient("secret"); const { server, ws, port, prevToken } = await startServerWithClient("secret");
const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws); const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
ws.close(); ws.close();
const scenarios: Array<{ const scenarios: Array<{
@@ -1109,7 +1127,10 @@ describe("gateway server auth/connect", () => {
for (const scenario of scenarios) { for (const scenario of scenarios) {
const ws2 = await openWs(port); const ws2 = await openWs(port);
try { try {
const res = await connectReq(ws2, scenario.opts); const res = await connectReq(ws2, {
...scenario.opts,
deviceIdentityPath,
});
scenario.assert(res); scenario.assert(res);
} finally { } finally {
ws2.close(); ws2.close();
@@ -1122,7 +1143,7 @@ describe("gateway server auth/connect", () => {
}); });
test("keeps shared-secret lockout separate from device-token auth", async () => { test("keeps shared-secret lockout separate from device-token auth", async () => {
const { server, port, prevToken, deviceToken } = const { server, port, prevToken, deviceToken, deviceIdentityPath } =
await startRateLimitedTokenServerWithPairedDeviceToken(); await startRateLimitedTokenServerWithPairedDeviceToken();
try { try {
const wsBadShared = await openWs(port); const wsBadShared = await openWs(port);
@@ -1137,7 +1158,7 @@ describe("gateway server auth/connect", () => {
wsSharedLocked.close(); wsSharedLocked.close();
const wsDevice = await openWs(port); 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); expect(deviceOk.ok).toBe(true);
wsDevice.close(); wsDevice.close();
} finally { } finally {
@@ -1147,16 +1168,16 @@ describe("gateway server auth/connect", () => {
}); });
test("keeps device-token lockout separate from shared-secret auth", async () => { test("keeps device-token lockout separate from shared-secret auth", async () => {
const { server, port, prevToken, deviceToken } = const { server, port, prevToken, deviceToken, deviceIdentityPath } =
await startRateLimitedTokenServerWithPairedDeviceToken(); await startRateLimitedTokenServerWithPairedDeviceToken();
try { try {
const wsBadDevice = await openWs(port); 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); expect(badDevice.ok).toBe(false);
wsBadDevice.close(); wsBadDevice.close();
const wsDeviceLocked = await openWs(port); 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.ok).toBe(false);
expect(deviceLocked.error?.message ?? "").toContain("retry later"); expect(deviceLocked.error?.message ?? "").toContain("retry later");
wsDeviceLocked.close(); wsDeviceLocked.close();
@@ -1167,7 +1188,10 @@ describe("gateway server auth/connect", () => {
wsShared.close(); wsShared.close();
const wsDeviceReal = await openWs(port); 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.ok).toBe(false);
expect(deviceStillLocked.error?.message ?? "").toContain("retry later"); expect(deviceStillLocked.error?.message ?? "").toContain("retry later");
wsDeviceReal.close(); wsDeviceReal.close();
@@ -1686,14 +1710,15 @@ describe("gateway server auth/connect", () => {
test("rejects revoked device token", async () => { test("rejects revoked device token", async () => {
const { revokeDeviceToken } = await import("../infra/device-pairing.js"); const { revokeDeviceToken } = await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret"); 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" }); await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
ws.close(); ws.close();
const ws2 = await openWs(port); 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); expect(res2.ok).toBe(false);
ws2.close(); ws2.close();

View File

@@ -202,6 +202,7 @@ describe("node.invoke approval bypass", () => {
readyResolve = resolve; readyResolve = resolve;
}); });
const resolvedDeviceIdentity = deviceIdentity ?? createDeviceIdentity();
const client = new GatewayClient({ const client = new GatewayClient({
url: `ws://127.0.0.1:${port}`, url: `ws://127.0.0.1:${port}`,
// Keep challenge timeout realistic in tests; 0 maps to a 250ms timeout and can // 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, mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [], scopes: [],
commands: ["system.run"], commands: ["system.run"],
deviceIdentity, deviceIdentity: resolvedDeviceIdentity,
onHelloOk: () => readyResolve?.(), onHelloOk: () => readyResolve?.(),
onEvent: (evt) => { onEvent: (evt) => {
if (evt.event !== "node.invoke.request") { if (evt.event !== "node.invoke.request") {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { CONFIG_PATH } from "../config/config.js"; 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 { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import type { GatewayClient } from "./client.js"; import type { GatewayClient } from "./client.js";
@@ -36,6 +37,9 @@ installConnectedControlUiServerSuite((started) => {
const connectNodeClient = async (params: { const connectNodeClient = async (params: {
port: number; port: number;
commands: string[]; commands: string[];
platform?: string;
deviceFamily?: string;
deviceIdentity?: DeviceIdentity;
instanceId?: string; instanceId?: string;
displayName?: string; displayName?: string;
onEvent?: (evt: { event?: string; payload?: unknown }) => void; onEvent?: (evt: { event?: string; payload?: unknown }) => void;
@@ -51,11 +55,13 @@ const connectNodeClient = async (params: {
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientVersion: "1.0.0", clientVersion: "1.0.0",
clientDisplayName: params.displayName, clientDisplayName: params.displayName,
platform: "ios", platform: params.platform ?? "ios",
deviceFamily: params.deviceFamily,
mode: GATEWAY_CLIENT_MODES.NODE, mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: params.instanceId, instanceId: params.instanceId,
scopes: [], scopes: [],
commands: params.commands, commands: params.commands,
deviceIdentity: params.deviceIdentity,
onEvent: params.onEvent, onEvent: params.onEvent,
timeoutMessage: "timeout waiting for node to connect", timeoutMessage: "timeout waiting for node to connect",
}); });
@@ -313,4 +319,51 @@ describe("gateway node command allowlist", () => {
allowedClient?.stop(); 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();
}
});
}); });

View File

@@ -32,7 +32,7 @@ import {
CANVAS_CAPABILITY_TTL_MS, CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken, mintCanvasCapabilityToken,
} from "../../canvas-capability.js"; } from "../../canvas-capability.js";
import { buildDeviceAuthPayload } from "../../device-auth.js"; import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
import { import {
isLocalishHost, isLocalishHost,
isLoopbackAddress, isLoopbackAddress,
@@ -122,7 +122,7 @@ function shouldAllowSilentLocalPairing(params: {
hasBrowserOriginHeader: boolean; hasBrowserOriginHeader: boolean;
isControlUi: boolean; isControlUi: boolean;
isWebchat: boolean; isWebchat: boolean;
reason: "not-paired" | "role-upgrade" | "scope-upgrade"; reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
}): boolean { }): boolean {
return ( return (
params.isLocalClient && 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: { export function attachGatewayWsMessageHandler(params: {
socket: WebSocket; socket: WebSocket;
upgradeReq: IncomingMessage; upgradeReq: IncomingMessage;
@@ -416,6 +420,7 @@ export function attachGatewayWsMessageHandler(params: {
const deviceRaw = connectParams.device; const deviceRaw = connectParams.device;
let devicePublicKey: string | null = null; let devicePublicKey: string | null = null;
let deviceAuthPayloadVersion: "v2" | "v3" | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token); const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password); const hasPasswordAuth = Boolean(connectParams.auth?.password);
const hasSharedAuth = hasTokenAuth || hasPasswordAuth; const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
@@ -583,7 +588,19 @@ export function attachGatewayWsMessageHandler(params: {
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch"); rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
return; 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, deviceId: device.id,
clientId: connectParams.client.id, clientId: connectParams.client.id,
clientMode: connectParams.client.mode, clientMode: connectParams.client.mode,
@@ -595,11 +612,18 @@ export function attachGatewayWsMessageHandler(params: {
}); });
const rejectDeviceSignatureInvalid = () => const rejectDeviceSignatureInvalid = () =>
rejectDeviceAuthInvalid("device-signature", "device signature invalid"); rejectDeviceAuthInvalid("device-signature", "device signature invalid");
const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature); const signatureOkV3 = verifyDeviceSignature(
if (!signatureOk) { device.publicKey,
payloadV3,
device.signature,
);
const signatureOkV2 =
!signatureOkV3 && verifyDeviceSignature(device.publicKey, payloadV2, device.signature);
if (!signatureOkV3 && !signatureOkV2) {
rejectDeviceSignatureInvalid(); rejectDeviceSignatureInvalid();
return; return;
} }
deviceAuthPayloadVersion = signatureOkV3 ? "v3" : "v2";
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey); devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
if (!devicePublicKey) { if (!devicePublicKey) {
rejectDeviceAuthInvalid("device-public-key", "device public key invalid"); 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}`, `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, displayName: connectParams.client.displayName,
platform: connectParams.client.platform, 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, clientId: connectParams.client.id,
clientMode: connectParams.client.mode, clientMode: connectParams.client.mode,
role, role,
@@ -678,7 +711,7 @@ export function attachGatewayWsMessageHandler(params: {
remoteIp: reportedClientIp, remoteIp: reportedClientIp,
}; };
const requirePairing = async ( const requirePairing = async (
reason: "not-paired" | "role-upgrade" | "scope-upgrade", reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
) => { ) => {
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({ const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
isLocalClient, isLocalClient,
@@ -690,7 +723,7 @@ export function attachGatewayWsMessageHandler(params: {
const pairing = await requestDevicePairing({ const pairing = await requestDevicePairing({
deviceId: device.id, deviceId: device.id,
publicKey: devicePublicKey, publicKey: devicePublicKey,
...clientAccessMetadata, ...clientPairingMetadata,
silent: allowSilentLocalPairing, silent: allowSilentLocalPairing,
}); });
const context = buildRequestContext(); const context = buildRequestContext();
@@ -747,6 +780,37 @@ export function attachGatewayWsMessageHandler(params: {
return; return;
} }
} else { } 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) const pairedRoles = Array.isArray(paired.roles)
? paired.roles ? paired.roles
: paired.role : 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); await updatePairedDeviceMetadata(device.id, clientAccessMetadata);
} }
} }

View File

@@ -1,4 +1,6 @@
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { import {
type DeviceIdentity, type DeviceIdentity,
@@ -15,7 +17,7 @@ import {
type GatewayClientName, type GatewayClientName,
} from "../utils/message-channel.js"; } from "../utils/message-channel.js";
import { GatewayClient } from "./client.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 { PROTOCOL_VERSION } from "./protocol/index.js";
import { startGatewayServer } from "./server.js"; import { startGatewayServer } from "./server.js";
@@ -31,6 +33,7 @@ export async function connectGatewayClient(params: {
clientVersion?: string; clientVersion?: string;
mode?: GatewayClientMode; mode?: GatewayClientMode;
platform?: string; platform?: string;
deviceFamily?: string;
role?: "operator" | "node"; role?: "operator" | "node";
scopes?: string[]; scopes?: string[];
caps?: string[]; caps?: string[];
@@ -42,6 +45,20 @@ export async function connectGatewayClient(params: {
timeoutMs?: number; timeoutMs?: number;
timeoutMessage?: string; 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) => { return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false; let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => { const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
@@ -63,14 +80,15 @@ export async function connectGatewayClient(params: {
clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST, clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: params.clientDisplayName ?? "vitest", clientDisplayName: params.clientDisplayName ?? "vitest",
clientVersion: params.clientVersion ?? "dev", clientVersion: params.clientVersion ?? "dev",
platform: params.platform, platform,
deviceFamily: params.deviceFamily,
mode: params.mode ?? GATEWAY_CLIENT_MODES.TEST, mode: params.mode ?? GATEWAY_CLIENT_MODES.TEST,
role: params.role, role,
scopes: params.scopes, scopes: params.scopes,
caps: params.caps, caps: params.caps,
commands: params.commands, commands: params.commands,
instanceId: params.instanceId, instanceId: params.instanceId,
deviceIdentity: params.deviceIdentity, deviceIdentity,
onEvent: params.onEvent, onEvent: params.onEvent,
onHelloOk: () => stop(undefined, client), onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err), onConnectError: (err) => stop(err),
@@ -127,7 +145,8 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
const connectNonce = await connectNoncePromise; const connectNonce = await connectNoncePromise;
const identity = loadOrCreateDeviceIdentity(); const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now(); const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({ const platform = process.platform;
const payload = buildDeviceAuthPayloadV3({
deviceId: identity.deviceId, deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST, clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST, clientMode: GATEWAY_CLIENT_MODES.TEST,
@@ -136,6 +155,7 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
signedAtMs, signedAtMs,
token: params.token ?? null, token: params.token ?? null,
nonce: connectNonce, nonce: connectNonce,
platform,
}); });
const device = { const device = {
id: identity.deviceId, id: identity.deviceId,
@@ -156,7 +176,7 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
id: GATEWAY_CLIENT_NAMES.TEST, id: GATEWAY_CLIENT_NAMES.TEST,
displayName: "vitest", displayName: "vitest",
version: "dev", version: "dev",
platform: process.platform, platform,
mode: GATEWAY_CLIENT_MODES.TEST, mode: GATEWAY_CLIENT_MODES.TEST,
}, },
caps: [], caps: [],

View File

@@ -18,7 +18,7 @@ import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key
import { captureEnv } from "../test-utils/env.js"; import { captureEnv } from "../test-utils/env.js";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.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 { PROTOCOL_VERSION } from "./protocol/index.js";
import type { GatewayServerOptions } from "./server.js"; import type { GatewayServerOptions } from "./server.js";
import { import {
@@ -421,6 +421,21 @@ type ConnectResponse = {
error?: { message?: string; code?: string; details?: unknown }; 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( export async function readConnectChallengeNonce(
ws: WebSocket, ws: WebSocket,
timeoutMs = 2_000, timeoutMs = 2_000,
@@ -478,6 +493,7 @@ export async function connectReq(
signedAt: number; signedAt: number;
nonce?: string; nonce?: string;
} | null; } | null;
deviceIdentityPath?: string;
skipConnectChallengeNonce?: boolean; skipConnectChallengeNonce?: boolean;
timeoutMs?: number; timeoutMs?: number;
}, },
@@ -527,9 +543,18 @@ export async function connectReq(
if (!connectChallengeNonce) { if (!connectChallengeNonce) {
throw new Error("missing connect.challenge nonce"); 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 signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({ const payload = buildDeviceAuthPayloadV3({
deviceId: identity.deviceId, deviceId: identity.deviceId,
clientId: client.id, clientId: client.id,
clientMode: client.mode, clientMode: client.mode,
@@ -538,6 +563,8 @@ export async function connectReq(
signedAtMs, signedAtMs,
token: authTokenForSignature ?? null, token: authTokenForSignature ?? null,
nonce: connectChallengeNonce, nonce: connectChallengeNonce,
platform: client.platform,
deviceFamily: client.deviceFamily,
}); });
return { return {
id: identity.deviceId, id: identity.deviceId,

View File

@@ -17,6 +17,7 @@ export type DevicePairingPendingRequest = {
publicKey: string; publicKey: string;
displayName?: string; displayName?: string;
platform?: string; platform?: string;
deviceFamily?: string;
clientId?: string; clientId?: string;
clientMode?: string; clientMode?: string;
role?: string; role?: string;
@@ -52,6 +53,7 @@ export type PairedDevice = {
publicKey: string; publicKey: string;
displayName?: string; displayName?: string;
platform?: string; platform?: string;
deviceFamily?: string;
clientId?: string; clientId?: string;
clientMode?: string; clientMode?: string;
role?: string; role?: string;
@@ -165,6 +167,7 @@ function mergePendingDevicePairingRequest(
...existing, ...existing,
displayName: incoming.displayName ?? existing.displayName, displayName: incoming.displayName ?? existing.displayName,
platform: incoming.platform ?? existing.platform, platform: incoming.platform ?? existing.platform,
deviceFamily: incoming.deviceFamily ?? existing.deviceFamily,
clientId: incoming.clientId ?? existing.clientId, clientId: incoming.clientId ?? existing.clientId,
clientMode: incoming.clientMode ?? existing.clientMode, clientMode: incoming.clientMode ?? existing.clientMode,
role: existingRole ?? incomingRole ?? undefined, role: existingRole ?? incomingRole ?? undefined,
@@ -297,6 +300,7 @@ export async function requestDevicePairing(
publicKey: req.publicKey, publicKey: req.publicKey,
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
deviceFamily: req.deviceFamily,
clientId: req.clientId, clientId: req.clientId,
clientMode: req.clientMode, clientMode: req.clientMode,
role: req.role, role: req.role,
@@ -360,6 +364,7 @@ export async function approveDevicePairing(
publicKey: pending.publicKey, publicKey: pending.publicKey,
displayName: pending.displayName, displayName: pending.displayName,
platform: pending.platform, platform: pending.platform,
deviceFamily: pending.deviceFamily,
clientId: pending.clientId, clientId: pending.clientId,
clientMode: pending.clientMode, clientMode: pending.clientMode,
role: pending.role, role: pending.role,