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/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.
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ sequenceDiagram
|
|||||||
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
- **Local** connects (loopback or the gateway host’s own tailnet address) can be
|
||||||
auto‑approved to keep same‑host UX smooth.
|
auto‑approved to keep same‑host 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.
|
||||||
- **Non‑local** connects still require explicit approval.
|
- **Non‑local** 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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("|");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user