@blamejs/exceptd-skills 0.12.16 → 0.12.20

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.
@@ -194,14 +194,63 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
194
194
  // v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
195
195
  // normalize(). Duplicated here to keep refresh-network free of cross-module
196
196
  // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
197
- // lib/sign.js — the three normalize() implementations form a byte-stability
198
- // contract.
197
+ // lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
198
+ // implementations form a byte-stability contract enforced by
199
+ // tests/normalize-contract.test.js.
199
200
  function normalizeSkillBytes(buf) {
200
201
  let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
201
202
  if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
202
203
  return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
203
204
  }
204
205
 
206
+ // Audit O P1-B + Q P1: in-line manifest-signature verifier. Kept here
207
+ // rather than imported from lib/verify.js so refresh-network.js retains
208
+ // its no-cross-module-dep posture (mirrors the per-skill verify path).
209
+ // ANY change to canonical-bytes computation here MUST stay in lockstep
210
+ // with lib/sign.js canonicalManifestBytes() / lib/verify.js
211
+ // canonicalManifestBytes() — tests/normalize-contract.test.js enforces.
212
+ function canonicalizeForRefresh(value) {
213
+ if (Array.isArray(value)) return value.map(canonicalizeForRefresh);
214
+ if (value && typeof value === "object") {
215
+ const out = {};
216
+ for (const key of Object.keys(value).sort()) {
217
+ out[key] = canonicalizeForRefresh(value[key]);
218
+ }
219
+ return out;
220
+ }
221
+ return value;
222
+ }
223
+ function canonicalManifestBytesForRefresh(manifest) {
224
+ const clone = Object.assign({}, manifest);
225
+ delete clone.manifest_signature;
226
+ const json = JSON.stringify(canonicalizeForRefresh(clone), null, 2);
227
+ return normalizeSkillBytes(Buffer.from(json, "utf8"));
228
+ }
229
+ function verifyTarballManifestSignature(manifest, publicKeyPem) {
230
+ const sig = manifest && manifest.manifest_signature;
231
+ if (!sig || typeof sig !== "object") return { status: "missing" };
232
+ if (typeof sig.signature_base64 !== "string") {
233
+ return { status: "invalid", reason: "manifest_signature.signature_base64 missing or not a string" };
234
+ }
235
+ if (sig.algorithm !== "Ed25519") {
236
+ return { status: "invalid", reason: `manifest_signature.algorithm must be 'Ed25519' (got ${JSON.stringify(sig.algorithm)})` };
237
+ }
238
+ let signatureBytes;
239
+ try { signatureBytes = Buffer.from(sig.signature_base64, "base64"); }
240
+ catch (e) { return { status: "invalid", reason: `malformed base64: ${e.message}` }; }
241
+ const bytes = canonicalManifestBytesForRefresh(manifest);
242
+ let ok = false;
243
+ try {
244
+ ok = crypto.verify(null, bytes, {
245
+ key: publicKeyPem,
246
+ dsaEncoding: "ieee-p1363",
247
+ }, signatureBytes);
248
+ } catch (e) {
249
+ return { status: "invalid", reason: `crypto.verify threw: ${e.message}` };
250
+ }
251
+ return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 manifest signature did not verify against local public.pem" };
252
+ }
253
+
205
254
  // Manifest path validation. Mirrors lib/verify.js validateSkillPath().
206
255
  function validateManifestSkillPath(skillPath) {
207
256
  if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
@@ -402,6 +451,33 @@ async function main() {
402
451
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
403
452
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
404
453
 
454
+ // Audit O P1-B + Q P1: verify the top-level manifest_signature against
455
+ // the LOCAL public key before honoring any entry in the tarball manifest.
456
+ // The previous flow iterated `manifest.skills[].signature` per-skill but
457
+ // never authenticated the manifest envelope itself — a coordinated
458
+ // attacker who flipped paths/names/atlas_refs on entries already covered
459
+ // by per-skill signatures (which sign only the skill body bytes, not the
460
+ // metadata around them) could re-shape catalog routing without breaking
461
+ // any per-skill signature. The manifest signature closes that gap.
462
+ //
463
+ // Unlike post-install verify (which warns-and-continues on missing
464
+ // signature for legacy-tarball compat), refresh-network REQUIRES the
465
+ // signature: this code path is publishing fresh content into the local
466
+ // tree, and the tarball must already be ≥ v0.12.17 to have reached the
467
+ // registry through the sign-all gate.
468
+ const manifestSigResult = verifyTarballManifestSignature(tarballManifest, localPubKeyText);
469
+ if (manifestSigResult.status !== "valid") {
470
+ emit({
471
+ ok: false,
472
+ error: `tarball manifest_signature ${manifestSigResult.status} — refusing to swap`,
473
+ reason: manifestSigResult.reason || null,
474
+ hint: manifestSigResult.status === "missing"
475
+ ? "Tarball predates v0.12.17 manifest signing. Run `npm update -g @blamejs/exceptd-skills` instead so the full provenance-verified install path runs."
476
+ : "Tarball manifest envelope failed Ed25519 verification against the LOCAL public key. Run `npm update -g @blamejs/exceptd-skills` for the full provenance-verified path, or report this tarball at https://github.com/blamejs/exceptd-skills/issues.",
477
+ }, opts.json);
478
+ process.exitCode = 5; return;
479
+ }
480
+
405
481
  // v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
406
482
  // path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
407
483
  // `path` (a forward-slash relative path like `skills/<name>/skill.md`,
@@ -603,4 +679,16 @@ if (require.main === module) {
603
679
  });
604
680
  }
605
681
 
606
- module.exports = { parseTar, fingerprintPublicKey };
682
+ module.exports = {
683
+ parseTar,
684
+ fingerprintPublicKey,
685
+ // Audit P P1-A: exported for tests/normalize-contract.test.js so the
686
+ // byte-stability contract can be asserted across all four normalize()
687
+ // implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
688
+ // scripts/verify-shipped-tarball.js).
689
+ normalizeSkillBytes,
690
+ // Audit O P1-B + Q P1: exported for in-process tests of the refresh
691
+ // path's manifest envelope check.
692
+ verifyTarballManifestSignature,
693
+ canonicalManifestBytesForRefresh,
694
+ };
@@ -323,7 +323,13 @@
323
323
  "confidence": { "type": "string", "enum": ["low", "medium", "high", "deterministic"] },
324
324
  "deterministic": { "type": "boolean", "description": "True if presence is definitive proof, not probabilistic." },
325
325
  "atlas_ref": { "type": "string" },
326
- "attack_ref": { "type": "string" }
326
+ "attack_ref": { "type": "string" },
327
+ "cve_ref": { "type": "string", "description": "Optional CVE / MAL-* / SNYK-* identifier this indicator binds to. When the indicator fires, the named entry is pulled into analyze.matched_cves[] (v0.12.14 F3)." },
328
+ "false_positive_checks_required": {
329
+ "type": "array",
330
+ "items": { "type": "string" },
331
+ "description": "v0.12.12+ contract: each entry is a check an AI assistant or operator must satisfy before this indicator's `hit` verdict can drive classification:detected. The runner downgrades a hit to inconclusive when this array is non-empty and no fp_checks attestation is supplied via signal_overrides[`<id>__fp_checks`]."
332
+ }
327
333
  }
328
334
  }
329
335
  },
package/lib/sign.js CHANGED
@@ -24,6 +24,38 @@
24
24
  * validateSkillPath() below). Without this a tampered manifest could
25
25
  * sign or verify arbitrary files outside the skills/ tree.
26
26
  *
27
+ * Manifest signing contract (must mirror lib/verify.js):
28
+ * After all individual skill signatures are written, sign-all signs
29
+ * the manifest itself. The canonical bytes are computed as:
30
+ * 1. Read the manifest object after all skill signatures land.
31
+ * 2. Delete the top-level `manifest_signature` field if present
32
+ * (idempotency — re-signing after rotation must produce the same
33
+ * canonical bytes whether or not a stale signature is there).
34
+ * 3. Serialize via JSON.stringify(obj, sortedTopLevelKeys, 2). The
35
+ * top-level keys are stringified in lexicographic order so a
36
+ * re-ordered manifest signs to the same bytes. Nested objects
37
+ * keep their natural key order (skills[] entries already follow
38
+ * a stable convention).
39
+ * 4. Apply normalize() (CRLF→LF, BOM strip) — same transform skills
40
+ * use, so the manifest signature survives any line-ending churn.
41
+ * ANY change to canonicalManifestBytes() or this contract requires
42
+ * the matching change in lib/verify.js. A coordinated attacker who
43
+ * rewrites manifest.json + manifest-snapshot.json + manifest-snapshot.sha256
44
+ * without the private key produces a manifest_signature mismatch that
45
+ * lib/verify.js refuses to load.
46
+ *
47
+ * Windows ACL contract:
48
+ * On win32, `fs.writeFileSync(..., { mode: 0o600 })` only affects
49
+ * read-only attributes — it does NOT establish a POSIX-style restrictive
50
+ * ACL. Any process running under the same desktop user can read the key
51
+ * by default ACL inheritance from the parent. After writing
52
+ * .keys/private.pem on Windows, restrictWindowsAcl() shells to icacls
53
+ * to strip inherited entries and grant Full Control only to the current
54
+ * user. If icacls is unavailable (Server Core, exotic shells), the call
55
+ * warns to stderr but does not fail keypair generation — generating the
56
+ * key was the load-bearing step; ACL tightening is best-effort hardening
57
+ * on top.
58
+ *
27
59
  * Signing ceremony:
28
60
  * 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
29
61
  * 2. node lib/sign.js sign-all — sign all skills (after any content change)
@@ -44,6 +76,7 @@
44
76
  const fs = require('fs');
45
77
  const path = require('path');
46
78
  const crypto = require('crypto');
79
+ const { execFileSync } = require('child_process');
47
80
 
48
81
  const ROOT = path.join(__dirname, '..');
49
82
  const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
@@ -79,6 +112,11 @@ function generateKeypair({ rotate = false } = {}) {
79
112
  fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
80
113
  fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
81
114
 
115
+ // Audit I P1-3: on win32, fs.writeFileSync `mode` does not produce
116
+ // a POSIX-style restrictive ACL. Tighten via icacls so other desktop
117
+ // users on the same workstation / CI runner can't read the key.
118
+ restrictWindowsAcl(PRIVATE_KEY_PATH);
119
+
82
120
  if (rotate) {
83
121
  console.log('[sign] Keypair rotated. All existing signatures are now invalid — run: node lib/sign.js sign-all');
84
122
  } else {
@@ -128,6 +166,16 @@ function signAll() {
128
166
  signed++;
129
167
  }
130
168
 
169
+ // Audit I P1-4: sign the manifest itself. Removes any existing
170
+ // manifest_signature field so the canonical bytes are deterministic
171
+ // across re-runs, signs with the private key, then writes the result.
172
+ // A coordinated attacker who rewrites the manifest (and snapshot, and
173
+ // snapshot SHA) without the private key produces an invalid manifest
174
+ // signature; lib/verify.js refuses to load the manifest.
175
+ delete manifest.manifest_signature;
176
+ const manifestSig = signCanonicalManifest(manifest, privateKey);
177
+ manifest.manifest_signature = manifestSig;
178
+
131
179
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
132
180
 
133
181
  // S5: verdict line FIRST, fingerprint banner after. An operator
@@ -136,7 +184,7 @@ function signAll() {
136
184
  if (errors > 0) {
137
185
  console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
138
186
  } else {
139
- console.log(`\n[sign] ${signed} skills signed.`);
187
+ console.log(`\n[sign] ${signed} skills signed. Manifest signed.`);
140
188
  }
141
189
  printFingerprintBanner();
142
190
 
@@ -160,6 +208,11 @@ function signOne(skillName) {
160
208
  skill.signed_at = new Date().toISOString();
161
209
  delete skill.sha256;
162
210
 
211
+ // P1-4: re-sign the manifest after the per-skill signature changes.
212
+ // Without this a single-skill sign leaves manifest_signature stale.
213
+ delete manifest.manifest_signature;
214
+ manifest.manifest_signature = signCanonicalManifest(manifest, privateKey);
215
+
163
216
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
164
217
  console.log(`[sign] Signed: ${skillName}`);
165
218
  printFingerprintBanner();
@@ -244,6 +297,113 @@ function loadManifest() {
244
297
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
245
298
  }
246
299
 
300
+ /**
301
+ * Audit I P1-4 — canonical byte form of the manifest, used for both
302
+ * signing (lib/sign.js) and verification (lib/verify.js).
303
+ *
304
+ * Contract: the same logical manifest content must produce the same bytes
305
+ * regardless of (a) whether a stale manifest_signature is present, (b)
306
+ * key order at any depth, (c) line endings or BOM.
307
+ *
308
+ * 1. Clone, delete manifest_signature.
309
+ * 2. Recursively sort object keys at every depth (NOT the top-level
310
+ * whitelist trap — see codex P1 PR #12: passing
311
+ * `Object.keys(manifest).sort()` as the JSON.stringify replacer-array
312
+ * treats it as a property allowlist applied to EVERY object level.
313
+ * Nested fields like `skills[].path` and `skills[].signature` got
314
+ * silently dropped from the canonical bytes, letting an attacker
315
+ * swap them without breaking the signature. Now we deep-canonicalize
316
+ * every object).
317
+ * 3. Apply normalize() — strip leading BOM, convert CRLF → LF.
318
+ *
319
+ * @param {object} manifest
320
+ * @returns {Buffer} canonical UTF-8 bytes
321
+ */
322
+ function canonicalize(value) {
323
+ if (Array.isArray(value)) return value.map(canonicalize);
324
+ if (value && typeof value === 'object') {
325
+ const out = {};
326
+ for (const key of Object.keys(value).sort()) {
327
+ out[key] = canonicalize(value[key]);
328
+ }
329
+ return out;
330
+ }
331
+ return value;
332
+ }
333
+
334
+ function canonicalManifestBytes(manifest) {
335
+ const clone = { ...manifest };
336
+ delete clone.manifest_signature;
337
+ const json = JSON.stringify(canonicalize(clone), null, 2);
338
+ return Buffer.from(normalize(json), 'utf8');
339
+ }
340
+
341
+ /**
342
+ * Sign the canonical manifest bytes with the Ed25519 private key.
343
+ * Returns the manifest_signature object literal to splice into the
344
+ * manifest top level.
345
+ *
346
+ * Audit O P1-A: the previous shape included a `signed_at` ISO timestamp.
347
+ * That field was stripped from the canonical bytes before signing (via
348
+ * `delete clone.manifest_signature`), so it was NOT covered by the
349
+ * signature — an attacker who replayed a known-valid signature could
350
+ * rewrite `signed_at` to any value, lending false freshness authority to
351
+ * a stale signature. The field is now omitted entirely. Freshness signal
352
+ * lives outside the signed bytes (git-log mtime of manifest.json, npm
353
+ * publish timestamp).
354
+ *
355
+ * @param {object} manifest
356
+ * @param {string} privateKey PEM-encoded Ed25519 private key
357
+ * @returns {{algorithm:'Ed25519', signature_base64:string}}
358
+ */
359
+ function signCanonicalManifest(manifest, privateKey) {
360
+ const bytes = canonicalManifestBytes(manifest);
361
+ const sig = crypto.sign(null, bytes, {
362
+ key: privateKey,
363
+ dsaEncoding: 'ieee-p1363',
364
+ });
365
+ return {
366
+ algorithm: 'Ed25519',
367
+ signature_base64: sig.toString('base64'),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Audit I P1-3 — tighten Windows ACL on the private key.
373
+ *
374
+ * fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
375
+ * attributes; the file inherits its ACL from the parent. icacls strips
376
+ * inheritance and grants Full Control only to the current user. Any
377
+ * failure (icacls missing, exotic shell, environment without USERNAME)
378
+ * is warned to stderr — generating the key was the load-bearing step,
379
+ * ACL tightening is best-effort hardening.
380
+ *
381
+ * @param {string} targetPath absolute path of the private key file
382
+ */
383
+ function restrictWindowsAcl(targetPath) {
384
+ if (process.platform !== 'win32') return;
385
+ const user = process.env.USERNAME;
386
+ if (!user) {
387
+ console.warn('[sign] WARN: USERNAME env var not set — skipping Windows ACL hardening on ' + targetPath);
388
+ return;
389
+ }
390
+ try {
391
+ execFileSync('icacls', [
392
+ targetPath,
393
+ '/inheritance:r',
394
+ '/grant:r',
395
+ `${user}:F`,
396
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
397
+ } catch (err) {
398
+ console.warn(
399
+ '[sign] WARN: icacls hardening failed on ' + targetPath + ': ' +
400
+ ((err && err.message) || String(err)) +
401
+ ' — the key was written but ACL inheritance was not stripped. ' +
402
+ 'Other desktop users on this machine may be able to read it.'
403
+ );
404
+ }
405
+ }
406
+
247
407
  function printFingerprintBanner() {
248
408
  if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
249
409
  try {
@@ -303,4 +463,13 @@ Signing ceremony (first time):
303
463
  }
304
464
  }
305
465
 
306
- module.exports = { generateKeypair, signAll, signOne, normalize, validateSkillPath };
466
+ module.exports = {
467
+ generateKeypair,
468
+ signAll,
469
+ signOne,
470
+ normalize,
471
+ validateSkillPath,
472
+ canonicalManifestBytes,
473
+ signCanonicalManifest,
474
+ restrictWindowsAcl,
475
+ };
package/lib/verify.js CHANGED
@@ -30,6 +30,24 @@
30
30
  * additionalProperties=false at the skill level catches typos and
31
31
  * unknown fields that would otherwise silently be dropped.
32
32
  *
33
+ * Manifest signature contract (must mirror lib/sign.js):
34
+ * The manifest carries a top-level `manifest_signature` field. Before
35
+ * iterating skills, loadManifestValidated() extracts the signature,
36
+ * recomputes the canonical bytes, and verifies against keys/public.pem.
37
+ * On failure, all skill verification is blocked with a structured
38
+ * error. When the field is absent (older v0.11.x / pre-v0.12.17
39
+ * tarballs in the wild), a warning is emitted but verification
40
+ * continues — this preserves backward compatibility for installs that
41
+ * predate manifest signing.
42
+ *
43
+ * Canonical bytes are computed identically to lib/sign.js
44
+ * canonicalManifestBytes():
45
+ * 1. Clone, delete manifest_signature.
46
+ * 2. JSON.stringify with top-level keys sorted lexicographically.
47
+ * 3. Apply normalize() — strip BOM, CRLF → LF.
48
+ * ANY change to the canonical form requires the matching change in
49
+ * lib/sign.js. Round-trip stability is a hard contract.
50
+ *
33
51
  * Signing ceremony: see lib/sign.js
34
52
  * Public key: keys/public.pem (tracked in repo)
35
53
  * Private key: .keys/private.pem (gitignored, kept off-repo)
@@ -112,7 +130,19 @@ function signAll() {
112
130
  const privateKey = loadPrivateKey();
113
131
  if (!privateKey) throw new Error('No private key at .keys/private.pem — run: node lib/sign.js generate-keypair');
114
132
 
115
- const manifest = loadManifestValidated();
133
+ // P1-4: load the manifest without the signature gate. We're about to
134
+ // mutate the manifest (re-sign skills + re-sign the manifest itself),
135
+ // so a stale manifest_signature mismatch here is expected — not a
136
+ // tampering signal. Schema + path validation still apply.
137
+ const manifest = loadManifest();
138
+ const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
139
+ const errors0 = validateAgainstSchema(manifest, schema, 'manifest');
140
+ if (errors0.length > 0) {
141
+ const detail = errors0.slice(0, 10).map(e => ' - ' + e).join('\n');
142
+ throw new Error(`[verify] manifest.json failed schema validation before re-sign:\n${detail}`);
143
+ }
144
+ for (const skill of (manifest.skills || [])) validateSkillPath(skill.path);
145
+
116
146
  const result = { signed: [], errors: [] };
117
147
 
118
148
  for (const skill of manifest.skills) {
@@ -128,8 +158,27 @@ function signAll() {
128
158
  result.signed.push(skill.name);
129
159
  }
130
160
 
161
+ // P1-4: re-sign the manifest after the per-skill signatures changed.
162
+ delete manifest.manifest_signature;
163
+ const canonical = canonicalManifestBytes(manifest);
164
+ const manifestSig = crypto.sign(null, canonical, {
165
+ key: privateKey, dsaEncoding: 'ieee-p1363',
166
+ });
167
+ // Audit O P1-A: `signed_at` is intentionally OMITTED. The previous shape
168
+ // emitted a `signed_at` timestamp alongside the Ed25519 signature, but
169
+ // `signed_at` was stripped from the canonical bytes before signing — so
170
+ // an attacker could replay a known-valid signature against the same
171
+ // canonical content while rewriting `signed_at` to any value, lending
172
+ // false freshness authority to a stale signature. Operators who need
173
+ // freshness signal should consult the git-log mtime of manifest.json
174
+ // (or the npm publish timestamp), which are external to the signed bytes.
175
+ manifest.manifest_signature = {
176
+ algorithm: 'Ed25519',
177
+ signature_base64: manifestSig.toString('base64'),
178
+ };
179
+
131
180
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
132
- console.log(`[verify] Signed ${result.signed.length} skills with Ed25519 private key.`);
181
+ console.log(`[verify] Signed ${result.signed.length} skills with Ed25519 private key. Manifest signed.`);
133
182
  return result;
134
183
  }
135
184
 
@@ -244,6 +293,94 @@ function loadManifest() {
244
293
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
245
294
  }
246
295
 
296
+ /**
297
+ * Audit I P1-4 — canonical byte form of the manifest.
298
+ *
299
+ * Mirrors lib/sign.js canonicalManifestBytes(). Any divergence here
300
+ * breaks the verify-after-sign round trip; do not modify in isolation.
301
+ *
302
+ * v0.12.17 (codex P1 PR #12): use deep canonicalize() instead of the
303
+ * top-level sortedKeys replacer-array. The replacer-array form acts as
304
+ * a property allowlist applied to EVERY object level — nested fields
305
+ * like skills[].path and skills[].signature got silently dropped from
306
+ * the canonical bytes, letting an attacker swap them without breaking
307
+ * the signature.
308
+ *
309
+ * @param {object} manifest
310
+ * @returns {Buffer} canonical UTF-8 bytes
311
+ */
312
+ function canonicalize(value) {
313
+ if (Array.isArray(value)) return value.map(canonicalize);
314
+ if (value && typeof value === 'object') {
315
+ const out = {};
316
+ for (const key of Object.keys(value).sort()) {
317
+ out[key] = canonicalize(value[key]);
318
+ }
319
+ return out;
320
+ }
321
+ return value;
322
+ }
323
+
324
+ function canonicalManifestBytes(manifest) {
325
+ const clone = { ...manifest };
326
+ delete clone.manifest_signature;
327
+ const json = JSON.stringify(canonicalize(clone), null, 2);
328
+ return Buffer.from(normalize(json), 'utf8');
329
+ }
330
+
331
+ /**
332
+ * Verify the top-level manifest_signature against keys/public.pem.
333
+ *
334
+ * Returns one of:
335
+ * { status: 'missing' } — field absent (legacy tarball; warn-but-proceed)
336
+ * { status: 'valid' } — signature verifies
337
+ * { status: 'invalid', — signature malformed, wrong key, or tampered
338
+ * reason: string }
339
+ * { status: 'no-key', — keys/public.pem absent
340
+ * reason: string }
341
+ *
342
+ * @param {object} manifest
343
+ */
344
+ function verifyManifestSignature(manifest) {
345
+ const sig = manifest && manifest.manifest_signature;
346
+ if (!sig || typeof sig !== 'object') return { status: 'missing' };
347
+ if (typeof sig.signature_base64 !== 'string') {
348
+ return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
349
+ }
350
+ // Audit O P1-E: require the algorithm field to be present and exactly
351
+ // 'Ed25519'. The previous form accepted a missing algorithm field
352
+ // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) which let a
353
+ // future downgrade attacker drop the field to bait a weaker default.
354
+ // lib/sign.js always writes the field, so no legitimate consumer breaks.
355
+ if (sig.algorithm !== 'Ed25519') {
356
+ return {
357
+ status: 'invalid',
358
+ reason: `manifest_signature.algorithm must be exactly 'Ed25519' (got ${JSON.stringify(sig.algorithm)})`,
359
+ };
360
+ }
361
+ const publicKey = loadPublicKey();
362
+ if (!publicKey) {
363
+ return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
364
+ }
365
+ let signatureBytes;
366
+ try {
367
+ signatureBytes = Buffer.from(sig.signature_base64, 'base64');
368
+ } catch (e) {
369
+ return { status: 'invalid', reason: `malformed base64 in manifest_signature: ${e.message}` };
370
+ }
371
+ const bytes = canonicalManifestBytes(manifest);
372
+ let ok = false;
373
+ try {
374
+ ok = crypto.verify(null, bytes, {
375
+ key: publicKey,
376
+ dsaEncoding: 'ieee-p1363',
377
+ }, signatureBytes);
378
+ } catch (e) {
379
+ return { status: 'invalid', reason: `crypto.verify threw: ${e.message}` };
380
+ }
381
+ return ok ? { status: 'valid' } : { status: 'invalid', reason: 'Ed25519 manifest signature did not verify against keys/public.pem — manifest.json has been tampered or signed with a different key' };
382
+ }
383
+
247
384
  /**
248
385
  * Load the manifest and validate it against
249
386
  * lib/schemas/manifest.schema.json + the path-traversal guard.
@@ -252,6 +389,14 @@ function loadManifest() {
252
389
  * is a fatal-class bug — surface it loudly rather than verify-against-
253
390
  * a-corrupt-manifest.
254
391
  *
392
+ * Audit I P1-4: also verifies the top-level manifest_signature. On
393
+ * invalid signature, throws a structured error blocking all skill
394
+ * verification (a coordinated attacker who rewrote manifest.json +
395
+ * manifest-snapshot.json + manifest-snapshot.sha256 still cannot forge
396
+ * the Ed25519 signature without the private key). When the signature
397
+ * field is absent, emits a stderr warning but proceeds — preserves
398
+ * backward compatibility for v0.12.16-and-earlier tarballs.
399
+ *
255
400
  * @returns {object}
256
401
  */
257
402
  function loadManifestValidated() {
@@ -269,6 +414,28 @@ function loadManifestValidated() {
269
414
  for (const skill of manifest.skills) {
270
415
  validateSkillPath(skill.path);
271
416
  }
417
+ // Audit I P1-4 — manifest signature gate. Runs after schema + path
418
+ // validation so a malformed manifest reports the structural failure
419
+ // before the cryptographic one.
420
+ const sigResult = verifyManifestSignature(manifest);
421
+ if (sigResult.status === 'invalid') {
422
+ throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. The manifest has been modified (or signed with a different key) since last sign-all. Refusing to verify any skill against this manifest.`);
423
+ }
424
+ if (sigResult.status === 'missing') {
425
+ // Audit O P1-D: dedupe the legacy-tarball warning. Many CLI verbs
426
+ // call loadManifestValidated() more than once per invocation; the
427
+ // previous console.warn spammed stderr per call. Node's emitWarning()
428
+ // with a stable `code` collapses repeated emissions automatically.
429
+ process.emitWarning(
430
+ 'manifest.json has no top-level manifest_signature field. This tarball predates v0.12.17 manifest signing; skills will still be verified but a coordinated rewrite of manifest.json could go undetected. Re-run `node lib/sign.js sign-all` to add the signature.',
431
+ { code: 'EXCEPTD_MANIFEST_UNSIGNED' }
432
+ );
433
+ } else if (sigResult.status === 'no-key') {
434
+ // Surfaced separately so the warning matches the missing-key path
435
+ // that verifyAll() already handles — don't fail here, the verifyAll
436
+ // entry point will emit a no_key result of its own.
437
+ console.warn(`[verify] WARN: cannot verify manifest_signature — ${sigResult.reason}.`);
438
+ }
272
439
  return manifest;
273
440
  }
274
441
 
@@ -554,5 +721,7 @@ module.exports = {
554
721
  validateAgainstSchema,
555
722
  publicKeyFingerprint,
556
723
  checkExpectedFingerprint,
724
+ canonicalManifestBytes,
725
+ verifyManifestSignature,
557
726
  EXPECTED_FINGERPRINT_PATH,
558
727
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-14T15:55:39.383Z",
3
+ "_generated_at": "2026-05-14T18:49:21.239Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [
@@ -1 +1 @@
1
- ca9d31e533c9d494e1ac5875e0a45176101438c3d75d44387187e367ccae21ad manifest-snapshot.json
1
+ 7e10f4b0b1c6cfa096e35ca28cdd8a95c19e51537cdd3e22628aecb87c72db1b manifest-snapshot.json