mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(gateway): pin paired reconnect metadata for node policy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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("|");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? "<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user