@blamejs/exceptd-skills 0.12.16 → 0.12.18

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.
@@ -391,7 +391,12 @@
391
391
  "description": "AWS Secret Access Key. Always findable in tandem with AKIA*. Independent finding because both halves are needed.",
392
392
  "confidence": "deterministic",
393
393
  "deterministic": true,
394
- "attack_ref": "T1552.001"
394
+ "attack_ref": "T1552.001",
395
+ "false_positive_checks_required": [
396
+ "Check for co-occurrence with an AKIA*/ASIA*/AGPA*/AIDA* access-key-id in a 10-line window. Without corroboration, the 40-char base64-ish string is plausibly a Base64-encoded JWT signature / random data / DB password — demote to medium when corroboration is absent.",
397
+ "If the value matches the AWS-published sample wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY, demote to miss (doc fixture).",
398
+ "Verify the file is not under examples/ / docs/ / fixtures/ AND not a known-test snapshot; demote when in a documented placeholder path."
399
+ ]
395
400
  },
396
401
  {
397
402
  "id": "gcp-service-account-json",
@@ -427,7 +432,12 @@
427
432
  "description": "Slack bot / user / refresh / app token.",
428
433
  "confidence": "deterministic",
429
434
  "deterministic": true,
430
- "attack_ref": "T1552.001"
435
+ "attack_ref": "T1552.001",
436
+ "false_positive_checks_required": [
437
+ "If the value is `xoxb-PLACEHOLDER` / `xoxb-EXAMPLE` / a Slack-published doc fixture (e.g. xoxb-12345-67890-AbCdEf), demote.",
438
+ "Verify the token format conforms to a current Slack token shape (xoxb- usually has at least three dash-separated numeric segments before the alpha suffix). Loose 10-char tail matches without segmentation can match unrelated strings; demote when segmentation is wrong.",
439
+ "If under examples/ / docs/ / fixtures/ AND no surrounding env var (SLACK_BOT_TOKEN / SLACK_USER_TOKEN) suggests an active config, demote."
440
+ ]
431
441
  },
432
442
  {
433
443
  "id": "stripe-secret-key",
@@ -436,7 +446,12 @@
436
446
  "description": "Stripe live or test secret key. Live keys are direct financial exposure.",
437
447
  "confidence": "deterministic",
438
448
  "deterministic": true,
439
- "attack_ref": "T1552.001"
449
+ "attack_ref": "T1552.001",
450
+ "false_positive_checks_required": [
451
+ "If the prefix is `sk_test_` AND the value matches a published Stripe sample test key (see Stripe API docs — they publish a small set explicitly for documentation use), demote — test keys cannot move funds.",
452
+ "Verify the file path is not under examples/, fixtures/, docs/, or a stripe-quickstart template — demote in documented placeholder paths.",
453
+ "For sk_live_*, cross-check against the Stripe API (`POST /v1/accounts/retrieve` or similar live-validity probe) only if the operator has authorised an external validation; otherwise treat the live prefix as deterministic."
454
+ ]
440
455
  },
441
456
  {
442
457
  "id": "jwt-token-with-secret-context",
@@ -463,7 +478,12 @@
463
478
  "description": "OpenAI API key (incl. project-scoped sk-proj-* form). Active key spend exposure.",
464
479
  "confidence": "deterministic",
465
480
  "deterministic": true,
466
- "attack_ref": "T1552.001"
481
+ "attack_ref": "T1552.001",
482
+ "false_positive_checks_required": [
483
+ "If the key prefix is `sk-test-*` / `sk-dummy-*` / `sk-XXXX` / contains only placeholder runs (XXXXX, 1234, abcd) it is a documentation fixture; demote.",
484
+ "Verify the key length meets the OpenAI minimum entropy floor (post-prefix length >= 48 chars). Below the floor is a placeholder; demote.",
485
+ "The regex `sk-[A-Za-z0-9_-]{40,}` also matches Anthropic sk-ant-* and other vendor-prefixed keys — confirm the vendor by surrounding context (env var name, comment) before classifying as OpenAI."
486
+ ]
467
487
  },
468
488
  {
469
489
  "id": "anthropic-api-key",
@@ -472,7 +492,12 @@
472
492
  "description": "Anthropic API key. Active key spend exposure.",
473
493
  "confidence": "deterministic",
474
494
  "deterministic": true,
475
- "attack_ref": "T1552.001"
495
+ "attack_ref": "T1552.001",
496
+ "false_positive_checks_required": [
497
+ "If the key is `sk-ant-api03-PLACEHOLDER...` / `sk-ant-test-*` / contains documented placeholder substrings (XXXX runs, all-zero runs), demote.",
498
+ "Verify the file is not under examples/, fixtures/, sdk-quickstart/, or a docs-snippet path — demote when in documented placeholder paths.",
499
+ "Confirm post-prefix length meets the Anthropic minimum entropy floor (>= 80 chars after sk-ant-(api03|admin01)-); below the floor is a fixture, demote."
500
+ ]
476
501
  },
477
502
  {
478
503
  "id": "world-writable-env-file",
@@ -56,6 +56,38 @@ function buildScoringInputs(kevEntry /*, nvdPayload */) {
56
56
  };
57
57
  }
58
58
 
59
+ /**
60
+ * Audit M P3-O — diff severity nuance for KEV-discovered drafts.
61
+ *
62
+ * Pre-fix every KEV-derived diff carried `severity: "high"`. Operators
63
+ * scanning the diff stream had no way to distinguish "patch in 21 days"
64
+ * from "active ransomware campaign, patch yesterday." Now:
65
+ *
66
+ * - ransomware_use === "Known" → "critical" (campaigns observed in the wild)
67
+ * - dueDate within 7 days of now → "critical" (CISA escalation window)
68
+ * - otherwise → "high" (still actively exploited per KEV listing)
69
+ *
70
+ * A KEV listing inherently means active exploitation; "low" / "medium"
71
+ * never apply here. The split is between "act today" and "act this sprint."
72
+ *
73
+ * @param {object} kevEntry
74
+ * @returns {"critical" | "high"}
75
+ */
76
+ function deriveKevSeverity(kevEntry) {
77
+ const ransomware = String(kevEntry?.knownRansomwareCampaignUse || "").toLowerCase() === "known";
78
+ if (ransomware) return "critical";
79
+ const due = kevEntry?.dueDate;
80
+ if (typeof due === "string" && /^\d{4}-\d{2}-\d{2}/.test(due)) {
81
+ const dueMs = Date.parse(due);
82
+ if (Number.isFinite(dueMs)) {
83
+ const deltaMs = dueMs - Date.now();
84
+ // Within the next 7 days OR already past due → critical.
85
+ if (deltaMs <= 7 * 86_400_000) return "critical";
86
+ }
87
+ }
88
+ return "high";
89
+ }
90
+
59
91
  const TODAY = new Date().toISOString().slice(0, 10);
60
92
  const TIMEOUT_MS = 10_000;
61
93
  const USER_AGENT = "exceptd-security/auto-discovery (+https://exceptd.com)";
@@ -259,7 +291,7 @@ function discoverNewKev(ctx, cap = DEFAULT_CAP) {
259
291
  op: "add",
260
292
  target: "cveCatalog",
261
293
  entry,
262
- severity: "high",
294
+ severity: deriveKevSeverity(kev),
263
295
  meta: {
264
296
  date_added: kev.dateAdded || null,
265
297
  vendor: kev.vendorProject || null,
@@ -534,6 +566,7 @@ module.exports = {
534
566
  discoverNewRfcs,
535
567
  buildKevDraftEntry,
536
568
  getProjectRfcGroups,
569
+ deriveKevSeverity,
537
570
  SEED_RFC_GROUPS,
538
571
  DEFAULT_CAP,
539
572
  };
@@ -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,105 @@ 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
+ * @param {object} manifest
347
+ * @param {string} privateKey PEM-encoded Ed25519 private key
348
+ * @returns {{algorithm:'Ed25519', signature_base64:string, signed_at:string}}
349
+ */
350
+ function signCanonicalManifest(manifest, privateKey) {
351
+ const bytes = canonicalManifestBytes(manifest);
352
+ const sig = crypto.sign(null, bytes, {
353
+ key: privateKey,
354
+ dsaEncoding: 'ieee-p1363',
355
+ });
356
+ return {
357
+ algorithm: 'Ed25519',
358
+ signature_base64: sig.toString('base64'),
359
+ signed_at: new Date().toISOString(),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Audit I P1-3 — tighten Windows ACL on the private key.
365
+ *
366
+ * fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
367
+ * attributes; the file inherits its ACL from the parent. icacls strips
368
+ * inheritance and grants Full Control only to the current user. Any
369
+ * failure (icacls missing, exotic shell, environment without USERNAME)
370
+ * is warned to stderr — generating the key was the load-bearing step,
371
+ * ACL tightening is best-effort hardening.
372
+ *
373
+ * @param {string} targetPath absolute path of the private key file
374
+ */
375
+ function restrictWindowsAcl(targetPath) {
376
+ if (process.platform !== 'win32') return;
377
+ const user = process.env.USERNAME;
378
+ if (!user) {
379
+ console.warn('[sign] WARN: USERNAME env var not set — skipping Windows ACL hardening on ' + targetPath);
380
+ return;
381
+ }
382
+ try {
383
+ execFileSync('icacls', [
384
+ targetPath,
385
+ '/inheritance:r',
386
+ '/grant:r',
387
+ `${user}:F`,
388
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
389
+ } catch (err) {
390
+ console.warn(
391
+ '[sign] WARN: icacls hardening failed on ' + targetPath + ': ' +
392
+ ((err && err.message) || String(err)) +
393
+ ' — the key was written but ACL inheritance was not stripped. ' +
394
+ 'Other desktop users on this machine may be able to read it.'
395
+ );
396
+ }
397
+ }
398
+
247
399
  function printFingerprintBanner() {
248
400
  if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
249
401
  try {
@@ -303,4 +455,13 @@ Signing ceremony (first time):
303
455
  }
304
456
  }
305
457
 
306
- module.exports = { generateKeypair, signAll, signOne, normalize, validateSkillPath };
458
+ module.exports = {
459
+ generateKeypair,
460
+ signAll,
461
+ signOne,
462
+ normalize,
463
+ validateSkillPath,
464
+ canonicalManifestBytes,
465
+ signCanonicalManifest,
466
+ restrictWindowsAcl,
467
+ };
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,20 @@ 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
+ manifest.manifest_signature = {
168
+ algorithm: 'Ed25519',
169
+ signature_base64: manifestSig.toString('base64'),
170
+ signed_at: new Date().toISOString(),
171
+ };
172
+
131
173
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
132
- console.log(`[verify] Signed ${result.signed.length} skills with Ed25519 private key.`);
174
+ console.log(`[verify] Signed ${result.signed.length} skills with Ed25519 private key. Manifest signed.`);
133
175
  return result;
134
176
  }
135
177
 
@@ -244,6 +286,86 @@ function loadManifest() {
244
286
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
245
287
  }
246
288
 
289
+ /**
290
+ * Audit I P1-4 — canonical byte form of the manifest.
291
+ *
292
+ * Mirrors lib/sign.js canonicalManifestBytes(). Any divergence here
293
+ * breaks the verify-after-sign round trip; do not modify in isolation.
294
+ *
295
+ * v0.12.17 (codex P1 PR #12): use deep canonicalize() instead of the
296
+ * top-level sortedKeys replacer-array. The replacer-array form acts as
297
+ * a property allowlist applied to EVERY object level — nested fields
298
+ * like skills[].path and skills[].signature got silently dropped from
299
+ * the canonical bytes, letting an attacker swap them without breaking
300
+ * the signature.
301
+ *
302
+ * @param {object} manifest
303
+ * @returns {Buffer} canonical UTF-8 bytes
304
+ */
305
+ function canonicalize(value) {
306
+ if (Array.isArray(value)) return value.map(canonicalize);
307
+ if (value && typeof value === 'object') {
308
+ const out = {};
309
+ for (const key of Object.keys(value).sort()) {
310
+ out[key] = canonicalize(value[key]);
311
+ }
312
+ return out;
313
+ }
314
+ return value;
315
+ }
316
+
317
+ function canonicalManifestBytes(manifest) {
318
+ const clone = { ...manifest };
319
+ delete clone.manifest_signature;
320
+ const json = JSON.stringify(canonicalize(clone), null, 2);
321
+ return Buffer.from(normalize(json), 'utf8');
322
+ }
323
+
324
+ /**
325
+ * Verify the top-level manifest_signature against keys/public.pem.
326
+ *
327
+ * Returns one of:
328
+ * { status: 'missing' } — field absent (legacy tarball; warn-but-proceed)
329
+ * { status: 'valid' } — signature verifies
330
+ * { status: 'invalid', — signature malformed, wrong key, or tampered
331
+ * reason: string }
332
+ * { status: 'no-key', — keys/public.pem absent
333
+ * reason: string }
334
+ *
335
+ * @param {object} manifest
336
+ */
337
+ function verifyManifestSignature(manifest) {
338
+ const sig = manifest && manifest.manifest_signature;
339
+ if (!sig || typeof sig !== 'object') return { status: 'missing' };
340
+ if (typeof sig.signature_base64 !== 'string') {
341
+ return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
342
+ }
343
+ if (sig.algorithm && sig.algorithm !== 'Ed25519') {
344
+ return { status: 'invalid', reason: `unsupported manifest_signature.algorithm: ${sig.algorithm}` };
345
+ }
346
+ const publicKey = loadPublicKey();
347
+ if (!publicKey) {
348
+ return { status: 'no-key', reason: 'public key missing at keys/public.pem' };
349
+ }
350
+ let signatureBytes;
351
+ try {
352
+ signatureBytes = Buffer.from(sig.signature_base64, 'base64');
353
+ } catch (e) {
354
+ return { status: 'invalid', reason: `malformed base64 in manifest_signature: ${e.message}` };
355
+ }
356
+ const bytes = canonicalManifestBytes(manifest);
357
+ let ok = false;
358
+ try {
359
+ ok = crypto.verify(null, bytes, {
360
+ key: publicKey,
361
+ dsaEncoding: 'ieee-p1363',
362
+ }, signatureBytes);
363
+ } catch (e) {
364
+ return { status: 'invalid', reason: `crypto.verify threw: ${e.message}` };
365
+ }
366
+ 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' };
367
+ }
368
+
247
369
  /**
248
370
  * Load the manifest and validate it against
249
371
  * lib/schemas/manifest.schema.json + the path-traversal guard.
@@ -252,6 +374,14 @@ function loadManifest() {
252
374
  * is a fatal-class bug — surface it loudly rather than verify-against-
253
375
  * a-corrupt-manifest.
254
376
  *
377
+ * Audit I P1-4: also verifies the top-level manifest_signature. On
378
+ * invalid signature, throws a structured error blocking all skill
379
+ * verification (a coordinated attacker who rewrote manifest.json +
380
+ * manifest-snapshot.json + manifest-snapshot.sha256 still cannot forge
381
+ * the Ed25519 signature without the private key). When the signature
382
+ * field is absent, emits a stderr warning but proceeds — preserves
383
+ * backward compatibility for v0.12.16-and-earlier tarballs.
384
+ *
255
385
  * @returns {object}
256
386
  */
257
387
  function loadManifestValidated() {
@@ -269,6 +399,21 @@ function loadManifestValidated() {
269
399
  for (const skill of manifest.skills) {
270
400
  validateSkillPath(skill.path);
271
401
  }
402
+ // Audit I P1-4 — manifest signature gate. Runs after schema + path
403
+ // validation so a malformed manifest reports the structural failure
404
+ // before the cryptographic one.
405
+ const sigResult = verifyManifestSignature(manifest);
406
+ if (sigResult.status === 'invalid') {
407
+ 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.`);
408
+ }
409
+ if (sigResult.status === 'missing') {
410
+ console.warn('[verify] WARN: 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.');
411
+ } else if (sigResult.status === 'no-key') {
412
+ // Surfaced separately so the warning matches the missing-key path
413
+ // that verifyAll() already handles — don't fail here, the verifyAll
414
+ // entry point will emit a no_key result of its own.
415
+ console.warn(`[verify] WARN: cannot verify manifest_signature — ${sigResult.reason}.`);
416
+ }
272
417
  return manifest;
273
418
  }
274
419
 
@@ -554,5 +699,7 @@ module.exports = {
554
699
  validateAgainstSchema,
555
700
  publicKeyFingerprint,
556
701
  checkExpectedFingerprint,
702
+ canonicalManifestBytes,
703
+ verifyManifestSignature,
557
704
  EXPECTED_FINGERPRINT_PATH,
558
705
  };
@@ -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