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