From 151215cd4be1377ffdc45a0abdee875053699dc1 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Mon, 16 Feb 2026 00:00:20 +0000 Subject: [PATCH] feat: device identity for OpenClaw 2026.2.14+ pairing (#6) - Generate Ed25519 keypair via Web Crypto API - Persist keypair in IndexedDB (survives page reloads) - Sign connect payload with device private key - Include device object in connect params (id, publicKey, signature, signedAt) - Handle NOT_PAIRED error with 'pairing' connection status - Show pairing-pending banner with instructions to run openclaw devices approve - Extract nonce from connect.challenge for v2 payload signing - Add i18n translations for pairing banner in all 8 languages Closes #6 --- package-lock.json | 4 +- package.json | 2 +- src/components/ConnectionBanner.tsx | 26 ++-- src/hooks/useGateway.ts | 16 ++- src/lib/__tests__/gateway.test.ts | 48 +++++++ src/lib/deviceIdentity.ts | 197 ++++++++++++++++++++++++++++ src/lib/gateway.ts | 89 ++++++++++--- src/lib/i18n.ts | 8 ++ src/types/index.ts | 2 +- 9 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 src/lib/deviceIdentity.ts diff --git a/package-lock.json b/package-lock.json index de2635f..bf32a0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pinchchat", - "version": "1.64.1", + "version": "1.64.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinchchat", - "version": "1.64.1", + "version": "1.64.2", "license": "MIT", "dependencies": { "@tailwindcss/vite": "^4.1.18", diff --git a/package.json b/package.json index c2bb019..074e853 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinchchat", - "version": "1.64.1", + "version": "1.64.2", "description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.", "type": "module", "repository": { diff --git a/src/components/ConnectionBanner.tsx b/src/components/ConnectionBanner.tsx index 72b09f0..39e66f6 100644 --- a/src/components/ConnectionBanner.tsx +++ b/src/components/ConnectionBanner.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Wifi, Loader2 } from 'lucide-react'; +import { Wifi, Loader2, ShieldAlert } from 'lucide-react'; import type { ConnectionStatus } from '../types'; import { useT } from '../hooks/useLocale'; @@ -7,7 +7,7 @@ interface Props { status: ConnectionStatus; } -type BannerState = 'hidden' | 'reconnecting' | 'reconnected'; +type BannerState = 'hidden' | 'reconnecting' | 'reconnected' | 'pairing'; export function ConnectionBanner({ status }: Props) { const t = useT(); @@ -21,8 +21,10 @@ export function ConnectionBanner({ status }: Props) { dismissTimer.current = null; } - if (current === 'disconnected' || current === 'connecting') { - if (prev === 'connected') { + if (current === 'pairing') { + setBanner('pairing'); + } else if (current === 'disconnected' || current === 'connecting') { + if (prev === 'connected' || prev === 'pairing') { setBanner('reconnecting'); } } else if (current === 'connected' && prev !== null && prev !== 'connected') { @@ -44,18 +46,26 @@ export function ConnectionBanner({ status }: Props) { if (banner === 'hidden') return null; const isReconnecting = banner === 'reconnecting'; + const isPairing = banner === 'pairing'; return (
- {isReconnecting ? ( + {isPairing ? ( + <> + + {t('connection.pairing')} + + ) : isReconnecting ? ( <> {t('connection.reconnecting')} diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index 845fe60..69cf6ce 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { GatewayClient, type JsonPayload } from '../lib/gateway'; import { genIdempotencyKey } from '../lib/utils'; import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials'; +import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity'; import { isSystemEvent } from '../lib/systemEvent'; import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache'; import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types'; @@ -271,7 +272,7 @@ export function useGateway() { } }, []); - const setupClient = useCallback((wsUrl: string, token: string) => { + const setupClient = useCallback(async (wsUrl: string, token: string) => { // Tear down existing client if (clientRef.current) { clientRef.current.disconnect(); @@ -280,6 +281,14 @@ export function useGateway() { const client = new GatewayClient(wsUrl, token); clientRef.current = client; + // Load device identity for signed connect handshake + try { + const identity = await getOrCreateDeviceIdentity(); + client.setDeviceIdentity(identity); + } catch (err) { + console.warn('[PinchChat] Failed to load device identity, connecting without it:', err); + } + client.onStatus((s) => { setStatus(s); if (s === 'connected') { @@ -291,6 +300,11 @@ export function useGateway() { loadSessions(); loadAgentIdentity(); loadHistory(activeSessionRef.current); + } else if (s === 'pairing') { + setAuthenticated(true); + setConnectError(null); + setIsConnecting(false); + isConnectingRef.current = false; } else if (s === 'disconnected' && !client.isConnected) { // If we never connected successfully, this is an auth/connection error if (isConnectingRef.current) { diff --git a/src/lib/__tests__/gateway.test.ts b/src/lib/__tests__/gateway.test.ts index 960ca85..54138db 100644 --- a/src/lib/__tests__/gateway.test.ts +++ b/src/lib/__tests__/gateway.test.ts @@ -96,6 +96,9 @@ describe('GatewayClient', () => { // Server sends challenge ws._receive({ type: 'event', event: 'connect.challenge' }); + // handleChallenge is async — flush microtasks + await vi.advanceTimersByTimeAsync(0); + // Client should have sent a connect request expect(ws.sent.length).toBe(1); const req = JSON.parse(ws.sent[0]!); @@ -165,6 +168,7 @@ describe('GatewayClient', () => { // Complete the challenge first const ws = MockWebSocket.instances[0]!; ws._receive({ type: 'event', event: 'connect.challenge' }); + await vi.advanceTimersByTimeAsync(0); const connectReq = JSON.parse(ws.sent[0]!); ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} }); await vi.advanceTimersByTimeAsync(0); @@ -184,6 +188,7 @@ describe('GatewayClient', () => { const ws = MockWebSocket.instances[0]!; ws._receive({ type: 'event', event: 'connect.challenge' }); + await vi.advanceTimersByTimeAsync(0); const connectReq = JSON.parse(ws.sent[0]!); ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} }); await vi.advanceTimersByTimeAsync(0); @@ -207,6 +212,7 @@ describe('GatewayClient', () => { const ws = MockWebSocket.instances[0]!; ws._receive({ type: 'event', event: 'connect.challenge' }); + await vi.advanceTimersByTimeAsync(0); const connectReq = JSON.parse(ws.sent[0]!); ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} }); await vi.advanceTimersByTimeAsync(0); @@ -271,4 +277,46 @@ describe('GatewayClient', () => { const ws = MockWebSocket.instances[0]!; expect(ws.url).toBe('ws://new:5678'); }); + + it('extracts nonce from challenge payload', async () => { + const gw = new GatewayClient('ws://test:1234', 'tok'); + gw.connect(); + await vi.advanceTimersByTimeAsync(1); + + const ws = MockWebSocket.instances[0]!; + // Server sends challenge with nonce + ws._receive({ type: 'event', event: 'connect.challenge', payload: { nonce: 'test-nonce-123' } }); + await vi.advanceTimersByTimeAsync(0); + + const req = JSON.parse(ws.sent[0]!); + expect(req.method).toBe('connect'); + // Device object won't be set (no identity), but the connect should still work + expect(req.params.auth.token).toBe('tok'); + + // Clean up + gw.disconnect(); + }); + + it('emits pairing status on NOT_PAIRED error', async () => { + const gw = new GatewayClient('ws://test:1234', 'tok'); + const statuses: string[] = []; + gw.onStatus(s => statuses.push(s)); + + gw.connect(); + await vi.advanceTimersByTimeAsync(1); + + const ws = MockWebSocket.instances[0]!; + ws._receive({ type: 'event', event: 'connect.challenge' }); + await vi.advanceTimersByTimeAsync(0); + + const req = JSON.parse(ws.sent[0]!); + // Server rejects with NOT_PAIRED + ws._receive({ type: 'res', id: req.id, ok: false, payload: { code: 'NOT_PAIRED', message: 'Device not paired' } }); + await vi.advanceTimersByTimeAsync(0); + + expect(statuses).toContain('pairing'); + + // Clean up + gw.disconnect(); + }); }); diff --git a/src/lib/deviceIdentity.ts b/src/lib/deviceIdentity.ts new file mode 100644 index 0000000..a1149b6 --- /dev/null +++ b/src/lib/deviceIdentity.ts @@ -0,0 +1,197 @@ +/** + * Device identity management for OpenClaw 2026.2.14+ device pairing. + * + * Generates an Ed25519 keypair via Web Crypto API, persists it in IndexedDB, + * and provides signing utilities for the gateway connect handshake. + */ + +const DB_NAME = 'pinchchat_device'; +const DB_VERSION = 1; +const STORE_NAME = 'identity'; +const IDENTITY_KEY = 'device'; + +export interface DeviceIdentity { + id: string; // SHA-256 hex fingerprint of the raw public key + publicKeyRaw: string; // base64url-encoded raw 32-byte public key + keyPair: CryptoKeyPair; +} + +// ── IndexedDB helpers ────────────────────────────────────────────── + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function idbGet(db: IDBDatabase, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); +} + +function idbPut(db: IDBDatabase, key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +// ── Encoding helpers ─────────────────────────────────────────────── + +function bufToBase64Url(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function bufToHex(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +// ── Key generation & fingerprinting ──────────────────────────────── + +async function generateKeyPair(): Promise { + return crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as Promise; +} + +/** + * Extract the raw 32-byte public key from a CryptoKey. + * SPKI for Ed25519 is a fixed 12-byte prefix + 32 bytes of key material. + */ +async function exportPublicKeyRaw(key: CryptoKey): Promise { + const spki = await crypto.subtle.exportKey('spki', key); + // Ed25519 SPKI = 12-byte prefix + 32-byte raw key + return spki.slice(12); +} + +async function fingerprintKey(key: CryptoKey): Promise { + const raw = await exportPublicKeyRaw(key); + const hash = await crypto.subtle.digest('SHA-256', raw); + return bufToHex(hash); +} + +// ── Persisted format (serialisable for IndexedDB) ────────────────── + +interface StoredIdentity { + version: 1; + deviceId: string; + publicKeyRaw: string; // base64url + jwkPublic: JsonWebKey; + jwkPrivate: JsonWebKey; +} + +// ── Public API ───────────────────────────────────────────────────── + +/** + * Load or create the device identity. + * The keypair is persisted in IndexedDB so it survives page reloads. + */ +export async function getOrCreateDeviceIdentity(): Promise { + const db = await openDB(); + + // Try loading existing identity + const stored = await idbGet(db, IDENTITY_KEY); + if (stored?.version === 1) { + try { + const privateKey = await crypto.subtle.importKey( + 'jwk', stored.jwkPrivate, { name: 'Ed25519' }, true, ['sign'], + ); + const publicKey = await crypto.subtle.importKey( + 'jwk', stored.jwkPublic, { name: 'Ed25519' }, true, ['verify'], + ); + db.close(); + return { + id: stored.deviceId, + publicKeyRaw: stored.publicKeyRaw, + keyPair: { privateKey, publicKey }, + }; + } catch { + // Corrupted — regenerate below + } + } + + // Generate new identity + const keyPair = await generateKeyPair(); + const raw = await exportPublicKeyRaw(keyPair.publicKey); + const publicKeyRaw = bufToBase64Url(raw); + const deviceId = await fingerprintKey(keyPair.publicKey); + const jwkPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + const jwkPrivate = await crypto.subtle.exportKey('jwk', keyPair.privateKey); + + const record: StoredIdentity = { + version: 1, + deviceId, + publicKeyRaw, + jwkPublic, + jwkPrivate, + }; + await idbPut(db, IDENTITY_KEY, record); + db.close(); + + return { id: deviceId, publicKeyRaw, keyPair }; +} + +// ── Signing ──────────────────────────────────────────────────────── + +/** + * Build the canonical payload string that the gateway expects to verify. + * Must match OpenClaw's `buildDeviceAuthPayload` exactly. + */ +export function buildDeviceAuthPayload(params: { + deviceId: string; + clientId: string; + clientMode: string; + role: string; + scopes: string[]; + signedAtMs: number; + token: string | null; + nonce?: string | null; +}): string { + const version = params.nonce ? 'v2' : 'v1'; + const scopes = params.scopes.join(','); + const token = params.token ?? ''; + const base = [ + version, + params.deviceId, + params.clientId, + params.clientMode, + params.role, + scopes, + String(params.signedAtMs), + token, + ]; + if (version === 'v2') base.push(params.nonce ?? ''); + return base.join('|'); +} + +/** + * Sign a payload string with the device's Ed25519 private key. + * Returns a base64url-encoded signature. + */ +export async function signPayload( + privateKey: CryptoKey, + payload: string, +): Promise { + const data = new TextEncoder().encode(payload); + const sig = await crypto.subtle.sign('Ed25519', privateKey, data); + return bufToBase64Url(sig); +} diff --git a/src/lib/gateway.ts b/src/lib/gateway.ts index c14e0b7..823fe13 100644 --- a/src/lib/gateway.ts +++ b/src/lib/gateway.ts @@ -1,4 +1,6 @@ import { genId } from './utils'; +import type { DeviceIdentity } from './deviceIdentity'; +import { buildDeviceAuthPayload, signPayload } from './deviceIdentity'; /** Debug logger — enable with localStorage.setItem('pinchchat:debug', '1') */ const isDebug = () => { @@ -24,18 +26,22 @@ interface GatewayMessage { error?: string; } +export type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; + export class GatewayClient { private ws: WebSocket | null = null; private pendingRequests = new Map void; reject: (e: unknown) => void }>(); private eventHandlers: GatewayEventHandler[] = []; - private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {}; + private _onStatus: (s: GatewayStatus) => void = () => {}; private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; private connected = false; private autoReconnect = true; + private connectNonce: string | null = null; private wsUrl: string; private authToken: string; + private deviceIdentity: DeviceIdentity | null = null; constructor(wsUrl?: string, authToken?: string) { this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`; @@ -48,7 +54,12 @@ export class GatewayClient { this.authToken = authToken; } - onStatus(fn: (s: 'disconnected' | 'connecting' | 'connected') => void) { + /** Set the device identity for signed connect handshakes. */ + setDeviceIdentity(identity: DeviceIdentity) { + this.deviceIdentity = identity; + } + + onStatus(fn: (s: GatewayStatus) => void) { this._onStatus = fn; } @@ -60,6 +71,7 @@ export class GatewayClient { connect() { if (this.ws) return; this.autoReconnect = true; + this.connectNonce = null; this._onStatus('connecting'); this.ws = new WebSocket(this.wsUrl); @@ -72,6 +84,9 @@ export class GatewayClient { if (msg.type === 'event') { if (msg.event === 'connect.challenge') { + // Extract nonce from challenge payload if present + const payload = msg.payload as Record | undefined; + this.connectNonce = (payload && typeof payload.nonce === 'string') ? payload.nonce : null; this.handleChallenge(); } else { for (const h of this.eventHandlers) h(msg.event ?? '', msg.payload ?? {}); @@ -99,30 +114,68 @@ export class GatewayClient { this.ws.onerror = (e) => { log('WS error', e); }; } - private handleChallenge() { + private async handleChallenge() { const id = genId('connect'); - this.request(id, 'connect', { - minProtocol: 3, - maxProtocol: 3, - client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' }, - role: 'operator', - scopes: ['operator.read', 'operator.write', 'operator.admin'], - caps: [], - commands: [], - permissions: {}, - auth: { token: this.authToken }, - locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en', - userAgent: `pinchchat/${__APP_VERSION__}`, - }).then((res) => { + const role = 'operator'; + const scopes = ['operator.read', 'operator.write', 'operator.admin']; + const signedAtMs = Date.now(); + const nonce = this.connectNonce ?? undefined; + + // Build device object if we have an identity + let device: Record | undefined; + if (this.deviceIdentity) { + const payload = buildDeviceAuthPayload({ + deviceId: this.deviceIdentity.id, + clientId: 'webchat', + clientMode: 'webchat', + role, + scopes, + signedAtMs, + token: this.authToken || null, + nonce, + }); + const signature = await signPayload(this.deviceIdentity.keyPair.privateKey, payload); + device = { + id: this.deviceIdentity.id, + publicKey: this.deviceIdentity.publicKeyRaw, + signature, + signedAt: signedAtMs, + nonce, + }; + } + + try { + const res = await this.request(id, 'connect', { + minProtocol: 3, + maxProtocol: 3, + client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' }, + role, + scopes, + caps: [], + commands: [], + permissions: {}, + auth: { token: this.authToken }, + device, + locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en', + userAgent: `pinchchat/${__APP_VERSION__}`, + }); log('connected!', res); this.connected = true; this.reconnectAttempts = 0; this._onStatus('connected'); - }).catch((err) => { + } catch (err) { log('connect failed:', err); + // Check if this is a NOT_PAIRED error + const errObj = err as Record | undefined; + if (errObj && (errObj.code === 'NOT_PAIRED' || (typeof errObj.message === 'string' && errObj.message.includes('NOT_PAIRED')))) { + log('device not paired — awaiting approval'); + this._onStatus('pairing'); + // Keep connection open and auto-reconnect; gateway may close us + return; + } this.autoReconnect = false; this.disconnect(); - }); + } } private scheduleReconnect() { diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index e1ff72c..9b98605 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -87,6 +87,7 @@ const en = { // Connection banner 'connection.reconnecting': 'Connection lost — reconnecting…', 'connection.reconnected': 'Reconnected!', + 'connection.pairing': 'Device pairing pending — run `openclaw devices approve` on your gateway', // Message actions 'message.copy': 'Copy message', @@ -257,6 +258,7 @@ const fr: Record = { 'connection.reconnecting': 'Connexion perdue — reconnexion…', 'connection.reconnected': 'Reconnecté !', + 'connection.pairing': 'Appairage en attente — exécutez `openclaw devices approve` sur votre gateway', 'message.copy': 'Copier le message', 'message.copied': 'Copié !', @@ -418,6 +420,7 @@ const es: Record = { 'connection.reconnecting': 'Conexión perdida — reconectando…', 'connection.reconnected': '¡Reconectado!', + 'connection.pairing': 'Emparejamiento pendiente — ejecute `openclaw devices approve` en su gateway', 'message.copy': 'Copiar mensaje', 'message.copied': '¡Copiado!', @@ -581,6 +584,7 @@ const de: Record = { 'connection.reconnecting': 'Verbindung verloren — wird wiederhergestellt…', 'connection.reconnected': 'Wieder verbunden!', + 'connection.pairing': 'Gerätekopplung ausstehend — führen Sie `openclaw devices approve` auf Ihrem Gateway aus', 'message.copy': 'Nachricht kopieren', 'message.copied': 'Kopiert!', @@ -742,6 +746,7 @@ const ja: Record = { 'connection.reconnecting': '接続が切断されました — 再接続中…', 'connection.reconnected': '再接続しました!', + 'connection.pairing': 'デバイスペアリング保留中 — ゲートウェイで `openclaw devices approve` を実行してください', 'message.copy': 'メッセージをコピー', 'message.copied': 'コピーしました!', @@ -903,6 +908,7 @@ const pt: Record = { 'connection.reconnecting': 'Conexão perdida — reconectando…', 'connection.reconnected': 'Reconectado!', + 'connection.pairing': 'Emparelhamento pendente — execute `openclaw devices approve` no seu gateway', 'message.copy': 'Copiar mensagem', 'message.copied': 'Copiado!', @@ -1064,6 +1070,7 @@ const zh: Record = { 'connection.reconnecting': '连接中断 — 正在重连…', 'connection.reconnected': '已重新连接!', + 'connection.pairing': '设备配对待处理 — 在网关上运行 `openclaw devices approve`', 'message.copy': '复制消息', 'message.copied': '已复制!', @@ -1225,6 +1232,7 @@ const it: Record = { 'connection.reconnecting': 'Connessione persa — riconnessione…', 'connection.reconnected': 'Riconnesso!', + 'connection.pairing': 'Accoppiamento dispositivo in attesa — esegui `openclaw devices approve` sul tuo gateway', 'message.copy': 'Copia messaggio', 'message.copied': 'Copiato!', diff --git a/src/types/index.ts b/src/types/index.ts index a63afb4..a97985b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -53,7 +53,7 @@ export interface AgentIdentity { agentId?: string; } -export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing'; export interface GatewayState { status: ConnectionStatus;