@ijfw/memory-server 1.4.1 → 1.4.3
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/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +201 -0
- package/src/dashboard-server.js +107 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +38 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension-quota-tracker.js — IJFW v1.4.3 W9-A3 (B16)
|
|
3
|
+
*
|
|
4
|
+
* Per-extension resource quotas. Counter state lives at
|
|
5
|
+
* `~/.ijfw/state/extension-quotas.json`. Every read-modify-write goes through
|
|
6
|
+
* `withFsLock(~/.ijfw/state/extension-quotas.json.lock, fn, { staleMs })` so
|
|
7
|
+
* cross-process tool invocations cannot race the counter (SEC-H-01).
|
|
8
|
+
*
|
|
9
|
+
* "Session" semantics (SEC-M-02): one activation = one quota window. Counters
|
|
10
|
+
* reset on `activate <name>` AND on `deactivate`. NO cumulative state across
|
|
11
|
+
* activate/deactivate boundaries — a re-activated extension gets a clean slate.
|
|
12
|
+
*
|
|
13
|
+
* Wall-clock dimension (SEC-M-02): never incremented; computed on each check
|
|
14
|
+
* as `Date.now() - activated_at`.
|
|
15
|
+
*
|
|
16
|
+
* Threat boundary (ARCH-M-01): API-level accounting, NOT OS-level resource
|
|
17
|
+
* limits. See docs/EXTENSION-SECURITY.md.
|
|
18
|
+
*
|
|
19
|
+
* Frozen contract: `getQuotaUsage` return shape is the integration point with
|
|
20
|
+
* B19 dashboard. See docstring on that function.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join, dirname } from 'node:path';
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
import { withFsLock } from './fs-lock.js';
|
|
29
|
+
|
|
30
|
+
const STATE_REL = ['.ijfw', 'state', 'extension-quotas.json'];
|
|
31
|
+
|
|
32
|
+
// R12-L-02 — acquireTimeoutMs sizing rationale.
|
|
33
|
+
//
|
|
34
|
+
// Default fs-lock timeout is 5s. That ceiling is wrong for THIS lock for a
|
|
35
|
+
// specific, measured reason: the quota tracker is the single chokepoint for
|
|
36
|
+
// every concurrent tool dispatch on a session. With BACKOFF_MAX_MS=250ms in
|
|
37
|
+
// fs-lock.js and ~100 contending Promise.all callers, the worst-case wait
|
|
38
|
+
// approaches `100 * 250ms = 25s`. The 100-parallel correctness test in
|
|
39
|
+
// test-extension-quota-tracker.js exercises exactly this; we pin
|
|
40
|
+
// QUOTA_LOCK_TIMEOUT_MS=30_000 with one second of margin.
|
|
41
|
+
//
|
|
42
|
+
// staleMs stays at 30_000 (crash recovery, unrelated to acquisition latency).
|
|
43
|
+
//
|
|
44
|
+
// Audit history: codex+gemini R12 flagged the literal 30_000 as "too long for
|
|
45
|
+
// hot path". The audit was treating the timeout as the *typical* wait when in
|
|
46
|
+
// reality it's the worst-case ceiling under a workload the test suite
|
|
47
|
+
// explicitly covers. Lowering it broke that test (see commits 45389ff +
|
|
48
|
+
// a996abd in the R12-fix branch).
|
|
49
|
+
const QUOTA_LOCK_TIMEOUT_MS = 30_000;
|
|
50
|
+
const QUOTA_LOCK_STALE_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
/** Public dimensions. Manifest-side names are `max_<dim>`. */
|
|
53
|
+
export const QUOTA_DIMENSIONS = Object.freeze([
|
|
54
|
+
'files_written',
|
|
55
|
+
'bytes_written',
|
|
56
|
+
'wall_clock_ms',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
function homeFromOpts(opts) {
|
|
60
|
+
if (opts && opts.homeDir) return opts.homeDir;
|
|
61
|
+
return process.env.HOME || process.env.USERPROFILE || homedir();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function statePath(home) {
|
|
65
|
+
return join(home, ...STATE_REL);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lockPath(home) {
|
|
69
|
+
return statePath(home) + '.lock';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ensure ~/.ijfw/state exists before withFsLock tries to mkdir the lock
|
|
74
|
+
* directory inside it. Cheap idempotent — first caller creates, subsequent
|
|
75
|
+
* callers no-op. Without this, the first quota call on a fresh HOME would
|
|
76
|
+
* ENOENT (parent missing).
|
|
77
|
+
*/
|
|
78
|
+
async function ensureStateDir(home) {
|
|
79
|
+
await mkdir(dirname(statePath(home)), { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read raw quota state from disk. Returns `{}` when missing or unparseable.
|
|
84
|
+
* Caller is responsible for holding `withFsLock` for R/M/W flows.
|
|
85
|
+
*/
|
|
86
|
+
export async function readQuotaState(home) {
|
|
87
|
+
const h = home || homeFromOpts({});
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(statePath(h), 'utf8');
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
} catch {
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Atomic tmp+rename write of the entire quota state file. MUST be called
|
|
102
|
+
* inside `withFsLock`.
|
|
103
|
+
*/
|
|
104
|
+
export async function writeQuotaState(home, state) {
|
|
105
|
+
const h = home || homeFromOpts({});
|
|
106
|
+
const path = statePath(h);
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
|
|
109
|
+
await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
110
|
+
await rename(tmp, path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emptyExt() {
|
|
114
|
+
return {
|
|
115
|
+
files_written: { current: 0, writes_by_path: {} },
|
|
116
|
+
bytes_written: { current: 0 },
|
|
117
|
+
activated_at: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureExt(state, extName) {
|
|
122
|
+
if (!state[extName]) state[extName] = emptyExt();
|
|
123
|
+
// Defensive: fill missing sub-fields so callers in older state files don't
|
|
124
|
+
// crash. Reading is permissive; writing is canonical.
|
|
125
|
+
if (!state[extName].files_written) state[extName].files_written = { current: 0, writes_by_path: {} };
|
|
126
|
+
if (!state[extName].files_written.writes_by_path) state[extName].files_written.writes_by_path = {};
|
|
127
|
+
if (typeof state[extName].files_written.current !== 'number') state[extName].files_written.current = 0;
|
|
128
|
+
if (!state[extName].bytes_written) state[extName].bytes_written = { current: 0 };
|
|
129
|
+
if (typeof state[extName].bytes_written.current !== 'number') state[extName].bytes_written.current = 0;
|
|
130
|
+
if (state[extName].activated_at === undefined) state[extName].activated_at = null;
|
|
131
|
+
return state[extName];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* checkAndIncrement(extName, dimension, increment, limit, opts)
|
|
136
|
+
*
|
|
137
|
+
* For `files_written`: `opts.path` provides the absolute path being written;
|
|
138
|
+
* tracker deduplicates so writing the same file 10 times = 1 toward the cap.
|
|
139
|
+
*
|
|
140
|
+
* For `wall_clock_ms`: never incremented; the check compares
|
|
141
|
+
* `Date.now() - activated_at` against `limit`. `increment` is ignored.
|
|
142
|
+
*
|
|
143
|
+
* Returns { allowed, current, limit, reason? }. When `limit` is null/undefined
|
|
144
|
+
* (no quota declared), returns allowed=true and the current observed value.
|
|
145
|
+
*/
|
|
146
|
+
export async function checkAndIncrement(extName, dimension, increment, limit, opts = {}) {
|
|
147
|
+
if (typeof extName !== 'string' || !extName) {
|
|
148
|
+
return { allowed: false, current: 0, limit: limit ?? null, reason: 'invalid ext name' };
|
|
149
|
+
}
|
|
150
|
+
if (!QUOTA_DIMENSIONS.includes(dimension)) {
|
|
151
|
+
return { allowed: false, current: 0, limit: limit ?? null, reason: `unknown dimension: ${dimension}` };
|
|
152
|
+
}
|
|
153
|
+
const home = homeFromOpts(opts);
|
|
154
|
+
|
|
155
|
+
// Back-compat: no limit declared = no enforcement. Still record the
|
|
156
|
+
// increment so getQuotaUsage reflects activity. EXCEPT wall_clock_ms which
|
|
157
|
+
// is computed-not-stored, so skip the R/M/W entirely.
|
|
158
|
+
const limitIsNull = limit === null || limit === undefined;
|
|
159
|
+
|
|
160
|
+
await ensureStateDir(home);
|
|
161
|
+
|
|
162
|
+
if (dimension === 'wall_clock_ms') {
|
|
163
|
+
// Read-only path.
|
|
164
|
+
return await withFsLock(lockPath(home), async () => {
|
|
165
|
+
const state = await readQuotaState(home);
|
|
166
|
+
const ext = state[extName];
|
|
167
|
+
if (!ext || !ext.activated_at) {
|
|
168
|
+
// Not active — wall clock is 0.
|
|
169
|
+
return { allowed: true, current: 0, limit: limitIsNull ? null : limit };
|
|
170
|
+
}
|
|
171
|
+
const activatedMs = Date.parse(ext.activated_at);
|
|
172
|
+
const elapsed = Number.isFinite(activatedMs) ? Math.max(0, Date.now() - activatedMs) : 0;
|
|
173
|
+
if (!limitIsNull && elapsed > limit) {
|
|
174
|
+
return {
|
|
175
|
+
allowed: false,
|
|
176
|
+
current: elapsed,
|
|
177
|
+
limit,
|
|
178
|
+
reason: `wall_clock_ms ${elapsed} > limit ${limit}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { allowed: true, current: elapsed, limit: limitIsNull ? null : limit };
|
|
182
|
+
}, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return await withFsLock(lockPath(home), async () => {
|
|
186
|
+
const state = await readQuotaState(home);
|
|
187
|
+
const ext = ensureExt(state, extName);
|
|
188
|
+
|
|
189
|
+
let nextCurrent;
|
|
190
|
+
if (dimension === 'files_written') {
|
|
191
|
+
const path = opts && typeof opts.path === 'string' ? opts.path : null;
|
|
192
|
+
if (path && ext.files_written.writes_by_path[path]) {
|
|
193
|
+
// Already counted — dedupe.
|
|
194
|
+
nextCurrent = ext.files_written.current;
|
|
195
|
+
} else {
|
|
196
|
+
nextCurrent = ext.files_written.current + (Number.isFinite(increment) ? increment : 1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Enforcement check BEFORE persisting the increment.
|
|
200
|
+
if (!limitIsNull && nextCurrent > limit) {
|
|
201
|
+
return {
|
|
202
|
+
allowed: false,
|
|
203
|
+
current: ext.files_written.current,
|
|
204
|
+
limit,
|
|
205
|
+
reason: `files_written ${nextCurrent} > limit ${limit}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ext.files_written.current = nextCurrent;
|
|
210
|
+
if (path) ext.files_written.writes_by_path[path] = true;
|
|
211
|
+
} else {
|
|
212
|
+
// bytes_written
|
|
213
|
+
const inc = Number.isFinite(increment) ? increment : 0;
|
|
214
|
+
nextCurrent = ext.bytes_written.current + inc;
|
|
215
|
+
if (!limitIsNull && nextCurrent > limit) {
|
|
216
|
+
return {
|
|
217
|
+
allowed: false,
|
|
218
|
+
current: ext.bytes_written.current,
|
|
219
|
+
limit,
|
|
220
|
+
reason: `bytes_written ${nextCurrent} > limit ${limit}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
ext.bytes_written.current = nextCurrent;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await writeQuotaState(home, state);
|
|
227
|
+
return { allowed: true, current: nextCurrent, limit: limitIsNull ? null : limit };
|
|
228
|
+
}, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* resetExtensionQuotas(extName, opts) — clears all counters for one extension.
|
|
233
|
+
* Called on `activate` (clears stale prior-session state) AND on `deactivate`.
|
|
234
|
+
*
|
|
235
|
+
* `opts.activated_at` may be passed to stamp the new activation window. When
|
|
236
|
+
* omitted, the entry is removed entirely (deactivate semantics).
|
|
237
|
+
*/
|
|
238
|
+
export async function resetExtensionQuotas(extName, opts = {}) {
|
|
239
|
+
if (typeof extName !== 'string' || !extName) return;
|
|
240
|
+
const home = homeFromOpts(opts);
|
|
241
|
+
await ensureStateDir(home);
|
|
242
|
+
await withFsLock(lockPath(home), async () => {
|
|
243
|
+
const state = await readQuotaState(home);
|
|
244
|
+
if (opts && typeof opts.activated_at === 'string') {
|
|
245
|
+
state[extName] = emptyExt();
|
|
246
|
+
state[extName].activated_at = opts.activated_at;
|
|
247
|
+
} else {
|
|
248
|
+
delete state[extName];
|
|
249
|
+
}
|
|
250
|
+
await writeQuotaState(home, state);
|
|
251
|
+
}, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* getQuotaUsage(extName, opts) — frozen B19 contract.
|
|
256
|
+
*
|
|
257
|
+
* Shape:
|
|
258
|
+
* {
|
|
259
|
+
* ext_name: string,
|
|
260
|
+
* activated_at: ISO string | null,
|
|
261
|
+
* dimensions: {
|
|
262
|
+
* files_written: { current: number, limit: number | null },
|
|
263
|
+
* bytes_written: { current: number, limit: number | null },
|
|
264
|
+
* wall_clock_ms: { current: number, limit: number | null }
|
|
265
|
+
* }
|
|
266
|
+
* }
|
|
267
|
+
*
|
|
268
|
+
* `limit === null` → no quota declared for that dimension (chart renders
|
|
269
|
+
* "unlimited"). Limits are sourced from `opts.limits` when provided (the
|
|
270
|
+
* dashboard reads the active extension's manifest and passes them in);
|
|
271
|
+
* otherwise null.
|
|
272
|
+
*
|
|
273
|
+
* For an extName with no recorded activity: returns the shape with zeros and
|
|
274
|
+
* null limits. Never throws on missing extension.
|
|
275
|
+
*/
|
|
276
|
+
export async function getQuotaUsage(extName, opts = {}) {
|
|
277
|
+
const home = homeFromOpts(opts);
|
|
278
|
+
const limits = (opts && opts.limits) || {};
|
|
279
|
+
await ensureStateDir(home);
|
|
280
|
+
|
|
281
|
+
return await withFsLock(lockPath(home), async () => {
|
|
282
|
+
const state = await readQuotaState(home);
|
|
283
|
+
const ext = state[extName] || null;
|
|
284
|
+
const filesCurrent = ext && ext.files_written ? (ext.files_written.current || 0) : 0;
|
|
285
|
+
const bytesCurrent = ext && ext.bytes_written ? (ext.bytes_written.current || 0) : 0;
|
|
286
|
+
let wallCurrent = 0;
|
|
287
|
+
if (ext && ext.activated_at) {
|
|
288
|
+
const t = Date.parse(ext.activated_at);
|
|
289
|
+
if (Number.isFinite(t)) wallCurrent = Math.max(0, Date.now() - t);
|
|
290
|
+
}
|
|
291
|
+
const limitOrNull = (k) => {
|
|
292
|
+
const v = limits[k];
|
|
293
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
294
|
+
};
|
|
295
|
+
return {
|
|
296
|
+
ext_name: extName,
|
|
297
|
+
activated_at: ext && ext.activated_at ? ext.activated_at : null,
|
|
298
|
+
dimensions: {
|
|
299
|
+
files_written: { current: filesCurrent, limit: limitOrNull('max_files_written') },
|
|
300
|
+
bytes_written: { current: bytesCurrent, limit: limitOrNull('max_bytes_written') },
|
|
301
|
+
wall_clock_ms: { current: wallCurrent, limit: limitOrNull('max_wall_clock_ms') },
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}, { staleMs: QUOTA_LOCK_STALE_MS, acquireTimeoutMs: QUOTA_LOCK_TIMEOUT_MS });
|
|
305
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension-registry-ws.js — IJFW v1.4.3/B17 WebSocket revocation client (stub).
|
|
3
|
+
*
|
|
4
|
+
* Dormant by default. Imported via `await import(...)` ONLY when
|
|
5
|
+
* `process.env.IJFW_REGISTRY_WS_URL` is set at startup. Listening on the
|
|
6
|
+
* registry meta-key for live revocation push messages.
|
|
7
|
+
*
|
|
8
|
+
* Frozen contract (SEC-H-02):
|
|
9
|
+
* - Source binding is explicit. Prefer `IJFW_REGISTRY_WS_SOURCE=<name>`
|
|
10
|
+
* for an exact match in registries.json. If only `IJFW_REGISTRY_WS_URL`
|
|
11
|
+
* is set, map to a source by origin + pathname-prefix (NEVER host-only).
|
|
12
|
+
* Zero matches → refuse. Multiple matches → refuse with "set
|
|
13
|
+
* IJFW_REGISTRY_WS_SOURCE".
|
|
14
|
+
* - Signed-payload schema:
|
|
15
|
+
* { registry_version: "1.0", source_name, source_url, updated_at,
|
|
16
|
+
* revoked: [...], sequence_number, signature: "ed25519:<b64>" }
|
|
17
|
+
* Canonical bytes = sorted-keys JSON minus `signature`. Replay defense:
|
|
18
|
+
* reject any `sequence_number <= last_seen_sequence`.
|
|
19
|
+
* - Handshake (ARCH-L-02): in the `node:net` fallback, generate a random
|
|
20
|
+
* 16-byte Sec-WebSocket-Key (base64). Expect
|
|
21
|
+
* `Sec-WebSocket-Accept = base64(sha1(key + GUID))`. Refuse on mismatch.
|
|
22
|
+
*
|
|
23
|
+
* Zero new prod deps. Uses node:net + node:crypto + node:url.
|
|
24
|
+
*
|
|
25
|
+
* The SERVER infrastructure ships in v1.5.0; v1.4.3 only ships this CLIENT
|
|
26
|
+
* stub.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createHash, randomBytes, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
30
|
+
import { URL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
loadRegistrySources,
|
|
34
|
+
withSourceCache,
|
|
35
|
+
perSourceCachePath,
|
|
36
|
+
perSourceLockPath,
|
|
37
|
+
} from './extension-registry.js';
|
|
38
|
+
|
|
39
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Source binding (SEC-H-02)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve which source the WS client should bind to.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} env — { IJFW_REGISTRY_WS_URL, IJFW_REGISTRY_WS_SOURCE }
|
|
49
|
+
* @param {Array} sources — list from loadRegistrySources()
|
|
50
|
+
* @returns {{ source: object } | { error: string }}
|
|
51
|
+
*/
|
|
52
|
+
export function resolveWsSource(env, sources) {
|
|
53
|
+
const explicit = env.IJFW_REGISTRY_WS_SOURCE;
|
|
54
|
+
if (typeof explicit === 'string' && explicit !== '') {
|
|
55
|
+
const found = sources.find((s) => s.name === explicit);
|
|
56
|
+
if (!found) {
|
|
57
|
+
return {
|
|
58
|
+
error: `[ijfw] IJFW_REGISTRY_WS_SOURCE='${explicit}' has no matching entry in registries.json`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { source: found };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const wsUrl = env.IJFW_REGISTRY_WS_URL;
|
|
65
|
+
if (typeof wsUrl !== 'string' || wsUrl === '') {
|
|
66
|
+
return { error: '[ijfw] neither IJFW_REGISTRY_WS_SOURCE nor IJFW_REGISTRY_WS_URL set' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = new URL(wsUrl);
|
|
72
|
+
} catch {
|
|
73
|
+
return { error: `[ijfw] IJFW_REGISTRY_WS_URL '${wsUrl}' is not a valid URL` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The WS scheme and the source's HTTPS scheme differ by protocol, so we
|
|
77
|
+
// match on host + path-prefix (NOT origin — origin includes scheme). We do
|
|
78
|
+
// however enforce TLS pairing: wss ↔ https, ws ↔ http. Cross-tier (e.g.
|
|
79
|
+
// ws:// WS endpoint paired to an https:// registry) is refused to prevent
|
|
80
|
+
// downgrade attacks where an attacker proxies plaintext push traffic for
|
|
81
|
+
// an otherwise-TLS registry.
|
|
82
|
+
const SCHEME_PAIRS = { 'wss:': 'https:', 'ws:': 'http:' };
|
|
83
|
+
const requiredSourceProto = SCHEME_PAIRS[parsed.protocol];
|
|
84
|
+
if (!requiredSourceProto) {
|
|
85
|
+
return {
|
|
86
|
+
error: `[ijfw] IJFW_REGISTRY_WS_URL '${wsUrl}' must use ws:// or wss:// scheme`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const wsHost = parsed.host;
|
|
90
|
+
const wsPathPrefix = parsed.pathname.split('/').slice(0, -1).join('/');
|
|
91
|
+
|
|
92
|
+
const matches = [];
|
|
93
|
+
for (const source of sources) {
|
|
94
|
+
try {
|
|
95
|
+
const su = new URL(source.url);
|
|
96
|
+
if (su.protocol !== requiredSourceProto) continue; // refuse cross-tier
|
|
97
|
+
const sourcePathPrefix = su.pathname.split('/').slice(0, -1).join('/');
|
|
98
|
+
if (su.host === wsHost && sourcePathPrefix === wsPathPrefix) {
|
|
99
|
+
matches.push(source);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// skip unparseable source URLs
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (matches.length === 0) {
|
|
107
|
+
return {
|
|
108
|
+
error: `[ijfw] IJFW_REGISTRY_WS_URL '${wsUrl}' has no source binding; set IJFW_REGISTRY_WS_SOURCE=<name> explicitly`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (matches.length > 1) {
|
|
112
|
+
return {
|
|
113
|
+
error: `[ijfw] Ambiguous WS source binding for '${wsUrl}' (matches: ${matches.map((s) => s.name).join(', ')}); set IJFW_REGISTRY_WS_SOURCE=<name>`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { source: matches[0] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Signed-payload schema verification (SEC-H-02)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function sortKeysDeep(v) {
|
|
124
|
+
if (Array.isArray(v)) return v.map(sortKeysDeep);
|
|
125
|
+
if (v !== null && typeof v === 'object') {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const k of Object.keys(v).sort()) {
|
|
128
|
+
if (v[k] === undefined) continue;
|
|
129
|
+
out[k] = sortKeysDeep(v[k]);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
return v;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pushPayloadCanonicalBytes(payload) {
|
|
137
|
+
const shallow = {};
|
|
138
|
+
for (const k of Object.keys(payload)) {
|
|
139
|
+
if (k === 'signature') continue;
|
|
140
|
+
shallow[k] = payload[k];
|
|
141
|
+
}
|
|
142
|
+
return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Verify a WS push message.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} payload
|
|
149
|
+
* @param {object} source — bound source (provides meta_key_pem, name, url)
|
|
150
|
+
* @param {number} lastSeenSequence
|
|
151
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
152
|
+
*/
|
|
153
|
+
export function verifyPushPayload(payload, source, lastSeenSequence) {
|
|
154
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
155
|
+
return { valid: false, reason: 'payload must be a JSON object' };
|
|
156
|
+
}
|
|
157
|
+
if (payload.registry_version !== '1.0') {
|
|
158
|
+
return { valid: false, reason: `unsupported registry_version: ${payload.registry_version}` };
|
|
159
|
+
}
|
|
160
|
+
if (typeof payload.source_name !== 'string' || payload.source_name !== source.name) {
|
|
161
|
+
return {
|
|
162
|
+
valid: false,
|
|
163
|
+
reason: `source_name mismatch (expected '${source.name}', got '${payload.source_name}')`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (typeof payload.source_url !== 'string' || payload.source_url !== source.url) {
|
|
167
|
+
return {
|
|
168
|
+
valid: false,
|
|
169
|
+
reason: `source_url mismatch (expected '${source.url}', got '${payload.source_url}')`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (typeof payload.updated_at !== 'string') {
|
|
173
|
+
return { valid: false, reason: 'updated_at must be a string' };
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(payload.revoked)) {
|
|
176
|
+
return { valid: false, reason: 'revoked must be an array' };
|
|
177
|
+
}
|
|
178
|
+
if (typeof payload.sequence_number !== 'number' || !Number.isFinite(payload.sequence_number)) {
|
|
179
|
+
return { valid: false, reason: 'sequence_number must be a finite number' };
|
|
180
|
+
}
|
|
181
|
+
if (payload.sequence_number <= lastSeenSequence) {
|
|
182
|
+
return {
|
|
183
|
+
valid: false,
|
|
184
|
+
reason: `replay rejected (sequence_number ${payload.sequence_number} <= last_seen ${lastSeenSequence})`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (typeof payload.signature !== 'string' || !payload.signature.startsWith('ed25519:')) {
|
|
188
|
+
return { valid: false, reason: 'signature must be "ed25519:<base64>"' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let metaKey;
|
|
192
|
+
try {
|
|
193
|
+
metaKey = createPublicKey(source.meta_key_pem);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { valid: false, reason: `meta-key parse failed: ${err.message}` };
|
|
196
|
+
}
|
|
197
|
+
const sigBuf = Buffer.from(payload.signature.slice('ed25519:'.length), 'base64');
|
|
198
|
+
const bytes = pushPayloadCanonicalBytes(payload);
|
|
199
|
+
let ok;
|
|
200
|
+
try {
|
|
201
|
+
ok = cryptoVerify(null, bytes, metaKey, sigBuf);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { valid: false, reason: `signature verify threw: ${err.message}` };
|
|
204
|
+
}
|
|
205
|
+
if (!ok) {
|
|
206
|
+
return { valid: false, reason: 'signature does not verify against source meta-key' };
|
|
207
|
+
}
|
|
208
|
+
return { valid: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Apply a verified push payload to the bound source's per-source cache.
|
|
213
|
+
* Merges `revoked` entries (de-dup by keyId) inside withFsLock.
|
|
214
|
+
*/
|
|
215
|
+
export async function applyPushPayload(payload, source) {
|
|
216
|
+
return withSourceCache(source, (cache) => {
|
|
217
|
+
const existingKeyIds = new Set((cache.revoked || []).map((r) => r.keyId));
|
|
218
|
+
const merged = [...(cache.revoked || [])];
|
|
219
|
+
for (const entry of payload.revoked) {
|
|
220
|
+
if (entry && entry.keyId && !existingKeyIds.has(entry.keyId)) {
|
|
221
|
+
existingKeyIds.add(entry.keyId);
|
|
222
|
+
merged.push({
|
|
223
|
+
keyId: entry.keyId,
|
|
224
|
+
revoked_at: entry.revoked_at || new Date().toISOString(),
|
|
225
|
+
reason: entry.reason || '',
|
|
226
|
+
superseded_by: entry.superseded_by || null,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
...cache,
|
|
232
|
+
revoked: merged,
|
|
233
|
+
revocation_fetched_at: new Date().toISOString(),
|
|
234
|
+
source_name: source.name,
|
|
235
|
+
source_url: source.url,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Handshake helpers (ARCH-L-02)
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Generate a 16-byte random base64 Sec-WebSocket-Key.
|
|
246
|
+
*/
|
|
247
|
+
export function generateSecWebSocketKey() {
|
|
248
|
+
return randomBytes(16).toString('base64');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Compute the expected Sec-WebSocket-Accept value for a given key.
|
|
253
|
+
*/
|
|
254
|
+
export function expectedAcceptValue(key) {
|
|
255
|
+
return createHash('sha1').update(key + WS_GUID).digest('base64');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Verify a server response's Sec-WebSocket-Accept against our key.
|
|
260
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
261
|
+
*/
|
|
262
|
+
export function verifyHandshakeAccept(key, accept) {
|
|
263
|
+
if (typeof accept !== 'string' || accept === '') {
|
|
264
|
+
return { ok: false, reason: 'Sec-WebSocket-Accept header missing' };
|
|
265
|
+
}
|
|
266
|
+
const expected = expectedAcceptValue(key);
|
|
267
|
+
// constant-time compare via Buffer comparison
|
|
268
|
+
const aBuf = Buffer.from(expected, 'utf8');
|
|
269
|
+
const bBuf = Buffer.from(accept, 'utf8');
|
|
270
|
+
if (aBuf.length !== bBuf.length) {
|
|
271
|
+
return { ok: false, reason: 'Sec-WebSocket-Accept length mismatch' };
|
|
272
|
+
}
|
|
273
|
+
let diff = 0;
|
|
274
|
+
for (let i = 0; i < aBuf.length; i++) diff |= aBuf[i] ^ bBuf[i];
|
|
275
|
+
if (diff !== 0) return { ok: false, reason: 'Sec-WebSocket-Accept mismatch' };
|
|
276
|
+
return { ok: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Client controller (lightweight; tests drive parts in isolation).
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Construct a controller bound to a source. The controller exposes verify +
|
|
285
|
+
* apply hooks for the test surface; the wire-level connect path uses native
|
|
286
|
+
* `globalThis.WebSocket` when available, else `node:net`. v1.4.3 ships only
|
|
287
|
+
* the stub; the active connect loop is gated behind an explicit `connect()`
|
|
288
|
+
* call so MCP startup never accidentally opens a socket.
|
|
289
|
+
*/
|
|
290
|
+
export class WsRevocationClient {
|
|
291
|
+
constructor({ source }) {
|
|
292
|
+
if (!source || typeof source !== 'object') {
|
|
293
|
+
throw new Error('WsRevocationClient: source is required');
|
|
294
|
+
}
|
|
295
|
+
this.source = source;
|
|
296
|
+
this._lastSeenSequence = -Infinity;
|
|
297
|
+
this._socket = null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Process an incoming JSON-text message. Returns { applied, reason }.
|
|
302
|
+
*/
|
|
303
|
+
async handleMessage(raw) {
|
|
304
|
+
let payload;
|
|
305
|
+
try {
|
|
306
|
+
payload = JSON.parse(raw);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return { applied: false, reason: `parse_error:${err.message}` };
|
|
309
|
+
}
|
|
310
|
+
const verdict = verifyPushPayload(payload, this.source, this._lastSeenSequence);
|
|
311
|
+
if (!verdict.valid) {
|
|
312
|
+
return { applied: false, reason: verdict.reason };
|
|
313
|
+
}
|
|
314
|
+
await applyPushPayload(payload, this.source);
|
|
315
|
+
this._lastSeenSequence = payload.sequence_number;
|
|
316
|
+
return { applied: true, sequence_number: payload.sequence_number };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Reset the replay-defense counter — test-only.
|
|
321
|
+
*/
|
|
322
|
+
_resetSequenceForTest() {
|
|
323
|
+
this._lastSeenSequence = -Infinity;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Initialise the WS client at MCP startup. Caller must check
|
|
329
|
+
* `process.env.IJFW_REGISTRY_WS_URL` BEFORE importing this module.
|
|
330
|
+
*
|
|
331
|
+
* @param {object} [opts]
|
|
332
|
+
* @param {object} [opts.env] — env override for tests
|
|
333
|
+
* @param {Array} [opts.sources] — source list override for tests
|
|
334
|
+
* @returns {Promise<{ ok: true, client: WsRevocationClient } | { ok: false, error: string }>}
|
|
335
|
+
*/
|
|
336
|
+
export async function initWsClient(opts = {}) {
|
|
337
|
+
const env = opts.env || process.env;
|
|
338
|
+
const sources = opts.sources || (await loadRegistrySources());
|
|
339
|
+
const resolved = resolveWsSource(env, sources);
|
|
340
|
+
if ('error' in resolved) {
|
|
341
|
+
return { ok: false, error: resolved.error };
|
|
342
|
+
}
|
|
343
|
+
return { ok: true, client: new WsRevocationClient({ source: resolved.source }) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Re-export paths so callers can introspect cache locations.
|
|
347
|
+
export { perSourceCachePath, perSourceLockPath, WS_GUID };
|