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
This commit is contained in:
Nicolas Varrot
2026-02-16 00:00:20 +00:00
parent da75ac3ccc
commit 151215cd4b
9 changed files with 361 additions and 31 deletions

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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 (
<div
role="alert"
aria-live="polite"
className={`flex items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition-all duration-500 animate-in slide-in-from-top ${
isReconnecting
? 'bg-amber-500/10 text-amber-300 border-b border-amber-500/20'
: 'bg-emerald-500/10 text-emerald-300 border-b border-emerald-500/20'
isPairing
? 'bg-blue-500/10 text-blue-300 border-b border-blue-500/20'
: isReconnecting
? 'bg-amber-500/10 text-amber-300 border-b border-amber-500/20'
: 'bg-emerald-500/10 text-emerald-300 border-b border-emerald-500/20'
}`}
>
{isReconnecting ? (
{isPairing ? (
<>
<ShieldAlert size={14} />
<span>{t('connection.pairing')}</span>
</>
) : isReconnecting ? (
<>
<Loader2 size={14} className="animate-spin" />
<span>{t('connection.reconnecting')}</span>

View File

@@ -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) {

View File

@@ -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();
});
});

197
src/lib/deviceIdentity.ts Normal file
View File

@@ -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<IDBDatabase> {
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<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
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<void> {
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<CryptoKeyPair> {
return crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) as Promise<CryptoKeyPair>;
}
/**
* 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<ArrayBuffer> {
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<string> {
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<DeviceIdentity> {
const db = await openDB();
// Try loading existing identity
const stored = await idbGet<StoredIdentity>(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<string> {
const data = new TextEncoder().encode(payload);
const sig = await crypto.subtle.sign('Ed25519', privateKey, data);
return bufToBase64Url(sig);
}

View File

@@ -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<string, { resolve: (v: JsonPayload) => void; reject: (e: unknown) => void }>();
private eventHandlers: GatewayEventHandler[] = [];
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
private _onStatus: (s: GatewayStatus) => void = () => {};
private reconnectTimer: ReturnType<typeof setTimeout> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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() {

View File

@@ -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<keyof typeof en, string> = {
'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<keyof typeof en, string> = {
'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<keyof typeof en, string> = {
'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<keyof typeof en, string> = {
'connection.reconnecting': '接続が切断されました — 再接続中…',
'connection.reconnected': '再接続しました!',
'connection.pairing': 'デバイスペアリング保留中 — ゲートウェイで `openclaw devices approve` を実行してください',
'message.copy': 'メッセージをコピー',
'message.copied': 'コピーしました!',
@@ -903,6 +908,7 @@ const pt: Record<keyof typeof en, string> = {
'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<keyof typeof en, string> = {
'connection.reconnecting': '连接中断 — 正在重连…',
'connection.reconnected': '已重新连接!',
'connection.pairing': '设备配对待处理 — 在网关上运行 `openclaw devices approve`',
'message.copy': '复制消息',
'message.copied': '已复制!',
@@ -1225,6 +1232,7 @@ const it: Record<keyof typeof en, string> = {
'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!',

View File

@@ -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;