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:
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 )
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
}
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:
src/lib/device-identity.ts (Key Storage)
src/lib/device-identity.ts (Key Generation)
src/lib/device-identity.ts (Key Loading)
src/lib/device-identity.ts (Signing)
// 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 , '' )
}
function fromBase64Url ( value : string ) : Uint8Array {
const normalized = value . replace ( /-/ g , '+' ). replace ( /_/ g , '/' )
const padded = normalized + '=' . repeat (( 4 - ( normalized . length % 4 )) % 4 )
const binary = atob ( padded )
const bytes = new Uint8Array ( binary . length )
for ( let i = 0 ; i < binary . length ; i ++ ) {
bytes [ i ] = binary . charCodeAt ( i )
}
return bytes
}
async function sha256Hex ( buffer : ArrayBuffer ) : Promise < string > {
const digest = await crypto . subtle . digest ( 'SHA-256' , new Uint8Array ( buffer ))
const bytes = new Uint8Array ( digest )
return Array . from ( bytes )
. map (( b ) => b . toString ( 16 ). padStart ( 2 , '0' ))
. join ( '' )
}
WebSocket Challenge-Response
The complete handshake flow:
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 )
Challenge
Gateway sends auth challenge with random nonce: {
"type" : "auth-challenge" ,
"nonce" : "b3f8e19d-4c2a-4e7f-9a1b-5d8c3e6f2a4d"
}
Sign
Client signs nonce with device private key: const identity = await getOrCreateDeviceIdentity ()
const { signature , signedAt } = await signPayload (
identity . privateKey ,
challenge . nonce
)
Respond
Client sends auth response with signature: {
"type" : "auth-response" ,
"device" : {
"id" : "a3c8f29e7b4d1c5e9f8a2b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f" ,
"publicKey" : "MCowBQYDK2VwAyEA..." ,
"signature" : "dGhpcyBpcyBhIHNpZ25hdHVyZQ..." ,
"signedAt" : 1709568000000
},
"token" : "gateway-auth-token"
}
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 })
}
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
Key Security
Protocol Security
Attack Resistance
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
Challenge-response : Prevents replay attacks
Timestamp : signedAt field provides freshness guarantee
Public key transmission : Only public key sent over wire
Token-based fallback : System works without device identity
Replay attacks : Nonce is single-use, signature includes timestamp
Man-in-the-middle : Use WSS (WebSocket Secure) in production
Key extraction : Private key stored in non-extractable CryptoKey
Signature forgery : Ed25519 provides 128-bit security level
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
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)
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()
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.
Key Management
Gateway Configuration
Error Handling
Never export or share private keys
Document device IDs for audit trails
Use device tokens for session continuity
Rotate device identity on security events
Enforce signature timestamp checks (±5 minutes)
Log device IDs for security auditing
Rate-limit auth attempts per device
Support both device identity and token-only auth
Gracefully degrade to token-only auth
Show clear UI feedback for auth failures
Log crypto errors for debugging
Implement reconnection with backoff