From 38ed481823aef30ce6619a156fbebb0b4f09e22b Mon Sep 17 00:00:00 2001 From: Mark Stones <110469541+markstones78@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:38:43 +0100 Subject: [PATCH] fix(openclaw-gateway): bind the device signature to the presented credential The signed device payload bound only `authToken`. The OpenClaw gateway verifies the signature against the credential actually presented for the connection (`auth.token` when a shared token is supplied, otherwise `auth.deviceToken`), so every device-token-only reconnect of a paired device failed with "device signature invalid". The signature token now follows the same precedence as the `auth` object (`authToken ?? deviceToken`). `resolveDeviceSignatureToken` and `buildDeviceAuthPayloadV3` are exported for the focused tests added alongside. Fixes #13218 --- .../src/server/execute.test.ts | 47 ++++++++++++++++++- .../openclaw-gateway/src/server/execute.ts | 18 ++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/adapters/openclaw-gateway/src/server/execute.test.ts b/packages/adapters/openclaw-gateway/src/server/execute.test.ts index 3689e41245..7e57ea0f64 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.test.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { buildAgentParams, resolveClaimedApiKeyPath, resolveSessionKey } from "./execute.js"; +import { + buildAgentParams, + buildDeviceAuthPayloadV3, + resolveClaimedApiKeyPath, + resolveDeviceSignatureToken, + resolveSessionKey, +} from "./execute.js"; describe("resolveSessionKey", () => { it("prefixes run-scoped session keys with the configured agent", () => { @@ -123,3 +129,42 @@ describe("resolveClaimedApiKeyPath", () => { expect(resolveClaimedApiKeyPath({})).toBe(DEFAULT_PATH); }); }); + +describe("device signature token binding", () => { + const basePayload = { + deviceId: "device-id", + clientId: "cli", + clientMode: "cli", + role: "operator", + scopes: ["operator.read", "operator.write"], + signedAtMs: 1_700_000_000_000, + nonce: "nonce-1", + platform: "linux", + deviceFamily: null, + }; + + it("binds the device token when no shared token is configured (device-token-only reconnect)", () => { + const token = resolveDeviceSignatureToken({ authToken: null, deviceToken: "D" }); + expect(token).toBe("D"); + const payload = buildDeviceAuthPayloadV3({ ...basePayload, token }); + expect(payload.split("|")[7]).toBe("D"); + }); + + it("binds the shared token when both are configured (gateway precedence: token, then deviceToken)", () => { + const token = resolveDeviceSignatureToken({ authToken: "S", deviceToken: "D" }); + expect(token).toBe("S"); + const payload = buildDeviceAuthPayloadV3({ ...basePayload, token }); + expect(payload.split("|")[7]).toBe("S"); + }); + + it("leaves the token slot empty only when no credential of either kind is configured", () => { + expect(resolveDeviceSignatureToken({ authToken: " ", deviceToken: undefined })).toBeNull(); + const payload = buildDeviceAuthPayloadV3({ ...basePayload, token: null }); + expect(payload.split("|")[7]).toBe(""); + }); + + it("keeps the v3 payload field order stable", () => { + const payload = buildDeviceAuthPayloadV3({ ...basePayload, token: "D" }); + expect(payload).toBe("v3|device-id|cli|cli|operator|operator.read,operator.write|1700000000000|D|nonce-1|linux|"); + }); +}); diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 7d79bd0437..a60058475c 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -559,7 +559,21 @@ function signDevicePayload(privateKeyPem: string, payload: string): string { return base64UrlEncode(sig); } -function buildDeviceAuthPayloadV3(params: { +/** + * The OpenClaw gateway verifies the signed device payload against the credential the client + * actually presents for the connection: `auth.token` when a shared token is supplied, otherwise + * `auth.deviceToken`. Binding only the shared token breaks every device-token-only reconnect + * (the gateway answers "device signature invalid"), so the signature token must follow the + * same precedence as the `auth` object built for `connect`. + */ +export function resolveDeviceSignatureToken(params: { + authToken?: string | null; + deviceToken?: string | null; +}): string | null { + return nonEmpty(params.authToken) ?? nonEmpty(params.deviceToken) ?? null; +} + +export function buildDeviceAuthPayloadV3(params: { deviceId: string; clientId: string; clientMode: string; @@ -1276,7 +1290,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise