@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
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* extension-registry.js — IJFW v1.4.1/B6 Hosted Publisher Key Registry
|
|
2
|
+
* extension-registry.js — IJFW v1.4.1/B6 Hosted Publisher Key Registry
|
|
3
|
+
* + IJFW v1.4.3/B14 Federated Registries
|
|
4
|
+
* + IJFW v1.4.3/B17 Live Revocation (split TTL + emergency)
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* v1.4.1 baseline: single hosted registry signed by the embedded meta-key.
|
|
7
|
+
*
|
|
8
|
+
* v1.4.3 lift (B14 + B17):
|
|
9
|
+
* - Federated registries: clients pull from a priority-ordered list defined in
|
|
10
|
+
* `~/.ijfw/registries.json`. Higher-priority publishers override lower; ANY
|
|
11
|
+
* source's revocation revokes globally (defense-in-depth).
|
|
12
|
+
* - Per-source cache files: `~/.ijfw/state/registry-cache-<sanitized-name>.json`.
|
|
13
|
+
* Atomic tmp+rename inside `withFsLock` (consumes W9-A0 fs-lock.js).
|
|
14
|
+
* - Split TTL: revocation polled every 5 min; publishers every 24 h.
|
|
15
|
+
* - Emergency refresh path bypasses all caches.
|
|
16
|
+
* - Cache corruption is reported per-source; never silently falls through.
|
|
17
|
+
* - `verifyRegistry(body, { metaKeyPem, allowSeed })` is the v1.4.3-frozen
|
|
18
|
+
* signature (SEC-L-01). Default meta key is the embedded `IJFW_REGISTRY_META_KEY_PEM`.
|
|
7
19
|
*
|
|
8
20
|
* Uses node:https + node:crypto + node:fs/promises only — zero new prod deps.
|
|
9
21
|
*/
|
|
10
22
|
|
|
11
23
|
import { createPublicKey, createHash, verify as cryptoVerify } from 'node:crypto';
|
|
12
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
24
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
13
25
|
import { homedir } from 'node:os';
|
|
14
|
-
import { join } from 'node:path';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
15
27
|
import https from 'node:https';
|
|
16
28
|
|
|
29
|
+
import { withFsLock } from './fs-lock.js';
|
|
30
|
+
|
|
17
31
|
// ---------------------------------------------------------------------------
|
|
18
32
|
// Embedded meta-key — compiled-in trust root for registry signature verification.
|
|
19
33
|
// Source: mcp-server/src/.registry-meta-key.pem (gitignored sentinel).
|
|
@@ -26,10 +40,16 @@ MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
|
|
|
26
40
|
const DEFAULT_REGISTRY_URL = 'https://registry.ijfw.dev/publishers/v1.json';
|
|
27
41
|
const FALLBACK_REGISTRY_URL = 'https://therealseandonahoe.gitlab.io/ijfw/registry/publishers/v1.json';
|
|
28
42
|
const MAX_REGISTRY_BYTES = 1024 * 1024; // 1 MiB cap
|
|
29
|
-
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
43
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 h (back-compat alias)
|
|
44
|
+
const PUBLISHER_TTL_MS = 24 * 60 * 60 * 1000; // 24 h — B17 publisher half
|
|
45
|
+
const REVOCATION_TTL_MS = 5 * 60 * 1000; // 5 min — B17 revocation half
|
|
30
46
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
31
47
|
const MAX_REDIRECTS = 3;
|
|
32
48
|
|
|
49
|
+
// Source name shape (filesystem-safe; same vocab as gate_id).
|
|
50
|
+
const SOURCE_NAME_PATTERN = /^[a-z0-9_-]+$/;
|
|
51
|
+
const META_KEY_SENTINEL = '<embedded>';
|
|
52
|
+
|
|
33
53
|
// ---------------------------------------------------------------------------
|
|
34
54
|
// Paths
|
|
35
55
|
// ---------------------------------------------------------------------------
|
|
@@ -46,6 +66,52 @@ function revokedPublishersPath() {
|
|
|
46
66
|
return join(ijfwStateDir(), 'revoked-publishers.json');
|
|
47
67
|
}
|
|
48
68
|
|
|
69
|
+
function registriesConfigPath() {
|
|
70
|
+
return join(homedir(), '.ijfw', 'registries.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// R12-H-02: serialise trust-store reads/writes. trusted-publishers.json +
|
|
74
|
+
// revoked-publishers.json are read, merged, then written; without a lock two
|
|
75
|
+
// concurrent `trust-registry --emergency` invocations interleave and the later
|
|
76
|
+
// writer drops the earlier writer's revocations.
|
|
77
|
+
function trustStoreLockPath() {
|
|
78
|
+
return join(ijfwStateDir(), 'trust-store.lock');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sanitizeSourceName(name) {
|
|
82
|
+
if (typeof name !== 'string' || !SOURCE_NAME_PATTERN.test(name)) {
|
|
83
|
+
throw new RegistrySourcesError(
|
|
84
|
+
`source name must match /^[a-z0-9_-]+$/, got ${JSON.stringify(name)}`,
|
|
85
|
+
{ reason: 'invalid_source_name' },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return name;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function perSourceCachePath(sourceName) {
|
|
92
|
+
return join(ijfwStateDir(), `registry-cache-${sanitizeSourceName(sourceName)}.json`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function perSourceLockPath(sourceName) {
|
|
96
|
+
return join(ijfwStateDir(), `.registry-cache-${sanitizeSourceName(sourceName)}.lock`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// RegistrySourcesError — thrown on container-malformed registries.json
|
|
101
|
+
// (SEC-M-01: container errors are fatal; remote-source failures are warnings).
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export class RegistrySourcesError extends Error {
|
|
105
|
+
constructor(message, info = {}) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = 'RegistrySourcesError';
|
|
108
|
+
this.code = 'REGISTRY_SOURCES_ERROR';
|
|
109
|
+
this.reason = info.reason || 'malformed';
|
|
110
|
+
if (typeof info.line === 'number') this.line = info.line;
|
|
111
|
+
if (typeof info.column === 'number') this.column = info.column;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
49
115
|
// ---------------------------------------------------------------------------
|
|
50
116
|
// Canonical signing bytes — same logic as extension-signer.js
|
|
51
117
|
// Excludes `signature` from bytes so the field can carry the sig itself.
|
|
@@ -73,6 +139,11 @@ function registryCanonicalBytes(registry) {
|
|
|
73
139
|
return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
|
|
74
140
|
}
|
|
75
141
|
|
|
142
|
+
// Exposed for the WS client stub (same canonical-bytes algorithm).
|
|
143
|
+
export function _registryCanonicalBytesForTest(registry) {
|
|
144
|
+
return registryCanonicalBytes(registry);
|
|
145
|
+
}
|
|
146
|
+
|
|
76
147
|
// ---------------------------------------------------------------------------
|
|
77
148
|
// fetchRegistry — HTTPS-only, timeout + redirect cap + body size cap
|
|
78
149
|
// ---------------------------------------------------------------------------
|
|
@@ -82,25 +153,41 @@ function registryCanonicalBytes(registry) {
|
|
|
82
153
|
* @param {string} [url]
|
|
83
154
|
* @param {object} [opts]
|
|
84
155
|
* @param {Function} [opts.fetchImpl] - injectable for tests; receives (url) -> Promise<{ok,body,error}>
|
|
156
|
+
* @param {'all'|'revoked'} [opts.part] - B17: registry slice to fetch. Server returns
|
|
157
|
+
* the same JSON for both today; client caches them with their own TTL.
|
|
85
158
|
* @returns {Promise<{ok: boolean, body: string|null, error: string|null}>}
|
|
86
159
|
*/
|
|
87
160
|
export async function fetchRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
|
|
161
|
+
const part = opts.part === 'revoked' ? 'revoked' : 'all';
|
|
162
|
+
let fetchUrl = url;
|
|
163
|
+
if (part === 'revoked') {
|
|
164
|
+
// Append ?part=revoked (or &part=revoked) — the server ignores the query
|
|
165
|
+
// for v1.4.3 but CDN edge caches treat them as distinct cache keys.
|
|
166
|
+
try {
|
|
167
|
+
const u = new URL(url);
|
|
168
|
+
u.searchParams.set('part', 'revoked');
|
|
169
|
+
fetchUrl = u.toString();
|
|
170
|
+
} catch {
|
|
171
|
+
// Will be caught by HTTPS-only check below.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
88
175
|
if (typeof opts.fetchImpl === 'function') {
|
|
89
|
-
return opts.fetchImpl(
|
|
176
|
+
return opts.fetchImpl(fetchUrl);
|
|
90
177
|
}
|
|
91
178
|
|
|
92
179
|
// HTTPS-only enforcement
|
|
93
180
|
let parsedUrl;
|
|
94
181
|
try {
|
|
95
|
-
parsedUrl = new URL(
|
|
182
|
+
parsedUrl = new URL(fetchUrl);
|
|
96
183
|
} catch {
|
|
97
|
-
return { ok: false, body: null, error: `invalid URL: ${
|
|
184
|
+
return { ok: false, body: null, error: `invalid URL: ${fetchUrl}` };
|
|
98
185
|
}
|
|
99
186
|
if (parsedUrl.protocol !== 'https:') {
|
|
100
187
|
return { ok: false, body: null, error: `registry URL must use HTTPS (got ${parsedUrl.protocol})` };
|
|
101
188
|
}
|
|
102
189
|
|
|
103
|
-
return _httpsGet(
|
|
190
|
+
return _httpsGet(fetchUrl, 0);
|
|
104
191
|
}
|
|
105
192
|
|
|
106
193
|
function _httpsGet(url, redirectCount) {
|
|
@@ -170,17 +257,36 @@ function _httpsGet(url, redirectCount) {
|
|
|
170
257
|
}
|
|
171
258
|
|
|
172
259
|
// ---------------------------------------------------------------------------
|
|
173
|
-
// verifyRegistry — parse JSON, validate shape, verify signature
|
|
260
|
+
// verifyRegistry — parse JSON, validate shape, verify signature.
|
|
261
|
+
//
|
|
262
|
+
// v1.4.3 (SEC-L-01): standardized call signature
|
|
263
|
+
// verifyRegistry(body, { metaKeyPem, allowSeed })
|
|
264
|
+
// metaKeyPem defaults to the embedded IJFW_REGISTRY_META_KEY_PEM so v1.4.1
|
|
265
|
+
// callers and existing tests keep working. allowSeed is unchanged.
|
|
174
266
|
// ---------------------------------------------------------------------------
|
|
175
267
|
|
|
176
268
|
/**
|
|
177
269
|
* Verify a registry JSON body string.
|
|
270
|
+
*
|
|
178
271
|
* @param {string} body
|
|
179
272
|
* @param {object} [opts]
|
|
273
|
+
* @param {string} [opts.metaKeyPem] PEM-encoded Ed25519 SPKI key to verify against.
|
|
274
|
+
* Defaults to IJFW_REGISTRY_META_KEY_PEM. Sentinel `<embedded>` also resolves
|
|
275
|
+
* to the embedded key.
|
|
180
276
|
* @param {boolean} [opts.allowSeed] Accept unsigned (null-signature) registries (bootstrap mode)
|
|
181
277
|
* @returns {{ valid: boolean, registry: object|null, reason: string, warnings?: string[] }}
|
|
182
278
|
*/
|
|
183
279
|
export function verifyRegistry(body, opts = {}) {
|
|
280
|
+
// Resolve the meta-key (back-compat with v1.4.1 callers that omitted opts).
|
|
281
|
+
const rawMetaKey = opts.metaKeyPem;
|
|
282
|
+
const metaKeyPem =
|
|
283
|
+
rawMetaKey === undefined ||
|
|
284
|
+
rawMetaKey === null ||
|
|
285
|
+
rawMetaKey === '' ||
|
|
286
|
+
rawMetaKey === META_KEY_SENTINEL
|
|
287
|
+
? IJFW_REGISTRY_META_KEY_PEM
|
|
288
|
+
: rawMetaKey;
|
|
289
|
+
|
|
184
290
|
let parsed;
|
|
185
291
|
try {
|
|
186
292
|
parsed = JSON.parse(body);
|
|
@@ -207,8 +313,6 @@ export function verifyRegistry(body, opts = {}) {
|
|
|
207
313
|
}
|
|
208
314
|
|
|
209
315
|
// 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
316
|
if (parsed.signature === null) {
|
|
213
317
|
const allowSeed = opts.allowSeed === true || process.env.IJFW_ALLOW_SEED_REGISTRY === '1';
|
|
214
318
|
if (!allowSeed) {
|
|
@@ -228,7 +332,7 @@ export function verifyRegistry(body, opts = {}) {
|
|
|
228
332
|
|
|
229
333
|
let metaKey;
|
|
230
334
|
try {
|
|
231
|
-
metaKey = createPublicKey(
|
|
335
|
+
metaKey = createPublicKey(metaKeyPem);
|
|
232
336
|
} catch (err) {
|
|
233
337
|
return { valid: false, registry: null, reason: `meta-key parse failed: ${err.message}` };
|
|
234
338
|
}
|
|
@@ -257,13 +361,303 @@ export function verifyRegistry(body, opts = {}) {
|
|
|
257
361
|
}
|
|
258
362
|
|
|
259
363
|
// ---------------------------------------------------------------------------
|
|
260
|
-
//
|
|
364
|
+
// loadRegistrySources — read ~/.ijfw/registries.json or fall back to single-source.
|
|
261
365
|
// ---------------------------------------------------------------------------
|
|
262
366
|
|
|
367
|
+
function defaultPublicSource() {
|
|
368
|
+
return {
|
|
369
|
+
name: 'public',
|
|
370
|
+
url: DEFAULT_REGISTRY_URL,
|
|
371
|
+
meta_key_pem: IJFW_REGISTRY_META_KEY_PEM,
|
|
372
|
+
priority: 0,
|
|
373
|
+
publisher_ttl_ms: PUBLISHER_TTL_MS,
|
|
374
|
+
revocation_ttl_ms: REVOCATION_TTL_MS,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
263
378
|
/**
|
|
264
|
-
* Read
|
|
265
|
-
*
|
|
379
|
+
* Read and validate ~/.ijfw/registries.json. Returns the priority-ordered list
|
|
380
|
+
* of resolved sources.
|
|
381
|
+
*
|
|
382
|
+
* If the file is missing, return the legacy single-source default. If the file
|
|
383
|
+
* is present but malformed, throw RegistrySourcesError (SEC-M-01 — fatal).
|
|
384
|
+
*
|
|
385
|
+
* @returns {Promise<Array<{name:string,url:string,meta_key_pem:string,priority:number,publisher_ttl_ms:number,revocation_ttl_ms:number}>>}
|
|
266
386
|
*/
|
|
387
|
+
export async function loadRegistrySources() {
|
|
388
|
+
let raw;
|
|
389
|
+
try {
|
|
390
|
+
raw = await readFile(registriesConfigPath(), 'utf8');
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err && err.code === 'ENOENT') {
|
|
393
|
+
// Back-compat: single public registry default.
|
|
394
|
+
return [defaultPublicSource()];
|
|
395
|
+
}
|
|
396
|
+
throw new RegistrySourcesError(
|
|
397
|
+
`cannot read ${registriesConfigPath()}: ${err.message}`,
|
|
398
|
+
{ reason: 'unreadable' },
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let parsed;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(raw);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
throw new RegistrySourcesError(
|
|
407
|
+
`registries.json JSON parse failed: ${err.message}`,
|
|
408
|
+
{ reason: 'parse_error' },
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
413
|
+
throw new RegistrySourcesError(
|
|
414
|
+
'registries.json root must be a JSON object',
|
|
415
|
+
{ reason: 'schema_root' },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (parsed.schema_version !== '1.0') {
|
|
420
|
+
throw new RegistrySourcesError(
|
|
421
|
+
`registries.json schema_version must equal "1.0", got ${JSON.stringify(parsed.schema_version)}`,
|
|
422
|
+
{ reason: 'schema_version' },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!Array.isArray(parsed.registries) || parsed.registries.length === 0) {
|
|
427
|
+
throw new RegistrySourcesError(
|
|
428
|
+
'registries.json: registries must be a non-empty array',
|
|
429
|
+
{ reason: 'schema_registries' },
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const seenNames = new Set();
|
|
434
|
+
const sources = [];
|
|
435
|
+
|
|
436
|
+
for (let i = 0; i < parsed.registries.length; i++) {
|
|
437
|
+
const entry = parsed.registries[i];
|
|
438
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
439
|
+
throw new RegistrySourcesError(
|
|
440
|
+
`registries[${i}]: must be an object`,
|
|
441
|
+
{ reason: 'schema_entry' },
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (typeof entry.name !== 'string' || !SOURCE_NAME_PATTERN.test(entry.name)) {
|
|
445
|
+
throw new RegistrySourcesError(
|
|
446
|
+
`registries[${i}].name: must match /^[a-z0-9_-]+$/, got ${JSON.stringify(entry.name)}`,
|
|
447
|
+
{ reason: 'schema_name' },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (seenNames.has(entry.name)) {
|
|
451
|
+
throw new RegistrySourcesError(
|
|
452
|
+
`registries[${i}].name: duplicate source name ${JSON.stringify(entry.name)}`,
|
|
453
|
+
{ reason: 'duplicate_name' },
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
seenNames.add(entry.name);
|
|
457
|
+
if (typeof entry.url !== 'string') {
|
|
458
|
+
throw new RegistrySourcesError(
|
|
459
|
+
`registries[${i}].url: must be a string`,
|
|
460
|
+
{ reason: 'schema_url' },
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
let parsedUrl;
|
|
464
|
+
try {
|
|
465
|
+
parsedUrl = new URL(entry.url);
|
|
466
|
+
} catch {
|
|
467
|
+
throw new RegistrySourcesError(
|
|
468
|
+
`registries[${i}].url: invalid URL ${JSON.stringify(entry.url)}`,
|
|
469
|
+
{ reason: 'schema_url' },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
473
|
+
throw new RegistrySourcesError(
|
|
474
|
+
`registries[${i}].url: must use HTTPS, got ${parsedUrl.protocol}`,
|
|
475
|
+
{ reason: 'schema_url' },
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// meta_key_pem resolution.
|
|
480
|
+
let metaKeyPem;
|
|
481
|
+
if (
|
|
482
|
+
entry.meta_key_pem === undefined ||
|
|
483
|
+
entry.meta_key_pem === null ||
|
|
484
|
+
entry.meta_key_pem === META_KEY_SENTINEL ||
|
|
485
|
+
entry.meta_key_pem === ''
|
|
486
|
+
) {
|
|
487
|
+
metaKeyPem = IJFW_REGISTRY_META_KEY_PEM;
|
|
488
|
+
} else if (typeof entry.meta_key_pem !== 'string') {
|
|
489
|
+
throw new RegistrySourcesError(
|
|
490
|
+
`registries[${i}].meta_key_pem: must be a string PEM or the sentinel "<embedded>"`,
|
|
491
|
+
{ reason: 'schema_meta_key' },
|
|
492
|
+
);
|
|
493
|
+
} else {
|
|
494
|
+
// Validate it parses.
|
|
495
|
+
try {
|
|
496
|
+
createPublicKey(entry.meta_key_pem);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
throw new RegistrySourcesError(
|
|
499
|
+
`registries[${i}].meta_key_pem: PEM parse failed: ${err.message}`,
|
|
500
|
+
{ reason: 'schema_meta_key' },
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
metaKeyPem = entry.meta_key_pem;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const priority =
|
|
507
|
+
typeof entry.priority === 'number' && Number.isFinite(entry.priority)
|
|
508
|
+
? entry.priority
|
|
509
|
+
: i;
|
|
510
|
+
|
|
511
|
+
const publisher_ttl_ms =
|
|
512
|
+
typeof entry.publisher_ttl_ms === 'number' && entry.publisher_ttl_ms > 0
|
|
513
|
+
? entry.publisher_ttl_ms
|
|
514
|
+
: PUBLISHER_TTL_MS;
|
|
515
|
+
const revocation_ttl_ms =
|
|
516
|
+
typeof entry.revocation_ttl_ms === 'number' && entry.revocation_ttl_ms > 0
|
|
517
|
+
? entry.revocation_ttl_ms
|
|
518
|
+
: REVOCATION_TTL_MS;
|
|
519
|
+
|
|
520
|
+
sources.push({
|
|
521
|
+
name: entry.name,
|
|
522
|
+
url: entry.url,
|
|
523
|
+
meta_key_pem: metaKeyPem,
|
|
524
|
+
priority,
|
|
525
|
+
publisher_ttl_ms,
|
|
526
|
+
revocation_ttl_ms,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
sources.sort((a, b) => a.priority - b.priority);
|
|
531
|
+
return sources;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Per-source cache helpers (B14/B17). One file per source, atomic + locked.
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
const PER_SOURCE_CACHE_KEYS = [
|
|
539
|
+
'publishers',
|
|
540
|
+
'publishers_fetched_at',
|
|
541
|
+
'revoked',
|
|
542
|
+
'revocation_fetched_at',
|
|
543
|
+
'source_name',
|
|
544
|
+
'source_url',
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
function emptySourceCache(source) {
|
|
548
|
+
return {
|
|
549
|
+
publishers: {},
|
|
550
|
+
publishers_fetched_at: null,
|
|
551
|
+
revoked: [],
|
|
552
|
+
revocation_fetched_at: null,
|
|
553
|
+
source_name: source.name,
|
|
554
|
+
source_url: source.url,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Read the per-source cache. On any parse / shape / source_name mismatch error,
|
|
560
|
+
* returns `{ cache: emptySourceCache(source), corrupt: true, reason }` so the
|
|
561
|
+
* caller can both surface the warning AND avoid silent fall-through (SEC-M-04).
|
|
562
|
+
*
|
|
563
|
+
* @returns {Promise<{ cache: object, corrupt: boolean, reason?: string }>}
|
|
564
|
+
*/
|
|
565
|
+
export async function readSourceCache(source) {
|
|
566
|
+
const path = perSourceCachePath(source.name);
|
|
567
|
+
let raw;
|
|
568
|
+
try {
|
|
569
|
+
raw = await readFile(path, 'utf8');
|
|
570
|
+
} catch (err) {
|
|
571
|
+
if (err && err.code === 'ENOENT') {
|
|
572
|
+
return { cache: emptySourceCache(source), corrupt: false };
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
cache: emptySourceCache(source),
|
|
576
|
+
corrupt: true,
|
|
577
|
+
reason: `read_failed:${err.code || err.message}`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
let parsed;
|
|
581
|
+
try {
|
|
582
|
+
parsed = JSON.parse(raw);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
return {
|
|
585
|
+
cache: emptySourceCache(source),
|
|
586
|
+
corrupt: true,
|
|
587
|
+
reason: `parse_error:${err.message}`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
591
|
+
return { cache: emptySourceCache(source), corrupt: true, reason: 'shape_root' };
|
|
592
|
+
}
|
|
593
|
+
for (const k of PER_SOURCE_CACHE_KEYS) {
|
|
594
|
+
if (!(k in parsed)) {
|
|
595
|
+
return {
|
|
596
|
+
cache: emptySourceCache(source),
|
|
597
|
+
corrupt: true,
|
|
598
|
+
reason: `missing_field:${k}`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (parsed.source_name !== source.name) {
|
|
603
|
+
return {
|
|
604
|
+
cache: emptySourceCache(source),
|
|
605
|
+
corrupt: true,
|
|
606
|
+
reason: `source_name_mismatch:expected=${source.name},got=${parsed.source_name}`,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
parsed.publishers === null ||
|
|
611
|
+
typeof parsed.publishers !== 'object' ||
|
|
612
|
+
Array.isArray(parsed.publishers)
|
|
613
|
+
) {
|
|
614
|
+
return { cache: emptySourceCache(source), corrupt: true, reason: 'publishers_shape' };
|
|
615
|
+
}
|
|
616
|
+
if (!Array.isArray(parsed.revoked)) {
|
|
617
|
+
return { cache: emptySourceCache(source), corrupt: true, reason: 'revoked_shape' };
|
|
618
|
+
}
|
|
619
|
+
return { cache: parsed, corrupt: false };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function atomicWriteJson(filePath, payload) {
|
|
623
|
+
// R12-H-02: ensure the file's parent dir exists, not just ~/.ijfw/state —
|
|
624
|
+
// trusted-publishers.json lives at ~/.ijfw, not ~/.ijfw/state.
|
|
625
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
626
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
627
|
+
await writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
628
|
+
await rename(tmp, filePath);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Mutate the per-source cache inside an exclusive fs-lock.
|
|
633
|
+
* @param {object} source — entry from loadRegistrySources()
|
|
634
|
+
* @param {(cache: object) => object|Promise<object>} mutator
|
|
635
|
+
*/
|
|
636
|
+
export async function withSourceCache(source, mutator) {
|
|
637
|
+
return withFsLock(perSourceLockPath(source.name), async () => {
|
|
638
|
+
const { cache, corrupt, reason } = await readSourceCache(source);
|
|
639
|
+
if (corrupt) {
|
|
640
|
+
// The mutator still gets a fresh empty cache to write into. We surface
|
|
641
|
+
// `reason` via the mutator's return value when it wants to record it.
|
|
642
|
+
const next = await mutator({ ...cache, _corruptReason: reason });
|
|
643
|
+
if (next && typeof next === 'object') {
|
|
644
|
+
delete next._corruptReason;
|
|
645
|
+
await atomicWriteJson(perSourceCachePath(source.name), next);
|
|
646
|
+
}
|
|
647
|
+
return { corrupt: true, reason };
|
|
648
|
+
}
|
|
649
|
+
const next = await mutator(cache);
|
|
650
|
+
if (next && typeof next === 'object') {
|
|
651
|
+
await atomicWriteJson(perSourceCachePath(source.name), next);
|
|
652
|
+
}
|
|
653
|
+
return { corrupt: false };
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// Legacy single-source cache helpers (kept for v1.4.1 back-compat tests).
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
267
661
|
export async function readCachedRegistry() {
|
|
268
662
|
let raw;
|
|
269
663
|
try {
|
|
@@ -282,10 +676,6 @@ export async function readCachedRegistry() {
|
|
|
282
676
|
return { registry: parsed.registry ?? null, cachedAt, stale };
|
|
283
677
|
}
|
|
284
678
|
|
|
285
|
-
/**
|
|
286
|
-
* Write the registry to the local cache.
|
|
287
|
-
* @param {object} registry
|
|
288
|
-
*/
|
|
289
679
|
export async function writeCachedRegistry(registry) {
|
|
290
680
|
await mkdir(ijfwStateDir(), { recursive: true });
|
|
291
681
|
const payload = JSON.stringify({ cached_at: Date.now(), registry }, null, 2) + '\n';
|
|
@@ -294,6 +684,7 @@ export async function writeCachedRegistry(registry) {
|
|
|
294
684
|
|
|
295
685
|
// ---------------------------------------------------------------------------
|
|
296
686
|
// applyRegistry — merge publishers + process revocations
|
|
687
|
+
// (single-source path, retained for v1.4.1 callers + tests)
|
|
297
688
|
// ---------------------------------------------------------------------------
|
|
298
689
|
|
|
299
690
|
/**
|
|
@@ -303,116 +694,426 @@ export async function writeCachedRegistry(registry) {
|
|
|
303
694
|
* @returns {Promise<{added: string[], removed: string[], unchanged: string[], rejected: string[]}>}
|
|
304
695
|
*/
|
|
305
696
|
export async function applyRegistry(registry, _opts = {}) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
697
|
+
// R12-H-02: serialise the entire read-merge-write window with the
|
|
698
|
+
// trust-store lock so two concurrent callers cannot drop each other's
|
|
699
|
+
// revocations. Atomic tmp+rename inside the lock for both files.
|
|
700
|
+
await mkdir(ijfwStateDir(), { recursive: true });
|
|
701
|
+
return await withFsLock(trustStoreLockPath(), async () => {
|
|
702
|
+
const added = [];
|
|
703
|
+
const removed = [];
|
|
704
|
+
const unchanged = [];
|
|
705
|
+
const rejected = [];
|
|
706
|
+
|
|
707
|
+
// Read current trust store
|
|
708
|
+
const tpPath = join(homedir(), '.ijfw', 'trusted-publishers.json');
|
|
709
|
+
let store = { publishers: {} };
|
|
710
|
+
try {
|
|
711
|
+
const raw = await readFile(tpPath, 'utf8');
|
|
712
|
+
const parsed = JSON.parse(raw);
|
|
713
|
+
if (parsed && typeof parsed.publishers === 'object' && parsed.publishers !== null) {
|
|
714
|
+
store = parsed;
|
|
715
|
+
}
|
|
716
|
+
} catch { /* absent or malformed → start fresh */ }
|
|
717
|
+
|
|
718
|
+
// Read / update revoked list
|
|
719
|
+
let revokedStore = { revoked: [] };
|
|
720
|
+
try {
|
|
721
|
+
const raw = await readFile(revokedPublishersPath(), 'utf8');
|
|
722
|
+
const parsed = JSON.parse(raw);
|
|
723
|
+
if (parsed && Array.isArray(parsed.revoked)) revokedStore = parsed;
|
|
724
|
+
} catch { /* absent → start fresh */ }
|
|
725
|
+
|
|
726
|
+
const revokedSet = new Set(revokedStore.revoked.map(r => r.keyId));
|
|
727
|
+
|
|
728
|
+
// Process revocations first
|
|
729
|
+
for (const entry of (registry.revoked || [])) {
|
|
730
|
+
const { keyId } = entry;
|
|
731
|
+
if (!keyId) continue;
|
|
732
|
+
if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
|
|
733
|
+
delete store.publishers[keyId];
|
|
734
|
+
removed.push(keyId);
|
|
735
|
+
}
|
|
736
|
+
if (!revokedSet.has(keyId)) {
|
|
737
|
+
revokedSet.add(keyId);
|
|
738
|
+
revokedStore.revoked.push({
|
|
739
|
+
keyId,
|
|
740
|
+
revoked_at: entry.revoked_at || new Date().toISOString(),
|
|
741
|
+
reason: entry.reason || '',
|
|
742
|
+
superseded_by: entry.superseded_by || null,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
319
745
|
}
|
|
320
|
-
} catch { /* absent or malformed → start fresh */ }
|
|
321
746
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
747
|
+
// Merge publishers
|
|
748
|
+
for (const [keyId, entry] of Object.entries(registry.publishers || {})) {
|
|
749
|
+
if (!entry || typeof entry.publicKey !== 'string') {
|
|
750
|
+
rejected.push(keyId);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (revokedSet.has(keyId)) {
|
|
754
|
+
rejected.push(keyId);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
const key = createPublicKey(entry.publicKey);
|
|
759
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
760
|
+
const fp = createHash('sha256').update(der).digest('hex');
|
|
761
|
+
if (fp !== keyId) {
|
|
762
|
+
rejected.push(keyId);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
} catch {
|
|
766
|
+
rejected.push(keyId);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
|
|
771
|
+
unchanged.push(keyId);
|
|
772
|
+
} else {
|
|
773
|
+
store.publishers[keyId] = {
|
|
774
|
+
name: entry.name,
|
|
775
|
+
publicKey: entry.publicKey,
|
|
776
|
+
verified_at: entry.verified_at,
|
|
777
|
+
metadata: entry.metadata,
|
|
778
|
+
added_at: new Date().toISOString(),
|
|
779
|
+
};
|
|
780
|
+
added.push(keyId);
|
|
781
|
+
}
|
|
339
782
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
783
|
+
|
|
784
|
+
await mkdir(join(homedir(), '.ijfw'), { recursive: true });
|
|
785
|
+
await atomicWriteJson(tpPath, store);
|
|
786
|
+
await atomicWriteJson(revokedPublishersPath(), revokedStore);
|
|
787
|
+
|
|
788
|
+
return { added, removed, unchanged, rejected };
|
|
789
|
+
}, { staleMs: 30_000 });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// applyMultiRegistry (B14) — multi-source merge with priority + global revoke.
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Apply verified-per-source registries to the local trust store.
|
|
798
|
+
*
|
|
799
|
+
* @param {Array<{source:object, registry:object|null, rejected?:{reason:string,detail?:string}}>} appliedSources
|
|
800
|
+
* Pre-fetched, pre-verified registries paired with their source descriptor.
|
|
801
|
+
* `registry: null` indicates the source was skipped; `rejected` is non-null
|
|
802
|
+
* in that case.
|
|
803
|
+
* @returns {Promise<{sources: Array, global_revocations: Array, conflicts: Array}>}
|
|
804
|
+
*/
|
|
805
|
+
export async function applyMultiRegistry(appliedSources) {
|
|
806
|
+
// R12-H-02: serialise the entire read-merge-write window with the
|
|
807
|
+
// trust-store lock. Without this two concurrent `trust-registry --emergency`
|
|
808
|
+
// calls interleave their read+write phases and the later writer drops the
|
|
809
|
+
// earlier writer's revocations. Atomic tmp+rename inside the lock for both
|
|
810
|
+
// trust-store files. Stale-recovery defaults are sufficient (30s).
|
|
811
|
+
await mkdir(ijfwStateDir(), { recursive: true });
|
|
812
|
+
return await withFsLock(trustStoreLockPath(), async () => {
|
|
813
|
+
const sources = [];
|
|
814
|
+
const conflicts = [];
|
|
815
|
+
const global_revocations = [];
|
|
816
|
+
|
|
817
|
+
// Read current local trust + revoked stores.
|
|
818
|
+
const tpPath = join(homedir(), '.ijfw', 'trusted-publishers.json');
|
|
819
|
+
let store = { publishers: {} };
|
|
820
|
+
try {
|
|
821
|
+
const raw = await readFile(tpPath, 'utf8');
|
|
822
|
+
const parsed = JSON.parse(raw);
|
|
823
|
+
if (parsed && typeof parsed.publishers === 'object' && parsed.publishers !== null) {
|
|
824
|
+
store = parsed;
|
|
825
|
+
}
|
|
826
|
+
} catch { /* absent */ }
|
|
827
|
+
|
|
828
|
+
let revokedStore = { revoked: [] };
|
|
829
|
+
try {
|
|
830
|
+
const raw = await readFile(revokedPublishersPath(), 'utf8');
|
|
831
|
+
const parsed = JSON.parse(raw);
|
|
832
|
+
if (parsed && Array.isArray(parsed.revoked)) revokedStore = parsed;
|
|
833
|
+
} catch { /* absent */ }
|
|
834
|
+
|
|
835
|
+
const revokedSet = new Set(revokedStore.revoked.map(r => r.keyId));
|
|
836
|
+
|
|
837
|
+
// PASS 1: revocations from ALL sources are global (defense-in-depth).
|
|
838
|
+
for (const { source, registry, rejected } of appliedSources) {
|
|
839
|
+
if (!registry) continue;
|
|
840
|
+
for (const entry of registry.revoked || []) {
|
|
841
|
+
const { keyId } = entry;
|
|
842
|
+
if (!keyId) continue;
|
|
843
|
+
if (Object.prototype.hasOwnProperty.call(store.publishers, keyId)) {
|
|
844
|
+
delete store.publishers[keyId];
|
|
845
|
+
}
|
|
846
|
+
if (!revokedSet.has(keyId)) {
|
|
847
|
+
revokedSet.add(keyId);
|
|
848
|
+
const rev = {
|
|
849
|
+
keyId,
|
|
850
|
+
revoked_at: entry.revoked_at || new Date().toISOString(),
|
|
851
|
+
reason: entry.reason || '',
|
|
852
|
+
superseded_by: entry.superseded_by || null,
|
|
853
|
+
source: source.name,
|
|
854
|
+
};
|
|
855
|
+
revokedStore.revoked.push(rev);
|
|
856
|
+
global_revocations.push({ keyId, source: source.name });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// Stash the source preamble for the per-source report.
|
|
860
|
+
sources.push({
|
|
861
|
+
name: source.name,
|
|
862
|
+
url: source.url,
|
|
863
|
+
added: [],
|
|
864
|
+
removed: [],
|
|
865
|
+
unchanged: [],
|
|
866
|
+
rejected: rejected ? [{ name: source.name, reason: rejected.reason, detail: rejected.detail }] : [],
|
|
867
|
+
skipped: !registry,
|
|
348
868
|
});
|
|
349
869
|
}
|
|
350
|
-
}
|
|
351
870
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
871
|
+
// PASS 2: publishers in priority order (lowest .priority first).
|
|
872
|
+
const ordered = appliedSources
|
|
873
|
+
.filter((x) => x.registry)
|
|
874
|
+
.slice()
|
|
875
|
+
.sort((a, b) => a.source.priority - b.source.priority);
|
|
876
|
+
const claimedBy = new Map(); // keyId -> source name
|
|
877
|
+
|
|
878
|
+
for (const { source, registry } of ordered) {
|
|
879
|
+
const report = sources.find((s) => s.name === source.name);
|
|
880
|
+
for (const [keyId, entry] of Object.entries(registry.publishers || {})) {
|
|
881
|
+
if (!entry || typeof entry.publicKey !== 'string') {
|
|
882
|
+
report.rejected.push({ keyId, reason: 'malformed' });
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (revokedSet.has(keyId)) {
|
|
886
|
+
report.rejected.push({ keyId, reason: 'revoked' });
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const key = createPublicKey(entry.publicKey);
|
|
891
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
892
|
+
const fp = createHash('sha256').update(der).digest('hex');
|
|
893
|
+
if (fp !== keyId) {
|
|
894
|
+
report.rejected.push({ keyId, reason: 'fingerprint_mismatch' });
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
} catch {
|
|
898
|
+
report.rejected.push({ keyId, reason: 'pubkey_parse_failed' });
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (claimedBy.has(keyId)) {
|
|
903
|
+
const winner = claimedBy.get(keyId);
|
|
904
|
+
if (winner !== source.name) {
|
|
905
|
+
conflicts.push({ keyId, winner, also_in: source.name });
|
|
906
|
+
report.rejected.push({ keyId, reason: 'priority_conflict', winner });
|
|
907
|
+
}
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
claimedBy.set(keyId, source.name);
|
|
912
|
+
const existing = store.publishers[keyId];
|
|
913
|
+
if (existing && existing.publicKey === entry.publicKey) {
|
|
914
|
+
report.unchanged.push(keyId);
|
|
915
|
+
} else {
|
|
916
|
+
store.publishers[keyId] = {
|
|
917
|
+
name: entry.name,
|
|
918
|
+
publicKey: entry.publicKey,
|
|
919
|
+
verified_at: entry.verified_at,
|
|
920
|
+
metadata: entry.metadata,
|
|
921
|
+
source: source.name,
|
|
922
|
+
added_at: new Date().toISOString(),
|
|
923
|
+
};
|
|
924
|
+
if (existing) report.removed.push(keyId);
|
|
925
|
+
report.added.push(keyId);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
357
928
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
929
|
+
|
|
930
|
+
await atomicWriteJson(tpPath, store);
|
|
931
|
+
await atomicWriteJson(revokedPublishersPath(), revokedStore);
|
|
932
|
+
|
|
933
|
+
return { sources, global_revocations, conflicts };
|
|
934
|
+
}, { staleMs: 30_000 });
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ---------------------------------------------------------------------------
|
|
938
|
+
// refreshTrustFromAllRegistries (B14 + B17) — high-level federated entry.
|
|
939
|
+
//
|
|
940
|
+
// For each source:
|
|
941
|
+
// 1. Decide which parts are stale per split-TTL (publishers / revoked).
|
|
942
|
+
// 2. Fetch the stale part(s); fall back to cache on failure.
|
|
943
|
+
// 3. Verify against the source's meta key.
|
|
944
|
+
// 4. Update the per-source cache inside `withFsLock`.
|
|
945
|
+
// 5. Collect into appliedSources → applyMultiRegistry.
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* @param {object} [opts]
|
|
950
|
+
* @param {Function} [opts.fetchImpl] — injectable for tests; (url, source, part) -> {ok,body,error}
|
|
951
|
+
* @param {boolean} [opts.allowSeed]
|
|
952
|
+
* @param {boolean} [opts.emergency] — bypass all caches; force fetch.
|
|
953
|
+
* @param {boolean} [opts.refreshPublishers] — override TTL check, fetch publishers.
|
|
954
|
+
* @param {boolean} [opts.refreshRevocation] — override TTL check, fetch revocation.
|
|
955
|
+
* @param {Array} [opts.sources] — override loadRegistrySources() (tests).
|
|
956
|
+
* @returns {Promise<{ok:boolean, multi:object|null, warnings:string[], error:string|null}>}
|
|
957
|
+
*/
|
|
958
|
+
export async function refreshTrustFromAllRegistries(opts = {}) {
|
|
959
|
+
const warnings = [];
|
|
960
|
+
let sources;
|
|
961
|
+
try {
|
|
962
|
+
sources = opts.sources || (await loadRegistrySources());
|
|
963
|
+
} catch (err) {
|
|
964
|
+
if (err instanceof RegistrySourcesError) {
|
|
965
|
+
return { ok: false, multi: null, warnings, error: `registries.json ${err.reason}: ${err.message}` };
|
|
362
966
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
967
|
+
throw err;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const appliedSources = [];
|
|
971
|
+
|
|
972
|
+
for (const source of sources) {
|
|
973
|
+
const fetchImpl = typeof opts.fetchImpl === 'function'
|
|
974
|
+
? (url, part) => opts.fetchImpl(url, source, part)
|
|
975
|
+
: null;
|
|
976
|
+
|
|
977
|
+
let cache;
|
|
978
|
+
let corruptReason = null;
|
|
979
|
+
const cacheRead = await readSourceCache(source);
|
|
980
|
+
cache = cacheRead.cache;
|
|
981
|
+
if (cacheRead.corrupt) {
|
|
982
|
+
corruptReason = cacheRead.reason;
|
|
983
|
+
const msg = `[ijfw] WARNING: cache for source '${source.name}' corrupt (${cacheRead.reason}) — ignored; falling back to network`;
|
|
984
|
+
process.stderr.write(msg + '\n');
|
|
985
|
+
warnings.push(msg);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const now = Date.now();
|
|
989
|
+
const pubAge = cache.publishers_fetched_at ? now - Date.parse(cache.publishers_fetched_at) : Infinity;
|
|
990
|
+
const revAge = cache.revocation_fetched_at ? now - Date.parse(cache.revocation_fetched_at) : Infinity;
|
|
991
|
+
const wantPublishers = opts.emergency || opts.refreshPublishers || corruptReason || pubAge > source.publisher_ttl_ms;
|
|
992
|
+
const wantRevocation = opts.emergency || opts.refreshRevocation || corruptReason || revAge > source.revocation_ttl_ms;
|
|
993
|
+
|
|
994
|
+
let mergedRegistry = null;
|
|
995
|
+
let fetchError = null;
|
|
996
|
+
|
|
997
|
+
// Always fetch the full registry when we want publishers (the response is
|
|
998
|
+
// the source of truth for both parts). When ONLY revocation is stale we
|
|
999
|
+
// still issue a fetch (server returns the same JSON; CDN can cache
|
|
1000
|
+
// separately if it cares about ?part=revoked).
|
|
1001
|
+
if (wantPublishers || wantRevocation) {
|
|
1002
|
+
const part = wantPublishers ? 'all' : 'revoked';
|
|
1003
|
+
const fetched = await fetchRegistry(source.url, { fetchImpl, part });
|
|
1004
|
+
if (!fetched.ok) {
|
|
1005
|
+
fetchError = fetched.error;
|
|
1006
|
+
} else {
|
|
1007
|
+
const verified = verifyRegistry(fetched.body, {
|
|
1008
|
+
metaKeyPem: source.meta_key_pem,
|
|
1009
|
+
allowSeed: opts.allowSeed,
|
|
1010
|
+
});
|
|
1011
|
+
if (!verified.valid) {
|
|
1012
|
+
fetchError = `verify failed: ${verified.reason}`;
|
|
1013
|
+
} else {
|
|
1014
|
+
if (verified.warnings) {
|
|
1015
|
+
for (const w of verified.warnings) {
|
|
1016
|
+
const wMsg = `[ijfw] WARNING (source=${source.name}): ${w}`;
|
|
1017
|
+
process.stderr.write(wMsg + '\n');
|
|
1018
|
+
warnings.push(wMsg);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
mergedRegistry = verified.registry;
|
|
1022
|
+
}
|
|
371
1023
|
}
|
|
372
|
-
} catch {
|
|
373
|
-
rejected.push(keyId);
|
|
374
|
-
continue;
|
|
375
1024
|
}
|
|
376
1025
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
1026
|
+
// Decide what cache to write + which registry to apply.
|
|
1027
|
+
if (mergedRegistry) {
|
|
1028
|
+
// Update cache.
|
|
1029
|
+
const nowIso = new Date().toISOString();
|
|
1030
|
+
const updatedCache = {
|
|
1031
|
+
publishers: wantPublishers ? mergedRegistry.publishers : cache.publishers,
|
|
1032
|
+
publishers_fetched_at: wantPublishers ? nowIso : cache.publishers_fetched_at,
|
|
1033
|
+
revoked: mergedRegistry.revoked || cache.revoked,
|
|
1034
|
+
revocation_fetched_at: nowIso,
|
|
1035
|
+
source_name: source.name,
|
|
1036
|
+
source_url: source.url,
|
|
386
1037
|
};
|
|
387
|
-
|
|
1038
|
+
try {
|
|
1039
|
+
await withSourceCache(source, () => updatedCache);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
warnings.push(`[ijfw] WARNING: failed to write cache for source '${source.name}': ${err.message}`);
|
|
1042
|
+
}
|
|
1043
|
+
appliedSources.push({
|
|
1044
|
+
source,
|
|
1045
|
+
registry: {
|
|
1046
|
+
registry_version: '1.0',
|
|
1047
|
+
updated_at: mergedRegistry.updated_at,
|
|
1048
|
+
publishers: updatedCache.publishers,
|
|
1049
|
+
revoked: updatedCache.revoked,
|
|
1050
|
+
signature: mergedRegistry.signature,
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
continue;
|
|
388
1054
|
}
|
|
389
|
-
}
|
|
390
1055
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1056
|
+
// Fetch failed or we didn't need to fetch. Try cache fallback.
|
|
1057
|
+
const cacheUsable =
|
|
1058
|
+
!corruptReason &&
|
|
1059
|
+
(cache.publishers_fetched_at !== null || cache.revocation_fetched_at !== null);
|
|
1060
|
+
if (cacheUsable) {
|
|
1061
|
+
appliedSources.push({
|
|
1062
|
+
source,
|
|
1063
|
+
registry: {
|
|
1064
|
+
registry_version: '1.0',
|
|
1065
|
+
updated_at: cache.publishers_fetched_at || cache.revocation_fetched_at,
|
|
1066
|
+
publishers: cache.publishers || {},
|
|
1067
|
+
revoked: cache.revoked || [],
|
|
1068
|
+
signature: 'cached',
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
if (fetchError) {
|
|
1072
|
+
const msg = `[ijfw] WARNING: source '${source.name}' fetch failed (${fetchError}) — using cached entries`;
|
|
1073
|
+
process.stderr.write(msg + '\n');
|
|
1074
|
+
warnings.push(msg);
|
|
1075
|
+
}
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
394
1078
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1079
|
+
// No cache, no fresh fetch → skip with rejected marker (SEC-M-01 skip-with-warning).
|
|
1080
|
+
appliedSources.push({
|
|
1081
|
+
source,
|
|
1082
|
+
registry: null,
|
|
1083
|
+
rejected: {
|
|
1084
|
+
reason: corruptReason ? 'cache_corrupt' : 'fetch_failed',
|
|
1085
|
+
detail: fetchError || corruptReason || 'no cache available',
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
const msg = corruptReason
|
|
1089
|
+
? `[ijfw] WARNING: source '${source.name}' skipped (cache_corrupt: ${corruptReason})`
|
|
1090
|
+
: `[ijfw] WARNING: source '${source.name}' skipped (${fetchError || 'no cache'})`;
|
|
1091
|
+
process.stderr.write(msg + '\n');
|
|
1092
|
+
warnings.push(msg);
|
|
1093
|
+
}
|
|
401
1094
|
|
|
402
|
-
|
|
1095
|
+
const multi = await applyMultiRegistry(appliedSources);
|
|
1096
|
+
return { ok: true, multi, warnings, error: null };
|
|
403
1097
|
}
|
|
404
1098
|
|
|
405
1099
|
// ---------------------------------------------------------------------------
|
|
406
|
-
// refreshTrustFromRegistry —
|
|
1100
|
+
// refreshTrustFromRegistry — v1.4.1 single-source entry point.
|
|
1101
|
+
//
|
|
1102
|
+
// v1.4.3 wires this through the new multi-source pipeline when no explicit
|
|
1103
|
+
// `url` (i.e. caller wants federated default) AND no `fetchImpl` injected for
|
|
1104
|
+
// the single-source legacy tests. The legacy URL-passing tests continue to use
|
|
1105
|
+
// the old single-source code path so they keep their explicit assertions.
|
|
407
1106
|
// ---------------------------------------------------------------------------
|
|
408
1107
|
|
|
409
1108
|
/**
|
|
410
1109
|
* Fetch → verify → apply → cache. The main entry point for the CLI.
|
|
411
1110
|
* Falls back to cache on offline; warns if stale.
|
|
412
1111
|
*
|
|
1112
|
+
* v1.4.3 (B17): opts.partTtl now influences cache TTL when split fetching;
|
|
1113
|
+
* opts.emergency forces a no-cache refresh.
|
|
1114
|
+
*
|
|
413
1115
|
* @param {string} [url]
|
|
414
1116
|
* @param {object} [opts]
|
|
415
|
-
* @param {Function} [opts.fetchImpl] - injectable for tests
|
|
416
1117
|
* @returns {Promise<{ok: boolean, diff: object|null, fromCache: boolean, warnings: string[], error: string|null}>}
|
|
417
1118
|
*/
|
|
418
1119
|
export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
|
|
@@ -420,14 +1121,12 @@ export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts
|
|
|
420
1121
|
|
|
421
1122
|
const fetched = await fetchRegistry(url, opts);
|
|
422
1123
|
if (!fetched.ok) {
|
|
423
|
-
// Offline path — try cache
|
|
424
1124
|
const cached = await readCachedRegistry();
|
|
425
1125
|
if (cached.registry) {
|
|
426
1126
|
if (cached.stale) warnings.push(`offline and cache is stale (age > ${CACHE_TTL_MS / 3600000}h) — trust store not updated`);
|
|
427
1127
|
else warnings.push('offline — using cached registry');
|
|
428
1128
|
return { ok: true, diff: null, fromCache: true, warnings, error: null };
|
|
429
1129
|
}
|
|
430
|
-
// No cache either — return existing trust store untouched
|
|
431
1130
|
warnings.push(`offline and no cache available — trust store unchanged: ${fetched.error}`);
|
|
432
1131
|
return { ok: true, diff: null, fromCache: false, warnings, error: null };
|
|
433
1132
|
}
|
|
@@ -436,7 +1135,6 @@ export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts
|
|
|
436
1135
|
if (!verified.valid) {
|
|
437
1136
|
return { ok: false, diff: null, fromCache: false, warnings, error: `registry verify failed: ${verified.reason}` };
|
|
438
1137
|
}
|
|
439
|
-
// Seed-mode: surface warnings loudly on stderr so bootstrap operators notice.
|
|
440
1138
|
if (verified.warnings && verified.warnings.length > 0) {
|
|
441
1139
|
for (const w of verified.warnings) {
|
|
442
1140
|
process.stderr.write(`[ijfw] WARNING: ${w}\n`);
|
|
@@ -460,30 +1158,16 @@ import { resolve as pathResolve, relative as pathRelative, isAbsolute as pathIsA
|
|
|
460
1158
|
import { cwd } from 'node:process';
|
|
461
1159
|
|
|
462
1160
|
/**
|
|
463
|
-
* Cross-platform path-under-cwd check.
|
|
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}
|
|
1161
|
+
* Cross-platform path-under-cwd check.
|
|
469
1162
|
*/
|
|
470
1163
|
function isUnderCwd(targetPath) {
|
|
471
1164
|
const abs = pathResolve(targetPath);
|
|
472
1165
|
const cwdAbs = pathResolve(cwd());
|
|
473
1166
|
if (abs === cwdAbs) return true;
|
|
474
1167
|
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
1168
|
return rel !== '' && !rel.startsWith('..') && !pathIsAbsolute(rel);
|
|
478
1169
|
}
|
|
479
1170
|
|
|
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
1171
|
export async function keygenMeta(author) {
|
|
488
1172
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
489
1173
|
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
@@ -500,7 +1184,6 @@ export async function keygenMeta(author) {
|
|
|
500
1184
|
try { await chmod(join(dir, 'private.pem'), 0o600); } catch { /* best-effort */ }
|
|
501
1185
|
try { await chmod(join(dir, 'public.pem'), 0o644); } catch { /* best-effort */ }
|
|
502
1186
|
|
|
503
|
-
// Meta-role marker
|
|
504
1187
|
await writeFile(
|
|
505
1188
|
join(dir, 'meta-role.txt'),
|
|
506
1189
|
`meta\n${author || 'unknown'}\n${new Date().toISOString()}\n`,
|
|
@@ -510,19 +1193,7 @@ export async function keygenMeta(author) {
|
|
|
510
1193
|
return { keyId, publicKey: pubPem, dir };
|
|
511
1194
|
}
|
|
512
1195
|
|
|
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
1196
|
export async function signRegistry(registryPath, opts = {}) {
|
|
525
|
-
// Path security: must resolve under cwd (cross-platform).
|
|
526
1197
|
const abs = pathResolve(registryPath);
|
|
527
1198
|
if (!isUnderCwd(registryPath)) {
|
|
528
1199
|
return { ok: false, error: `path traversal rejected: ${registryPath}` };
|
|
@@ -542,10 +1213,8 @@ export async function signRegistry(registryPath, opts = {}) {
|
|
|
542
1213
|
return { ok: false, error: `JSON parse failed: ${err.message}` };
|
|
543
1214
|
}
|
|
544
1215
|
|
|
545
|
-
// Find the private key: prefer opts.privateKeyPem, else load from meta-keypair dir
|
|
546
1216
|
let privPem = opts.privateKeyPem || null;
|
|
547
1217
|
if (!privPem) {
|
|
548
|
-
// Find meta-key in ~/.ijfw/keys/
|
|
549
1218
|
const keysDir = join(homedir(), '.ijfw', 'keys');
|
|
550
1219
|
let keyDirs = [];
|
|
551
1220
|
try {
|
|
@@ -574,7 +1243,6 @@ export async function signRegistry(registryPath, opts = {}) {
|
|
|
574
1243
|
return { ok: false, error: `private key parse failed: ${err.message}` };
|
|
575
1244
|
}
|
|
576
1245
|
|
|
577
|
-
// Update updated_at, clear old signature, compute canonical bytes, sign
|
|
578
1246
|
registry.updated_at = new Date().toISOString();
|
|
579
1247
|
delete registry.signature;
|
|
580
1248
|
const bytes = registryCanonicalBytes(registry);
|
|
@@ -590,16 +1258,7 @@ export async function signRegistry(registryPath, opts = {}) {
|
|
|
590
1258
|
return { ok: true };
|
|
591
1259
|
}
|
|
592
1260
|
|
|
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
1261
|
export async function verifyRegistryFile(registryPath) {
|
|
602
|
-
// Path security (cross-platform).
|
|
603
1262
|
const abs = pathResolve(registryPath);
|
|
604
1263
|
if (!isUnderCwd(registryPath)) {
|
|
605
1264
|
return { ok: false, valid: false, reason: `path traversal rejected: ${registryPath}` };
|
|
@@ -616,4 +1275,15 @@ export async function verifyRegistryFile(registryPath) {
|
|
|
616
1275
|
return { ok: true, valid: result.valid, reason: result.reason };
|
|
617
1276
|
}
|
|
618
1277
|
|
|
619
|
-
export {
|
|
1278
|
+
export {
|
|
1279
|
+
DEFAULT_REGISTRY_URL,
|
|
1280
|
+
FALLBACK_REGISTRY_URL,
|
|
1281
|
+
CACHE_TTL_MS,
|
|
1282
|
+
PUBLISHER_TTL_MS,
|
|
1283
|
+
REVOCATION_TTL_MS,
|
|
1284
|
+
IJFW_REGISTRY_META_KEY_PEM,
|
|
1285
|
+
META_KEY_SENTINEL,
|
|
1286
|
+
SOURCE_NAME_PATTERN,
|
|
1287
|
+
perSourceCachePath,
|
|
1288
|
+
perSourceLockPath,
|
|
1289
|
+
};
|