lazyclaw 4.2.2 → 5.0.0

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.
Files changed (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. package/workspace.mjs +18 -3
@@ -0,0 +1,664 @@
1
+ // WS gateway device-authentication state machine — PURE logic, no sockets.
2
+ //
3
+ // Transport is decided elsewhere: the daemon stays localhost-bound and any
4
+ // remote exposure is the user's own tunnel (Tailscale / Cloudflare) plus TLS
5
+ // and an auth-token. This module owns only the device-identity, challenge,
6
+ // canonical-payload, signature-verification and pairing-token state machine.
7
+ //
8
+ // Everything here is unit-testable with node:crypto alone:
9
+ // - Ed25519 keys via crypto.generateKeyPairSync('ed25519')
10
+ // - Ed25519 sign / verify with the `null` algorithm (the only valid choice
11
+ // for Ed25519 in node:crypto — the digest is built into the scheme)
12
+ //
13
+ // Wall-clock is never read inside the verification path: callers inject
14
+ // `nowMs`. That keeps verifyConnect deterministic and lets tests drive the
15
+ // skew window without faking timers.
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import os from 'node:os';
20
+ import crypto from 'node:crypto';
21
+
22
+ const GATEWAY_DIRNAME = 'gateway';
23
+ const DEVICES_FILENAME = 'devices.json';
24
+
25
+ // Canonical sign-payload format tag. Bumping this string invalidates every
26
+ // signature produced by an older client, which is the intended migration
27
+ // lever — old payloads simply stop verifying.
28
+ const PAYLOAD_VERSION = 'v3';
29
+ const FIELD_SEP = '|';
30
+
31
+ const DEFAULT_MAX_SKEW_MS = 120_000;
32
+
33
+ export function defaultConfigDir() {
34
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
35
+ }
36
+
37
+ export function gatewayDir(configDir = defaultConfigDir()) {
38
+ return path.join(configDir, GATEWAY_DIRNAME);
39
+ }
40
+
41
+ export function devicesPath(configDir = defaultConfigDir()) {
42
+ return path.join(gatewayDir(configDir), DEVICES_FILENAME);
43
+ }
44
+
45
+ // Coerce any accepted public-key representation to its DER SPKI bytes.
46
+ // Accepts: a PEM string, a DER Buffer/Uint8Array, or a node:crypto
47
+ // KeyObject. The DER SPKI byte sequence is the canonical identity surface —
48
+ // fingerprinting it (rather than the PEM text) means whitespace / line-ending
49
+ // differences in a PEM never change a device id.
50
+ function toSpkiDer(publicKeyPemOrDer) {
51
+ if (publicKeyPemOrDer == null) {
52
+ throw new Error('public key is required');
53
+ }
54
+ // Already a KeyObject.
55
+ if (typeof publicKeyPemOrDer === 'object'
56
+ && typeof publicKeyPemOrDer.export === 'function'
57
+ && publicKeyPemOrDer.type === 'public') {
58
+ return publicKeyPemOrDer.export({ type: 'spki', format: 'der' });
59
+ }
60
+ // Raw DER bytes.
61
+ if (Buffer.isBuffer(publicKeyPemOrDer) || publicKeyPemOrDer instanceof Uint8Array) {
62
+ const der = Buffer.from(publicKeyPemOrDer);
63
+ // Re-import + re-export so a non-SPKI DER blob is rejected loudly and the
64
+ // bytes are normalised to canonical SPKI form.
65
+ const key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
66
+ return key.export({ type: 'spki', format: 'der' });
67
+ }
68
+ // PEM string.
69
+ if (typeof publicKeyPemOrDer === 'string') {
70
+ const key = crypto.createPublicKey(publicKeyPemOrDer);
71
+ return key.export({ type: 'spki', format: 'der' });
72
+ }
73
+ throw new Error('unsupported public key representation');
74
+ }
75
+
76
+ // Coerce to a KeyObject usable by crypto.verify.
77
+ function toPublicKeyObject(publicKey) {
78
+ if (typeof publicKey === 'object'
79
+ && typeof publicKey.export === 'function'
80
+ && publicKey.type === 'public') {
81
+ return publicKey;
82
+ }
83
+ if (Buffer.isBuffer(publicKey) || publicKey instanceof Uint8Array) {
84
+ return crypto.createPublicKey({ key: Buffer.from(publicKey), format: 'der', type: 'spki' });
85
+ }
86
+ if (typeof publicKey === 'string') {
87
+ return crypto.createPublicKey(publicKey);
88
+ }
89
+ throw new Error('unsupported public key representation');
90
+ }
91
+
92
+ /**
93
+ * Stable device identity: `sha256:<hex>` of the DER SPKI public-key bytes.
94
+ * Deterministic for a given key, independent of PEM vs DER input.
95
+ *
96
+ * @param {string|Buffer|Uint8Array|crypto.KeyObject} publicKeyPemOrDer
97
+ * @returns {string}
98
+ */
99
+ export function deviceIdFromPublicKey(publicKeyPemOrDer) {
100
+ const der = toSpkiDer(publicKeyPemOrDer);
101
+ const hex = crypto.createHash('sha256').update(der).digest('hex');
102
+ return `sha256:${hex}`;
103
+ }
104
+
105
+ /**
106
+ * Mint a one-shot connection challenge. The nonce is 32 random bytes
107
+ * (hex-encoded → 64 chars); `ts` is the current epoch-ms at mint time so a
108
+ * caller can age out unredeemed challenges.
109
+ *
110
+ * @returns {{ nonce: string, ts: number }}
111
+ */
112
+ export function createChallenge() {
113
+ return {
114
+ nonce: crypto.randomBytes(32).toString('hex'),
115
+ ts: Date.now(),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * In-memory single-use challenge ledger. verifyConnect is intentionally pure
121
+ * (it cannot mutate state), so the anti-replay guarantee lives here: every
122
+ * minted challenge can be redeemed AT MOST ONCE and only inside its freshness
123
+ * window.
124
+ *
125
+ * Lifecycle per connect:
126
+ * const c = registry.create(); // hand `c` to the client
127
+ * ...client signs payload carrying c.nonce...
128
+ * if (!verifyConnect({ ..., challenge: c, nowMs })) reject;
129
+ * if (!registry.consume(c.nonce, nowMs)) reject; // replay / expiry guard
130
+ *
131
+ * `nowMs` is injected on consume() (never read from the wall-clock) so the
132
+ * skew window is test-drivable and consistent with verifyConnect.
133
+ */
134
+ export class ChallengeRegistry {
135
+ /**
136
+ * @param {{ maxSkewMs?: number }} [opts]
137
+ */
138
+ constructor({ maxSkewMs = DEFAULT_MAX_SKEW_MS, maxPending = 10000, sweepEvery = 256 } = {}) {
139
+ this.maxSkewMs = maxSkewMs;
140
+ this.maxPending = maxPending;
141
+ this.sweepEvery = sweepEvery;
142
+ this._sinceSweep = 0;
143
+ /** @type {Map<string, number>} nonce -> mint ts (epoch ms) */
144
+ this._pending = new Map();
145
+ }
146
+
147
+ /**
148
+ * Mint and register a fresh challenge. Identical shape to createChallenge().
149
+ * The ledger is self-healing: stale nonces (older than ±maxSkewMs, which
150
+ * can never be consumed anyway) are swept opportunistically, and a hard
151
+ * cap evicts the oldest so an unauthenticated flood of un-redeemed
152
+ * challenges can never grow memory without bound.
153
+ * @returns {{ nonce: string, ts: number }}
154
+ */
155
+ create() {
156
+ const challenge = createChallenge();
157
+ this._pending.set(challenge.nonce, challenge.ts);
158
+ if (++this._sinceSweep >= this.sweepEvery) {
159
+ this._sinceSweep = 0;
160
+ this._sweep(challenge.ts);
161
+ }
162
+ // Hard cap — evict oldest (Map preserves insertion order) until within
163
+ // bound. O(1) amortized per create under a sustained flood.
164
+ while (this._pending.size > this.maxPending) {
165
+ const oldest = this._pending.keys().next().value;
166
+ if (oldest === undefined) break;
167
+ this._pending.delete(oldest);
168
+ }
169
+ return challenge;
170
+ }
171
+
172
+ // Drop entries that can no longer verify (outside ±maxSkewMs of nowMs).
173
+ _sweep(nowMs) {
174
+ for (const [nonce, ts] of this._pending) {
175
+ if (Math.abs(nowMs - ts) > this.maxSkewMs) this._pending.delete(nonce);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Redeem a challenge exactly once. Returns true only when the nonce is
181
+ * known, has NOT been consumed before, and is still within ±maxSkewMs of
182
+ * `nowMs`. Any successful or expired redemption removes the nonce so it can
183
+ * never be replayed.
184
+ *
185
+ * @param {string} nonce
186
+ * @param {number} nowMs
187
+ * @returns {boolean}
188
+ */
189
+ consume(nonce, nowMs) {
190
+ if (typeof nonce !== 'string' || nonce.length === 0) return false;
191
+ if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) return false;
192
+ const ts = this._pending.get(nonce);
193
+ if (ts === undefined) {
194
+ // Unknown OR already consumed — either way, reject as replay.
195
+ return false;
196
+ }
197
+ // Single-use: drop it now, before any further decision, so a concurrent or
198
+ // repeated call can never redeem the same nonce twice.
199
+ this._pending.delete(nonce);
200
+ const skew = Math.abs(nowMs - ts);
201
+ if (skew > this.maxSkewMs) {
202
+ return false; // expired (stale or implausibly future)
203
+ }
204
+ return true;
205
+ }
206
+ }
207
+
208
+ // Canonicalise scopes to a single deterministic token so two clients that
209
+ // list the same scopes in a different order still produce an identical
210
+ // signed payload. Sorted + comma-joined.
211
+ function canonScopes(scopes) {
212
+ if (scopes == null) return '';
213
+ const arr = Array.isArray(scopes) ? scopes : [scopes];
214
+ return arr.map((s) => String(s)).sort().join(',');
215
+ }
216
+
217
+ // Replace the field separator inside a value so a malicious field can't
218
+ // inject extra logical fields into the canonical string ("|" smuggling).
219
+ function safeField(value) {
220
+ return String(value ?? '').split(FIELD_SEP).join('%7C');
221
+ }
222
+
223
+ /**
224
+ * Build the canonical string a client signs and the gateway re-derives to
225
+ * verify. Field order is fixed and version-tagged; scopes are order-normalised.
226
+ *
227
+ * Layout:
228
+ * v3|<deviceId>|<clientId>|<clientMode>|<role>|<scopes>|<signedAtMs>|<token>|<nonce>|<platform>|<deviceFamily>
229
+ *
230
+ * @param {{
231
+ * deviceId: string, clientId: string, clientMode: string, role: string,
232
+ * scopes: string[]|string, signedAtMs: number, token: string, nonce: string,
233
+ * platform: string, deviceFamily: string,
234
+ * }} fields
235
+ * @returns {string}
236
+ */
237
+ export function buildSignPayload({
238
+ deviceId,
239
+ clientId,
240
+ clientMode,
241
+ role,
242
+ scopes,
243
+ signedAtMs,
244
+ token,
245
+ nonce,
246
+ platform,
247
+ deviceFamily,
248
+ } = {}) {
249
+ const parts = [
250
+ PAYLOAD_VERSION,
251
+ safeField(deviceId),
252
+ safeField(clientId),
253
+ safeField(clientMode),
254
+ safeField(role),
255
+ safeField(canonScopes(scopes)),
256
+ safeField(signedAtMs),
257
+ safeField(token),
258
+ safeField(nonce),
259
+ safeField(platform),
260
+ safeField(deviceFamily),
261
+ ];
262
+ return parts.join(FIELD_SEP);
263
+ }
264
+
265
+ // Pull the signedAtMs / nonce out of a canonical payload string. Positional —
266
+ // must stay in lock-step with buildSignPayload's field order. Returns null
267
+ // for a malformed / wrong-version payload so the caller can reject cleanly
268
+ // rather than throwing.
269
+ function parsePayload(payload) {
270
+ if (typeof payload !== 'string') return null;
271
+ const parts = payload.split(FIELD_SEP);
272
+ if (parts.length !== 11) return null;
273
+ if (parts[0] !== PAYLOAD_VERSION) return null;
274
+ return {
275
+ version: parts[0],
276
+ deviceId: parts[1],
277
+ clientId: parts[2],
278
+ clientMode: parts[3],
279
+ role: parts[4],
280
+ scopes: parts[5],
281
+ signedAtMs: Number(parts[6]),
282
+ token: parts[7],
283
+ nonce: parts[8],
284
+ platform: parts[9],
285
+ deviceFamily: parts[10],
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Verify a connect attempt. Four independent gates, all of which must pass:
291
+ * 1. Ed25519 signature over the exact payload bytes is valid for publicKey.
292
+ * 2. The publicKey hashes to the deviceId the payload CLAIMS
293
+ * (identity binding — a valid signature alone only proves the holder of
294
+ * *some* key signed these bytes; without this gate an attacker could sign
295
+ * a payload claiming a victim's deviceId with their own key and pass).
296
+ * 3. The payload's embedded nonce equals the live challenge's nonce
297
+ * (replay binding — a signature is useless against a different challenge).
298
+ * 4. The payload's signedAtMs is within ±maxSkewMs of the injected nowMs
299
+ * (freshness — neither stale nor implausibly future).
300
+ *
301
+ * `nowMs` is injected, never read from the wall-clock here, so the function
302
+ * is pure and deterministic.
303
+ *
304
+ * IMPORTANT (anti-replay): this function is intentionally pure — it does NOT
305
+ * consume the challenge. Comparing the payload nonce to challenge.nonce only
306
+ * binds the signature to THIS challenge; it does not stop the SAME
307
+ * (payload,signature) pair from verifying twice. Callers MUST drive a
308
+ * single-use ChallengeRegistry and call registry.consume(nonce, nowMs) exactly
309
+ * once per accepted connect, treating a false return as a replay/expiry reject.
310
+ *
311
+ * @param {{
312
+ * payload: string, signature: string, publicKey: any,
313
+ * challenge: { nonce: string, ts?: number },
314
+ * maxSkewMs?: number, nowMs: number,
315
+ * }} args
316
+ * @returns {{ ok: boolean, reason: string }}
317
+ */
318
+ export function verifyConnect({
319
+ payload,
320
+ signature,
321
+ publicKey,
322
+ challenge,
323
+ maxSkewMs = DEFAULT_MAX_SKEW_MS,
324
+ nowMs,
325
+ } = {}) {
326
+ if (typeof payload !== 'string' || payload.length === 0) {
327
+ return { ok: false, reason: 'missing payload' };
328
+ }
329
+ if (typeof signature !== 'string' || signature.length === 0) {
330
+ return { ok: false, reason: 'missing signature' };
331
+ }
332
+ if (!challenge || typeof challenge.nonce !== 'string' || challenge.nonce.length === 0) {
333
+ return { ok: false, reason: 'missing challenge' };
334
+ }
335
+ if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) {
336
+ return { ok: false, reason: 'missing nowMs' };
337
+ }
338
+
339
+ const parsed = parsePayload(payload);
340
+ if (!parsed) {
341
+ return { ok: false, reason: 'malformed payload' };
342
+ }
343
+
344
+ // Gate 1 — signature. Ed25519 verify uses the `null` algorithm. We do this
345
+ // BEFORE trusting any field inside the payload, since only a valid signature
346
+ // proves the payload bytes are authentic.
347
+ let sigOk = false;
348
+ let derivedDeviceId;
349
+ try {
350
+ const keyObj = toPublicKeyObject(publicKey);
351
+ // Pin the scheme: crypto.verify(null, …) would otherwise silently
352
+ // accept RSA / EC signatures too. Device identity is Ed25519-only.
353
+ if (keyObj.asymmetricKeyType !== 'ed25519') {
354
+ return { ok: false, reason: 'unsupported key type (ed25519 only)' };
355
+ }
356
+ const sigBytes = Buffer.from(signature, 'base64');
357
+ sigOk = crypto.verify(null, Buffer.from(payload), keyObj, sigBytes);
358
+ // Derive the canonical device id from the SAME key we just verified with,
359
+ // so Gate 2 can bind it to the payload's claimed identity.
360
+ derivedDeviceId = deviceIdFromPublicKey(keyObj);
361
+ } catch {
362
+ // Generic reason — don't reflect node:crypto internals to the caller.
363
+ return { ok: false, reason: 'signature verification failed' };
364
+ }
365
+ if (!sigOk) {
366
+ return { ok: false, reason: 'bad signature' };
367
+ }
368
+
369
+ // Gate 2 — identity binding. The deviceId the payload CLAIMS must be the one
370
+ // derived from the public key that produced the signature. Without this, a
371
+ // valid signature over a payload naming a VICTIM's deviceId (signed with the
372
+ // ATTACKER's own key, presenting the ATTACKER's own pubkey) would pass.
373
+ // Exact, constant-string compare — both sides are `sha256:<hex>`.
374
+ if (parsed.deviceId !== derivedDeviceId) {
375
+ return { ok: false, reason: 'device id mismatch' };
376
+ }
377
+
378
+ // Gate 3 — nonce binds the signature to THIS challenge (anti-replay).
379
+ if (parsed.nonce !== challenge.nonce) {
380
+ return { ok: false, reason: 'nonce mismatch' };
381
+ }
382
+
383
+ // Gate 4 — freshness. signedAtMs must sit within ±maxSkewMs of nowMs.
384
+ if (!Number.isFinite(parsed.signedAtMs)) {
385
+ return { ok: false, reason: 'missing signedAtMs' };
386
+ }
387
+ const skew = Math.abs(nowMs - parsed.signedAtMs);
388
+ if (skew > maxSkewMs) {
389
+ const dir = parsed.signedAtMs > nowMs ? 'future' : 'stale';
390
+ return { ok: false, reason: `signature ${dir}: skew ${skew}ms exceeds ${maxSkewMs}ms` };
391
+ }
392
+
393
+ return { ok: true, reason: 'ok' };
394
+ }
395
+
396
+ function nowIso() {
397
+ return new Date().toISOString();
398
+ }
399
+
400
+ function freshToken() {
401
+ return crypto.randomBytes(32).toString('hex');
402
+ }
403
+
404
+ function freshRequestId() {
405
+ return 'pr_' + crypto.randomBytes(12).toString('hex');
406
+ }
407
+
408
+ // devices.json holds plaintext bearer tokens, so it must be owner-only on
409
+ // disk. We set restrictive modes on create AND re-assert them with chmod after
410
+ // the rename, because the active umask can clear bits at mkdir/open time and a
411
+ // pre-existing file/dir keeps its old (possibly looser) mode otherwise.
412
+ const DEVICES_DIR_MODE = 0o700;
413
+ const DEVICES_FILE_MODE = 0o600;
414
+
415
+ // Bounds on the pending-requests table (an unauthenticated attacker minting
416
+ // fresh keypairs could otherwise grow it without limit).
417
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000; // 24h
418
+ const MAX_PENDING_REQUESTS = 1000;
419
+
420
+ // Thrown when the pending-requests ceiling is hit. The caller (gateway
421
+ // connect handler) maps this to a 429 rather than a 500.
422
+ export class PairingCapError extends Error {
423
+ constructor(message) {
424
+ super(message);
425
+ this.name = 'PairingCapError';
426
+ this.code = 'PAIRING_CAP';
427
+ }
428
+ }
429
+
430
+ function writeAtomic(filePath, obj) {
431
+ const dir = path.dirname(filePath);
432
+ fs.mkdirSync(dir, { recursive: true, mode: DEVICES_DIR_MODE });
433
+ fs.chmodSync(dir, DEVICES_DIR_MODE);
434
+ const tmp = filePath + '.tmp';
435
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: DEVICES_FILE_MODE });
436
+ fs.chmodSync(tmp, DEVICES_FILE_MODE);
437
+ fs.renameSync(tmp, filePath);
438
+ // rename preserves the source mode, but re-assert in case the destination
439
+ // pre-existed with a looser mode on some filesystems.
440
+ fs.chmodSync(filePath, DEVICES_FILE_MODE);
441
+ }
442
+
443
+ /**
444
+ * JSON-file-backed pairing + token store under <configDir>/gateway/devices.json.
445
+ *
446
+ * Security model:
447
+ * - A pairing REQUEST never yields a token. It only records intent in a
448
+ * `pending` state. The owner must explicitly approve() it.
449
+ * - approve() mints a fresh 32-byte token and ROTATES it on every call, so
450
+ * re-pairing a device always invalidates its previous token.
451
+ * - A device id that was never approved has no token (tokenFor → null,
452
+ * isApproved → false).
453
+ * - revoke() removes the approval and token.
454
+ *
455
+ * On-disk shape:
456
+ * {
457
+ * version: 1,
458
+ * requests: { <requestId>: { requestId, deviceId, platform, label, status, createdAt } },
459
+ * devices: { <deviceId>: { deviceId, platform, label, token, approvedAt } }
460
+ * }
461
+ */
462
+ export class PairingStore {
463
+ constructor(configDir = defaultConfigDir()) {
464
+ this.configDir = configDir;
465
+ this.path = devicesPath(configDir);
466
+ this._data = this._load();
467
+ }
468
+
469
+ _load() {
470
+ try {
471
+ const raw = fs.readFileSync(this.path, 'utf8');
472
+ const parsed = JSON.parse(raw);
473
+ return {
474
+ version: 1,
475
+ requests: parsed.requests && typeof parsed.requests === 'object' ? parsed.requests : {},
476
+ devices: parsed.devices && typeof parsed.devices === 'object' ? parsed.devices : {},
477
+ };
478
+ } catch {
479
+ // Missing or unreadable → empty store.
480
+ return { version: 1, requests: {}, devices: {} };
481
+ }
482
+ }
483
+
484
+ _persist() {
485
+ writeAtomic(this.path, this._data);
486
+ }
487
+
488
+ // Drop pending requests older than the TTL. Approved/other-status records
489
+ // are kept (they are the device roster, not the abuse surface). Mutates
490
+ // _data in place; the caller persists.
491
+ _prunePending() {
492
+ const cutoff = Date.now() - PENDING_TTL_MS;
493
+ for (const [id, r] of Object.entries(this._data.requests)) {
494
+ if (r && r.status === 'pending') {
495
+ const created = Date.parse(r.createdAt || '') || 0;
496
+ if (created < cutoff) delete this._data.requests[id];
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Record a pairing request for a device. Returns a pending receipt.
503
+ * NEVER returns a token — approval is a separate, explicit step.
504
+ *
505
+ * @param {{ deviceId: string, platform?: string, label?: string }} args
506
+ * @returns {{ requestId: string, status: 'pending' }}
507
+ */
508
+ requestPairing({ deviceId, platform = '', label = '' } = {}) {
509
+ if (!deviceId || typeof deviceId !== 'string') {
510
+ throw new Error('requestPairing requires a deviceId');
511
+ }
512
+ // Bound the on-disk requests table: age out stale pending requests, then
513
+ // refuse once a hard ceiling is hit. Without this, an attacker minting a
514
+ // fresh keypair (hence a fresh deviceId) per connect could grow
515
+ // devices.json without bound (each unapproved connect persists a row).
516
+ this._prunePending();
517
+ const pendingCount = Object.values(this._data.requests).filter((r) => r && r.status === 'pending').length;
518
+ if (pendingCount >= MAX_PENDING_REQUESTS) {
519
+ throw new PairingCapError(`too many pending pairing requests (cap ${MAX_PENDING_REQUESTS}); approve or wait for expiry`);
520
+ }
521
+ const requestId = freshRequestId();
522
+ this._data.requests[requestId] = {
523
+ requestId,
524
+ deviceId,
525
+ platform: String(platform || ''),
526
+ label: String(label || ''),
527
+ status: 'pending',
528
+ createdAt: nowIso(),
529
+ };
530
+ this._persist();
531
+ // Deliberately narrow return: pending receipt only, no token.
532
+ return { requestId, status: 'pending' };
533
+ }
534
+
535
+ /**
536
+ * Approve a pending request and mint a FRESH token for its device. This is
537
+ * one-shot per request: a request can only move pending → approved exactly
538
+ * once. Re-approving an already-approved (or otherwise non-pending) request
539
+ * throws, so a stale/replayed approve cannot silently rotate a live token.
540
+ *
541
+ * To rotate a device's token, submit a NEW requestPairing() and approve
542
+ * that fresh (pending) request.
543
+ *
544
+ * @param {string} requestId
545
+ * @returns {{ deviceId: string, token: string }}
546
+ */
547
+ approve(requestId) {
548
+ const req = this._data.requests[requestId];
549
+ if (!req) {
550
+ throw new Error(`unknown pairing request: ${requestId}`);
551
+ }
552
+ if (req.status !== 'pending') {
553
+ throw new Error(`request not pending: ${requestId} (status=${req.status})`);
554
+ }
555
+ const token = freshToken();
556
+ this._data.devices[req.deviceId] = {
557
+ deviceId: req.deviceId,
558
+ platform: req.platform || '',
559
+ label: req.label || '',
560
+ token,
561
+ approvedAt: nowIso(),
562
+ };
563
+ req.status = 'approved';
564
+ req.approvedAt = nowIso();
565
+ this._persist();
566
+ return { deviceId: req.deviceId, token };
567
+ }
568
+
569
+ /**
570
+ * Whether a device has a live (non-revoked) token.
571
+ * @param {string} deviceId
572
+ * @returns {boolean}
573
+ */
574
+ isApproved(deviceId) {
575
+ const dev = this._data.devices[deviceId];
576
+ return !!(dev && typeof dev.token === 'string' && dev.token.length > 0);
577
+ }
578
+
579
+ /**
580
+ * The live token for a device, or null when the device was never approved
581
+ * or has been revoked.
582
+ * @param {string} deviceId
583
+ * @returns {string|null}
584
+ */
585
+ tokenFor(deviceId) {
586
+ const dev = this._data.devices[deviceId];
587
+ return dev && typeof dev.token === 'string' && dev.token.length > 0 ? dev.token : null;
588
+ }
589
+
590
+ /**
591
+ * Constant-time check that `presentedToken` matches the live token for
592
+ * `deviceId`. Uses crypto.timingSafeEqual so a remote attacker cannot recover
593
+ * the token byte-by-byte from response timing.
594
+ *
595
+ * timingSafeEqual throws on length mismatch, so we short-circuit to false
596
+ * when the lengths differ. The length of a bearer token is not itself a
597
+ * secret, so leaking only "wrong length" via the short-circuit is acceptable
598
+ * and unavoidable. Unknown device / missing token / non-string input all
599
+ * return false without throwing.
600
+ *
601
+ * @param {string} deviceId
602
+ * @param {string} presentedToken
603
+ * @returns {boolean}
604
+ */
605
+ verifyToken(deviceId, presentedToken) {
606
+ const stored = this.tokenFor(deviceId);
607
+ if (typeof stored !== 'string' || stored.length === 0) return false;
608
+ if (typeof presentedToken !== 'string' || presentedToken.length === 0) return false;
609
+ const a = Buffer.from(stored, 'utf8');
610
+ const b = Buffer.from(presentedToken, 'utf8');
611
+ if (a.length !== b.length) return false; // length mismatch — cannot timingSafeEqual
612
+ return crypto.timingSafeEqual(a, b);
613
+ }
614
+
615
+ /**
616
+ * Revoke a device: drop its approval and token. Idempotent.
617
+ * @param {string} deviceId
618
+ * @returns {{ deviceId: string, revoked: boolean }}
619
+ */
620
+ revoke(deviceId) {
621
+ const existed = !!this._data.devices[deviceId];
622
+ delete this._data.devices[deviceId];
623
+ this._persist();
624
+ return { deviceId, revoked: existed };
625
+ }
626
+
627
+ /**
628
+ * All pending pairing requests (awaiting an explicit approve()), newest
629
+ * first. Tokens are never part of a request record, so this is safe to
630
+ * surface in a CLI listing.
631
+ * @returns {Array<{requestId,deviceId,platform,label,status,createdAt}>}
632
+ */
633
+ pending() {
634
+ return Object.values(this._data.requests)
635
+ .filter((r) => r && r.status === 'pending')
636
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
637
+ }
638
+
639
+ /**
640
+ * The existing pending request for a device, or null. Lets the connect
641
+ * handler avoid piling up a fresh request on every unapproved reconnect.
642
+ * @param {string} deviceId
643
+ * @returns {object|null}
644
+ */
645
+ pendingForDevice(deviceId) {
646
+ return Object.values(this._data.requests)
647
+ .find((r) => r && r.status === 'pending' && r.deviceId === deviceId) || null;
648
+ }
649
+
650
+ /**
651
+ * Approved devices with the token MASKED — for a CLI/dashboard listing
652
+ * that must never echo a live bearer token.
653
+ * @returns {Array<{deviceId,platform,label,approvedAt,tokenMasked}>}
654
+ */
655
+ devicesList() {
656
+ return Object.values(this._data.devices).map((d) => ({
657
+ deviceId: d.deviceId,
658
+ platform: d.platform || '',
659
+ label: d.label || '',
660
+ approvedAt: d.approvedAt || '',
661
+ tokenMasked: d.token ? `${String(d.token).slice(0, 6)}…${String(d.token).slice(-4)}` : '',
662
+ }));
663
+ }
664
+ }