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
This commit is contained in:
Mark Stones 2026-09-11 12:38:43 +01:00
parent 932c8bec56
commit 38ed481823
2 changed files with 62 additions and 3 deletions

View File

@ -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|");
});
});

View File

@ -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<AdapterExec
role,
scopes,
signedAtMs,
token: authToken,
token: resolveDeviceSignatureToken({ authToken, deviceToken }),
nonce,
platform: process.platform,
deviceFamily,