openclaw - ✅(Solved) Fix [Bug]: Control UI stores reusable device-auth secrets in browser localStorage [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#64163Fetched 2026-04-11 06:16:08
View on GitHub
Comments
0
Participants
1
Timeline
2
Reactions
0
Author
Participants
Timeline (top)
closed ×1cross-referenced ×1

Root Cause

No documented SECURITY.md trust boundary is crossed by the localStorage write itself; the Control UI is persisting device-auth material inside an already authenticated browser origin. openclaw/docs/gateway/security/index.md explicitly treats canvas content as arbitrary HTML/JS and warns not to make that content share the same origin as privileged web surfaces unless the implications are understood, which makes extractable persistent browser storage a concrete defense-in-depth gap rather than a standalone auth bypass. This finding is not covered by the Out of Scope section because the remediation measurably reduces reusable credential exposure, but exploitation still depends on a separate same-origin JavaScript foothold, so the correct category is hardening.

Impact

The Control UI persists both its Ed25519 device signing key and its cached operator device token in localStorage. Any same-origin JavaScript foothold in that browser origin can extract both values and reuse them to establish a fresh operator WebSocket session with operator.admin, operator.read, operator.write, operator.approvals, and operator.pairing scopes.

Fix Action

Fixed

PR fix notes

PR #64165: fix: Control UI stores reusable device-auth secrets in browser...

Description (problem / solution / changelog)

Fix Summary

The Control UI persists both its Ed25519 device signing key and its cached operator device token in localStorage. Any same-origin JavaScript foothold in that browser origin can extract both values and reuse them to establish a fresh operator WebSocket session with operator.admin, operator.read, operator.write, operator.approvals, and operator.pairing scopes.

Issue Linkage

Fixes #64163

Security Snapshot

  • CVSS v3.1: 8.0 (High)
  • CVSS v4.0: 8.8 (High)

Implementation Details

Files Changed

  • docs/web/control-ui.md (+5/-4)
  • ui/src/ui/device-auth-session-storage.test.ts (+106/-0)
  • ui/src/ui/device-auth.ts (+26/-4)
  • ui/src/ui/device-identity.ts (+41/-2)
  • ui/src/ui/gateway.node.test.ts (+6/-1)

Technical Analysis

  1. Open the Control UI over https:// or localhost and complete one successful device-authenticated connection.
  2. In the same browser profile, read the stored values with localStorage.getItem("openclaw-device-identity-v1") and localStorage.getItem("openclaw.device.auth.v1").
  3. Observe that the first entry contains deviceId, publicKey, and the raw Ed25519 privateKey, while the second entry contains the cached operator deviceToken, role, and scopes.
  4. Open a fresh WebSocket connection to the Gateway endpoint and wait for the connect.challenge nonce.
  5. Recreate the browser connect payload using the Control UI logic in ui/src/ui/gateway.ts:243-253: build the device-auth payload for role operator, token=<stolen device token>, and the full CONTROL_UI_OPERATOR_SCOPES, then sign it with signDevicePayload(privateKey, payload).
  6. Send a connect frame with auth.token set to the stolen device token and device set to the stolen device id/public key plus the fresh signature.
  7. The server accepts the signature via resolveDeviceSignaturePayloadVersion(), treats auth.token as a device-token fallback in resolveConnectAuthState(), accepts that token via verifyDeviceToken(), and returns hello-ok with operator auth state.

Validation Evidence

  • Command: pnpm test ui/src/ui/device-auth-session-storage.test.ts ui/src/ui/gateway.node.test.ts
  • Status: passed (with pre-existing baseline failures)

Risk and Compatibility

non-breaking; no known regression impact

AI-Assisted Disclosure

  • AI-assisted: yes
  • Model: opencode/claude-sonnet-4.6

Changed files

  • docs/web/control-ui.md (modified, +5/-4)
  • ui/src/ui/device-auth-session-storage.test.ts (added, +106/-0)
  • ui/src/ui/device-auth.ts (modified, +26/-4)
  • ui/src/ui/device-identity.ts (modified, +41/-2)
  • ui/src/ui/gateway.node.test.ts (modified, +6/-1)

Code Example

// ui/src/ui/device-identity.ts
const stored: StoredIdentity = {
  version: 1,
  deviceId: identity.deviceId,
  publicKey: identity.publicKey,
  privateKey: identity.privateKey,
  createdAtMs: Date.now(),
};
storage?.setItem(STORAGE_KEY, JSON.stringify(stored));

// ui/src/ui/device-auth.ts
function writeStore(store: DeviceAuthStore) {
  getSafeLocalStorage()?.setItem(STORAGE_KEY, JSON.stringify(store));
}
RAW_BUFFERClick to expand / collapse

Severity Assessment

CVSS Assessment

Metricv3.1v4.0
Score8.0 / 10.08.8 / 10.0
SeverityHighHigh
VectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:NCVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N
CalculatorCVSS v3.1 CalculatorCVSS v4.0 Calculator

Threat Model Alignment

Classification: hardening

No documented SECURITY.md trust boundary is crossed by the localStorage write itself; the Control UI is persisting device-auth material inside an already authenticated browser origin. openclaw/docs/gateway/security/index.md explicitly treats canvas content as arbitrary HTML/JS and warns not to make that content share the same origin as privileged web surfaces unless the implications are understood, which makes extractable persistent browser storage a concrete defense-in-depth gap rather than a standalone auth bypass. This finding is not covered by the Out of Scope section because the remediation measurably reduces reusable credential exposure, but exploitation still depends on a separate same-origin JavaScript foothold, so the correct category is hardening.

Impact

The Control UI persists both its Ed25519 device signing key and its cached operator device token in localStorage. Any same-origin JavaScript foothold in that browser origin can extract both values and reuse them to establish a fresh operator WebSocket session with operator.admin, operator.read, operator.write, operator.approvals, and operator.pairing scopes.

Affected Component

Files:

  • ui/src/ui/device-identity.ts:61-114 loads and stores the browser device identity, including the raw private key, under openclaw-device-identity-v1.
  • ui/src/ui/device-auth.ts:12-40 persists cached device tokens under openclaw.device.auth.v1 in localStorage.
  • ui/src/ui/gateway.ts:385-443 loads the stored identity, requests operator scopes, signs the connect payload with the stored private key, and caches the returned device token after a successful hello-ok.
  • src/gateway/server/ws-connection/handshake-auth-helpers.ts:180-217 accepts signatures generated from the stolen private key.
  • src/gateway/server/ws-connection/auth-context.ts:62-75 and src/gateway/server/ws-connection/auth-context.ts:194-241 treat auth.token as a device-token fallback and accept a stolen cached device token when it matches the paired device and requested scopes.
  • src/infra/device-pairing.ts:37-45 and src/infra/device-pairing.ts:770-817 show that device tokens are long-lived pairing records with revocation/rotation timestamps but no expiry check.
  • ui/src/ui/storage.ts:153-184 and ui/src/ui/storage.ts:228-231 keep the shared gateway token in session storage only, showing that persistent browser storage for auth material is already treated as sensitive elsewhere in the UI.
// ui/src/ui/device-identity.ts
const stored: StoredIdentity = {
  version: 1,
  deviceId: identity.deviceId,
  publicKey: identity.publicKey,
  privateKey: identity.privateKey,
  createdAtMs: Date.now(),
};
storage?.setItem(STORAGE_KEY, JSON.stringify(stored));

// ui/src/ui/device-auth.ts
function writeStore(store: DeviceAuthStore) {
  getSafeLocalStorage()?.setItem(STORAGE_KEY, JSON.stringify(store));
}

Technical Reproduction

  1. Open the Control UI over https:// or localhost and complete one successful device-authenticated connection.
  2. In the same browser profile, read the stored values with localStorage.getItem("openclaw-device-identity-v1") and localStorage.getItem("openclaw.device.auth.v1").
  3. Observe that the first entry contains deviceId, publicKey, and the raw Ed25519 privateKey, while the second entry contains the cached operator deviceToken, role, and scopes.
  4. Open a fresh WebSocket connection to the Gateway endpoint and wait for the connect.challenge nonce.
  5. Recreate the browser connect payload using the Control UI logic in ui/src/ui/gateway.ts:243-253: build the device-auth payload for role operator, token=<stolen device token>, and the full CONTROL_UI_OPERATOR_SCOPES, then sign it with signDevicePayload(privateKey, payload).
  6. Send a connect frame with auth.token set to the stolen device token and device set to the stolen device id/public key plus the fresh signature.
  7. The server accepts the signature via resolveDeviceSignaturePayloadVersion(), treats auth.token as a device-token fallback in resolveConnectAuthState(), accepts that token via verifyDeviceToken(), and returns hello-ok with operator auth state.

Demonstrated Impact

This is a reusable credential pair, not a one-shot bootstrap artifact. ui/src/ui/gateway.ts:566-597 prefers the cached device token when no explicit shared token or password is present, and buildGatewayConnectDevice() at ui/src/ui/gateway.ts:229-261 signs the fresh connect payload with the stored private key. On the server side, auth-context.ts:62-75 converts auth.token into a deviceTokenCandidate fallback, then resolveConnectAuthDecision() at auth-context.ts:194-241 accepts that token when verifyDeviceToken() passes. src/infra/device-pairing.ts:37-45 defines device tokens with createdAtMs, rotatedAtMs, revokedAtMs, and lastUsedAtMs, and verifyDeviceToken() at src/infra/device-pairing.ts:770-817 enforces equality, revocation state, and approved-scope bounds only; there is no expiry field or TTL check. Together, the extracted private key and cached device token let same-origin JavaScript reopen operator sessions until the token is revoked or rotated or the pairing is removed. The existing browser UI already avoids persistent storage for the shared gateway token by keeping it session-scoped in ui/src/ui/storage.ts:230-231, so removing device-auth material from extractable localStorage yields a concrete reduction in reusable credential exposure.

Environment

Source inspection against the current openclaw/ tree. Preconditions: a browser has completed at least one successful Control UI device-auth handshake, the device is already paired, hello-ok has cached an operator device token, and the browser profile has working localStorage access for the Gateway origin.

Remediation Advice

Keep browser device-auth material non-extractable or session-scoped. Store the device signing key as a non-extractable Web Crypto key instead of serialized localStorage data, move cached device tokens out of persistent origin storage, and align Control UI device-auth handling with the existing session-only treatment used for the shared gateway token.

<!-- submission-marker:CR-pom-control-ui-stores-device-auth-secrets-in-localstorage-2 -->

extent analysis

TL;DR

The most likely fix is to store browser device-auth material in a non-extractable manner, such as using Web Crypto keys, and move cached device tokens out of persistent origin storage.

Guidance

  • Identify and modify the code responsible for storing device-auth material in localStorage, specifically in ui/src/ui/device-identity.ts and ui/src/ui/device-auth.ts.
  • Replace the use of localStorage with a non-extractable storage solution, such as Web Crypto keys, to store the device signing key.
  • Move cached device tokens out of persistent origin storage and consider using a session-scoped storage solution, aligning with the existing treatment used for the shared gateway token.
  • Review and update the relevant code in ui/src/ui/gateway.ts, src/gateway/server/ws-connection/handshake-auth-helpers.ts, and src/gateway/server/ws-connection/auth-context.ts to accommodate the changes.

Example

// Example of using Web Crypto keys to store the device signing key
const storedKey = await crypto.subtle.generateKey(
  {
    name: 'Ed25519',
    namedCurve: 'Ed25519',
  },
  true,
  ['sign']
);

Notes

The provided code snippets and file paths are specific to the openclaw/ tree and may require adjustments based on the actual codebase. Additionally, the implementation of Web Crypto keys and session-scoped storage solutions may vary depending on the specific requirements and constraints of the project.

Recommendation

Apply the workaround by storing browser device-auth material in a non-extractable manner and moving cached device tokens out of persistent origin storage. This approach reduces the exposure of reusable credentials and aligns with the existing security practices in the project.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING