feat: Ed25519 device identity for WebSocket challenge-response handshake (#85)
Add client-side Ed25519 key pair generation and nonce signing for OpenClaw gateway protocol v3 connect.challenge flow. Keys persist in localStorage and are reused across sessions. The handshake falls back gracefully to auth-token-only mode when Ed25519 is unavailable. Closes #74, closes #79, closes #81
This commit is contained in:
parent
f0f22129be
commit
a4a606d5ac
|
|
@ -0,0 +1,138 @@
|
|||
'use client'
|
||||
|
||||
/**
|
||||
* Ed25519 device identity for OpenClaw gateway protocol v3 challenge-response.
|
||||
*
|
||||
* Generates a persistent Ed25519 key pair on first use, stores it in localStorage,
|
||||
* and signs server nonces during the WebSocket connect handshake.
|
||||
*
|
||||
* Falls back gracefully when Ed25519 is unavailable (older browsers) —
|
||||
* the handshake proceeds without device identity (auth-token-only mode).
|
||||
*/
|
||||
|
||||
// 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
|
||||
publicKeyBase64: string
|
||||
privateKey: CryptoKey
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function toBase64(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)
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): Uint8Array {
|
||||
const binary = atob(b64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function generateUUID(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// ── Key management ───────────────────────────────────────────────
|
||||
|
||||
async function importPrivateKey(pkcs8Bytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('pkcs8', pkcs8Bytes.buffer as ArrayBuffer, 'Ed25519', false, ['sign'])
|
||||
}
|
||||
|
||||
async function createNewIdentity(): Promise<DeviceIdentity> {
|
||||
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
|
||||
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
|
||||
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
|
||||
|
||||
const deviceId = generateUUID()
|
||||
const publicKeyBase64 = toBase64(pubRaw)
|
||||
const privateKeyBase64 = toBase64(privPkcs8)
|
||||
|
||||
localStorage.setItem(STORAGE_DEVICE_ID, deviceId)
|
||||
localStorage.setItem(STORAGE_PUBKEY, publicKeyBase64)
|
||||
localStorage.setItem(STORAGE_PRIVKEY, privateKeyBase64)
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
publicKeyBase64,
|
||||
privateKey: keyPair.privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns existing device identity from localStorage or generates a new one.
|
||||
* Throws if Ed25519 is not supported by the browser.
|
||||
*/
|
||||
export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
||||
const storedId = localStorage.getItem(STORAGE_DEVICE_ID)
|
||||
const storedPub = localStorage.getItem(STORAGE_PUBKEY)
|
||||
const storedPriv = localStorage.getItem(STORAGE_PRIVKEY)
|
||||
|
||||
if (storedId && storedPub && storedPriv) {
|
||||
try {
|
||||
const privateKey = await importPrivateKey(fromBase64(storedPriv))
|
||||
return {
|
||||
deviceId: storedId,
|
||||
publicKeyBase64: storedPub,
|
||||
privateKey,
|
||||
}
|
||||
} catch {
|
||||
// Stored key corrupted — regenerate
|
||||
console.warn('Device identity keys corrupted, regenerating...')
|
||||
}
|
||||
}
|
||||
|
||||
return createNewIdentity()
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a server nonce with the Ed25519 private key.
|
||||
* Returns base64-encoded signature and signing timestamp.
|
||||
*/
|
||||
export async function signChallenge(
|
||||
privateKey: CryptoKey,
|
||||
nonce: string
|
||||
): Promise<{ signature: string; signedAt: number }> {
|
||||
const encoder = new TextEncoder()
|
||||
const nonceBytes = encoder.encode(nonce)
|
||||
const signedAt = Date.now()
|
||||
const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, nonceBytes)
|
||||
return {
|
||||
signature: toBase64(signatureBuffer),
|
||||
signedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads cached device token from localStorage (returned by gateway on successful connect). */
|
||||
export function getCachedDeviceToken(): string | null {
|
||||
return localStorage.getItem(STORAGE_DEVICE_TOKEN)
|
||||
}
|
||||
|
||||
/** Caches the device token returned by the gateway after successful connect. */
|
||||
export function cacheDeviceToken(token: string): void {
|
||||
localStorage.setItem(STORAGE_DEVICE_TOKEN, token)
|
||||
}
|
||||
|
||||
/** Removes all device identity data from localStorage (for troubleshooting). */
|
||||
export function clearDeviceIdentity(): void {
|
||||
localStorage.removeItem(STORAGE_DEVICE_ID)
|
||||
localStorage.removeItem(STORAGE_PUBKEY)
|
||||
localStorage.removeItem(STORAGE_PRIVKEY)
|
||||
localStorage.removeItem(STORAGE_DEVICE_TOKEN)
|
||||
}
|
||||
|
|
@ -3,6 +3,12 @@
|
|||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { useMissionControl } from '@/store'
|
||||
import { normalizeModel } from '@/lib/utils'
|
||||
import {
|
||||
getOrCreateDeviceIdentity,
|
||||
signChallenge,
|
||||
getCachedDeviceToken,
|
||||
cacheDeviceToken,
|
||||
} from '@/lib/device-identity'
|
||||
|
||||
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
||||
const PROTOCOL_VERSION = 3
|
||||
|
|
@ -128,8 +134,34 @@ export function useWebSocket() {
|
|||
}
|
||||
}, [setConnection])
|
||||
|
||||
// Send the connect handshake
|
||||
const sendConnectHandshake = useCallback((ws: WebSocket, nonce?: string) => {
|
||||
// Send the connect handshake (async for Ed25519 device identity signing)
|
||||
const sendConnectHandshake = useCallback(async (ws: WebSocket, nonce?: string) => {
|
||||
let device: {
|
||||
id: string
|
||||
publicKey: string
|
||||
signature: string
|
||||
signedAt: number
|
||||
nonce: string
|
||||
} | undefined
|
||||
|
||||
if (nonce) {
|
||||
try {
|
||||
const identity = await getOrCreateDeviceIdentity()
|
||||
const { signature, signedAt } = await signChallenge(identity.privateKey, nonce)
|
||||
device = {
|
||||
id: identity.deviceId,
|
||||
publicKey: identity.publicKeyBase64,
|
||||
signature,
|
||||
signedAt,
|
||||
nonce,
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Device identity unavailable, proceeding without:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const cachedToken = getCachedDeviceToken()
|
||||
|
||||
const connectRequest = {
|
||||
type: 'req',
|
||||
method: 'connect',
|
||||
|
|
@ -149,7 +181,9 @@ export function useWebSocket() {
|
|||
scopes: ['operator.admin'],
|
||||
auth: authTokenRef.current
|
||||
? { token: authTokenRef.current }
|
||||
: undefined
|
||||
: undefined,
|
||||
device,
|
||||
deviceToken: cachedToken || undefined,
|
||||
}
|
||||
}
|
||||
console.log('Sending connect handshake:', connectRequest)
|
||||
|
|
@ -252,6 +286,10 @@ export function useWebSocket() {
|
|||
console.log('Handshake complete!')
|
||||
handshakeCompleteRef.current = true
|
||||
reconnectAttemptsRef.current = 0
|
||||
// Cache device token if returned by gateway
|
||||
if (frame.result?.deviceToken) {
|
||||
cacheDeviceToken(frame.result.deviceToken)
|
||||
}
|
||||
setConnection({
|
||||
isConnected: true,
|
||||
lastConnected: new Date(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* E2E tests for Ed25519 device identity (Issues #74, #79, #81).
|
||||
*
|
||||
* These run in a real Chromium browser to exercise Web Crypto Ed25519 and
|
||||
* localStorage persistence — the same environment Mission Control uses.
|
||||
*/
|
||||
|
||||
test.describe('Device Identity — Ed25519 key management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to the app so we have a page context with localStorage
|
||||
await page.goto('/')
|
||||
// Clear any leftover device identity from previous runs
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('mc-device-id')
|
||||
localStorage.removeItem('mc-device-pubkey')
|
||||
localStorage.removeItem('mc-device-privkey')
|
||||
localStorage.removeItem('mc-device-token')
|
||||
})
|
||||
})
|
||||
|
||||
test('generates Ed25519 key pair and stores in localStorage', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
// Check Ed25519 support first
|
||||
try {
|
||||
await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
} catch {
|
||||
return { skipped: true, reason: 'Ed25519 not supported in this browser' }
|
||||
}
|
||||
|
||||
// Dynamically import the module (bundled by Next.js)
|
||||
// Since we can't import from the bundle directly, replicate the core logic
|
||||
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
|
||||
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
|
||||
|
||||
// base64 encode
|
||||
const toBase64 = (buf: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
const deviceId = crypto.randomUUID()
|
||||
const pubB64 = toBase64(pubRaw)
|
||||
const privB64 = toBase64(privPkcs8)
|
||||
|
||||
localStorage.setItem('mc-device-id', deviceId)
|
||||
localStorage.setItem('mc-device-pubkey', pubB64)
|
||||
localStorage.setItem('mc-device-privkey', privB64)
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
deviceId,
|
||||
pubKeyLength: pubRaw.byteLength,
|
||||
privKeyLength: privPkcs8.byteLength,
|
||||
storedId: localStorage.getItem('mc-device-id'),
|
||||
storedPub: localStorage.getItem('mc-device-pubkey'),
|
||||
storedPriv: localStorage.getItem('mc-device-privkey'),
|
||||
}
|
||||
})
|
||||
|
||||
if (result.skipped) {
|
||||
test.skip(true, result.reason as string)
|
||||
return
|
||||
}
|
||||
|
||||
// Ed25519 public key is always 32 bytes raw
|
||||
expect(result.pubKeyLength).toBe(32)
|
||||
// PKCS8-wrapped Ed25519 private key is 48 bytes
|
||||
expect(result.privKeyLength).toBe(48)
|
||||
// Values persisted to localStorage
|
||||
expect(result.storedId).toBe(result.deviceId)
|
||||
expect(result.storedPub).toBeTruthy()
|
||||
expect(result.storedPriv).toBeTruthy()
|
||||
})
|
||||
|
||||
test('signs a nonce and produces a valid Ed25519 signature', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
try {
|
||||
await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
} catch {
|
||||
return { skipped: true, reason: 'Ed25519 not supported' }
|
||||
}
|
||||
|
||||
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
const nonce = 'test-nonce-abc123'
|
||||
const encoder = new TextEncoder()
|
||||
const nonceBytes = encoder.encode(nonce)
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, nonceBytes)
|
||||
const verified = await crypto.subtle.verify('Ed25519', keyPair.publicKey, signatureBuffer, nonceBytes)
|
||||
|
||||
return {
|
||||
skipped: false,
|
||||
signatureLength: signatureBuffer.byteLength,
|
||||
verified,
|
||||
}
|
||||
})
|
||||
|
||||
if (result.skipped) {
|
||||
test.skip(true, result.reason as string)
|
||||
return
|
||||
}
|
||||
|
||||
// Ed25519 signature is always 64 bytes
|
||||
expect(result.signatureLength).toBe(64)
|
||||
// Signature must verify against the same nonce
|
||||
expect(result.verified).toBe(true)
|
||||
})
|
||||
|
||||
test('persisted key pair survives page reload', async ({ page }) => {
|
||||
const firstRun = await page.evaluate(async () => {
|
||||
try {
|
||||
const kp = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey)
|
||||
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey)
|
||||
const toBase64 = (buf: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||
return btoa(binary)
|
||||
}
|
||||
const deviceId = crypto.randomUUID()
|
||||
localStorage.setItem('mc-device-id', deviceId)
|
||||
localStorage.setItem('mc-device-pubkey', toBase64(pubRaw))
|
||||
localStorage.setItem('mc-device-privkey', toBase64(privPkcs8))
|
||||
return { skipped: false, deviceId }
|
||||
} catch {
|
||||
return { skipped: true, reason: 'Ed25519 not supported' }
|
||||
}
|
||||
})
|
||||
|
||||
if (firstRun.skipped) {
|
||||
test.skip(true, firstRun.reason as string)
|
||||
return
|
||||
}
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
|
||||
const afterReload = await page.evaluate(() => ({
|
||||
deviceId: localStorage.getItem('mc-device-id'),
|
||||
pubKey: localStorage.getItem('mc-device-pubkey'),
|
||||
privKey: localStorage.getItem('mc-device-privkey'),
|
||||
}))
|
||||
|
||||
expect(afterReload.deviceId).toBe(firstRun.deviceId)
|
||||
expect(afterReload.pubKey).toBeTruthy()
|
||||
expect(afterReload.privKey).toBeTruthy()
|
||||
})
|
||||
|
||||
test('reimported private key can sign and verify', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
try {
|
||||
await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
} catch {
|
||||
return { skipped: true, reason: 'Ed25519 not supported' }
|
||||
}
|
||||
|
||||
const toBase64 = (buf: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||
return btoa(binary)
|
||||
}
|
||||
const fromBase64 = (b64: string) => {
|
||||
const binary = atob(b64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Generate and export
|
||||
const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
|
||||
const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
|
||||
const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
|
||||
|
||||
// Serialize to base64 (simulates localStorage round-trip)
|
||||
const pubB64 = toBase64(pubRaw)
|
||||
const privB64 = toBase64(privPkcs8)
|
||||
|
||||
// Re-import from base64
|
||||
const reimportedPriv = await crypto.subtle.importKey(
|
||||
'pkcs8', fromBase64(privB64).buffer as ArrayBuffer, 'Ed25519', false, ['sign']
|
||||
)
|
||||
const reimportedPub = await crypto.subtle.importKey(
|
||||
'raw', fromBase64(pubB64).buffer as ArrayBuffer, 'Ed25519', false, ['verify']
|
||||
)
|
||||
|
||||
// Sign with reimported private key, verify with reimported public key
|
||||
const nonce = 'round-trip-nonce-xyz'
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(nonce)
|
||||
const sig = await crypto.subtle.sign('Ed25519', reimportedPriv, data)
|
||||
const ok = await crypto.subtle.verify('Ed25519', reimportedPub, sig, data)
|
||||
|
||||
return { skipped: false, verified: ok, signatureLength: sig.byteLength }
|
||||
})
|
||||
|
||||
if (result.skipped) {
|
||||
test.skip(true, result.reason as string)
|
||||
return
|
||||
}
|
||||
|
||||
expect(result.verified).toBe(true)
|
||||
expect(result.signatureLength).toBe(64)
|
||||
})
|
||||
|
||||
test('device token cache read/write', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('mc-device-token', 'tok_test_abc123')
|
||||
})
|
||||
|
||||
const token = await page.evaluate(() => localStorage.getItem('mc-device-token'))
|
||||
expect(token).toBe('tok_test_abc123')
|
||||
|
||||
// Clear
|
||||
await page.evaluate(() => localStorage.removeItem('mc-device-token'))
|
||||
const cleared = await page.evaluate(() => localStorage.getItem('mc-device-token'))
|
||||
expect(cleared).toBeNull()
|
||||
})
|
||||
|
||||
test('clearDeviceIdentity removes all storage keys', async ({ page }) => {
|
||||
// Set all keys
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('mc-device-id', 'test-id')
|
||||
localStorage.setItem('mc-device-pubkey', 'test-pub')
|
||||
localStorage.setItem('mc-device-privkey', 'test-priv')
|
||||
localStorage.setItem('mc-device-token', 'test-token')
|
||||
})
|
||||
|
||||
// Clear (replicate clearDeviceIdentity logic)
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('mc-device-id')
|
||||
localStorage.removeItem('mc-device-pubkey')
|
||||
localStorage.removeItem('mc-device-privkey')
|
||||
localStorage.removeItem('mc-device-token')
|
||||
})
|
||||
|
||||
const remaining = await page.evaluate(() => ({
|
||||
id: localStorage.getItem('mc-device-id'),
|
||||
pub: localStorage.getItem('mc-device-pubkey'),
|
||||
priv: localStorage.getItem('mc-device-privkey'),
|
||||
token: localStorage.getItem('mc-device-token'),
|
||||
}))
|
||||
|
||||
expect(remaining.id).toBeNull()
|
||||
expect(remaining.pub).toBeNull()
|
||||
expect(remaining.priv).toBeNull()
|
||||
expect(remaining.token).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue