Skip to main content

Overview

Device Identity provides cryptographic authentication for WebSocket connections using Ed25519 digital signatures. Each browser generates a persistent key pair and signs server challenges during the gateway handshake, enabling secure device-level authentication beyond simple API tokens.
This implements OpenClaw gateway protocol v3 challenge-response. The system gracefully falls back to token-only auth if Ed25519 is unavailable (older browsers).

Architecture

The device identity system has three layers:
1

Key Generation

On first use, the browser generates an Ed25519 key pair and stores it in localStorage:
const keyPair = await crypto.subtle.generateKey(
  'Ed25519',
  true,  // extractable
  ['sign', 'verify']
)

const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)

// Device ID = SHA-256(public key) in lowercase hex
const deviceId = await sha256Hex(pubRaw)
2

Challenge-Response

During WebSocket connect, the gateway sends a nonce. The client signs it:
// Gateway sends: { type: 'auth-challenge', nonce: 'random-string' }

// Client signs nonce with private key
const signature = await signPayload(privateKey, nonce, Date.now())

// Client responds:
{
  type: 'auth-response',
  device: {
    id: deviceId,              // SHA-256(public key)
    publicKey: publicKeyBase64, // Base64url-encoded raw key
    signature: signature,       // Base64url-encoded signature
    signedAt: timestamp         // Signature timestamp
  },
  token: gatewayToken  // Optional gateway auth token
}
3

Token Caching

On successful auth, the gateway returns a device token cached for reuse:
// Gateway responds: { type: 'auth-success', deviceToken: '...' }

cacheDeviceToken(deviceToken)
// Stored in localStorage: 'mc-device-token'

Implementation

The complete device identity module:
// localStorage keys
const STORAGE_DEVICE_ID = 'mc-device-id'
const STORAGE_PUBKEY = 'mc-device-pubkey'
const STORAGE_PRIVKEY = 'mc-device-privkey'
const STORAGE_DEVICE_TOKEN = 'mc-device-token'

export interface DeviceIdentity {
  deviceId: string              // SHA-256(publicKey) hex
  publicKeyBase64: string       // Base64url raw public key
  privateKey: CryptoKey         // Non-extractable after import
}

Base64url Encoding

The protocol uses base64url (RFC 4648) for binary data:
function toBase64Url(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer)
  let binary = ''
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i])
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '')
}

WebSocket Challenge-Response

The complete handshake flow:
1

Connect

Client initiates WebSocket connection to gateway:
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${proto}://gateway-host:18789`
const ws = new WebSocket(wsUrl)
2

Challenge

Gateway sends auth challenge with random nonce:
{
  "type": "auth-challenge",
  "nonce": "b3f8e19d-4c2a-4e7f-9a1b-5d8c3e6f2a4d"
}
3

Sign

Client signs nonce with device private key:
const identity = await getOrCreateDeviceIdentity()
const { signature, signedAt } = await signPayload(
  identity.privateKey,
  challenge.nonce
)
4

Respond

Client sends auth response with signature:
{
  "type": "auth-response",
  "device": {
    "id": "a3c8f29e7b4d1c5e9f8a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f",
    "publicKey": "MCowBQYDK2VwAyEA...",
    "signature": "dGhpcyBpcyBhIHNpZ25hdHVyZQ...",
    "signedAt": 1709568000000
  },
  "token": "gateway-auth-token"
}
5

Verify (Gateway)

Gateway verifies signature and responds:
// Gateway verifies:
const isValid = await crypto.subtle.verify(
  'Ed25519',
  publicKey,
  signature,
  nonce
)

if (isValid) {
  const deviceToken = generateDeviceToken()
  send({ type: 'auth-success', deviceToken })
}
6

Cache Token

Client caches device token for future connections:
export function cacheDeviceToken(token: string): void {
  localStorage.setItem(STORAGE_DEVICE_TOKEN, token)
}

export function getCachedDeviceToken(): string | null {
  return localStorage.getItem(STORAGE_DEVICE_TOKEN)
}

Security Properties

  • Ed25519: State-of-the-art elliptic curve signature scheme
  • Non-extractable: Private key cannot be exported after import
  • localStorage: Keys persist across browser sessions
  • Device-specific: Each browser instance has unique identity

Graceful Degradation

The system falls back when Ed25519 is unavailable:
export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
  try {
    // Try Ed25519
    return await getOrCreateDeviceIdentityInternal()
  } catch (error) {
    if (error.name === 'NotSupportedError') {
      console.warn('Ed25519 not supported, using token-only auth')
      // Fall back to gateway token only
      return null
    }
    throw error
  }
}

// In WebSocket connect:
if (deviceIdentity) {
  // Send challenge-response with device identity
  sendAuthResponse({ device, token })
} else {
  // Send token-only auth
  sendAuthResponse({ token })
}
Browser Compatibility: Ed25519 requires Chrome 113+, Firefox 102+, Safari 17+. Older browsers fall back to token-only authentication.

Management Operations

Clear Device Identity

Force regeneration of device keys:
export function clearDeviceIdentity(): void {
  localStorage.removeItem(STORAGE_DEVICE_ID)
  localStorage.removeItem(STORAGE_PUBKEY)
  localStorage.removeItem(STORAGE_PRIVKEY)
  localStorage.removeItem(STORAGE_DEVICE_TOKEN)
}

// Use from browser console:
// clearDeviceIdentity()
// location.reload()

Inspect Device Identity

View current device ID:
import { getOrCreateDeviceIdentity } from '@/lib/device-identity'

const identity = await getOrCreateDeviceIdentity()
console.log('Device ID:', identity.deviceId)
console.log('Public Key:', identity.publicKeyBase64)

Export for Gateway Registration

Some gateways require pre-registration:
const identity = await getOrCreateDeviceIdentity()
const registration = {
  deviceId: identity.deviceId,
  publicKey: identity.publicKeyBase64,
  name: 'Mission Control Browser',
  createdAt: new Date().toISOString(),
}

console.log(JSON.stringify(registration, null, 2))

Troubleshooting

1

Connection fails with 'Invalid signature'

  • Clear device identity and regenerate: clearDeviceIdentity()
  • Check browser console for Ed25519 support errors
  • Verify gateway is running protocol v3
  • Ensure system clock is synchronized (signature timestamp validation)
2

Keys corrupted or not loading

  • Check localStorage size limits (5-10MB per origin)
  • Verify no extensions clearing localStorage
  • Inspect Application > Local Storage in DevTools
  • Force regeneration with clearDeviceIdentity()
3

Fallback to token-only auth

  • Check browser version (Ed25519 requires modern browsers)
  • Verify crypto.subtle is available (requires HTTPS or localhost)
  • Review browser console for “NotSupportedError” messages

Best Practices

Production Deployments: Always use WSS (WebSocket Secure) in production. Ed25519 signatures don’t encrypt the payload—they only provide authentication.
  • Never export or share private keys
  • Document device IDs for audit trails
  • Use device tokens for session continuity
  • Rotate device identity on security events