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.
- package/README.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/skill_view.mjs +43 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +22 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- 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
|
+
}
|