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:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pinchchat",
|
"name": "pinchchat",
|
||||||
"version": "1.64.1",
|
"version": "1.64.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pinchchat",
|
"name": "pinchchat",
|
||||||
"version": "1.64.1",
|
"version": "1.64.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pinchchat",
|
"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.",
|
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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 type { ConnectionStatus } from '../types';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BannerState = 'hidden' | 'reconnecting' | 'reconnected';
|
type BannerState = 'hidden' | 'reconnecting' | 'reconnected' | 'pairing';
|
||||||
|
|
||||||
export function ConnectionBanner({ status }: Props) {
|
export function ConnectionBanner({ status }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -21,8 +21,10 @@ export function ConnectionBanner({ status }: Props) {
|
|||||||
dismissTimer.current = null;
|
dismissTimer.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current === 'disconnected' || current === 'connecting') {
|
if (current === 'pairing') {
|
||||||
if (prev === 'connected') {
|
setBanner('pairing');
|
||||||
|
} else if (current === 'disconnected' || current === 'connecting') {
|
||||||
|
if (prev === 'connected' || prev === 'pairing') {
|
||||||
setBanner('reconnecting');
|
setBanner('reconnecting');
|
||||||
}
|
}
|
||||||
} else if (current === 'connected' && prev !== null && prev !== 'connected') {
|
} else if (current === 'connected' && prev !== null && prev !== 'connected') {
|
||||||
@@ -44,18 +46,26 @@ export function ConnectionBanner({ status }: Props) {
|
|||||||
if (banner === 'hidden') return null;
|
if (banner === 'hidden') return null;
|
||||||
|
|
||||||
const isReconnecting = banner === 'reconnecting';
|
const isReconnecting = banner === 'reconnecting';
|
||||||
|
const isPairing = banner === 'pairing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
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 ${
|
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
|
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-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'
|
: '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" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
<span>{t('connection.reconnecting')}</span>
|
<span>{t('connection.reconnecting')}</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
||||||
import { genIdempotencyKey } from '../lib/utils';
|
import { genIdempotencyKey } from '../lib/utils';
|
||||||
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
||||||
|
import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity';
|
||||||
import { isSystemEvent } from '../lib/systemEvent';
|
import { isSystemEvent } from '../lib/systemEvent';
|
||||||
import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache';
|
import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache';
|
||||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types';
|
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
|
// Tear down existing client
|
||||||
if (clientRef.current) {
|
if (clientRef.current) {
|
||||||
clientRef.current.disconnect();
|
clientRef.current.disconnect();
|
||||||
@@ -280,6 +281,14 @@ export function useGateway() {
|
|||||||
const client = new GatewayClient(wsUrl, token);
|
const client = new GatewayClient(wsUrl, token);
|
||||||
clientRef.current = client;
|
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) => {
|
client.onStatus((s) => {
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
if (s === 'connected') {
|
if (s === 'connected') {
|
||||||
@@ -291,6 +300,11 @@ export function useGateway() {
|
|||||||
loadSessions();
|
loadSessions();
|
||||||
loadAgentIdentity();
|
loadAgentIdentity();
|
||||||
loadHistory(activeSessionRef.current);
|
loadHistory(activeSessionRef.current);
|
||||||
|
} else if (s === 'pairing') {
|
||||||
|
setAuthenticated(true);
|
||||||
|
setConnectError(null);
|
||||||
|
setIsConnecting(false);
|
||||||
|
isConnectingRef.current = false;
|
||||||
} else if (s === 'disconnected' && !client.isConnected) {
|
} else if (s === 'disconnected' && !client.isConnected) {
|
||||||
// If we never connected successfully, this is an auth/connection error
|
// If we never connected successfully, this is an auth/connection error
|
||||||
if (isConnectingRef.current) {
|
if (isConnectingRef.current) {
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ describe('GatewayClient', () => {
|
|||||||
// Server sends challenge
|
// Server sends challenge
|
||||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
|
||||||
|
// handleChallenge is async — flush microtasks
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
// Client should have sent a connect request
|
// Client should have sent a connect request
|
||||||
expect(ws.sent.length).toBe(1);
|
expect(ws.sent.length).toBe(1);
|
||||||
const req = JSON.parse(ws.sent[0]!);
|
const req = JSON.parse(ws.sent[0]!);
|
||||||
@@ -165,6 +168,7 @@ describe('GatewayClient', () => {
|
|||||||
// Complete the challenge first
|
// Complete the challenge first
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
const connectReq = JSON.parse(ws.sent[0]!);
|
const connectReq = JSON.parse(ws.sent[0]!);
|
||||||
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
@@ -184,6 +188,7 @@ describe('GatewayClient', () => {
|
|||||||
|
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
const connectReq = JSON.parse(ws.sent[0]!);
|
const connectReq = JSON.parse(ws.sent[0]!);
|
||||||
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
@@ -207,6 +212,7 @@ describe('GatewayClient', () => {
|
|||||||
|
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws._receive({ type: 'event', event: 'connect.challenge' });
|
ws._receive({ type: 'event', event: 'connect.challenge' });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
const connectReq = JSON.parse(ws.sent[0]!);
|
const connectReq = JSON.parse(ws.sent[0]!);
|
||||||
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
ws._receive({ type: 'res', id: connectReq.id, ok: true, payload: {} });
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
@@ -271,4 +277,46 @@ describe('GatewayClient', () => {
|
|||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
expect(ws.url).toBe('ws://new:5678');
|
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
197
src/lib/deviceIdentity.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { genId } from './utils';
|
import { genId } from './utils';
|
||||||
|
import type { DeviceIdentity } from './deviceIdentity';
|
||||||
|
import { buildDeviceAuthPayload, signPayload } from './deviceIdentity';
|
||||||
|
|
||||||
/** Debug logger — enable with localStorage.setItem('pinchchat:debug', '1') */
|
/** Debug logger — enable with localStorage.setItem('pinchchat:debug', '1') */
|
||||||
const isDebug = () => {
|
const isDebug = () => {
|
||||||
@@ -24,18 +26,22 @@ interface GatewayMessage {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing';
|
||||||
|
|
||||||
export class GatewayClient {
|
export class GatewayClient {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private pendingRequests = new Map<string, { resolve: (v: JsonPayload) => void; reject: (e: unknown) => void }>();
|
private pendingRequests = new Map<string, { resolve: (v: JsonPayload) => void; reject: (e: unknown) => void }>();
|
||||||
private eventHandlers: GatewayEventHandler[] = [];
|
private eventHandlers: GatewayEventHandler[] = [];
|
||||||
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
|
private _onStatus: (s: GatewayStatus) => void = () => {};
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private reconnectAttempts = 0;
|
private reconnectAttempts = 0;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private autoReconnect = true;
|
private autoReconnect = true;
|
||||||
|
private connectNonce: string | null = null;
|
||||||
|
|
||||||
private wsUrl: string;
|
private wsUrl: string;
|
||||||
private authToken: string;
|
private authToken: string;
|
||||||
|
private deviceIdentity: DeviceIdentity | null = null;
|
||||||
|
|
||||||
constructor(wsUrl?: string, authToken?: string) {
|
constructor(wsUrl?: string, authToken?: string) {
|
||||||
this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
|
this.wsUrl = wsUrl || `ws://${window.location.hostname}:18789`;
|
||||||
@@ -48,7 +54,12 @@ export class GatewayClient {
|
|||||||
this.authToken = authToken;
|
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;
|
this._onStatus = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +71,7 @@ export class GatewayClient {
|
|||||||
connect() {
|
connect() {
|
||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
this.autoReconnect = true;
|
this.autoReconnect = true;
|
||||||
|
this.connectNonce = null;
|
||||||
this._onStatus('connecting');
|
this._onStatus('connecting');
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
|
||||||
@@ -72,6 +84,9 @@ export class GatewayClient {
|
|||||||
|
|
||||||
if (msg.type === 'event') {
|
if (msg.type === 'event') {
|
||||||
if (msg.event === 'connect.challenge') {
|
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();
|
this.handleChallenge();
|
||||||
} else {
|
} else {
|
||||||
for (const h of this.eventHandlers) h(msg.event ?? '', msg.payload ?? {});
|
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); };
|
this.ws.onerror = (e) => { log('WS error', e); };
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChallenge() {
|
private async handleChallenge() {
|
||||||
const id = genId('connect');
|
const id = genId('connect');
|
||||||
this.request(id, 'connect', {
|
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,
|
minProtocol: 3,
|
||||||
maxProtocol: 3,
|
maxProtocol: 3,
|
||||||
client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' },
|
client: { id: 'webchat', version: __APP_VERSION__, platform: 'web', mode: 'webchat' },
|
||||||
role: 'operator',
|
role,
|
||||||
scopes: ['operator.read', 'operator.write', 'operator.admin'],
|
scopes,
|
||||||
caps: [],
|
caps: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
permissions: {},
|
permissions: {},
|
||||||
auth: { token: this.authToken },
|
auth: { token: this.authToken },
|
||||||
|
device,
|
||||||
locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en',
|
locale: (typeof navigator !== 'undefined' ? navigator.language : undefined) || 'en',
|
||||||
userAgent: `pinchchat/${__APP_VERSION__}`,
|
userAgent: `pinchchat/${__APP_VERSION__}`,
|
||||||
}).then((res) => {
|
});
|
||||||
log('connected!', res);
|
log('connected!', res);
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this._onStatus('connected');
|
this._onStatus('connected');
|
||||||
}).catch((err) => {
|
} catch (err) {
|
||||||
log('connect failed:', 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.autoReconnect = false;
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect() {
|
private scheduleReconnect() {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const en = {
|
|||||||
// Connection banner
|
// Connection banner
|
||||||
'connection.reconnecting': 'Connection lost — reconnecting…',
|
'connection.reconnecting': 'Connection lost — reconnecting…',
|
||||||
'connection.reconnected': 'Reconnected!',
|
'connection.reconnected': 'Reconnected!',
|
||||||
|
'connection.pairing': 'Device pairing pending — run `openclaw devices approve` on your gateway',
|
||||||
|
|
||||||
// Message actions
|
// Message actions
|
||||||
'message.copy': 'Copy message',
|
'message.copy': 'Copy message',
|
||||||
@@ -257,6 +258,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': 'Connexion perdue — reconnexion…',
|
'connection.reconnecting': 'Connexion perdue — reconnexion…',
|
||||||
'connection.reconnected': 'Reconnecté !',
|
'connection.reconnected': 'Reconnecté !',
|
||||||
|
'connection.pairing': 'Appairage en attente — exécutez `openclaw devices approve` sur votre gateway',
|
||||||
|
|
||||||
'message.copy': 'Copier le message',
|
'message.copy': 'Copier le message',
|
||||||
'message.copied': 'Copié !',
|
'message.copied': 'Copié !',
|
||||||
@@ -418,6 +420,7 @@ const es: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': 'Conexión perdida — reconectando…',
|
'connection.reconnecting': 'Conexión perdida — reconectando…',
|
||||||
'connection.reconnected': '¡Reconectado!',
|
'connection.reconnected': '¡Reconectado!',
|
||||||
|
'connection.pairing': 'Emparejamiento pendiente — ejecute `openclaw devices approve` en su gateway',
|
||||||
|
|
||||||
'message.copy': 'Copiar mensaje',
|
'message.copy': 'Copiar mensaje',
|
||||||
'message.copied': '¡Copiado!',
|
'message.copied': '¡Copiado!',
|
||||||
@@ -581,6 +584,7 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': 'Verbindung verloren — wird wiederhergestellt…',
|
'connection.reconnecting': 'Verbindung verloren — wird wiederhergestellt…',
|
||||||
'connection.reconnected': 'Wieder verbunden!',
|
'connection.reconnected': 'Wieder verbunden!',
|
||||||
|
'connection.pairing': 'Gerätekopplung ausstehend — führen Sie `openclaw devices approve` auf Ihrem Gateway aus',
|
||||||
|
|
||||||
'message.copy': 'Nachricht kopieren',
|
'message.copy': 'Nachricht kopieren',
|
||||||
'message.copied': 'Kopiert!',
|
'message.copied': 'Kopiert!',
|
||||||
@@ -742,6 +746,7 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': '接続が切断されました — 再接続中…',
|
'connection.reconnecting': '接続が切断されました — 再接続中…',
|
||||||
'connection.reconnected': '再接続しました!',
|
'connection.reconnected': '再接続しました!',
|
||||||
|
'connection.pairing': 'デバイスペアリング保留中 — ゲートウェイで `openclaw devices approve` を実行してください',
|
||||||
|
|
||||||
'message.copy': 'メッセージをコピー',
|
'message.copy': 'メッセージをコピー',
|
||||||
'message.copied': 'コピーしました!',
|
'message.copied': 'コピーしました!',
|
||||||
@@ -903,6 +908,7 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': 'Conexão perdida — reconectando…',
|
'connection.reconnecting': 'Conexão perdida — reconectando…',
|
||||||
'connection.reconnected': 'Reconectado!',
|
'connection.reconnected': 'Reconectado!',
|
||||||
|
'connection.pairing': 'Emparelhamento pendente — execute `openclaw devices approve` no seu gateway',
|
||||||
|
|
||||||
'message.copy': 'Copiar mensagem',
|
'message.copy': 'Copiar mensagem',
|
||||||
'message.copied': 'Copiado!',
|
'message.copied': 'Copiado!',
|
||||||
@@ -1064,6 +1070,7 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': '连接中断 — 正在重连…',
|
'connection.reconnecting': '连接中断 — 正在重连…',
|
||||||
'connection.reconnected': '已重新连接!',
|
'connection.reconnected': '已重新连接!',
|
||||||
|
'connection.pairing': '设备配对待处理 — 在网关上运行 `openclaw devices approve`',
|
||||||
|
|
||||||
'message.copy': '复制消息',
|
'message.copy': '复制消息',
|
||||||
'message.copied': '已复制!',
|
'message.copied': '已复制!',
|
||||||
@@ -1225,6 +1232,7 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
|
|
||||||
'connection.reconnecting': 'Connessione persa — riconnessione…',
|
'connection.reconnecting': 'Connessione persa — riconnessione…',
|
||||||
'connection.reconnected': 'Riconnesso!',
|
'connection.reconnected': 'Riconnesso!',
|
||||||
|
'connection.pairing': 'Accoppiamento dispositivo in attesa — esegui `openclaw devices approve` sul tuo gateway',
|
||||||
|
|
||||||
'message.copy': 'Copia messaggio',
|
'message.copy': 'Copia messaggio',
|
||||||
'message.copied': 'Copiato!',
|
'message.copied': 'Copiato!',
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export interface AgentIdentity {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'pairing';
|
||||||
|
|
||||||
export interface GatewayState {
|
export interface GatewayState {
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
|
|||||||
Reference in New Issue
Block a user