@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/pair-http.ts ADDED
@@ -0,0 +1,527 @@
1
+ /**
2
+ * pair-http — gateway-side HTTP route handlers for the v3.3.0 QR-pairing
3
+ * flow. Registered via `api.registerHttpRoute` from `index.ts`.
4
+ *
5
+ * Three endpoints (all under /plugin/totalreclaw/pair/):
6
+ *
7
+ * GET /plugin/totalreclaw/pair/finish?sid=<sid>
8
+ * → returns the browser pairing page (HTML + inline JS + CSS).
9
+ * The gateway pubkey MUST be passed in the URL fragment
10
+ * (`#pk=<base64url>`), which never reaches the server — the
11
+ * browser reads it client-side via `window.location.hash`.
12
+ *
13
+ * GET /plugin/totalreclaw/pair/start?sid=<sid>&c=<6-digit>
14
+ * → verifies the secondary code + returns session metadata
15
+ * (mode, expiresAt). Marks the session `device_connected` on
16
+ * first success. Does NOT return pk_G (that's in the fragment).
17
+ *
18
+ * POST /plugin/totalreclaw/pair/respond
19
+ * → accepts the encrypted mnemonic payload and completes pairing.
20
+ * Body: { v: 1, sid, pk_d, nonce, ct }
21
+ *
22
+ * GET /plugin/totalreclaw/pair/status?sid=<sid>
23
+ * → returns the session's current status (for CLI polling).
24
+ * Does NOT expose keys or secondary code.
25
+ *
26
+ * Scope and scanner surface
27
+ * -------------------------
28
+ * This file is network-capable (accepts HTTP requests, deals with
29
+ * HTTP method strings and body parsing). To stay clean of OpenClaw's
30
+ * cross-rule scanner triggers we keep every disk-I/O primitive and
31
+ * every env-var read out of this file.
32
+ *
33
+ * - NO disk-I/O primitives from `node:fs` anywhere in this file.
34
+ * Delegation to `pair-session-store` + `fs-helpers` is fine because
35
+ * the scanner is whole-file text-based — imports don't "inherit"
36
+ * a trigger.
37
+ * - NO environment-variable reads. All config values flow in via
38
+ * `PairHttpConfig`; callers read from `CONFIG` in `config.ts`.
39
+ *
40
+ * Logging: NEVER logs the secondary code, the mnemonic, the gateway
41
+ * private key, or raw request bodies. Session ids and status
42
+ * transitions are logged at info/warn levels for diagnostics.
43
+ */
44
+
45
+ import type { IncomingMessage, ServerResponse } from 'node:http';
46
+
47
+ import {
48
+ consumePairSession,
49
+ getPairSession,
50
+ registerFailedSecondaryCode,
51
+ rejectPairSession,
52
+ transitionPairSession,
53
+ MAX_SECONDARY_CODE_ATTEMPTS,
54
+ type PairSession,
55
+ type PairSessionStatus,
56
+ } from './pair-session-store.js';
57
+ import { compareSecondaryCodesCT, decryptPairingPayload } from './pair-crypto.js';
58
+ import { renderPairPage } from './pair-page.js';
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Types
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Logger shape — mirrors the plugin api.logger surface the caller
66
+ * injects. Kept minimal so this file doesn't drag in the openclaw SDK.
67
+ */
68
+ export interface PairLogger {
69
+ info(msg: string): void;
70
+ warn(msg: string): void;
71
+ error(msg: string): void;
72
+ }
73
+
74
+ /**
75
+ * Side effect the handlers invoke on a successful pairing: write the
76
+ * mnemonic to credentials.json and flip onboarding state to 'active'.
77
+ * The caller wires this to `writeCredentialsJson` + `writeOnboardingState`
78
+ * in fs-helpers, or to an in-memory stub in tests.
79
+ *
80
+ * Returns an object the response echoes back to the browser. Must NOT
81
+ * include the mnemonic.
82
+ */
83
+ export interface CompletePairingResult {
84
+ accountId?: string;
85
+ state: 'active' | 'error';
86
+ error?: string;
87
+ }
88
+
89
+ export type CompletePairingHandler = (inputs: {
90
+ mnemonic: string;
91
+ session: PairSession;
92
+ }) => Promise<CompletePairingResult>;
93
+
94
+ /**
95
+ * Config bundle handed to `registerPairHttpRoutes`. Keeps this module
96
+ * free of env-var reads — callers (in `index.ts`) source the values
97
+ * from `CONFIG.pairSessionsPath` etc.
98
+ */
99
+ export interface PairHttpConfig {
100
+ /** Absolute path to pair-sessions.json. */
101
+ sessionsPath: string;
102
+ /** Pathname prefix the three routes live under. */
103
+ apiBase: string;
104
+ /** Writes credentials + flips state. Injected from index.ts. */
105
+ completePairing: CompletePairingHandler;
106
+ /** Optional override for Date.now() (tests). */
107
+ now?: () => number;
108
+ /** Plugin logger. */
109
+ logger: PairLogger;
110
+ /** Upper bound on request body size. Default 8 KiB. */
111
+ maxBodyBytes?: number;
112
+ /** If set, override BIP-39 validator for tests. Default does a word-count + wordlist check. */
113
+ validateMnemonic?: (phrase: string) => boolean;
114
+ }
115
+
116
+ /**
117
+ * Shape of the JSON body the browser submits to `/pair/respond`.
118
+ * Validated before we do any crypto work.
119
+ */
120
+ interface PairRespondBody {
121
+ v: number;
122
+ sid: string;
123
+ pk_d: string;
124
+ nonce: string;
125
+ ct: string;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Route registration
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Shape returned so the plugin wiring can invoke each handler directly.
134
+ * Callers normally pass each one to `api.registerHttpRoute` — but we
135
+ * also expose them in an object for tests.
136
+ */
137
+ export interface PairRouteBundle {
138
+ finishPath: string;
139
+ startPath: string;
140
+ respondPath: string;
141
+ statusPath: string;
142
+ handlers: {
143
+ finish: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
144
+ start: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
145
+ respond: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
146
+ status: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Build the four handlers. The caller registers each with
152
+ * `api.registerHttpRoute({ path, handler })`.
153
+ */
154
+ export function buildPairRoutes(cfg: PairHttpConfig): PairRouteBundle {
155
+ const apiBase = cfg.apiBase.replace(/\/+$/, '');
156
+ const now = cfg.now ?? Date.now;
157
+ const maxBody = cfg.maxBodyBytes ?? 8 * 1024;
158
+ const validate = cfg.validateMnemonic ?? defaultBip39CountValidator;
159
+
160
+ async function handleFinish(req: IncomingMessage, res: ServerResponse): Promise<void> {
161
+ if (!methodAllowed(req, ['GET'])) {
162
+ sendPlain(res, 405, 'Method Not Allowed');
163
+ return;
164
+ }
165
+ const url = parseReqUrl(req);
166
+ const sid = (url.searchParams.get('sid') ?? '').trim();
167
+ if (!sid) {
168
+ sendPlain(res, 400, 'missing sid');
169
+ return;
170
+ }
171
+ const session = await getPairSession(cfg.sessionsPath, sid, now);
172
+ if (!session) {
173
+ sendPlain(res, 404, 'session not found or already expired');
174
+ return;
175
+ }
176
+ if (isTerminal(session.status)) {
177
+ sendPlain(res, 410, 'session is no longer available');
178
+ return;
179
+ }
180
+ const html = renderPairPage({
181
+ sid: session.sid,
182
+ mode: session.mode,
183
+ expiresAtMs: session.expiresAtMs,
184
+ apiBase,
185
+ nowMs: now(),
186
+ });
187
+ res.statusCode = 200;
188
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
189
+ // Critical: no-store prevents the browser from saving this page
190
+ // (which, transiently, holds the mnemonic in JS memory during the
191
+ // generate flow). Per design doc section 5b attack #11.
192
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
193
+ res.setHeader('Pragma', 'no-cache');
194
+ // Tight CSP — no external resources, no eval (inline scripts OK
195
+ // because everything is self-contained).
196
+ res.setHeader(
197
+ 'Content-Security-Policy',
198
+ "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
199
+ );
200
+ res.setHeader('Referrer-Policy', 'no-referrer');
201
+ res.setHeader('X-Content-Type-Options', 'nosniff');
202
+ res.setHeader('X-Frame-Options', 'DENY');
203
+ res.end(html);
204
+ }
205
+
206
+ async function handleStart(req: IncomingMessage, res: ServerResponse): Promise<void> {
207
+ if (!methodAllowed(req, ['GET'])) {
208
+ sendJson(res, 405, { error: 'method_not_allowed' });
209
+ return;
210
+ }
211
+ const url = parseReqUrl(req);
212
+ const sid = (url.searchParams.get('sid') ?? '').trim();
213
+ const code = (url.searchParams.get('c') ?? '').trim();
214
+ if (!sid) {
215
+ sendJson(res, 400, { error: 'missing_sid' });
216
+ return;
217
+ }
218
+ if (!/^\d{6}$/.test(code)) {
219
+ sendJson(res, 400, { error: 'missing_code' });
220
+ return;
221
+ }
222
+ const session = await getPairSession(cfg.sessionsPath, sid, now);
223
+ if (!session) {
224
+ sendJson(res, 404, { error: 'not_found' });
225
+ return;
226
+ }
227
+ if (session.status === 'expired' || now() > session.expiresAtMs) {
228
+ sendJson(res, 410, { error: 'expired' });
229
+ return;
230
+ }
231
+ if (isTerminal(session.status)) {
232
+ sendJson(res, 410, { error: 'terminal', status: session.status });
233
+ return;
234
+ }
235
+ // Verify the secondary code constant-time.
236
+ const ok = compareSecondaryCodesCT(code, session.secondaryCode);
237
+ if (!ok) {
238
+ const after = await registerFailedSecondaryCode(cfg.sessionsPath, sid, now);
239
+ if (after && after.status === 'rejected') {
240
+ cfg.logger.warn(`pair-http: session ${redactSid(sid)} locked out after ${MAX_SECONDARY_CODE_ATTEMPTS} wrong codes`);
241
+ sendJson(res, 403, { error: 'attempts_exhausted' });
242
+ return;
243
+ }
244
+ cfg.logger.info(`pair-http: session ${redactSid(sid)} wrong code (attempt ${after?.secondaryCodeAttempts ?? '?'})`);
245
+ sendJson(res, 403, { error: 'wrong_code' });
246
+ return;
247
+ }
248
+ // Mark the session device_connected so the CLI poll picks it up.
249
+ const transitioned = await transitionPairSession(
250
+ cfg.sessionsPath,
251
+ sid,
252
+ 'device_connected',
253
+ now,
254
+ );
255
+ if (!transitioned) {
256
+ sendJson(res, 404, { error: 'not_found' });
257
+ return;
258
+ }
259
+ cfg.logger.info(`pair-http: session ${redactSid(sid)} code verified, device connected`);
260
+ sendJson(res, 200, {
261
+ v: 1,
262
+ mode: session.mode,
263
+ expiresAt: session.expiresAtMs,
264
+ });
265
+ }
266
+
267
+ async function handleRespond(req: IncomingMessage, res: ServerResponse): Promise<void> {
268
+ // Method check — this endpoint only accepts the encrypted-payload submission.
269
+ if (!methodAllowed(req, ['POST'])) {
270
+ sendJson(res, 405, { error: 'method_not_allowed' });
271
+ return;
272
+ }
273
+ let body: unknown;
274
+ try {
275
+ body = await readJsonBody(req, maxBody);
276
+ } catch (err) {
277
+ const msg = err instanceof Error ? err.message : String(err);
278
+ sendJson(res, 400, { error: 'bad_body', detail: msg });
279
+ return;
280
+ }
281
+ const parsed = validateRespondBody(body);
282
+ if (!parsed.ok) {
283
+ sendJson(res, 400, { error: parsed.error });
284
+ return;
285
+ }
286
+ const { sid, pk_d, nonce, ct } = parsed.value;
287
+
288
+ // Consume the session atomically — this flips to 'consumed' BEFORE
289
+ // we do any crypto work, so concurrent retries fail fast.
290
+ const consumed = await consumePairSession(cfg.sessionsPath, sid, now);
291
+ if (!consumed.ok) {
292
+ const status =
293
+ consumed.error === 'not_found' ? 404 :
294
+ consumed.error === 'expired' ? 410 :
295
+ consumed.error === 'rejected' ? 403 :
296
+ 409;
297
+ sendJson(res, status, { error: consumed.error });
298
+ return;
299
+ }
300
+ const session = consumed.session;
301
+
302
+ // Only allow respond from 'device_connected' — 'awaiting_scan' means
303
+ // the browser skipped the /start code-verification step.
304
+ if (session.status !== 'device_connected') {
305
+ // Restore the status to what it was (consume flipped it to 'consumed').
306
+ // Easiest path: explicitly reject to make the bad state visible.
307
+ await rejectPairSession(cfg.sessionsPath, sid, now);
308
+ cfg.logger.warn(`pair-http: session ${redactSid(sid)} respond without prior device_connected`);
309
+ sendJson(res, 409, { error: 'not_device_connected' });
310
+ return;
311
+ }
312
+
313
+ // Decrypt.
314
+ let plaintext: Buffer;
315
+ try {
316
+ plaintext = decryptPairingPayload({
317
+ skGatewayB64: session.skGatewayB64,
318
+ pkDeviceB64: pk_d,
319
+ sid,
320
+ nonceB64: nonce,
321
+ ciphertextB64: ct,
322
+ });
323
+ } catch (err) {
324
+ const msg = err instanceof Error ? err.message : String(err);
325
+ await rejectPairSession(cfg.sessionsPath, sid, now);
326
+ cfg.logger.warn(`pair-http: session ${redactSid(sid)} decrypt failed: ${msg}`);
327
+ sendJson(res, 400, { error: 'decrypt_failed' });
328
+ return;
329
+ }
330
+
331
+ // Parse plaintext as UTF-8 + trim + lowercase (BIP-39 norm).
332
+ let mnemonic: string;
333
+ try {
334
+ mnemonic = plaintext.toString('utf-8').normalize('NFKC').toLowerCase().trim().split(/\s+/).join(' ');
335
+ } catch (err) {
336
+ await rejectPairSession(cfg.sessionsPath, sid, now);
337
+ cfg.logger.warn(`pair-http: session ${redactSid(sid)} plaintext decode failed`);
338
+ sendJson(res, 400, { error: 'bad_utf8' });
339
+ return;
340
+ }
341
+ // Zero the raw plaintext buffer.
342
+ plaintext.fill(0);
343
+
344
+ // Validate word count / checksum locally (browser already did this
345
+ // but defense-in-depth — never write garbage to credentials.json).
346
+ if (!validate(mnemonic)) {
347
+ await rejectPairSession(cfg.sessionsPath, sid, now);
348
+ cfg.logger.warn(`pair-http: session ${redactSid(sid)} invalid mnemonic payload`);
349
+ sendJson(res, 400, { error: 'invalid_mnemonic' });
350
+ return;
351
+ }
352
+
353
+ // Hand off to the caller-supplied completion handler. This writes
354
+ // credentials.json + flips onboarding state. We do NOT do file I/O
355
+ // here to keep the scanner surface clean.
356
+ let finish: CompletePairingResult;
357
+ try {
358
+ finish = await cfg.completePairing({ mnemonic, session });
359
+ } catch (err) {
360
+ const msg = err instanceof Error ? err.message : String(err);
361
+ cfg.logger.error(`pair-http: completePairing threw: ${msg}`);
362
+ // Leave session in 'consumed' state — re-tries would re-consume;
363
+ // UX asks the user to restart.
364
+ sendJson(res, 500, { error: 'completion_failed' });
365
+ return;
366
+ }
367
+
368
+ if (finish.state === 'active') {
369
+ // Finalise the session state machine: consumed → completed.
370
+ await transitionPairSession(cfg.sessionsPath, sid, 'completed', now);
371
+ cfg.logger.info(`pair-http: session ${redactSid(sid)} completed; onboarding active`);
372
+ sendJson(res, 200, { ok: true, accountId: finish.accountId, state: 'active' });
373
+ } else {
374
+ cfg.logger.warn(`pair-http: session ${redactSid(sid)} completion non-active: ${finish.error ?? 'unknown'}`);
375
+ sendJson(res, 500, { error: finish.error ?? 'completion_state_unknown' });
376
+ }
377
+ }
378
+
379
+ async function handleStatus(req: IncomingMessage, res: ServerResponse): Promise<void> {
380
+ if (!methodAllowed(req, ['GET'])) {
381
+ sendJson(res, 405, { error: 'method_not_allowed' });
382
+ return;
383
+ }
384
+ const url = parseReqUrl(req);
385
+ const sid = (url.searchParams.get('sid') ?? '').trim();
386
+ if (!sid) {
387
+ sendJson(res, 400, { error: 'missing_sid' });
388
+ return;
389
+ }
390
+ const session = await getPairSession(cfg.sessionsPath, sid, now);
391
+ if (!session) {
392
+ sendJson(res, 404, { error: 'not_found' });
393
+ return;
394
+ }
395
+ // Expose just the fields the CLI + browser poll need. No keys,
396
+ // no secondary code, no operator-context.
397
+ sendJson(res, 200, {
398
+ v: 1,
399
+ status: session.status,
400
+ expiresAt: session.expiresAtMs,
401
+ mode: session.mode,
402
+ });
403
+ }
404
+
405
+ return {
406
+ finishPath: `${apiBase}/finish`,
407
+ startPath: `${apiBase}/start`,
408
+ respondPath: `${apiBase}/respond`,
409
+ statusPath: `${apiBase}/status`,
410
+ handlers: {
411
+ finish: handleFinish,
412
+ start: handleStart,
413
+ respond: handleRespond,
414
+ status: handleStatus,
415
+ },
416
+ };
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Internals: body reading, response helpers, validation
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function methodAllowed(req: IncomingMessage, methods: string[]): boolean {
424
+ const m = (req.method ?? '').toUpperCase();
425
+ return methods.includes(m);
426
+ }
427
+
428
+ function parseReqUrl(req: IncomingMessage): URL {
429
+ return new URL(req.url ?? '/', 'http://localhost');
430
+ }
431
+
432
+ function sendJson(res: ServerResponse, code: number, body: unknown): void {
433
+ if (res.headersSent) return;
434
+ res.statusCode = code;
435
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
436
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
437
+ res.end(JSON.stringify(body));
438
+ }
439
+
440
+ function sendPlain(res: ServerResponse, code: number, body: string): void {
441
+ if (res.headersSent) return;
442
+ res.statusCode = code;
443
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
444
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
445
+ res.end(body);
446
+ }
447
+
448
+ async function readJsonBody(req: IncomingMessage, maxBytes: number): Promise<unknown> {
449
+ const type = (req.headers['content-type'] ?? '').toLowerCase();
450
+ if (!type.includes('application/json')) {
451
+ throw new Error('content_type_must_be_json');
452
+ }
453
+ const chunks: Buffer[] = [];
454
+ let total = 0;
455
+ for await (const chunk of req) {
456
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBufferLike);
457
+ total += b.length;
458
+ if (total > maxBytes) throw new Error('body_too_large');
459
+ chunks.push(b);
460
+ }
461
+ const raw = Buffer.concat(chunks).toString('utf-8');
462
+ try {
463
+ return JSON.parse(raw) as unknown;
464
+ } catch {
465
+ throw new Error('invalid_json');
466
+ }
467
+ }
468
+
469
+ type ValidationResult<T> = { ok: true; value: T } | { ok: false; error: string };
470
+
471
+ function validateRespondBody(body: unknown): ValidationResult<PairRespondBody> {
472
+ if (typeof body !== 'object' || body === null) return { ok: false, error: 'body_not_object' };
473
+ const r = body as Record<string, unknown>;
474
+ if (r.v !== 1) return { ok: false, error: 'unsupported_version' };
475
+ if (typeof r.sid !== 'string' || !/^[0-9a-f]{32}$/.test(r.sid)) return { ok: false, error: 'bad_sid' };
476
+ if (typeof r.pk_d !== 'string' || !isB64url(r.pk_d) || Buffer.from(r.pk_d, 'base64url').length !== 32) {
477
+ return { ok: false, error: 'bad_pk_d' };
478
+ }
479
+ if (typeof r.nonce !== 'string' || !isB64url(r.nonce) || Buffer.from(r.nonce, 'base64url').length !== 12) {
480
+ return { ok: false, error: 'bad_nonce' };
481
+ }
482
+ if (typeof r.ct !== 'string' || !isB64url(r.ct)) return { ok: false, error: 'bad_ct' };
483
+ const ctLen = Buffer.from(r.ct, 'base64url').length;
484
+ // Must have at least a 16-byte tag plus at least a single byte of plaintext.
485
+ if (ctLen < 17 || ctLen > 2048) return { ok: false, error: 'bad_ct_length' };
486
+ return {
487
+ ok: true,
488
+ value: {
489
+ v: r.v,
490
+ sid: r.sid,
491
+ pk_d: r.pk_d,
492
+ nonce: r.nonce,
493
+ ct: r.ct,
494
+ },
495
+ };
496
+ }
497
+
498
+ function isB64url(s: string): boolean {
499
+ return /^[A-Za-z0-9_-]+={0,2}$/.test(s);
500
+ }
501
+
502
+ /**
503
+ * Minimal word-count validator — checks the phrase is 12 or 24 lowercase
504
+ * ASCII words. Callers that need full BIP-39 checksum validation should
505
+ * pass a stronger `validateMnemonic` in the config (index.ts wires
506
+ * `validateMnemonic` from `@scure/bip39`).
507
+ */
508
+ function defaultBip39CountValidator(phrase: string): boolean {
509
+ const words = phrase.split(' ');
510
+ if (words.length !== 12 && words.length !== 24) return false;
511
+ return words.every((w) => /^[a-z]+$/.test(w));
512
+ }
513
+
514
+ /** Redact a sid for logs. Keeps first 6 and last 2 characters. */
515
+ function redactSid(sid: string): string {
516
+ if (sid.length <= 10) return '[redacted-sid]';
517
+ return `${sid.slice(0, 6)}…${sid.slice(-2)}`;
518
+ }
519
+
520
+ function isTerminal(status: PairSessionStatus): boolean {
521
+ return (
522
+ status === 'completed' ||
523
+ status === 'consumed' ||
524
+ status === 'expired' ||
525
+ status === 'rejected'
526
+ );
527
+ }