@ijfw/memory-server 1.4.0 → 1.4.1

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,619 @@
1
+ /**
2
+ * extension-registry.js — IJFW v1.4.1/B6 Hosted Publisher Key Registry.
3
+ *
4
+ * Fetches, verifies, and applies a canonical registry of trusted publishers
5
+ * signed by the IJFW meta-key. Clients cache the registry locally with a
6
+ * 24 h TTL; offline fallback returns the cached copy with a warning.
7
+ *
8
+ * Uses node:https + node:crypto + node:fs/promises only — zero new prod deps.
9
+ */
10
+
11
+ import { createPublicKey, createHash, verify as cryptoVerify } from 'node:crypto';
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import https from 'node:https';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Embedded meta-key — compiled-in trust root for registry signature verification.
19
+ // Source: mcp-server/src/.registry-meta-key.pem (gitignored sentinel).
20
+ // Rotation requires a new v1.4.x release with a new key inlined here.
21
+ // ---------------------------------------------------------------------------
22
+ const IJFW_REGISTRY_META_KEY_PEM = `-----BEGIN PUBLIC KEY-----
23
+ MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
24
+ -----END PUBLIC KEY-----`;
25
+
26
+ const DEFAULT_REGISTRY_URL = 'https://registry.ijfw.dev/publishers/v1.json';
27
+ const FALLBACK_REGISTRY_URL = 'https://therealseandonahoe.gitlab.io/ijfw/registry/publishers/v1.json';
28
+ const MAX_REGISTRY_BYTES = 1024 * 1024; // 1 MiB cap
29
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
30
+ const FETCH_TIMEOUT_MS = 10_000;
31
+ const MAX_REDIRECTS = 3;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Paths
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function ijfwStateDir() {
38
+ return join(homedir(), '.ijfw', 'state');
39
+ }
40
+
41
+ function registryCachePath() {
42
+ return join(ijfwStateDir(), 'registry-cache.json');
43
+ }
44
+
45
+ function revokedPublishersPath() {
46
+ return join(ijfwStateDir(), 'revoked-publishers.json');
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Canonical signing bytes — same logic as extension-signer.js
51
+ // Excludes `signature` from bytes so the field can carry the sig itself.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function sortKeysDeep(v) {
55
+ if (Array.isArray(v)) return v.map(sortKeysDeep);
56
+ if (v !== null && typeof v === 'object') {
57
+ const out = {};
58
+ for (const k of Object.keys(v).sort()) {
59
+ if (v[k] === undefined) continue;
60
+ out[k] = sortKeysDeep(v[k]);
61
+ }
62
+ return out;
63
+ }
64
+ return v;
65
+ }
66
+
67
+ function registryCanonicalBytes(registry) {
68
+ const shallow = {};
69
+ for (const k of Object.keys(registry)) {
70
+ if (k === 'signature') continue;
71
+ shallow[k] = registry[k];
72
+ }
73
+ return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // fetchRegistry — HTTPS-only, timeout + redirect cap + body size cap
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Fetch the registry JSON from a URL.
82
+ * @param {string} [url]
83
+ * @param {object} [opts]
84
+ * @param {Function} [opts.fetchImpl] - injectable for tests; receives (url) -> Promise<{ok,body,error}>
85
+ * @returns {Promise<{ok: boolean, body: string|null, error: string|null}>}
86
+ */
87
+ export async function fetchRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
88
+ if (typeof opts.fetchImpl === 'function') {
89
+ return opts.fetchImpl(url);
90
+ }
91
+
92
+ // HTTPS-only enforcement
93
+ let parsedUrl;
94
+ try {
95
+ parsedUrl = new URL(url);
96
+ } catch {
97
+ return { ok: false, body: null, error: `invalid URL: ${url}` };
98
+ }
99
+ if (parsedUrl.protocol !== 'https:') {
100
+ return { ok: false, body: null, error: `registry URL must use HTTPS (got ${parsedUrl.protocol})` };
101
+ }
102
+
103
+ return _httpsGet(url, 0);
104
+ }
105
+
106
+ function _httpsGet(url, redirectCount) {
107
+ return new Promise((resolve) => {
108
+ let parsedUrl;
109
+ try {
110
+ parsedUrl = new URL(url);
111
+ } catch {
112
+ return resolve({ ok: false, body: null, error: `invalid redirect URL: ${url}` });
113
+ }
114
+ if (parsedUrl.protocol !== 'https:') {
115
+ return resolve({ ok: false, body: null, error: `redirect to non-HTTPS rejected: ${url}` });
116
+ }
117
+
118
+ const req = https.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
119
+ const { statusCode, headers } = res;
120
+
121
+ // Handle redirects
122
+ if (statusCode >= 301 && statusCode <= 308 && headers.location) {
123
+ res.resume();
124
+ if (redirectCount >= MAX_REDIRECTS) {
125
+ return resolve({ ok: false, body: null, error: `too many redirects (max ${MAX_REDIRECTS})` });
126
+ }
127
+ return resolve(_httpsGet(headers.location, redirectCount + 1));
128
+ }
129
+
130
+ if (statusCode !== 200) {
131
+ res.resume();
132
+ return resolve({ ok: false, body: null, error: `HTTP ${statusCode}` });
133
+ }
134
+
135
+ const chunks = [];
136
+ let totalBytes = 0;
137
+ let oversize = false;
138
+
139
+ res.on('data', (chunk) => {
140
+ totalBytes += chunk.length;
141
+ if (totalBytes > MAX_REGISTRY_BYTES) {
142
+ oversize = true;
143
+ req.destroy();
144
+ return;
145
+ }
146
+ chunks.push(chunk);
147
+ });
148
+
149
+ res.on('end', () => {
150
+ if (oversize) {
151
+ return resolve({ ok: false, body: null, error: `registry response exceeds ${MAX_REGISTRY_BYTES} bytes` });
152
+ }
153
+ resolve({ ok: true, body: Buffer.concat(chunks).toString('utf8'), error: null });
154
+ });
155
+
156
+ res.on('error', (err) => {
157
+ resolve({ ok: false, body: null, error: err.message });
158
+ });
159
+ });
160
+
161
+ req.on('timeout', () => {
162
+ req.destroy();
163
+ resolve({ ok: false, body: null, error: `fetch timeout after ${FETCH_TIMEOUT_MS}ms` });
164
+ });
165
+
166
+ req.on('error', (err) => {
167
+ resolve({ ok: false, body: null, error: err.message });
168
+ });
169
+ });
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // verifyRegistry — parse JSON, validate shape, verify signature
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Verify a registry JSON body string.
178
+ * @param {string} body
179
+ * @param {object} [opts]
180
+ * @param {boolean} [opts.allowSeed] Accept unsigned (null-signature) registries (bootstrap mode)
181
+ * @returns {{ valid: boolean, registry: object|null, reason: string, warnings?: string[] }}
182
+ */
183
+ export function verifyRegistry(body, opts = {}) {
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(body);
187
+ } catch (err) {
188
+ return { valid: false, registry: null, reason: `JSON parse failed: ${err.message}` };
189
+ }
190
+
191
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
192
+ return { valid: false, registry: null, reason: 'registry must be a JSON object' };
193
+ }
194
+
195
+ // Shape validation
196
+ if (parsed.registry_version !== '1.0') {
197
+ return { valid: false, registry: null, reason: `unsupported registry_version: ${parsed.registry_version}` };
198
+ }
199
+ if (typeof parsed.updated_at !== 'string') {
200
+ return { valid: false, registry: null, reason: 'missing or invalid updated_at' };
201
+ }
202
+ if (parsed.publishers === null || typeof parsed.publishers !== 'object' || Array.isArray(parsed.publishers)) {
203
+ return { valid: false, registry: null, reason: 'publishers must be an object' };
204
+ }
205
+ if (!Array.isArray(parsed.revoked)) {
206
+ return { valid: false, registry: null, reason: 'revoked must be an array' };
207
+ }
208
+
209
+ // Signature verification — null signature is only accepted in seed/bootstrap mode.
210
+ // In production (default), a null signature is rejected to prevent MITM serving
211
+ // an unsigned registry that bypasses all publisher trust decisions.
212
+ if (parsed.signature === null) {
213
+ const allowSeed = opts.allowSeed === true || process.env.IJFW_ALLOW_SEED_REGISTRY === '1';
214
+ if (!allowSeed) {
215
+ return { valid: false, registry: null, reason: 'signature missing — production clients require signed registry' };
216
+ }
217
+ return {
218
+ valid: true,
219
+ registry: parsed,
220
+ reason: 'unsigned (seed)',
221
+ warnings: ['registry has no signature — running in seed/bootstrap mode only'],
222
+ };
223
+ }
224
+
225
+ if (typeof parsed.signature !== 'string' || !parsed.signature.startsWith('ed25519:')) {
226
+ return { valid: false, registry: null, reason: 'signature must be null or "ed25519:<base64>"' };
227
+ }
228
+
229
+ let metaKey;
230
+ try {
231
+ metaKey = createPublicKey(IJFW_REGISTRY_META_KEY_PEM);
232
+ } catch (err) {
233
+ return { valid: false, registry: null, reason: `meta-key parse failed: ${err.message}` };
234
+ }
235
+
236
+ const sigB64 = parsed.signature.slice('ed25519:'.length);
237
+ let sigBuf;
238
+ try {
239
+ sigBuf = Buffer.from(sigB64, 'base64');
240
+ } catch {
241
+ return { valid: false, registry: null, reason: 'signature base64 decode failed' };
242
+ }
243
+
244
+ const bytes = registryCanonicalBytes(parsed);
245
+ let ok;
246
+ try {
247
+ ok = cryptoVerify(null, bytes, metaKey, sigBuf);
248
+ } catch (err) {
249
+ return { valid: false, registry: null, reason: `signature verify threw: ${err.message}` };
250
+ }
251
+
252
+ if (!ok) {
253
+ return { valid: false, registry: null, reason: 'signature does not verify against meta-key' };
254
+ }
255
+
256
+ return { valid: true, registry: parsed, reason: 'ok' };
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Cache helpers
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /**
264
+ * Read the cached registry from disk.
265
+ * @returns {Promise<{registry: object|null, cachedAt: number|null, stale: boolean}>}
266
+ */
267
+ export async function readCachedRegistry() {
268
+ let raw;
269
+ try {
270
+ raw = await readFile(registryCachePath(), 'utf8');
271
+ } catch {
272
+ return { registry: null, cachedAt: null, stale: true };
273
+ }
274
+ let parsed;
275
+ try {
276
+ parsed = JSON.parse(raw);
277
+ } catch {
278
+ return { registry: null, cachedAt: null, stale: true };
279
+ }
280
+ const cachedAt = typeof parsed.cached_at === 'number' ? parsed.cached_at : null;
281
+ const stale = cachedAt === null || (Date.now() - cachedAt) > CACHE_TTL_MS;
282
+ return { registry: parsed.registry ?? null, cachedAt, stale };
283
+ }
284
+
285
+ /**
286
+ * Write the registry to the local cache.
287
+ * @param {object} registry
288
+ */
289
+ export async function writeCachedRegistry(registry) {
290
+ await mkdir(ijfwStateDir(), { recursive: true });
291
+ const payload = JSON.stringify({ cached_at: Date.now(), registry }, null, 2) + '\n';
292
+ await writeFile(registryCachePath(), payload, 'utf8');
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // applyRegistry — merge publishers + process revocations
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * Apply a verified registry to the local trust store.
301
+ * @param {object} registry
302
+ * @param {object} [opts]
303
+ * @returns {Promise<{added: string[], removed: string[], unchanged: string[], rejected: string[]}>}
304
+ */
305
+ export async function applyRegistry(registry, _opts = {}) {
306
+ const added = [];
307
+ const removed = [];
308
+ const unchanged = [];
309
+ const rejected = [];
310
+
311
+ // Read current trust store
312
+ const tpPath = join(homedir(), '.ijfw', 'trusted-publishers.json');
313
+ let store = { publishers: {} };
314
+ try {
315
+ const raw = await readFile(tpPath, 'utf8');
316
+ const parsed = JSON.parse(raw);
317
+ if (parsed && typeof parsed.publishers === 'object' && parsed.publishers !== null) {
318
+ store = parsed;
319
+ }
320
+ } catch { /* absent or malformed → start fresh */ }
321
+
322
+ // Read / update revoked list
323
+ let revokedStore = { revoked: [] };
324
+ try {
325
+ const raw = await readFile(revokedPublishersPath(), 'utf8');
326
+ const parsed = JSON.parse(raw);
327
+ if (parsed && Array.isArray(parsed.revoked)) revokedStore = parsed;
328
+ } catch { /* absent → start fresh */ }
329
+
330
+ const revokedSet = new Set(revokedStore.revoked.map(r => r.keyId));
331
+
332
+ // Process revocations first
333
+ for (const entry of (registry.revoked || [])) {
334
+ const { keyId } = entry;
335
+ if (!keyId) continue;
336
+ if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
337
+ delete store.publishers[keyId];
338
+ removed.push(keyId);
339
+ }
340
+ // Record in revoked-publishers.json if not already there
341
+ if (!revokedSet.has(keyId)) {
342
+ revokedSet.add(keyId);
343
+ revokedStore.revoked.push({
344
+ keyId,
345
+ revoked_at: entry.revoked_at || new Date().toISOString(),
346
+ reason: entry.reason || '',
347
+ superseded_by: entry.superseded_by || null,
348
+ });
349
+ }
350
+ }
351
+
352
+ // Merge publishers
353
+ for (const [keyId, entry] of Object.entries(registry.publishers || {})) {
354
+ if (!entry || typeof entry.publicKey !== 'string') {
355
+ rejected.push(keyId);
356
+ continue;
357
+ }
358
+ // Don't re-add revoked publishers
359
+ if (revokedSet.has(keyId)) {
360
+ rejected.push(keyId);
361
+ continue;
362
+ }
363
+ // Verify fingerprint matches keyId
364
+ try {
365
+ const key = createPublicKey(entry.publicKey);
366
+ const der = key.export({ type: 'spki', format: 'der' });
367
+ const fp = createHash('sha256').update(der).digest('hex');
368
+ if (fp !== keyId) {
369
+ rejected.push(keyId);
370
+ continue;
371
+ }
372
+ } catch {
373
+ rejected.push(keyId);
374
+ continue;
375
+ }
376
+
377
+ if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
378
+ unchanged.push(keyId);
379
+ } else {
380
+ store.publishers[keyId] = {
381
+ name: entry.name,
382
+ publicKey: entry.publicKey,
383
+ verified_at: entry.verified_at,
384
+ metadata: entry.metadata,
385
+ added_at: new Date().toISOString(),
386
+ };
387
+ added.push(keyId);
388
+ }
389
+ }
390
+
391
+ // Persist
392
+ await mkdir(join(homedir(), '.ijfw'), { recursive: true });
393
+ await writeFile(tpPath, JSON.stringify(store, null, 2) + '\n', 'utf8');
394
+
395
+ await mkdir(ijfwStateDir(), { recursive: true });
396
+ await writeFile(
397
+ revokedPublishersPath(),
398
+ JSON.stringify(revokedStore, null, 2) + '\n',
399
+ 'utf8',
400
+ );
401
+
402
+ return { added, removed, unchanged, rejected };
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // refreshTrustFromRegistry — high-level entry point
407
+ // ---------------------------------------------------------------------------
408
+
409
+ /**
410
+ * Fetch → verify → apply → cache. The main entry point for the CLI.
411
+ * Falls back to cache on offline; warns if stale.
412
+ *
413
+ * @param {string} [url]
414
+ * @param {object} [opts]
415
+ * @param {Function} [opts.fetchImpl] - injectable for tests
416
+ * @returns {Promise<{ok: boolean, diff: object|null, fromCache: boolean, warnings: string[], error: string|null}>}
417
+ */
418
+ export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
419
+ const warnings = [];
420
+
421
+ const fetched = await fetchRegistry(url, opts);
422
+ if (!fetched.ok) {
423
+ // Offline path — try cache
424
+ const cached = await readCachedRegistry();
425
+ if (cached.registry) {
426
+ if (cached.stale) warnings.push(`offline and cache is stale (age > ${CACHE_TTL_MS / 3600000}h) — trust store not updated`);
427
+ else warnings.push('offline — using cached registry');
428
+ return { ok: true, diff: null, fromCache: true, warnings, error: null };
429
+ }
430
+ // No cache either — return existing trust store untouched
431
+ warnings.push(`offline and no cache available — trust store unchanged: ${fetched.error}`);
432
+ return { ok: true, diff: null, fromCache: false, warnings, error: null };
433
+ }
434
+
435
+ const verified = verifyRegistry(fetched.body, opts);
436
+ if (!verified.valid) {
437
+ return { ok: false, diff: null, fromCache: false, warnings, error: `registry verify failed: ${verified.reason}` };
438
+ }
439
+ // Seed-mode: surface warnings loudly on stderr so bootstrap operators notice.
440
+ if (verified.warnings && verified.warnings.length > 0) {
441
+ for (const w of verified.warnings) {
442
+ process.stderr.write(`[ijfw] WARNING: ${w}\n`);
443
+ warnings.push(w);
444
+ }
445
+ }
446
+
447
+ const diff = await applyRegistry(verified.registry, opts);
448
+ await writeCachedRegistry(verified.registry);
449
+
450
+ return { ok: true, diff, fromCache: false, warnings, error: null };
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Signing CLI helpers (keygen-meta, sign-registry, verify-registry)
455
+ // ---------------------------------------------------------------------------
456
+
457
+ import { generateKeyPairSync, createPrivateKey, sign as cryptoSign } from 'node:crypto';
458
+ import { chmod } from 'node:fs/promises';
459
+ import { resolve as pathResolve, relative as pathRelative, isAbsolute as pathIsAbsolute } from 'node:path';
460
+ import { cwd } from 'node:process';
461
+
462
+ /**
463
+ * Cross-platform path-under-cwd check. Returns true when `targetPath` resolves
464
+ * under the current working directory. Uses path.relative + path.isAbsolute so
465
+ * the result is correct on Windows (\ separators, drive letters) and POSIX.
466
+ *
467
+ * @param {string} targetPath
468
+ * @returns {boolean}
469
+ */
470
+ function isUnderCwd(targetPath) {
471
+ const abs = pathResolve(targetPath);
472
+ const cwdAbs = pathResolve(cwd());
473
+ if (abs === cwdAbs) return true;
474
+ const rel = pathRelative(cwdAbs, abs);
475
+ // Outside cwd: relative path starts with '..' or is absolute (e.g. on
476
+ // Windows when target is on a different drive than cwd).
477
+ return rel !== '' && !rel.startsWith('..') && !pathIsAbsolute(rel);
478
+ }
479
+
480
+ /**
481
+ * Generate a registry meta-keypair and persist under ~/.ijfw/keys/<keyId>/.
482
+ * Writes a `meta-role.txt` marker file to distinguish from publisher keys.
483
+ *
484
+ * @param {string} author
485
+ * @returns {Promise<{keyId: string, publicKey: string, dir: string}>}
486
+ */
487
+ export async function keygenMeta(author) {
488
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
489
+ const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
490
+ const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
491
+ const der = publicKey.export({ type: 'spki', format: 'der' });
492
+ const keyId = createHash('sha256').update(der).digest('hex');
493
+
494
+ const dir = join(homedir(), '.ijfw', 'keys', keyId);
495
+ await mkdir(dir, { recursive: true, mode: 0o700 });
496
+ try { await chmod(dir, 0o700); } catch { /* best-effort */ }
497
+
498
+ await writeFile(join(dir, 'public.pem'), pubPem, 'utf8');
499
+ await writeFile(join(dir, 'private.pem'), privPem, { encoding: 'utf8', mode: 0o600 });
500
+ try { await chmod(join(dir, 'private.pem'), 0o600); } catch { /* best-effort */ }
501
+ try { await chmod(join(dir, 'public.pem'), 0o644); } catch { /* best-effort */ }
502
+
503
+ // Meta-role marker
504
+ await writeFile(
505
+ join(dir, 'meta-role.txt'),
506
+ `meta\n${author || 'unknown'}\n${new Date().toISOString()}\n`,
507
+ 'utf8',
508
+ );
509
+
510
+ return { keyId, publicKey: pubPem, dir };
511
+ }
512
+
513
+ /**
514
+ * Sign a registry JSON file in place. Updates `signature` and `updated_at`.
515
+ * Writes atomically.
516
+ *
517
+ * Path must resolve under cwd() (path traversal defence).
518
+ *
519
+ * @param {string} registryPath
520
+ * @param {object} [opts]
521
+ * @param {string} [opts.privateKeyPem] - if not provided, loads from ~/.ijfw/keys/<keyId>/private.pem
522
+ * @returns {Promise<{ok: boolean, error?: string}>}
523
+ */
524
+ export async function signRegistry(registryPath, opts = {}) {
525
+ // Path security: must resolve under cwd (cross-platform).
526
+ const abs = pathResolve(registryPath);
527
+ if (!isUnderCwd(registryPath)) {
528
+ return { ok: false, error: `path traversal rejected: ${registryPath}` };
529
+ }
530
+
531
+ let raw;
532
+ try {
533
+ raw = await readFile(abs, 'utf8');
534
+ } catch (err) {
535
+ return { ok: false, error: `read failed: ${err.message}` };
536
+ }
537
+
538
+ let registry;
539
+ try {
540
+ registry = JSON.parse(raw);
541
+ } catch (err) {
542
+ return { ok: false, error: `JSON parse failed: ${err.message}` };
543
+ }
544
+
545
+ // Find the private key: prefer opts.privateKeyPem, else load from meta-keypair dir
546
+ let privPem = opts.privateKeyPem || null;
547
+ if (!privPem) {
548
+ // Find meta-key in ~/.ijfw/keys/
549
+ const keysDir = join(homedir(), '.ijfw', 'keys');
550
+ let keyDirs = [];
551
+ try {
552
+ const { readdir } = await import('node:fs/promises');
553
+ keyDirs = await readdir(keysDir);
554
+ } catch { /* none found */ }
555
+
556
+ for (const kid of keyDirs) {
557
+ const markerPath = join(keysDir, kid, 'meta-role.txt');
558
+ try {
559
+ await readFile(markerPath, 'utf8');
560
+ privPem = await readFile(join(keysDir, kid, 'private.pem'), 'utf8');
561
+ break;
562
+ } catch { /* not a meta key dir */ }
563
+ }
564
+ }
565
+
566
+ if (!privPem) {
567
+ return { ok: false, error: 'no meta private key found; run keygen-meta first or pass privateKeyPem in opts' };
568
+ }
569
+
570
+ let privKey;
571
+ try {
572
+ privKey = createPrivateKey(privPem);
573
+ } catch (err) {
574
+ return { ok: false, error: `private key parse failed: ${err.message}` };
575
+ }
576
+
577
+ // Update updated_at, clear old signature, compute canonical bytes, sign
578
+ registry.updated_at = new Date().toISOString();
579
+ delete registry.signature;
580
+ const bytes = registryCanonicalBytes(registry);
581
+ const sigBuf = cryptoSign(null, bytes, privKey);
582
+ registry.signature = `ed25519:${sigBuf.toString('base64')}`;
583
+
584
+ try {
585
+ await writeFile(abs, JSON.stringify(registry, null, 2) + '\n', 'utf8');
586
+ } catch (err) {
587
+ return { ok: false, error: `write failed: ${err.message}` };
588
+ }
589
+
590
+ return { ok: true };
591
+ }
592
+
593
+ /**
594
+ * Verify a registry JSON file's signature against the compiled-in meta-key.
595
+ *
596
+ * Path must resolve under cwd().
597
+ *
598
+ * @param {string} registryPath
599
+ * @returns {Promise<{ok: boolean, valid: boolean, reason: string}>}
600
+ */
601
+ export async function verifyRegistryFile(registryPath) {
602
+ // Path security (cross-platform).
603
+ const abs = pathResolve(registryPath);
604
+ if (!isUnderCwd(registryPath)) {
605
+ return { ok: false, valid: false, reason: `path traversal rejected: ${registryPath}` };
606
+ }
607
+
608
+ let raw;
609
+ try {
610
+ raw = await readFile(abs, 'utf8');
611
+ } catch (err) {
612
+ return { ok: false, valid: false, reason: `read failed: ${err.message}` };
613
+ }
614
+
615
+ const result = verifyRegistry(raw);
616
+ return { ok: true, valid: result.valid, reason: result.reason };
617
+ }
618
+
619
+ export { DEFAULT_REGISTRY_URL, FALLBACK_REGISTRY_URL, CACHE_TTL_MS, IJFW_REGISTRY_META_KEY_PEM };