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

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