@ijfw/memory-server 1.4.1 → 1.4.4

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.
@@ -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 };