@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.2
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/CHANGELOG.md +917 -0
- package/README.md +31 -2
- package/config.ts +5 -0
- package/first-run.ts +131 -0
- package/index.ts +256 -9
- package/onboarding-cli.ts +9 -2
- package/package.json +4 -2
- package/pair-cli.ts +351 -0
- package/pair-crypto.ts +474 -0
- package/pair-http.ts +527 -0
- package/pair-page.ts +841 -0
- package/pair-session-store.ts +764 -0
- package/subgraph-store.ts +2 -2
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. Inline scripts are OK because
|
|
195
|
+
// everything is self-contained; no runtime code evaluation is used.
|
|
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 recovery-phrase 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
|
+
}
|