diff --git a/src/lib/device-identity.ts b/src/lib/device-identity.ts new file mode 100644 index 0000000..020bb9a --- /dev/null +++ b/src/lib/device-identity.ts @@ -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 { + return crypto.subtle.importKey('pkcs8', pkcs8Bytes.buffer as ArrayBuffer, 'Ed25519', false, ['sign']) +} + +async function createNewIdentity(): Promise { + 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 { + 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) +} diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index 7acb8fa..460a475 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -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(), diff --git a/tests/device-identity.spec.ts b/tests/device-identity.spec.ts new file mode 100644 index 0000000..12c0b18 --- /dev/null +++ b/tests/device-identity.spec.ts @@ -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() + }) +})