mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
22dd3d4ed0
commit
75bcc5cebe
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user