@ijfw/memory-server 1.4.0 → 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/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- 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-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
|
@@ -0,0 +1,1289 @@
|
|
|
1
|
+
/**
|
|
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)
|
|
5
|
+
*
|
|
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`.
|
|
19
|
+
*
|
|
20
|
+
* Uses node:https + node:crypto + node:fs/promises only — zero new prod deps.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createPublicKey, createHash, verify as cryptoVerify } from 'node:crypto';
|
|
24
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import https from 'node:https';
|
|
28
|
+
|
|
29
|
+
import { withFsLock } from './fs-lock.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Embedded meta-key — compiled-in trust root for registry signature verification.
|
|
33
|
+
// Source: mcp-server/src/.registry-meta-key.pem (gitignored sentinel).
|
|
34
|
+
// Rotation requires a new v1.4.x release with a new key inlined here.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const IJFW_REGISTRY_META_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
37
|
+
MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
|
|
38
|
+
-----END PUBLIC KEY-----`;
|
|
39
|
+
|
|
40
|
+
const DEFAULT_REGISTRY_URL = 'https://registry.ijfw.dev/publishers/v1.json';
|
|
41
|
+
const FALLBACK_REGISTRY_URL = 'https://therealseandonahoe.gitlab.io/ijfw/registry/publishers/v1.json';
|
|
42
|
+
const MAX_REGISTRY_BYTES = 1024 * 1024; // 1 MiB cap
|
|
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
|
|
46
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
47
|
+
const MAX_REDIRECTS = 3;
|
|
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
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Paths
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function ijfwStateDir() {
|
|
58
|
+
return join(homedir(), '.ijfw', 'state');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function registryCachePath() {
|
|
62
|
+
return join(ijfwStateDir(), 'registry-cache.json');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function revokedPublishersPath() {
|
|
66
|
+
return join(ijfwStateDir(), 'revoked-publishers.json');
|
|
67
|
+
}
|
|
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
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Canonical signing bytes — same logic as extension-signer.js
|
|
117
|
+
// Excludes `signature` from bytes so the field can carry the sig itself.
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function sortKeysDeep(v) {
|
|
121
|
+
if (Array.isArray(v)) return v.map(sortKeysDeep);
|
|
122
|
+
if (v !== null && typeof v === 'object') {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const k of Object.keys(v).sort()) {
|
|
125
|
+
if (v[k] === undefined) continue;
|
|
126
|
+
out[k] = sortKeysDeep(v[k]);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
return v;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function registryCanonicalBytes(registry) {
|
|
134
|
+
const shallow = {};
|
|
135
|
+
for (const k of Object.keys(registry)) {
|
|
136
|
+
if (k === 'signature') continue;
|
|
137
|
+
shallow[k] = registry[k];
|
|
138
|
+
}
|
|
139
|
+
return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Exposed for the WS client stub (same canonical-bytes algorithm).
|
|
143
|
+
export function _registryCanonicalBytesForTest(registry) {
|
|
144
|
+
return registryCanonicalBytes(registry);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// fetchRegistry — HTTPS-only, timeout + redirect cap + body size cap
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fetch the registry JSON from a URL.
|
|
153
|
+
* @param {string} [url]
|
|
154
|
+
* @param {object} [opts]
|
|
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.
|
|
158
|
+
* @returns {Promise<{ok: boolean, body: string|null, error: string|null}>}
|
|
159
|
+
*/
|
|
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
|
+
|
|
175
|
+
if (typeof opts.fetchImpl === 'function') {
|
|
176
|
+
return opts.fetchImpl(fetchUrl);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// HTTPS-only enforcement
|
|
180
|
+
let parsedUrl;
|
|
181
|
+
try {
|
|
182
|
+
parsedUrl = new URL(fetchUrl);
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: false, body: null, error: `invalid URL: ${fetchUrl}` };
|
|
185
|
+
}
|
|
186
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
187
|
+
return { ok: false, body: null, error: `registry URL must use HTTPS (got ${parsedUrl.protocol})` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return _httpsGet(fetchUrl, 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _httpsGet(url, redirectCount) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
let parsedUrl;
|
|
196
|
+
try {
|
|
197
|
+
parsedUrl = new URL(url);
|
|
198
|
+
} catch {
|
|
199
|
+
return resolve({ ok: false, body: null, error: `invalid redirect URL: ${url}` });
|
|
200
|
+
}
|
|
201
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
202
|
+
return resolve({ ok: false, body: null, error: `redirect to non-HTTPS rejected: ${url}` });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const req = https.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
|
206
|
+
const { statusCode, headers } = res;
|
|
207
|
+
|
|
208
|
+
// Handle redirects
|
|
209
|
+
if (statusCode >= 301 && statusCode <= 308 && headers.location) {
|
|
210
|
+
res.resume();
|
|
211
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
212
|
+
return resolve({ ok: false, body: null, error: `too many redirects (max ${MAX_REDIRECTS})` });
|
|
213
|
+
}
|
|
214
|
+
return resolve(_httpsGet(headers.location, redirectCount + 1));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (statusCode !== 200) {
|
|
218
|
+
res.resume();
|
|
219
|
+
return resolve({ ok: false, body: null, error: `HTTP ${statusCode}` });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const chunks = [];
|
|
223
|
+
let totalBytes = 0;
|
|
224
|
+
let oversize = false;
|
|
225
|
+
|
|
226
|
+
res.on('data', (chunk) => {
|
|
227
|
+
totalBytes += chunk.length;
|
|
228
|
+
if (totalBytes > MAX_REGISTRY_BYTES) {
|
|
229
|
+
oversize = true;
|
|
230
|
+
req.destroy();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
chunks.push(chunk);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
res.on('end', () => {
|
|
237
|
+
if (oversize) {
|
|
238
|
+
return resolve({ ok: false, body: null, error: `registry response exceeds ${MAX_REGISTRY_BYTES} bytes` });
|
|
239
|
+
}
|
|
240
|
+
resolve({ ok: true, body: Buffer.concat(chunks).toString('utf8'), error: null });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
res.on('error', (err) => {
|
|
244
|
+
resolve({ ok: false, body: null, error: err.message });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
req.on('timeout', () => {
|
|
249
|
+
req.destroy();
|
|
250
|
+
resolve({ ok: false, body: null, error: `fetch timeout after ${FETCH_TIMEOUT_MS}ms` });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
req.on('error', (err) => {
|
|
254
|
+
resolve({ ok: false, body: null, error: err.message });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
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.
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Verify a registry JSON body string.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} body
|
|
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.
|
|
276
|
+
* @param {boolean} [opts.allowSeed] Accept unsigned (null-signature) registries (bootstrap mode)
|
|
277
|
+
* @returns {{ valid: boolean, registry: object|null, reason: string, warnings?: string[] }}
|
|
278
|
+
*/
|
|
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
|
+
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(body);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
return { valid: false, registry: null, reason: `JSON parse failed: ${err.message}` };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
298
|
+
return { valid: false, registry: null, reason: 'registry must be a JSON object' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Shape validation
|
|
302
|
+
if (parsed.registry_version !== '1.0') {
|
|
303
|
+
return { valid: false, registry: null, reason: `unsupported registry_version: ${parsed.registry_version}` };
|
|
304
|
+
}
|
|
305
|
+
if (typeof parsed.updated_at !== 'string') {
|
|
306
|
+
return { valid: false, registry: null, reason: 'missing or invalid updated_at' };
|
|
307
|
+
}
|
|
308
|
+
if (parsed.publishers === null || typeof parsed.publishers !== 'object' || Array.isArray(parsed.publishers)) {
|
|
309
|
+
return { valid: false, registry: null, reason: 'publishers must be an object' };
|
|
310
|
+
}
|
|
311
|
+
if (!Array.isArray(parsed.revoked)) {
|
|
312
|
+
return { valid: false, registry: null, reason: 'revoked must be an array' };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Signature verification — null signature is only accepted in seed/bootstrap mode.
|
|
316
|
+
if (parsed.signature === null) {
|
|
317
|
+
const allowSeed = opts.allowSeed === true || process.env.IJFW_ALLOW_SEED_REGISTRY === '1';
|
|
318
|
+
if (!allowSeed) {
|
|
319
|
+
return { valid: false, registry: null, reason: 'signature missing — production clients require signed registry' };
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
valid: true,
|
|
323
|
+
registry: parsed,
|
|
324
|
+
reason: 'unsigned (seed)',
|
|
325
|
+
warnings: ['registry has no signature — running in seed/bootstrap mode only'],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (typeof parsed.signature !== 'string' || !parsed.signature.startsWith('ed25519:')) {
|
|
330
|
+
return { valid: false, registry: null, reason: 'signature must be null or "ed25519:<base64>"' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let metaKey;
|
|
334
|
+
try {
|
|
335
|
+
metaKey = createPublicKey(metaKeyPem);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return { valid: false, registry: null, reason: `meta-key parse failed: ${err.message}` };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const sigB64 = parsed.signature.slice('ed25519:'.length);
|
|
341
|
+
let sigBuf;
|
|
342
|
+
try {
|
|
343
|
+
sigBuf = Buffer.from(sigB64, 'base64');
|
|
344
|
+
} catch {
|
|
345
|
+
return { valid: false, registry: null, reason: 'signature base64 decode failed' };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const bytes = registryCanonicalBytes(parsed);
|
|
349
|
+
let ok;
|
|
350
|
+
try {
|
|
351
|
+
ok = cryptoVerify(null, bytes, metaKey, sigBuf);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return { valid: false, registry: null, reason: `signature verify threw: ${err.message}` };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!ok) {
|
|
357
|
+
return { valid: false, registry: null, reason: 'signature does not verify against meta-key' };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { valid: true, registry: parsed, reason: 'ok' };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// loadRegistrySources — read ~/.ijfw/registries.json or fall back to single-source.
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
|
|
378
|
+
/**
|
|
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}>>}
|
|
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
|
+
|
|
661
|
+
export async function readCachedRegistry() {
|
|
662
|
+
let raw;
|
|
663
|
+
try {
|
|
664
|
+
raw = await readFile(registryCachePath(), 'utf8');
|
|
665
|
+
} catch {
|
|
666
|
+
return { registry: null, cachedAt: null, stale: true };
|
|
667
|
+
}
|
|
668
|
+
let parsed;
|
|
669
|
+
try {
|
|
670
|
+
parsed = JSON.parse(raw);
|
|
671
|
+
} catch {
|
|
672
|
+
return { registry: null, cachedAt: null, stale: true };
|
|
673
|
+
}
|
|
674
|
+
const cachedAt = typeof parsed.cached_at === 'number' ? parsed.cached_at : null;
|
|
675
|
+
const stale = cachedAt === null || (Date.now() - cachedAt) > CACHE_TTL_MS;
|
|
676
|
+
return { registry: parsed.registry ?? null, cachedAt, stale };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function writeCachedRegistry(registry) {
|
|
680
|
+
await mkdir(ijfwStateDir(), { recursive: true });
|
|
681
|
+
const payload = JSON.stringify({ cached_at: Date.now(), registry }, null, 2) + '\n';
|
|
682
|
+
await writeFile(registryCachePath(), payload, 'utf8');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// applyRegistry — merge publishers + process revocations
|
|
687
|
+
// (single-source path, retained for v1.4.1 callers + tests)
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Apply a verified registry to the local trust store.
|
|
692
|
+
* @param {object} registry
|
|
693
|
+
* @param {object} [opts]
|
|
694
|
+
* @returns {Promise<{added: string[], removed: string[], unchanged: string[], rejected: string[]}>}
|
|
695
|
+
*/
|
|
696
|
+
export async function applyRegistry(registry, _opts = {}) {
|
|
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
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
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
|
+
}
|
|
782
|
+
}
|
|
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,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
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
|
+
}
|
|
928
|
+
}
|
|
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}` };
|
|
966
|
+
}
|
|
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
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
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,
|
|
1037
|
+
};
|
|
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;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
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
|
+
}
|
|
1078
|
+
|
|
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
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const multi = await applyMultiRegistry(appliedSources);
|
|
1096
|
+
return { ok: true, multi, warnings, error: null };
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
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.
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Fetch → verify → apply → cache. The main entry point for the CLI.
|
|
1110
|
+
* Falls back to cache on offline; warns if stale.
|
|
1111
|
+
*
|
|
1112
|
+
* v1.4.3 (B17): opts.partTtl now influences cache TTL when split fetching;
|
|
1113
|
+
* opts.emergency forces a no-cache refresh.
|
|
1114
|
+
*
|
|
1115
|
+
* @param {string} [url]
|
|
1116
|
+
* @param {object} [opts]
|
|
1117
|
+
* @returns {Promise<{ok: boolean, diff: object|null, fromCache: boolean, warnings: string[], error: string|null}>}
|
|
1118
|
+
*/
|
|
1119
|
+
export async function refreshTrustFromRegistry(url = DEFAULT_REGISTRY_URL, opts = {}) {
|
|
1120
|
+
const warnings = [];
|
|
1121
|
+
|
|
1122
|
+
const fetched = await fetchRegistry(url, opts);
|
|
1123
|
+
if (!fetched.ok) {
|
|
1124
|
+
const cached = await readCachedRegistry();
|
|
1125
|
+
if (cached.registry) {
|
|
1126
|
+
if (cached.stale) warnings.push(`offline and cache is stale (age > ${CACHE_TTL_MS / 3600000}h) — trust store not updated`);
|
|
1127
|
+
else warnings.push('offline — using cached registry');
|
|
1128
|
+
return { ok: true, diff: null, fromCache: true, warnings, error: null };
|
|
1129
|
+
}
|
|
1130
|
+
warnings.push(`offline and no cache available — trust store unchanged: ${fetched.error}`);
|
|
1131
|
+
return { ok: true, diff: null, fromCache: false, warnings, error: null };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const verified = verifyRegistry(fetched.body, opts);
|
|
1135
|
+
if (!verified.valid) {
|
|
1136
|
+
return { ok: false, diff: null, fromCache: false, warnings, error: `registry verify failed: ${verified.reason}` };
|
|
1137
|
+
}
|
|
1138
|
+
if (verified.warnings && verified.warnings.length > 0) {
|
|
1139
|
+
for (const w of verified.warnings) {
|
|
1140
|
+
process.stderr.write(`[ijfw] WARNING: ${w}\n`);
|
|
1141
|
+
warnings.push(w);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const diff = await applyRegistry(verified.registry, opts);
|
|
1146
|
+
await writeCachedRegistry(verified.registry);
|
|
1147
|
+
|
|
1148
|
+
return { ok: true, diff, fromCache: false, warnings, error: null };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ---------------------------------------------------------------------------
|
|
1152
|
+
// Signing CLI helpers (keygen-meta, sign-registry, verify-registry)
|
|
1153
|
+
// ---------------------------------------------------------------------------
|
|
1154
|
+
|
|
1155
|
+
import { generateKeyPairSync, createPrivateKey, sign as cryptoSign } from 'node:crypto';
|
|
1156
|
+
import { chmod } from 'node:fs/promises';
|
|
1157
|
+
import { resolve as pathResolve, relative as pathRelative, isAbsolute as pathIsAbsolute } from 'node:path';
|
|
1158
|
+
import { cwd } from 'node:process';
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Cross-platform path-under-cwd check.
|
|
1162
|
+
*/
|
|
1163
|
+
function isUnderCwd(targetPath) {
|
|
1164
|
+
const abs = pathResolve(targetPath);
|
|
1165
|
+
const cwdAbs = pathResolve(cwd());
|
|
1166
|
+
if (abs === cwdAbs) return true;
|
|
1167
|
+
const rel = pathRelative(cwdAbs, abs);
|
|
1168
|
+
return rel !== '' && !rel.startsWith('..') && !pathIsAbsolute(rel);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export async function keygenMeta(author) {
|
|
1172
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
1173
|
+
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
1174
|
+
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
1175
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
1176
|
+
const keyId = createHash('sha256').update(der).digest('hex');
|
|
1177
|
+
|
|
1178
|
+
const dir = join(homedir(), '.ijfw', 'keys', keyId);
|
|
1179
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
1180
|
+
try { await chmod(dir, 0o700); } catch { /* best-effort */ }
|
|
1181
|
+
|
|
1182
|
+
await writeFile(join(dir, 'public.pem'), pubPem, 'utf8');
|
|
1183
|
+
await writeFile(join(dir, 'private.pem'), privPem, { encoding: 'utf8', mode: 0o600 });
|
|
1184
|
+
try { await chmod(join(dir, 'private.pem'), 0o600); } catch { /* best-effort */ }
|
|
1185
|
+
try { await chmod(join(dir, 'public.pem'), 0o644); } catch { /* best-effort */ }
|
|
1186
|
+
|
|
1187
|
+
await writeFile(
|
|
1188
|
+
join(dir, 'meta-role.txt'),
|
|
1189
|
+
`meta\n${author || 'unknown'}\n${new Date().toISOString()}\n`,
|
|
1190
|
+
'utf8',
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
return { keyId, publicKey: pubPem, dir };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export async function signRegistry(registryPath, opts = {}) {
|
|
1197
|
+
const abs = pathResolve(registryPath);
|
|
1198
|
+
if (!isUnderCwd(registryPath)) {
|
|
1199
|
+
return { ok: false, error: `path traversal rejected: ${registryPath}` };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
let raw;
|
|
1203
|
+
try {
|
|
1204
|
+
raw = await readFile(abs, 'utf8');
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
return { ok: false, error: `read failed: ${err.message}` };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
let registry;
|
|
1210
|
+
try {
|
|
1211
|
+
registry = JSON.parse(raw);
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
return { ok: false, error: `JSON parse failed: ${err.message}` };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let privPem = opts.privateKeyPem || null;
|
|
1217
|
+
if (!privPem) {
|
|
1218
|
+
const keysDir = join(homedir(), '.ijfw', 'keys');
|
|
1219
|
+
let keyDirs = [];
|
|
1220
|
+
try {
|
|
1221
|
+
const { readdir } = await import('node:fs/promises');
|
|
1222
|
+
keyDirs = await readdir(keysDir);
|
|
1223
|
+
} catch { /* none found */ }
|
|
1224
|
+
|
|
1225
|
+
for (const kid of keyDirs) {
|
|
1226
|
+
const markerPath = join(keysDir, kid, 'meta-role.txt');
|
|
1227
|
+
try {
|
|
1228
|
+
await readFile(markerPath, 'utf8');
|
|
1229
|
+
privPem = await readFile(join(keysDir, kid, 'private.pem'), 'utf8');
|
|
1230
|
+
break;
|
|
1231
|
+
} catch { /* not a meta key dir */ }
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (!privPem) {
|
|
1236
|
+
return { ok: false, error: 'no meta private key found; run keygen-meta first or pass privateKeyPem in opts' };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
let privKey;
|
|
1240
|
+
try {
|
|
1241
|
+
privKey = createPrivateKey(privPem);
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
return { ok: false, error: `private key parse failed: ${err.message}` };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
registry.updated_at = new Date().toISOString();
|
|
1247
|
+
delete registry.signature;
|
|
1248
|
+
const bytes = registryCanonicalBytes(registry);
|
|
1249
|
+
const sigBuf = cryptoSign(null, bytes, privKey);
|
|
1250
|
+
registry.signature = `ed25519:${sigBuf.toString('base64')}`;
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
await writeFile(abs, JSON.stringify(registry, null, 2) + '\n', 'utf8');
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
return { ok: false, error: `write failed: ${err.message}` };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return { ok: true };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export async function verifyRegistryFile(registryPath) {
|
|
1262
|
+
const abs = pathResolve(registryPath);
|
|
1263
|
+
if (!isUnderCwd(registryPath)) {
|
|
1264
|
+
return { ok: false, valid: false, reason: `path traversal rejected: ${registryPath}` };
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
let raw;
|
|
1268
|
+
try {
|
|
1269
|
+
raw = await readFile(abs, 'utf8');
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
return { ok: false, valid: false, reason: `read failed: ${err.message}` };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const result = verifyRegistry(raw);
|
|
1275
|
+
return { ok: true, valid: result.valid, reason: result.reason };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
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
|
+
};
|