-
-
Notifications
You must be signed in to change notification settings - Fork 69.5k
[Bug]: Control UI "device signature invalid" — token field mismatch between client signing and server #39667
Description
Bug type
Regression (worked before, now fails)
Summary
Bug Report: Control UI "device signature invalid" — token field mismatch between client signing and server verification
Environment
- OpenClaw version: 2026.3.7-beta.1
- Browser: Chrome (secure context,
crypto.subtleavailable) - Auth mode:
token
Summary
The Control UI's device signature verification always fails when using shared token authentication (auth.mode: "token"), because the client signs the payload with one token value while the server reconstructs the payload with a different token value for verification. This creates a deadlock:
dangerouslyDisableDeviceAuth: false(default) → "device signature invalid", cannot connectdangerouslyDisableDeviceAuth: true(workaround) → connects, but disconnects on every page refresh because nodeviceTokenis ever issued
Steps to Reproduce
- Configure gateway with shared token auth:
{ "gateway": { "auth": { "mode": "token", "token": "<shared-token>" }, "controlUi": {} } } - Access the Control UI dashboard via HTTPS (e.g., Codespaces port forwarding)
- Enter the shared gateway token and click "Connect"
- Observe: "device signature invalid" error
Root Cause Analysis
The mismatch
The signed payload includes a token field. The client and server use different values for this field:
| Code location | Token value used | |
|---|---|---|
| Client signs with | ui/src/ui/gateway.ts line 247 |
deviceToken ?? null (per-device JWT from localStorage, or null on first connect) |
| Server verifies with | src/gateway/server/ws-connection/message-handler.ts lines 184, 200 |
auth.token ?? auth.deviceToken ?? null (shared gateway token, since auth.token is always set) |
Detailed trace
Client side (ui/src/ui/gateway.ts, sendConnect() method):
// Line 208: authToken = shared gateway token from config/URL
let authToken = this.opts.token;
// Lines 219-225: auth object sent to server uses the SHARED token
const auth = authToken || this.opts.password
? { token: authToken, password: this.opts.password }
: undefined;
// Lines 240-249: BUT the signed payload uses deviceToken (different value!)
const payload = buildDeviceAuthPayload({
// ...
token: deviceToken ?? null, // ← signs with deviceToken, NOT authToken
nonce,
});Server side (src/gateway/server/ws-connection/message-handler.ts, resolveDeviceSignaturePayloadVersion()):
// Lines 184, 200: server reconstructs payload using auth.token (the shared token)
token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null,
// ↑ this resolves to the SHARED gateway token, because client sends auth.token = shared tokenPayload format (src/gateway/device-auth.ts lines 20-34):
v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}
Why the signatures never match
Scenario 1: First connect (no cached deviceToken)
Client signs: v2|device123|...| |nonce (token = null → empty string)
Server builds: v2|device123|...|5d183ea5…|nonce (token = shared gateway token)
^^^^^^^^
MISMATCH → "device signature invalid"
Scenario 2: Reconnect with cached deviceToken
Client signs: v2|device123|...|eyJhbGci…|nonce (token = deviceToken JWT)
Server builds: v2|device123|...|5d183ea5…|nonce (token = shared gateway token)
^^^^^^^^
MISMATCH → "device signature invalid"
In both cases, auth.token (shared token) takes precedence in the server's ?? chain, but the client never signs with the shared token.
Impact: The dangerouslyDisableDeviceAuth deadlock
Because device signature verification is broken with shared token auth, users must set dangerouslyDisableDeviceAuth: true. But this creates a secondary problem:
connect-policy.ts line 31:
device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw,When enabled, the server discards the device identity entirely, which means:
- No pairing request is created
- No
deviceTokenis issued inhello-okresponse - The browser cannot persist any auth credential in localStorage
- Every page refresh loses the in-memory shared token → immediate disconnect
storage.ts lines 60-61:
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: defaults.token,The shared gateway token is intentionally not persisted in localStorage (security by design), so on page refresh it's gone. Without a deviceToken fallback, the user must re-enter the token every time.
Expected Behavior
The client and server should use the same token value when building the signature payload. Either:
- The client should sign with the shared token (matching what the server reconstructs), or
- The server should reconstruct the payload using the device token (matching what the client signs), or
- The client should send the device token in
auth.deviceTokenso the server's??chain falls through correctly
Suggested Fix
In ui/src/ui/gateway.ts, the auth object construction (lines 219-225) should include deviceToken when available:
const auth = authToken || this.opts.password || deviceToken
? {
token: authToken,
deviceToken: deviceToken, // ← ADD: send deviceToken separately
password: this.opts.password,
}
: undefined;This way, on the server side:
auth.token= shared gateway token (for authentication)auth.deviceToken= the per-device JWT (for signature payload reconstruction)- When
auth.tokenis present, server uses it for auth; when reconstructing the signed payload, server should preferauth.deviceTokenfor the token field
Alternatively, align the client signing to use the same value the server expects:
// In buildDeviceAuthPayload call:
token: authToken ?? deviceToken ?? null, // sign with shared token if availableGateway Config (for reference)
{
"gateway": {
"port": 18789,
"mode": "local",
"bind": "lan",
"controlUi": {
"dangerouslyAllowHostHeaderOriginFallback": true,
"allowInsecureAuth": true,
"dangerouslyDisableDeviceAuth": true
},
"auth": {
"mode": "token",
"token": "<redacted>"
},
"trustedProxies": ["0.0.0.0/0"]
}
}Steps to reproduce
Bug Report: Control UI "device signature invalid" — token field mismatch between client signing and server verification
Environment
- OpenClaw version: 2026.3.7-beta.1
- Platform: GitHub Codespaces (Linux)
- Access method: Codespaces port forwarding (
https://<codespace>-18789.app.github.dev) - Browser: Chrome (secure context,
crypto.subtleavailable) - Auth mode:
token
Summary
The Control UI's device signature verification always fails when using shared token authentication (auth.mode: "token"), because the client signs the payload with one token value while the server reconstructs the payload with a different token value for verification. This creates a deadlock:
dangerouslyDisableDeviceAuth: false(default) → "device signature invalid", cannot connectdangerouslyDisableDeviceAuth: true(workaround) → connects, but disconnects on every page refresh because nodeviceTokenis ever issued
Steps to Reproduce
- Configure gateway with shared token auth:
{ "gateway": { "auth": { "mode": "token", "token": "<shared-token>" }, "controlUi": {} } } - Access the Control UI dashboard via HTTPS (e.g., Codespaces port forwarding)
- Enter the shared gateway token and click "Connect"
- Observe: "device signature invalid" error
Root Cause Analysis
The mismatch
The signed payload includes a token field. The client and server use different values for this field:
| Code location | Token value used | |
|---|---|---|
| Client signs with | ui/src/ui/gateway.ts line 247 |
deviceToken ?? null (per-device JWT from localStorage, or null on first connect) |
| Server verifies with | src/gateway/server/ws-connection/message-handler.ts lines 184, 200 |
auth.token ?? auth.deviceToken ?? null (shared gateway token, since auth.token is always set) |
Detailed trace
Client side (ui/src/ui/gateway.ts, sendConnect() method):
// Line 208: authToken = shared gateway token from config/URL
let authToken = this.opts.token;
// Lines 219-225: auth object sent to server uses the SHARED token
const auth = authToken || this.opts.password
? { token: authToken, password: this.opts.password }
: undefined;
// Lines 240-249: BUT the signed payload uses deviceToken (different value!)
const payload = buildDeviceAuthPayload({
// ...
token: deviceToken ?? null, // ← signs with deviceToken, NOT authToken
nonce,
});Server side (src/gateway/server/ws-connection/message-handler.ts, resolveDeviceSignaturePayloadVersion()):
// Lines 184, 200: server reconstructs payload using auth.token (the shared token)
token: params.connectParams.auth?.token ?? params.connectParams.auth?.deviceToken ?? null,
// ↑ this resolves to the SHARED gateway token, because client sends auth.token = shared tokenPayload format (src/gateway/device-auth.ts lines 20-34):
v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}
Why the signatures never match
Scenario 1: First connect (no cached deviceToken)
Client signs: v2|device123|...| |nonce (token = null → empty string)
Server builds: v2|device123|...|5d183ea5…|nonce (token = shared gateway token)
^^^^^^^^
MISMATCH → "device signature invalid"
Scenario 2: Reconnect with cached deviceToken
Client signs: v2|device123|...|eyJhbGci…|nonce (token = deviceToken JWT)
Server builds: v2|device123|...|5d183ea5…|nonce (token = shared gateway token)
^^^^^^^^
MISMATCH → "device signature invalid"
In both cases, auth.token (shared token) takes precedence in the server's ?? chain, but the client never signs with the shared token.
Impact: The dangerouslyDisableDeviceAuth deadlock
Because device signature verification is broken with shared token auth, users must set dangerouslyDisableDeviceAuth: true. But this creates a secondary problem:
connect-policy.ts line 31:
device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw,When enabled, the server discards the device identity entirely, which means:
- No pairing request is created
- No
deviceTokenis issued inhello-okresponse - The browser cannot persist any auth credential in localStorage
- Every page refresh loses the in-memory shared token → immediate disconnect
storage.ts lines 60-61:
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: defaults.token,The shared gateway token is intentionally not persisted in localStorage (security by design), so on page refresh it's gone. Without a deviceToken fallback, the user must re-enter the token every time.
Expected Behavior
The client and server should use the same token value when building the signature payload. Either:
- The client should sign with the shared token (matching what the server reconstructs), or
- The server should reconstruct the payload using the device token (matching what the client signs), or
- The client should send the device token in
auth.deviceTokenso the server's??chain falls through correctly
Suggested Fix
In ui/src/ui/gateway.ts, the auth object construction (lines 219-225) should include deviceToken when available:
const auth = authToken || this.opts.password || deviceToken
? {
token: authToken,
deviceToken: deviceToken, // ← ADD: send deviceToken separately
password: this.opts.password,
}
: undefined;This way, on the server side:
auth.token= shared gateway token (for authentication)auth.deviceToken= the per-device JWT (for signature payload reconstruction)- When
auth.tokenis present, server uses it for auth; when reconstructing the signed payload, server should preferauth.deviceTokenfor the token field
Alternatively, align the client signing to use the same value the server expects:
// In buildDeviceAuthPayload call:
token: authToken ?? deviceToken ?? null, // sign with shared token if availableCurrent Workaround
A Tampermonkey userscript that injects #token=<shared-token> into the URL on every page load (@run-at document-start), before the app's <script type="module"> executes. The app reads the token from the URL hash in applySettingsFromUrl(), re-establishing the connection on each refresh.
Gateway Config (for reference)
{
"gateway": {
"port": 18789,
"mode": "local",
"bind": "lan",
"controlUi": {
"dangerouslyAllowHostHeaderOriginFallback": true,
"allowInsecureAuth": true,
"dangerouslyDisableDeviceAuth": true
},
"auth": {
"mode": "token",
"token": "<redacted>"
},
"trustedProxies": ["0.0.0.0/0"]
}
}Expected behavior
The client and server should use the same token value when building the signature payload. Either:
- The client should sign with the shared token (matching what the server reconstructs), or
- The server should reconstruct the payload using the device token (matching what the client signs), or
- The client should send the device token in
auth.deviceTokenso the server's??chain falls through correctly
Actual behavior
dangerouslyDisableDeviceAuth: false=》device signature invalid
dangerouslyDisableDeviceAuth: true=》"device identity required" on every page refresh, must re-enter gateway token to reconnect
OpenClaw version
2026.3.7-beta.1
Operating system
Ubuntu 24.04.3 LTS
Install method
No response
Logs, screenshots, and evidence
Impact and severity
No response
Additional information
No response