nyk commited on
Commit
a79a97a
·
unverified ·
1 Parent(s): f5d72bc

feat: Ed25519 device identity for WebSocket challenge-response handshake (#85)

Browse files

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

src/lib/device-identity.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ /**
4
+ * Ed25519 device identity for OpenClaw gateway protocol v3 challenge-response.
5
+ *
6
+ * Generates a persistent Ed25519 key pair on first use, stores it in localStorage,
7
+ * and signs server nonces during the WebSocket connect handshake.
8
+ *
9
+ * Falls back gracefully when Ed25519 is unavailable (older browsers) —
10
+ * the handshake proceeds without device identity (auth-token-only mode).
11
+ */
12
+
13
+ // localStorage keys
14
+ const STORAGE_DEVICE_ID = 'mc-device-id'
15
+ const STORAGE_PUBKEY = 'mc-device-pubkey'
16
+ const STORAGE_PRIVKEY = 'mc-device-privkey'
17
+ const STORAGE_DEVICE_TOKEN = 'mc-device-token'
18
+
19
+ export interface DeviceIdentity {
20
+ deviceId: string
21
+ publicKeyBase64: string
22
+ privateKey: CryptoKey
23
+ }
24
+
25
+ // ── Helpers ──────────────────────────────────────────────────────
26
+
27
+ function toBase64(buffer: ArrayBuffer): string {
28
+ const bytes = new Uint8Array(buffer)
29
+ let binary = ''
30
+ for (let i = 0; i < bytes.byteLength; i++) {
31
+ binary += String.fromCharCode(bytes[i])
32
+ }
33
+ return btoa(binary)
34
+ }
35
+
36
+ function fromBase64(b64: string): Uint8Array {
37
+ const binary = atob(b64)
38
+ const bytes = new Uint8Array(binary.length)
39
+ for (let i = 0; i < binary.length; i++) {
40
+ bytes[i] = binary.charCodeAt(i)
41
+ }
42
+ return bytes
43
+ }
44
+
45
+ function generateUUID(): string {
46
+ return crypto.randomUUID()
47
+ }
48
+
49
+ // ── Key management ───────────────────────────────────────────────
50
+
51
+ async function importPrivateKey(pkcs8Bytes: Uint8Array): Promise<CryptoKey> {
52
+ return crypto.subtle.importKey('pkcs8', pkcs8Bytes.buffer as ArrayBuffer, 'Ed25519', false, ['sign'])
53
+ }
54
+
55
+ async function createNewIdentity(): Promise<DeviceIdentity> {
56
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
57
+
58
+ const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
59
+ const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
60
+
61
+ const deviceId = generateUUID()
62
+ const publicKeyBase64 = toBase64(pubRaw)
63
+ const privateKeyBase64 = toBase64(privPkcs8)
64
+
65
+ localStorage.setItem(STORAGE_DEVICE_ID, deviceId)
66
+ localStorage.setItem(STORAGE_PUBKEY, publicKeyBase64)
67
+ localStorage.setItem(STORAGE_PRIVKEY, privateKeyBase64)
68
+
69
+ return {
70
+ deviceId,
71
+ publicKeyBase64,
72
+ privateKey: keyPair.privateKey,
73
+ }
74
+ }
75
+
76
+ // ── Public API ───────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Returns existing device identity from localStorage or generates a new one.
80
+ * Throws if Ed25519 is not supported by the browser.
81
+ */
82
+ export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
83
+ const storedId = localStorage.getItem(STORAGE_DEVICE_ID)
84
+ const storedPub = localStorage.getItem(STORAGE_PUBKEY)
85
+ const storedPriv = localStorage.getItem(STORAGE_PRIVKEY)
86
+
87
+ if (storedId && storedPub && storedPriv) {
88
+ try {
89
+ const privateKey = await importPrivateKey(fromBase64(storedPriv))
90
+ return {
91
+ deviceId: storedId,
92
+ publicKeyBase64: storedPub,
93
+ privateKey,
94
+ }
95
+ } catch {
96
+ // Stored key corrupted — regenerate
97
+ console.warn('Device identity keys corrupted, regenerating...')
98
+ }
99
+ }
100
+
101
+ return createNewIdentity()
102
+ }
103
+
104
+ /**
105
+ * Signs a server nonce with the Ed25519 private key.
106
+ * Returns base64-encoded signature and signing timestamp.
107
+ */
108
+ export async function signChallenge(
109
+ privateKey: CryptoKey,
110
+ nonce: string
111
+ ): Promise<{ signature: string; signedAt: number }> {
112
+ const encoder = new TextEncoder()
113
+ const nonceBytes = encoder.encode(nonce)
114
+ const signedAt = Date.now()
115
+ const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, nonceBytes)
116
+ return {
117
+ signature: toBase64(signatureBuffer),
118
+ signedAt,
119
+ }
120
+ }
121
+
122
+ /** Reads cached device token from localStorage (returned by gateway on successful connect). */
123
+ export function getCachedDeviceToken(): string | null {
124
+ return localStorage.getItem(STORAGE_DEVICE_TOKEN)
125
+ }
126
+
127
+ /** Caches the device token returned by the gateway after successful connect. */
128
+ export function cacheDeviceToken(token: string): void {
129
+ localStorage.setItem(STORAGE_DEVICE_TOKEN, token)
130
+ }
131
+
132
+ /** Removes all device identity data from localStorage (for troubleshooting). */
133
+ export function clearDeviceIdentity(): void {
134
+ localStorage.removeItem(STORAGE_DEVICE_ID)
135
+ localStorage.removeItem(STORAGE_PUBKEY)
136
+ localStorage.removeItem(STORAGE_PRIVKEY)
137
+ localStorage.removeItem(STORAGE_DEVICE_TOKEN)
138
+ }
src/lib/websocket.ts CHANGED
@@ -3,6 +3,12 @@
3
  import { useCallback, useRef, useEffect } from 'react'
4
  import { useMissionControl } from '@/store'
5
  import { normalizeModel } from '@/lib/utils'
 
 
 
 
 
 
6
 
7
  // Gateway protocol version (v3 required by OpenClaw 2026.x)
8
  const PROTOCOL_VERSION = 3
@@ -128,8 +134,34 @@ export function useWebSocket() {
128
  }
129
  }, [setConnection])
130
 
131
- // Send the connect handshake
132
- const sendConnectHandshake = useCallback((ws: WebSocket, nonce?: string) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  const connectRequest = {
134
  type: 'req',
135
  method: 'connect',
@@ -149,7 +181,9 @@ export function useWebSocket() {
149
  scopes: ['operator.admin'],
150
  auth: authTokenRef.current
151
  ? { token: authTokenRef.current }
152
- : undefined
 
 
153
  }
154
  }
155
  console.log('Sending connect handshake:', connectRequest)
@@ -252,6 +286,10 @@ export function useWebSocket() {
252
  console.log('Handshake complete!')
253
  handshakeCompleteRef.current = true
254
  reconnectAttemptsRef.current = 0
 
 
 
 
255
  setConnection({
256
  isConnected: true,
257
  lastConnected: new Date(),
 
3
  import { useCallback, useRef, useEffect } from 'react'
4
  import { useMissionControl } from '@/store'
5
  import { normalizeModel } from '@/lib/utils'
6
+ import {
7
+ getOrCreateDeviceIdentity,
8
+ signChallenge,
9
+ getCachedDeviceToken,
10
+ cacheDeviceToken,
11
+ } from '@/lib/device-identity'
12
 
13
  // Gateway protocol version (v3 required by OpenClaw 2026.x)
14
  const PROTOCOL_VERSION = 3
 
134
  }
135
  }, [setConnection])
136
 
137
+ // Send the connect handshake (async for Ed25519 device identity signing)
138
+ const sendConnectHandshake = useCallback(async (ws: WebSocket, nonce?: string) => {
139
+ let device: {
140
+ id: string
141
+ publicKey: string
142
+ signature: string
143
+ signedAt: number
144
+ nonce: string
145
+ } | undefined
146
+
147
+ if (nonce) {
148
+ try {
149
+ const identity = await getOrCreateDeviceIdentity()
150
+ const { signature, signedAt } = await signChallenge(identity.privateKey, nonce)
151
+ device = {
152
+ id: identity.deviceId,
153
+ publicKey: identity.publicKeyBase64,
154
+ signature,
155
+ signedAt,
156
+ nonce,
157
+ }
158
+ } catch (err) {
159
+ console.warn('Device identity unavailable, proceeding without:', err)
160
+ }
161
+ }
162
+
163
+ const cachedToken = getCachedDeviceToken()
164
+
165
  const connectRequest = {
166
  type: 'req',
167
  method: 'connect',
 
181
  scopes: ['operator.admin'],
182
  auth: authTokenRef.current
183
  ? { token: authTokenRef.current }
184
+ : undefined,
185
+ device,
186
+ deviceToken: cachedToken || undefined,
187
  }
188
  }
189
  console.log('Sending connect handshake:', connectRequest)
 
286
  console.log('Handshake complete!')
287
  handshakeCompleteRef.current = true
288
  reconnectAttemptsRef.current = 0
289
+ // Cache device token if returned by gateway
290
+ if (frame.result?.deviceToken) {
291
+ cacheDeviceToken(frame.result.deviceToken)
292
+ }
293
  setConnection({
294
  isConnected: true,
295
  lastConnected: new Date(),
tests/device-identity.spec.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+
3
+ /**
4
+ * E2E tests for Ed25519 device identity (Issues #74, #79, #81).
5
+ *
6
+ * These run in a real Chromium browser to exercise Web Crypto Ed25519 and
7
+ * localStorage persistence — the same environment Mission Control uses.
8
+ */
9
+
10
+ test.describe('Device Identity — Ed25519 key management', () => {
11
+ test.beforeEach(async ({ page }) => {
12
+ // Navigate to the app so we have a page context with localStorage
13
+ await page.goto('/')
14
+ // Clear any leftover device identity from previous runs
15
+ await page.evaluate(() => {
16
+ localStorage.removeItem('mc-device-id')
17
+ localStorage.removeItem('mc-device-pubkey')
18
+ localStorage.removeItem('mc-device-privkey')
19
+ localStorage.removeItem('mc-device-token')
20
+ })
21
+ })
22
+
23
+ test('generates Ed25519 key pair and stores in localStorage', async ({ page }) => {
24
+ const result = await page.evaluate(async () => {
25
+ // Check Ed25519 support first
26
+ try {
27
+ await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
28
+ } catch {
29
+ return { skipped: true, reason: 'Ed25519 not supported in this browser' }
30
+ }
31
+
32
+ // Dynamically import the module (bundled by Next.js)
33
+ // Since we can't import from the bundle directly, replicate the core logic
34
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
35
+ const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
36
+ const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
37
+
38
+ // base64 encode
39
+ const toBase64 = (buf: ArrayBuffer) => {
40
+ const bytes = new Uint8Array(buf)
41
+ let binary = ''
42
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
43
+ return btoa(binary)
44
+ }
45
+
46
+ const deviceId = crypto.randomUUID()
47
+ const pubB64 = toBase64(pubRaw)
48
+ const privB64 = toBase64(privPkcs8)
49
+
50
+ localStorage.setItem('mc-device-id', deviceId)
51
+ localStorage.setItem('mc-device-pubkey', pubB64)
52
+ localStorage.setItem('mc-device-privkey', privB64)
53
+
54
+ return {
55
+ skipped: false,
56
+ deviceId,
57
+ pubKeyLength: pubRaw.byteLength,
58
+ privKeyLength: privPkcs8.byteLength,
59
+ storedId: localStorage.getItem('mc-device-id'),
60
+ storedPub: localStorage.getItem('mc-device-pubkey'),
61
+ storedPriv: localStorage.getItem('mc-device-privkey'),
62
+ }
63
+ })
64
+
65
+ if (result.skipped) {
66
+ test.skip(true, result.reason as string)
67
+ return
68
+ }
69
+
70
+ // Ed25519 public key is always 32 bytes raw
71
+ expect(result.pubKeyLength).toBe(32)
72
+ // PKCS8-wrapped Ed25519 private key is 48 bytes
73
+ expect(result.privKeyLength).toBe(48)
74
+ // Values persisted to localStorage
75
+ expect(result.storedId).toBe(result.deviceId)
76
+ expect(result.storedPub).toBeTruthy()
77
+ expect(result.storedPriv).toBeTruthy()
78
+ })
79
+
80
+ test('signs a nonce and produces a valid Ed25519 signature', async ({ page }) => {
81
+ const result = await page.evaluate(async () => {
82
+ try {
83
+ await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
84
+ } catch {
85
+ return { skipped: true, reason: 'Ed25519 not supported' }
86
+ }
87
+
88
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
89
+ const nonce = 'test-nonce-abc123'
90
+ const encoder = new TextEncoder()
91
+ const nonceBytes = encoder.encode(nonce)
92
+
93
+ const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, nonceBytes)
94
+ const verified = await crypto.subtle.verify('Ed25519', keyPair.publicKey, signatureBuffer, nonceBytes)
95
+
96
+ return {
97
+ skipped: false,
98
+ signatureLength: signatureBuffer.byteLength,
99
+ verified,
100
+ }
101
+ })
102
+
103
+ if (result.skipped) {
104
+ test.skip(true, result.reason as string)
105
+ return
106
+ }
107
+
108
+ // Ed25519 signature is always 64 bytes
109
+ expect(result.signatureLength).toBe(64)
110
+ // Signature must verify against the same nonce
111
+ expect(result.verified).toBe(true)
112
+ })
113
+
114
+ test('persisted key pair survives page reload', async ({ page }) => {
115
+ const firstRun = await page.evaluate(async () => {
116
+ try {
117
+ const kp = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
118
+ const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey)
119
+ const privPkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey)
120
+ const toBase64 = (buf: ArrayBuffer) => {
121
+ const bytes = new Uint8Array(buf)
122
+ let binary = ''
123
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
124
+ return btoa(binary)
125
+ }
126
+ const deviceId = crypto.randomUUID()
127
+ localStorage.setItem('mc-device-id', deviceId)
128
+ localStorage.setItem('mc-device-pubkey', toBase64(pubRaw))
129
+ localStorage.setItem('mc-device-privkey', toBase64(privPkcs8))
130
+ return { skipped: false, deviceId }
131
+ } catch {
132
+ return { skipped: true, reason: 'Ed25519 not supported' }
133
+ }
134
+ })
135
+
136
+ if (firstRun.skipped) {
137
+ test.skip(true, firstRun.reason as string)
138
+ return
139
+ }
140
+
141
+ // Reload the page
142
+ await page.reload()
143
+
144
+ const afterReload = await page.evaluate(() => ({
145
+ deviceId: localStorage.getItem('mc-device-id'),
146
+ pubKey: localStorage.getItem('mc-device-pubkey'),
147
+ privKey: localStorage.getItem('mc-device-privkey'),
148
+ }))
149
+
150
+ expect(afterReload.deviceId).toBe(firstRun.deviceId)
151
+ expect(afterReload.pubKey).toBeTruthy()
152
+ expect(afterReload.privKey).toBeTruthy()
153
+ })
154
+
155
+ test('reimported private key can sign and verify', async ({ page }) => {
156
+ const result = await page.evaluate(async () => {
157
+ try {
158
+ await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
159
+ } catch {
160
+ return { skipped: true, reason: 'Ed25519 not supported' }
161
+ }
162
+
163
+ const toBase64 = (buf: ArrayBuffer) => {
164
+ const bytes = new Uint8Array(buf)
165
+ let binary = ''
166
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
167
+ return btoa(binary)
168
+ }
169
+ const fromBase64 = (b64: string) => {
170
+ const binary = atob(b64)
171
+ const bytes = new Uint8Array(binary.length)
172
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
173
+ return bytes
174
+ }
175
+
176
+ // Generate and export
177
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])
178
+ const pubRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey)
179
+ const privPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
180
+
181
+ // Serialize to base64 (simulates localStorage round-trip)
182
+ const pubB64 = toBase64(pubRaw)
183
+ const privB64 = toBase64(privPkcs8)
184
+
185
+ // Re-import from base64
186
+ const reimportedPriv = await crypto.subtle.importKey(
187
+ 'pkcs8', fromBase64(privB64).buffer as ArrayBuffer, 'Ed25519', false, ['sign']
188
+ )
189
+ const reimportedPub = await crypto.subtle.importKey(
190
+ 'raw', fromBase64(pubB64).buffer as ArrayBuffer, 'Ed25519', false, ['verify']
191
+ )
192
+
193
+ // Sign with reimported private key, verify with reimported public key
194
+ const nonce = 'round-trip-nonce-xyz'
195
+ const encoder = new TextEncoder()
196
+ const data = encoder.encode(nonce)
197
+ const sig = await crypto.subtle.sign('Ed25519', reimportedPriv, data)
198
+ const ok = await crypto.subtle.verify('Ed25519', reimportedPub, sig, data)
199
+
200
+ return { skipped: false, verified: ok, signatureLength: sig.byteLength }
201
+ })
202
+
203
+ if (result.skipped) {
204
+ test.skip(true, result.reason as string)
205
+ return
206
+ }
207
+
208
+ expect(result.verified).toBe(true)
209
+ expect(result.signatureLength).toBe(64)
210
+ })
211
+
212
+ test('device token cache read/write', async ({ page }) => {
213
+ await page.evaluate(() => {
214
+ localStorage.setItem('mc-device-token', 'tok_test_abc123')
215
+ })
216
+
217
+ const token = await page.evaluate(() => localStorage.getItem('mc-device-token'))
218
+ expect(token).toBe('tok_test_abc123')
219
+
220
+ // Clear
221
+ await page.evaluate(() => localStorage.removeItem('mc-device-token'))
222
+ const cleared = await page.evaluate(() => localStorage.getItem('mc-device-token'))
223
+ expect(cleared).toBeNull()
224
+ })
225
+
226
+ test('clearDeviceIdentity removes all storage keys', async ({ page }) => {
227
+ // Set all keys
228
+ await page.evaluate(() => {
229
+ localStorage.setItem('mc-device-id', 'test-id')
230
+ localStorage.setItem('mc-device-pubkey', 'test-pub')
231
+ localStorage.setItem('mc-device-privkey', 'test-priv')
232
+ localStorage.setItem('mc-device-token', 'test-token')
233
+ })
234
+
235
+ // Clear (replicate clearDeviceIdentity logic)
236
+ await page.evaluate(() => {
237
+ localStorage.removeItem('mc-device-id')
238
+ localStorage.removeItem('mc-device-pubkey')
239
+ localStorage.removeItem('mc-device-privkey')
240
+ localStorage.removeItem('mc-device-token')
241
+ })
242
+
243
+ const remaining = await page.evaluate(() => ({
244
+ id: localStorage.getItem('mc-device-id'),
245
+ pub: localStorage.getItem('mc-device-pubkey'),
246
+ priv: localStorage.getItem('mc-device-privkey'),
247
+ token: localStorage.getItem('mc-device-token'),
248
+ }))
249
+
250
+ expect(remaining.id).toBeNull()
251
+ expect(remaining.pub).toBeNull()
252
+ expect(remaining.priv).toBeNull()
253
+ expect(remaining.token).toBeNull()
254
+ })
255
+ })