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;