fix(ui): keep Control UI device identity working on plain-HTTP origins (#124724)

* fix(ui): keep Control UI device identity working on plain-HTTP origins

@noble/ed25519 defaults its SHA-512 provider to crypto.subtle, which
browsers gate to secure contexts, so device identity silently vanished
on http:// LAN dashboards and connects fell back to shared-credential
auth with no pairing. Wire a lazy pure-JS @noble/hashes fallback for
SHA-512 and the fingerprint SHA-256, and drop the isSecureContext gate
in the connect path. Secure contexts keep the platform digests and pay
no startup bytes: the fallback loads as its own lazy chunk, kept out of
the gateway-runtime startup chunk on purpose.

* test(ui): cover device identity minting and signing without crypto.subtle

New jsdom regression suite fails on pre-fix code (subtle-less crypto stub
with getRandomValues, which real insecure contexts keep). Rewrites the
gateway connect tests that previously asserted the device-less insecure
fallback: an insecure context now attaches a device identity.

* docs(web): plain-HTTP dashboards now pair with a device identity

The signing key never crosses the wire, so HTTP+pairing is strictly
stronger than the old HTTP token-only fallback; HTTPS (Tailscale Serve)
stays the recommendation for transport privacy.

* fix(ui): drop unnecessary boolean literal compare in secure-context timing meta

* test(ui): declare device.id on the connect-frame test shape

* test(ui): split the subtle-less scope-upgrade e2e into the two real invariants

Without crypto.subtle the browser can now sign, so the banner offers the
explicit admin upgrade; manual-only guidance is reserved for browsers that
cannot mint an identity at all (no WebCrypto RNG). Also corrects the
connect-path comment: blocked storage yields an ephemeral identity, only a
failed mint degrades device-less.

* fix(ui): address review findings on the HTTP device-identity path

- Storage-blocked pages keep one stable in-memory identity per page
  lifetime instead of minting a fresh unpaired key on every reconnect,
  and a write-rejecting store no longer fails the mint (regression tests
  bite pre-fix).
- Connect timing now reports the real browser secure-context fact via a
  shared browserSecureContext() helper instead of inferring it from
  device-identity presence.
- Docs state the accepted trusted-proxy contract: browsers attach a
  device identity on every origin, so first connects follow the standard
  pairing flow (deviceAutoApprove or a one-time approval); device-less
  admission remains only for browsers that cannot mint an identity.

* refactor(ui): trim the connect-path additions under the max-lines cap
This commit is contained in:
Peter Steinberger
2026-08-16 12:20:00 -07:00
committed by GitHub
parent 22dd3d4ed0
commit 75bcc5cebe
11 changed files with 211 additions and 43 deletions
+3 -3
View File
@@ -688,11 +688,11 @@ Trusted proxy headers do not make node device pairing automatically trusted - `g
### Control UI over HTTP
The Control UI needs a secure context (HTTPS or localhost) to generate device identity.
The Control UI generates device identity with pure-JS Ed25519, so pairing works on any origin, including plain HTTP.
- Token/password auth does not replace browser device identity over remote plain HTTP. Use HTTPS (for example, Tailscale Serve) or open the UI on `127.0.0.1` from the Gateway host.
- Token/password auth does not replace browser device identity: HTTP browsers still pair with a signed device key, which never crosses the wire. Prefer HTTPS (for example, Tailscale Serve) — plaintext transport still exposes the page and the shared secret to on-path attackers.
- `gateway.controlUi.dangerouslyDisableDeviceAuth`: retired break-glass input, now fully inert. Control UI browsers pair through the normal device flow; `openclaw doctor --fix` removes the legacy key.
- Separately, successful `gateway.auth.mode: "trusted-proxy"` authentication can admit **operator** Control UI sessions without device identity. This does not extend to node-role Control UI sessions.
- Separately, successful `gateway.auth.mode: "trusted-proxy"` authentication can admit **operator** Control UI sessions without device identity when the browser cannot supply one. Browsers that can mint an identity (any origin, including plain HTTP) follow the normal pairing flow instead — automatic with `deviceAutoApprove`, otherwise a one-time approval. This does not extend to node-role Control UI sessions.
### Insecure/dangerous flags
+3 -3
View File
@@ -203,12 +203,12 @@ Enabling this option delegates new browser device enrollment entirely to the rev
## Control UI pairing behavior
When `gateway.auth.mode = "trusted-proxy"` is active and the request passes trusted-proxy checks, Control UI WebSocket sessions can connect without device pairing identity.
Browsers attach a device identity on every origin, including plain HTTP, so first connects follow the standard pairing flow: automatic approval when [`deviceAutoApprove`](#automatic-device-approval) is enabled, otherwise a one-time approval on the Gateway host. When `gateway.auth.mode = "trusted-proxy"` is active and the request passes trusted-proxy checks, only Control UI sessions from browsers that cannot supply a device identity at all are admitted device-less.
Scope implications:
- Device-less Control UI WebSocket sessions cannot self-declare permissions. OpenClaw clears their requested scope list to `[]`, then applies any matching server-side `identityScopes` grant after proxy identity verification.
- If methods fail with `missing scope` after a successful WebSocket connect, use HTTPS so the browser can generate device identity and complete pairing. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
- If methods fail with `missing scope` after a successful WebSocket connect, reload so the browser pairs its device identity, or approve the pending device request. See [Control UI insecure HTTP](/web/control-ui#insecure-http).
Reverse-proxy scope capping: if your proxy sends `x-openclaw-scopes` on the Control UI WebSocket upgrade request, OpenClaw caps device enrollment or upgrade requests and the final union of device-authorized and identity-granted session scopes. This header does not grant scopes; it only narrows authority. When `deviceAutoApprove.enabled` is true, the cap also limits the persistent device grant written by [automatic device approval](#automatic-device-approval).
@@ -537,7 +537,7 @@ Separate, non-trusted-proxy-specific findings also apply whenever Control UI is
Fix:
- For Control UI, use HTTPS so the browser can generate device identity and complete pairing.
- For Control UI, reload the dashboard so the browser generates device identity and completes pairing (works over HTTP too).
- For custom automation, use device identity/pairing, the reserved direct-local `gateway-client` backend helper path, or [admin HTTP RPC](/plugins/admin-http-rpc).
- Do not add the retired `gateway.controlUi.dangerouslyDisableDeviceAuth` key to current config; it is ignored and `openclaw doctor --fix` removes it.
+4 -2
View File
@@ -661,13 +661,15 @@ Tokenless Serve auth assumes the gateway host is trusted. If untrusted local cod
## Insecure HTTP
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`), the browser runs in a **non-secure context** and blocks WebCrypto. OpenClaw rejects token/password Control UI connections without device identity; a shared secret cannot replace browser identity.
Opening the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`) works: device identity is generated and signed with pure-JS Ed25519, so pairing does not depend on WebCrypto or a secure context. The signing key never leaves the browser, which makes it the one credential a plaintext transport cannot leak — unlike the shared token, which any on-path observer of an HTTP connection can read.
Plain HTTP remains a downgraded transport: an active attacker on the path can modify the page and capture anything in it. Prefer HTTPS wherever possible — Tailscale Serve gives you a real certificate with no configuration — and treat HTTP as a LAN-only convenience. Browsers also withhold secure-context features (for example passkeys) on HTTP, and Chrome's Local Network Access rules increasingly restrict plaintext local requests.
The supported device-less exception is successful operator Control UI auth
through `gateway.auth.mode: "trusted-proxy"`. There is no persistent config
switch that disables device identity.
**Recommended fix:** use HTTPS (Tailscale Serve) or open the UI locally at `https://<magicdns>/` (Serve) or `http://127.0.0.1:18789/` (on the gateway host).
**Recommended setup:** HTTPS via `https://<magicdns>/` (Tailscale Serve) or the UI locally at `http://127.0.0.1:18789/` (on the gateway host).
<AccordionGroup>
<Accordion title="Trusted-proxy note">
+3
View File
@@ -2366,6 +2366,9 @@ importers:
'@noble/ed25519':
specifier: 3.1.0
version: 3.1.0
'@noble/hashes':
specifier: 2.2.0
version: 2.2.0
'@novnc/novnc':
specifier: 1.7.0
version: 1.7.0
+3 -5
View File
@@ -59,11 +59,9 @@ export function controlUiStableChunkName(id: string): string | undefined {
return "config-runtime";
}
if (
moduleIdIncludesPackage(id, "@noble/ed25519") ||
moduleIdIncludesPackage(id, "@noble/hashes") ||
moduleIdIncludesPackage(id, "ipaddr.js")
) {
// @noble/hashes stays out of this startup chunk deliberately: it is only
// dynamically imported as the insecure-context fallback digest provider.
if (moduleIdIncludesPackage(id, "@noble/ed25519") || moduleIdIncludesPackage(id, "ipaddr.js")) {
return "gateway-runtime";
}
+1
View File
@@ -21,6 +21,7 @@
"@modelcontextprotocol/ext-apps": "1.7.5",
"@modelcontextprotocol/sdk": "1.30.0",
"@noble/ed25519": "3.1.0",
"@noble/hashes": "2.2.0",
"@novnc/novnc": "1.7.0",
"@openclaw/gateway-client": "workspace:*",
"@openclaw/gateway-protocol": "workspace:*",
+16 -9
View File
@@ -168,6 +168,7 @@ type ConnectFrame = {
caps?: string[];
scopes?: string[];
device?: {
id?: string;
signedAt?: number;
};
};
@@ -312,8 +313,11 @@ function getLatestWebSocket(): MockWebSocket {
}
function stubInsecureCrypto() {
// Real insecure contexts keep randomUUID/getRandomValues; only crypto.subtle
// is gated to secure contexts.
vi.stubGlobal("crypto", {
randomUUID: () => "req-insecure",
getRandomValues: (array: Uint8Array) => array.fill(7),
});
}
@@ -1038,7 +1042,9 @@ describe("GatewayBrowserClient", () => {
phase: "hello",
hasChallenge: true,
usedFallback: false,
secureContext: true,
// The Node test host has no window, so the reported browser secure-context
// fact is false even though a device identity is present.
secureContext: false,
hasDeviceIdentity: true,
hasDevice: true,
hasAuthToken: true,
@@ -1423,7 +1429,7 @@ describe("GatewayBrowserClient", () => {
client.stop();
});
it("uses a Gateway-owned recovery scope without browser crypto or client credentials", async () => {
it("uses a Gateway-owned recovery scope without shared credentials on an insecure context", async () => {
localStorage.clear();
stubInsecureCrypto();
const onRecoveryScopeChange = vi.fn();
@@ -1450,7 +1456,8 @@ describe("GatewayBrowserClient", () => {
await vi.waitFor(() => expect(onRecoveryScopeChange).toHaveBeenCalledOnce());
expect(connectFrame.params?.auth).toBeUndefined();
expect(connectFrame.params?.device).toBeUndefined();
// Pure-JS signing keeps device identity available even without crypto.subtle.
expect(connectFrame.params?.device?.id).toBe("device-1");
expect(client.recoveryScope).toBe("gateway-recovery-scope");
expect(client.recoveryScopeReady).toBe(true);
client.stop();
@@ -1625,7 +1632,7 @@ describe("GatewayBrowserClient", () => {
});
});
it("sends explicit shared token on insecure first connect without cached device fallback", async () => {
it("attaches device identity alongside an explicit shared token on an insecure context", async () => {
stubInsecureCrypto();
const client = new GatewayBrowserClient({
url: "ws://gateway.example:18789",
@@ -1641,11 +1648,11 @@ describe("GatewayBrowserClient", () => {
password: undefined,
deviceToken: undefined,
});
expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled();
expect(signDevicePayloadMock).not.toHaveBeenCalled();
expect(connectFrame.params?.device?.id).toBe("device-1");
expect(signDevicePayloadMock).toHaveBeenCalled();
});
it("sends explicit shared password on insecure first connect without cached device fallback", async () => {
it("attaches device identity alongside an explicit shared password on an insecure context", async () => {
stubInsecureCrypto();
const client = new GatewayBrowserClient({
url: "ws://gateway.example:18789",
@@ -1661,8 +1668,8 @@ describe("GatewayBrowserClient", () => {
password: "shared-password", // pragma: allowlist secret
deviceToken: undefined,
});
expect(loadOrCreateDeviceIdentityMock).not.toHaveBeenCalled();
expect(signDevicePayloadMock).not.toHaveBeenCalled();
expect(connectFrame.params?.device?.id).toBe("device-1");
expect(signDevicePayloadMock).toHaveBeenCalled();
});
it("uses cached device tokens only when no explicit shared auth is provided", async () => {
+16 -16
View File
@@ -72,6 +72,11 @@ export class GatewayRequestError extends GatewayProtocolRequestError {
}
}
function browserSecureContext(): boolean {
const win = typeof window !== "undefined" ? window : undefined;
return win?.isSecureContext === true;
}
function isLoopbackIPv4Host(host: string): boolean {
const octets = host.split(".");
return (
@@ -399,7 +404,7 @@ export class GatewayBrowserClient {
private connectPlanTimingPayload(plan: ConnectPlan): Partial<GatewayConnectTiming> {
return {
secureContext: Boolean(plan.deviceIdentity),
secureContext: browserSecureContext(),
hasDeviceIdentity: Boolean(plan.deviceIdentity),
hasDevice: Boolean(plan.params.device),
hasAuthToken: Boolean(plan.selectedAuth.authToken),
@@ -429,25 +434,20 @@ export class GatewayBrowserClient {
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitPassword = this.opts.password?.trim() || undefined;
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
// Token/password auth cannot replace browser device identity over plain HTTP.
const isSecureContext = typeof crypto !== "undefined" && Boolean(crypto.subtle);
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
// Pure-JS Ed25519 signing keeps device identity working on any origin,
// including plain-HTTP dashboards without crypto.subtle; only a failed
// mint (no WebCrypto RNG) degrades to a device-less connect.
let selectedAuth: GatewayConnectAuthSelection = {
authToken: explicitGatewayToken,
authPassword: explicitPassword,
};
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
this.client.recordTiming("device-identity-ready", generation, undefined, {
secureContext: true,
hasDeviceIdentity: true,
});
selectedAuth = this.selectConnectAuth({
role,
deviceId: deviceIdentity.deviceId,
});
const deviceIdentity = await loadOrCreateDeviceIdentity().catch(() => null);
this.client.recordTiming("device-identity-ready", generation, undefined, {
secureContext: browserSecureContext(),
hasDeviceIdentity: deviceIdentity !== null,
});
if (deviceIdentity) {
selectedAuth = this.selectConnectAuth({ role, deviceId: deviceIdentity.deviceId });
}
const scopes = resolveGatewayConnectScopes({
requestedScopes: selectedAuth.authBootstrapToken
+30 -1
View File
@@ -413,7 +413,7 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
}
});
it("shows manual repair guidance without a signed browser device", async () => {
it("offers the admin upgrade without crypto.subtle", async () => {
const context = await createContext();
const page = await context.newPage();
await page.addInitScript(() => {
@@ -422,6 +422,35 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
value: undefined,
});
});
const gateway = await installMockGateway(page, {
operatorScopes: LIMITED_SCOPES,
methodResponses: {
"device.scopes.requestUpgrade": { requestId: "upgrade-insecure" },
},
});
await page.goto(`${server.baseUrl}chat`);
// Pure-JS Ed25519 signs the device connect on insecure contexts, so the
// explicit upgrade path stays available instead of manual-only guidance.
await page.getByRole("button", { name: "Request admin" }).waitFor();
expect(await gateway.getRequests("device.scopes.requestUpgrade")).toHaveLength(0);
});
it("shows manual repair guidance when the browser cannot mint a device identity", async () => {
const context = await createContext();
const page = await context.newPage();
await page.addInitScript(() => {
// Without a WebCrypto RNG the identity mint fails and the client
// degrades to a device-less connect that cannot sign upgrade requests.
Object.defineProperty(globalThis.crypto, "subtle", {
configurable: true,
value: undefined,
});
Object.defineProperty(globalThis.crypto, "getRandomValues", {
configurable: true,
value: undefined,
});
});
const gateway = await installMockGateway(page, { operatorScopes: LIMITED_SCOPES });
await page.goto(`${server.baseUrl}chat`);
@@ -0,0 +1,96 @@
/* @vitest-environment jsdom */
// Plain-HTTP origins have no crypto.subtle; device identity must still mint,
// fingerprint, and sign with the pure-JS paths so pairing works everywhere.
import { hashes, verifyAsync } from "@noble/ed25519";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./index.ts";
function base64UrlDecode(value: string): Uint8Array {
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
const binary = atob(normalized);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
describe("device identity on an insecure context", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
// Real insecure contexts keep getRandomValues; only crypto.subtle is gated.
vi.stubGlobal("crypto", {
getRandomValues: (array: Uint8Array) => {
for (let i = 0; i < array.length; i += 1) {
array[i] = (i * 37 + 11) % 256;
}
return array;
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("mints, persists, and signs a device identity without crypto.subtle", async () => {
expect(globalThis.crypto.subtle).toBeUndefined();
const identity = await loadOrCreateDeviceIdentity();
expect(identity.deviceId).toMatch(/^[0-9a-f]{64}$/);
// The identity round-trips from storage with a stable id.
const reloaded = await loadOrCreateDeviceIdentity();
expect(reloaded.deviceId).toBe(identity.deviceId);
const payload = "insecure-context-payload";
const signature = await signDevicePayload(identity.privateKey, payload);
await expect(
verifyAsync(
base64UrlDecode(signature),
new TextEncoder().encode(payload),
base64UrlDecode(identity.publicKey),
),
).resolves.toBe(true);
});
it("keeps one stable identity per page lifetime when storage is blocked", async () => {
// The suite's default RNG stub is deterministic, which would mint identical
// keys and mask churn; every draw must differ for this regression to bite.
let draw = 0;
vi.stubGlobal("crypto", {
getRandomValues: (array: Uint8Array) => {
draw += 1;
for (let i = 0; i < array.length; i += 1) {
array[i] = (i * 37 + draw) % 256;
}
return array;
},
});
vi.stubGlobal("localStorage", undefined);
vi.resetModules();
const cold = await import("./index.ts");
const first = await cold.loadOrCreateDeviceIdentity();
const second = await cold.loadOrCreateDeviceIdentity();
expect(first.deviceId).toMatch(/^[0-9a-f]{64}$/);
expect(second.deviceId).toBe(first.deviceId);
});
it("still mints an identity when the store rejects writes", async () => {
vi.stubGlobal("localStorage", {
getItem: () => null,
setItem: () => {
throw new Error("quota exceeded");
},
});
vi.resetModules();
const cold = await import("./index.ts");
const identity = await cold.loadOrCreateDeviceIdentity();
expect(identity.deviceId).toMatch(/^[0-9a-f]{64}$/);
});
it("resolves the SHA-512 provider without crypto.subtle", async () => {
expect(typeof hashes.sha512Async).toBe("function");
const digest = await hashes.sha512Async?.(new Uint8Array([1, 2, 3]));
expect(digest).toHaveLength(64);
});
});
+36 -4
View File
@@ -1,7 +1,7 @@
// Presentation-free by contract: confirmations and secret reveals belong to the owning
// page, because native window.confirm/window.prompt silently answer in webviews with no
// dialog bridge and would end the action with no outcome and no recorded reason.
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
import { getPublicKeyAsync, hashes, signAsync, utils } from "@noble/ed25519";
import { gatewayCredentialScope } from "@openclaw/gateway-client/browser";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
@@ -15,6 +15,19 @@ import { cloneConfigObject, removePathValue, setPathValue } from "../config-form
// Shared Nodes operations used by the Control UI page and Gateway event hooks.
import { formatUiError } from "../format-error.ts";
// @noble/ed25519 defaults its SHA-512 to crypto.subtle, which browsers gate to
// secure contexts. On plain-HTTP origins the pure-JS digests load lazily so
// device identity keeps working there — the signing key is the one credential
// that never crosses the wire — while secure contexts pay no startup bytes.
const loadPureSha2 = () => import("@noble/hashes/sha2.js");
const subtleSha512Async = hashes.sha512Async;
hashes.sha512Async = async (message: Uint8Array) => {
if (globalThis.crypto?.subtle && subtleSha512Async) {
return await subtleSha512Async(message);
}
return (await loadPureSha2()).sha512(message);
};
type GatewayRequestClient = {
request<T = unknown>(method: string, params?: unknown): Promise<T>;
};
@@ -897,8 +910,14 @@ function bytesToHex(bytes: Uint8Array): string {
}
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", publicKey.slice().buffer);
return bytesToHex(new Uint8Array(hash));
// Prefer the platform digest where the context provides it; the pure-JS
// fallback keeps identity working on plain-HTTP origins without subtle.
const subtle = globalThis.crypto?.subtle;
if (subtle) {
const hash = await subtle.digest("SHA-256", publicKey.slice().buffer);
return bytesToHex(new Uint8Array(hash));
}
return bytesToHex((await loadPureSha2()).sha256(publicKey));
}
async function generateIdentity(): Promise<DeviceIdentity> {
@@ -912,6 +931,11 @@ async function generateIdentity(): Promise<DeviceIdentity> {
};
}
// Storage-blocked pages (for example private browsing) must still present one
// stable device per page lifetime; minting a fresh key on every reconnect
// would raise a new unpaired request each time and never retain approval.
let sessionDeviceIdentity: DeviceIdentity | null = null;
export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
const storage = getSafeLocalStorage();
try {
@@ -948,6 +972,9 @@ export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
// Invalid local identity is replaced below.
}
if (sessionDeviceIdentity) {
return sessionDeviceIdentity;
}
const identity = await generateIdentity();
const stored: StoredIdentity = {
version: 1,
@@ -956,7 +983,12 @@ export async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
privateKey: identity.privateKey,
createdAtMs: Date.now(),
};
storage?.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored));
try {
storage?.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored));
} catch {
// A write-rejecting store still gets the in-memory identity below.
}
sessionDeviceIdentity = identity;
return identity;
}