From 85fd834b118110a044223e7e81ef16793cc5f492 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 20:50:19 -0500 Subject: [PATCH] Update gateway connect handshake payload - Send the newer client identity and device metadata fields - Encode device keys and signatures in the expected format - Add test coverage for the modern connect request payload --- apps/server/src/openclawGatewayTest.test.ts | 42 ++++ apps/server/src/openclawGatewayTest.ts | 23 +- .../provider/Layers/OpenClawGatewayClient.ts | 220 +++++++++++++----- 3 files changed, 218 insertions(+), 67 deletions(-) diff --git a/apps/server/src/openclawGatewayTest.test.ts b/apps/server/src/openclawGatewayTest.test.ts index 9b28327d5..f10c7a946 100644 --- a/apps/server/src/openclawGatewayTest.test.ts +++ b/apps/server/src/openclawGatewayTest.test.ts @@ -9,8 +9,32 @@ type GatewayRequestFrame = { type?: unknown; id?: unknown; method?: unknown; + params?: { + client?: { + id?: unknown; + displayName?: unknown; + mode?: unknown; + deviceFamily?: unknown; + }; + auth?: { + password?: unknown; + token?: unknown; + deviceToken?: unknown; + }; + device?: { + id?: unknown; + publicKey?: unknown; + signature?: unknown; + signedAt?: unknown; + nonce?: unknown; + }; + }; }; +function isBase64Url(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value); +} + afterEach(async () => { await Promise.all( [...servers].map( @@ -82,11 +106,14 @@ describe("runOpenclawGatewayTest", () => { }); it("passes when the modern connect handshake succeeds", async () => { + let connectParams: GatewayRequestFrame["params"]; + const gateway = await createGatewayServer((socket) => { sendChallenge(socket); socket.on("message", (data) => { const message = JSON.parse(data.toString()) as GatewayRequestFrame; if (message.type === "req" && message.method === "connect") { + connectParams = message.params; socket.send( JSON.stringify({ type: "res", @@ -108,6 +135,21 @@ describe("runOpenclawGatewayTest", () => { expect(result.steps.find((step) => step.name === "WebSocket connect")?.status).toBe("pass"); expect(result.steps.find((step) => step.name === "Gateway handshake")?.status).toBe("pass"); expect(result.diagnostics?.observedNotifications).toContain("connect.challenge"); + + expect(connectParams?.client?.id).toBe("gateway-client"); + expect(connectParams?.client?.mode).toBe("backend"); + expect(connectParams?.client?.displayName).toBe("OK Code gateway test"); + expect(connectParams?.client?.deviceFamily).toBe("server"); + expect(connectParams?.auth?.password).toBe("topsecret"); + expect(connectParams?.auth?.token).toBeUndefined(); + expect(connectParams?.auth?.deviceToken).toBeUndefined(); + expect(connectParams?.device?.id).toMatch(/^[a-f0-9]{64}$/); + expect(connectParams?.device?.id).not.toMatch(/^device_/); + expect(isBase64Url(connectParams?.device?.publicKey)).toBe(true); + expect(String(connectParams?.device?.publicKey)).not.toContain("BEGIN"); + expect(isBase64Url(connectParams?.device?.signature)).toBe(true); + expect(connectParams?.device?.nonce).toBe("nonce-123"); + expect(typeof connectParams?.device?.signedAt).toBe("number"); }); it("reports pairing-required detail codes from the connect handshake", async () => { diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index e04d99fa7..a80c30597 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -10,7 +10,11 @@ import type { TestOpenclawGatewayStepStatus, } from "@okcode/contracts"; import { serverBuildInfo } from "./buildInfo.ts"; -import { connectOpenClawGateway } from "./provider/Layers/OpenClawGatewayClient.ts"; +import { + OPENCLAW_GATEWAY_CLIENT_IDS, + OPENCLAW_GATEWAY_CLIENT_MODES, + connectOpenClawGateway, +} from "./provider/Layers/OpenClawGatewayClient.ts"; const OPENCLAW_TEST_CONNECT_TIMEOUT_MS = 10_000; const OPENCLAW_TEST_RPC_TIMEOUT_MS = 10_000; @@ -371,6 +375,17 @@ function buildHints( ); } + if ( + errorLower.includes("/client/id") || + errorLower.includes("/client/mode") || + errorLower.includes("client id") || + errorLower.includes("client mode") + ) { + hints.push( + "The gateway rejected the advertised client identity. That usually means the gateway expects a newer OpenClaw `connect.params.client` allowlist than this OK Code build is using.", + ); + } + if ( diagnostics.hostKind === "tailscale" && (detailCode === "PAIRING_REQUIRED" || @@ -523,7 +538,8 @@ export async function runOpenclawGatewayTest( role: "operator", scopes: [...OPENCLAW_OPERATOR_SCOPES], client: { - id: "okcode", + id: OPENCLAW_GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + displayName: "OK Code gateway test", version: serverBuildInfo.version, platform: process.platform === "darwin" @@ -531,7 +547,8 @@ export async function runOpenclawGatewayTest( : process.platform === "win32" ? "windows" : process.platform, - mode: "operator", + deviceFamily: "server", + mode: OPENCLAW_GATEWAY_CLIENT_MODES.BACKEND, }, userAgent: `okcode/${serverBuildInfo.version}`, locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", diff --git a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts index 62f24711f..aa9d4cc86 100644 --- a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts +++ b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts @@ -15,12 +15,49 @@ const OPENCLAW_PROTOCOL_VERSION = 3; const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const AUTH_STATE_FILE_NAME = "openclaw-gateway-auth.json"; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export const OPENCLAW_GATEWAY_CLIENT_IDS = { + WEBCHAT_UI: "webchat-ui", + CONTROL_UI: "openclaw-control-ui", + TUI: "openclaw-tui", + WEBCHAT: "webchat", + CLI: "cli", + GATEWAY_CLIENT: "gateway-client", + MACOS_APP: "openclaw-macos", + IOS_APP: "openclaw-ios", + ANDROID_APP: "openclaw-android", + NODE_HOST: "node-host", + TEST: "test", + FINGERPRINT: "fingerprint", + PROBE: "openclaw-probe", +} as const; + +export type OpenClawGatewayClientId = + (typeof OPENCLAW_GATEWAY_CLIENT_IDS)[keyof typeof OPENCLAW_GATEWAY_CLIENT_IDS]; + +export const OPENCLAW_GATEWAY_CLIENT_MODES = { + WEBCHAT: "webchat", + CLI: "cli", + UI: "ui", + BACKEND: "backend", + NODE: "node", + PROBE: "probe", + TEST: "test", +} as const; + +export type OpenClawGatewayClientMode = + (typeof OPENCLAW_GATEWAY_CLIENT_MODES)[keyof typeof OPENCLAW_GATEWAY_CLIENT_MODES]; export interface OpenClawGatewayClientInfo { - readonly id: string; + readonly id: OpenClawGatewayClientId; + readonly displayName?: string | undefined; readonly version: string; readonly platform: string; - readonly mode: "operator" | "node"; + readonly deviceFamily?: string | undefined; + readonly modelIdentifier?: string | undefined; + readonly mode: OpenClawGatewayClientMode; + readonly instanceId?: string | undefined; } export interface OpenClawGatewayConnectOptions { @@ -150,17 +187,35 @@ function exportPrivateKeyPem(privateKey: ReturnType): s return privateKey.export({ format: "pem", type: "pkcs8" }).toString(); } -function fingerprintPublicKey(publicKeyPem: string): string { +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); +} + +function derivePublicKeyRaw(publicKeyPem: string): Buffer { const publicKey = createPublicKey(publicKeyPem); - const publicKeyDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; - return createHash("sha256").update(publicKeyDer).digest("hex"); + const spki = publicKey.export({ format: "der", type: "spki" }) as Buffer; + if ( + spki.length === ED25519_SPKI_PREFIX.length + 32 && + spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) + ) { + return spki.subarray(ED25519_SPKI_PREFIX.length); + } + return spki; +} + +function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { + return base64UrlEncode(derivePublicKeyRaw(publicKeyPem)); +} + +function fingerprintPublicKey(publicKeyPem: string): string { + return createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex"); } function makeDeviceIdentity(): OpenClawDeviceIdentity { const { privateKey, publicKey } = generateKeyPairSync("ed25519"); const privateKeyPem = exportPrivateKeyPem(privateKey); const publicKeyPem = exportPublicKeyPem(publicKey); - const deviceId = `device_${fingerprintPublicKey(publicKeyPem)}`; + const deviceId = fingerprintPublicKey(publicKeyPem); return { id: deviceId, privateKeyPem, @@ -168,34 +223,46 @@ function makeDeviceIdentity(): OpenClawDeviceIdentity { }; } -function buildSignaturePayload(input: { - readonly nonce: string; - readonly signedAt: number; +function normalizeDeviceMetadataForAuth(value?: string | undefined): string { + const trimmed = value?.trim() ?? ""; + return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32)); +} + +function buildDeviceAuthPayloadV3(input: { + readonly deviceId: string; readonly client: OpenClawGatewayClientInfo; readonly role: "operator" | "node"; readonly scopes: ReadonlyArray; - readonly authValue?: string | undefined; - readonly deviceFamily: string; + readonly signedAtMs: number; + readonly token?: string | undefined; + readonly nonce: string; }): string { - return JSON.stringify({ - version: 3, - nonce: input.nonce, - signedAt: input.signedAt, - client: input.client, - role: input.role, - scopes: input.scopes, - authValue: input.authValue ?? null, - deviceFamily: input.deviceFamily, - }); -} - -function signChallenge( + return [ + "v3", + input.deviceId, + input.client.id, + input.client.mode, + input.role, + input.scopes.join(","), + String(input.signedAtMs), + input.token ?? "", + input.nonce, + normalizeDeviceMetadataForAuth(input.client.platform), + normalizeDeviceMetadataForAuth(input.client.deviceFamily), + ].join("|"); +} + +function signDevicePayload( identity: OpenClawDeviceIdentity, - input: Parameters[0], + input: Parameters[0], ): string { const privateKey = createPrivateKey(identity.privateKeyPem); - const signature = cryptoSign(null, Buffer.from(buildSignaturePayload(input)), privateKey); - return signature.toString("base64"); + const signature = cryptoSign( + null, + Buffer.from(buildDeviceAuthPayloadV3(input), "utf8"), + privateKey, + ); + return base64UrlEncode(signature); } async function readAuthState(stateDir: string): Promise { @@ -239,6 +306,26 @@ async function writeAuthState( await writeFile(getAuthStatePath(stateDir), `${JSON.stringify(state, null, 2)}\n`, "utf8"); } +function normalizePersistedAuthState( + state: PersistedOpenClawGatewayAuthState, +): PersistedOpenClawGatewayAuthState { + try { + const normalizedDeviceId = fingerprintPublicKey(state.device.publicKeyPem); + if (normalizedDeviceId === state.device.id) { + return state; + } + return { + ...state, + device: { + ...state.device, + id: normalizedDeviceId, + }, + }; + } catch { + return state; + } +} + class OpenClawGatewayAuthStore { private cachedState: PersistedOpenClawGatewayAuthState | undefined; @@ -249,13 +336,17 @@ class OpenClawGatewayAuthStore { return this.cachedState; } - const loaded = (await readAuthState(this.stateDir)) ?? { - version: 1, - device: makeDeviceIdentity(), - deviceTokens: {}, - }; + const storedState = await readAuthState(this.stateDir); + const loaded: PersistedOpenClawGatewayAuthState = + storedState !== null + ? normalizePersistedAuthState(storedState) + : { + version: 1 as const, + device: makeDeviceIdentity(), + deviceTokens: {}, + }; this.cachedState = loaded; - if ((await readAuthState(this.stateDir)) === null) { + if (storedState === null || storedState.device.id !== loaded.device.id) { await writeAuthState(this.stateDir, loaded); } return loaded; @@ -340,50 +431,55 @@ function buildConnectParams(input: { readonly caps?: ReadonlyArray | undefined; readonly commands?: ReadonlyArray | undefined; readonly permissions?: Record | undefined; - readonly deviceFamily: string; }): Record { - const signedAt = Date.now(); - const authValue = - input.auth.kind === "password" || input.auth.kind === "deviceToken" - ? input.auth.value - : undefined; + const signedAtMs = Date.now(); + const auth = + input.auth.kind === "password" + ? { + password: input.auth.value, + } + : input.auth.kind === "deviceToken" + ? { + // Legacy compatibility: device-token auth keeps `token` populated too. + token: input.auth.value, + deviceToken: input.auth.value, + } + : undefined; + const signatureToken = input.auth.kind === "deviceToken" ? input.auth.value : undefined; return { minProtocol: OPENCLAW_PROTOCOL_VERSION, maxProtocol: OPENCLAW_PROTOCOL_VERSION, - client: input.client, + client: { + id: input.client.id, + ...(input.client.displayName ? { displayName: input.client.displayName } : {}), + version: input.client.version, + platform: input.client.platform, + ...(input.client.deviceFamily ? { deviceFamily: input.client.deviceFamily } : {}), + ...(input.client.modelIdentifier ? { modelIdentifier: input.client.modelIdentifier } : {}), + mode: input.client.mode, + ...(input.client.instanceId ? { instanceId: input.client.instanceId } : {}), + }, role: input.role, scopes: [...input.scopes], caps: [...(input.caps ?? [])], commands: [...(input.commands ?? [])], permissions: { ...input.permissions }, - ...(input.auth.kind === "password" - ? { - auth: { - password: input.auth.value, - }, - } - : input.auth.kind === "deviceToken" - ? { - auth: { - deviceToken: input.auth.value, - }, - } - : {}), + ...(auth ? { auth } : {}), locale: input.locale ?? (Intl.DateTimeFormat().resolvedOptions().locale || "en-US"), userAgent: input.userAgent, device: { id: input.deviceIdentity.id, - publicKey: input.deviceIdentity.publicKeyPem, - signature: signChallenge(input.deviceIdentity, { - nonce: input.challengeNonce, - signedAt, + publicKey: publicKeyRawBase64UrlFromPem(input.deviceIdentity.publicKeyPem), + signature: signDevicePayload(input.deviceIdentity, { + deviceId: input.deviceIdentity.id, client: input.client, role: input.role, scopes: input.scopes, - ...(authValue !== undefined ? { authValue } : {}), - deviceFamily: input.deviceFamily, + signedAtMs, + ...(signatureToken !== undefined ? { token: signatureToken } : {}), + nonce: input.challengeNonce, }), - signedAt, + signedAt: signedAtMs, nonce: input.challengeNonce, }, }; @@ -414,7 +510,6 @@ export async function connectOpenClawGateway( const deviceIdentity = await authStore.getDeviceIdentity(); const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; - const deviceFamily = "server"; const candidateAuthSelections: OpenClawGatewayAuthSelection[] = []; if (options.password && options.password.length > 0) { @@ -456,7 +551,6 @@ export async function connectOpenClawGateway( caps: options.caps, commands: options.commands, permissions: options.permissions, - deviceFamily, sessionKey: options.sessionKey ?? `okcode:${normalizePathSegments(options.client.id)}`, }); return connection; @@ -498,7 +592,6 @@ async function connectOnce(input: { readonly caps?: ReadonlyArray | undefined; readonly commands?: ReadonlyArray | undefined; readonly permissions?: Record | undefined; - readonly deviceFamily: string; readonly sessionKey: string; }): Promise { return await new Promise((resolve, reject) => { @@ -713,7 +806,6 @@ async function connectOnce(input: { caps: input.caps, commands: input.commands, permissions: input.permissions, - deviceFamily: input.deviceFamily, }), }), );