@blamejs/exceptd-skills 0.12.10 → 0.12.13

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/lib/verify.js CHANGED
@@ -8,6 +8,28 @@
8
8
  * specific keypair signed each skill. Even if the manifest is updated, a valid
9
9
  * signature requires the private key, which never enters this repository.
10
10
  *
11
+ * Byte-stability contract (must mirror lib/sign.js):
12
+ * Skill content is normalized BEFORE the signature is verified:
13
+ * 1. Strip a UTF-8 BOM (U+FEFF) if present.
14
+ * 2. Convert CRLF line endings to LF.
15
+ * The same normalization runs in lib/sign.js. A skill file checked
16
+ * out with core.autocrlf=true on Windows therefore verifies against
17
+ * a signature produced on Linux CI (LF). ANY change to normalize()
18
+ * requires the matching change in lib/sign.js — round-trip stability
19
+ * is a hard contract. The v0.11.x signature regression (operators
20
+ * ran `exceptd doctor --signatures` and saw 0/38) was a single
21
+ * instance of this contract drifting; do not relax it.
22
+ *
23
+ * Manifest entries are validated through validateSkillPath() before
24
+ * any file is read. A tampered manifest with `path: "../../../etc/passwd"`
25
+ * cannot escape the skills/ tree. The whole manifest is rejected on
26
+ * the first traversal attempt.
27
+ *
28
+ * The manifest object itself is validated against
29
+ * lib/schemas/manifest.schema.json before any skill is touched.
30
+ * additionalProperties=false at the skill level catches typos and
31
+ * unknown fields that would otherwise silently be dropped.
32
+ *
11
33
  * Signing ceremony: see lib/sign.js
12
34
  * Public key: keys/public.pem (tracked in repo)
13
35
  * Private key: .keys/private.pem (gitignored, kept off-repo)
@@ -28,6 +50,7 @@ const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
28
50
  const SKILLS_DIR = path.join(ROOT, 'skills');
29
51
  const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
30
52
  const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
53
+ const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
31
54
 
32
55
  // --- public API ---
33
56
 
@@ -42,7 +65,7 @@ function verifyAll() {
42
65
  return { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: true };
43
66
  }
44
67
 
45
- const manifest = loadManifest();
68
+ const manifest = loadManifestValidated();
46
69
  const result = { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: false };
47
70
 
48
71
  for (const skill of manifest.skills) {
@@ -65,7 +88,7 @@ function verifyOne(skillName) {
65
88
  const publicKey = loadPublicKey();
66
89
  if (!publicKey) throw new Error('No public key at keys/public.pem');
67
90
 
68
- const manifest = loadManifest();
91
+ const manifest = loadManifestValidated();
69
92
  const skill = manifest.skills.find(s => s.name === skillName);
70
93
  if (!skill) throw new Error(`Skill not in manifest: ${skillName}`);
71
94
 
@@ -81,7 +104,7 @@ function signAll() {
81
104
  const privateKey = loadPrivateKey();
82
105
  if (!privateKey) throw new Error('No private key at .keys/private.pem — run: node lib/sign.js generate-keypair');
83
106
 
84
- const manifest = loadManifest();
107
+ const manifest = loadManifestValidated();
85
108
  const result = { signed: [], errors: [] };
86
109
 
87
110
  for (const skill of manifest.skills) {
@@ -104,6 +127,56 @@ function signAll() {
104
127
 
105
128
  // --- private helpers ---
106
129
 
130
+ /**
131
+ * Normalize skill content for byte-stable verification.
132
+ *
133
+ * Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
134
+ * line endings to LF. lib/sign.js applies the exact same transform —
135
+ * see the byte-stability contract in the file header.
136
+ *
137
+ * @param {string} content
138
+ * @returns {string}
139
+ */
140
+ function normalize(content) {
141
+ let s = content;
142
+ if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
143
+ return s.replace(/\r\n/g, '\n');
144
+ }
145
+
146
+ /**
147
+ * Validate a manifest skill.path entry to prevent path traversal.
148
+ *
149
+ * skill.path MUST be a string.
150
+ * skill.path MUST start with "skills/".
151
+ * skill.path MUST NOT contain "..".
152
+ * skill.path MUST NOT contain backslashes.
153
+ *
154
+ * Same shape as lib/sign.js validateSkillPath(); the two functions
155
+ * are intentionally duplicated rather than cross-imported so the
156
+ * verify path has no runtime dependency on the sign path.
157
+ *
158
+ * @param {string} skillPath
159
+ * @returns {string}
160
+ */
161
+ function validateSkillPath(skillPath) {
162
+ if (typeof skillPath !== 'string') {
163
+ throw new Error(`[verify] manifest skill.path must be a string, got ${typeof skillPath}`);
164
+ }
165
+ // Backslash check runs BEFORE the prefix check so a Windows-style
166
+ // path ("skills\foo\skill.md") returns the clearer "use forward
167
+ // slashes" diagnostic, not the misleading "must start with skills/".
168
+ if (skillPath.includes('\\')) {
169
+ throw new Error(`[verify] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
170
+ }
171
+ if (!skillPath.startsWith('skills/')) {
172
+ throw new Error(`[verify] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
173
+ }
174
+ if (skillPath.includes('..')) {
175
+ throw new Error(`[verify] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
176
+ }
177
+ return skillPath;
178
+ }
179
+
107
180
  function verifySkill(skill, publicKey) {
108
181
  if (!skill.signature) {
109
182
  return { status: 'missing_sig', reason: 'No Ed25519 signature in manifest — run: node lib/sign.js sign-all' };
@@ -128,7 +201,8 @@ function verifySkill(skill, publicKey) {
128
201
  }
129
202
 
130
203
  function sign(content, privateKey) {
131
- const signature = crypto.sign(null, Buffer.from(content, 'utf8'), {
204
+ const normalized = normalize(content);
205
+ const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
132
206
  key: privateKey,
133
207
  dsaEncoding: 'ieee-p1363'
134
208
  });
@@ -138,7 +212,8 @@ function sign(content, privateKey) {
138
212
  function verify(content, signatureBase64, publicKey) {
139
213
  try {
140
214
  const signature = Buffer.from(signatureBase64, 'base64');
141
- return crypto.verify(null, Buffer.from(content, 'utf8'), {
215
+ const normalized = normalize(content);
216
+ return crypto.verify(null, Buffer.from(normalized, 'utf8'), {
142
217
  key: publicKey,
143
218
  dsaEncoding: 'ieee-p1363'
144
219
  }, signature);
@@ -161,6 +236,136 @@ function loadManifest() {
161
236
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
162
237
  }
163
238
 
239
+ /**
240
+ * Load the manifest and validate it against
241
+ * lib/schemas/manifest.schema.json + the path-traversal guard.
242
+ *
243
+ * Throws on schema violation OR traversal-pattern paths. Either case
244
+ * is a fatal-class bug — surface it loudly rather than verify-against-
245
+ * a-corrupt-manifest.
246
+ *
247
+ * @returns {object}
248
+ */
249
+ function loadManifestValidated() {
250
+ const manifest = loadManifest();
251
+ const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
252
+ const errors = validateAgainstSchema(manifest, schema, 'manifest');
253
+ if (errors.length > 0) {
254
+ const detail = errors.slice(0, 10).map(e => ' - ' + e).join('\n');
255
+ const more = errors.length > 10 ? `\n ...and ${errors.length - 10} more` : '';
256
+ throw new Error(`[verify] manifest.json failed schema validation:\n${detail}${more}`);
257
+ }
258
+ if (!Array.isArray(manifest.skills)) {
259
+ throw new Error('[verify] manifest.json: skills must be an array');
260
+ }
261
+ for (const skill of manifest.skills) {
262
+ validateSkillPath(skill.path);
263
+ }
264
+ return manifest;
265
+ }
266
+
267
+ // --- JSON schema validator (subset) ---
268
+ //
269
+ // Mirrors lib/validate-cve-catalog.js's inline validator. Supports the
270
+ // schema features manifest.schema.json actually uses: type, required,
271
+ // properties, additionalProperties, items, pattern, minLength,
272
+ // minItems, $defs / $ref (root-relative only — "#/$defs/foo"). Zero
273
+ // external deps.
274
+
275
+ function typeOf(value) {
276
+ if (value === null) return 'null';
277
+ if (Array.isArray(value)) return 'array';
278
+ return typeof value;
279
+ }
280
+
281
+ function typeMatches(value, expected) {
282
+ if (Array.isArray(expected)) return expected.some(t => typeMatches(value, t));
283
+ const actual = typeOf(value);
284
+ if (expected === 'integer') return actual === 'number' && Number.isInteger(value);
285
+ return actual === expected;
286
+ }
287
+
288
+ function resolveRef(ref, root) {
289
+ if (!ref.startsWith('#/')) {
290
+ throw new Error(`[verify] unsupported $ref form (must be root-relative): ${ref}`);
291
+ }
292
+ const parts = ref.slice(2).split('/');
293
+ let cur = root;
294
+ for (const p of parts) {
295
+ if (cur === undefined || cur === null) {
296
+ throw new Error(`[verify] cannot resolve $ref ${ref}`);
297
+ }
298
+ cur = cur[p];
299
+ }
300
+ if (cur === undefined) {
301
+ throw new Error(`[verify] $ref ${ref} did not resolve`);
302
+ }
303
+ return cur;
304
+ }
305
+
306
+ function validateAgainstSchema(value, schema, here, root) {
307
+ const rootSchema = root || schema;
308
+ const errors = [];
309
+ let effectiveSchema = schema;
310
+ if (schema && schema.$ref) {
311
+ effectiveSchema = resolveRef(schema.$ref, rootSchema);
312
+ }
313
+
314
+ if (effectiveSchema.type !== undefined) {
315
+ if (!typeMatches(value, effectiveSchema.type)) {
316
+ errors.push(`${here}: expected type ${JSON.stringify(effectiveSchema.type)}, got ${typeOf(value)}`);
317
+ return errors;
318
+ }
319
+ }
320
+
321
+ const t = typeOf(value);
322
+
323
+ if (t === 'string') {
324
+ if (effectiveSchema.minLength !== undefined && value.length < effectiveSchema.minLength) {
325
+ errors.push(`${here}: string shorter than minLength ${effectiveSchema.minLength}`);
326
+ }
327
+ if (effectiveSchema.pattern !== undefined) {
328
+ const re = new RegExp(effectiveSchema.pattern);
329
+ if (!re.test(value)) {
330
+ errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${effectiveSchema.pattern}/`);
331
+ }
332
+ }
333
+ if (effectiveSchema.format === 'uri') {
334
+ try { new URL(value); } catch { errors.push(`${here}: not a valid URI`); }
335
+ }
336
+ }
337
+
338
+ if (t === 'array') {
339
+ if (effectiveSchema.minItems !== undefined && value.length < effectiveSchema.minItems) {
340
+ errors.push(`${here}: array shorter than minItems ${effectiveSchema.minItems}`);
341
+ }
342
+ if (effectiveSchema.items !== undefined) {
343
+ value.forEach((item, idx) => {
344
+ errors.push(...validateAgainstSchema(item, effectiveSchema.items, `${here}[${idx}]`, rootSchema));
345
+ });
346
+ }
347
+ }
348
+
349
+ if (t === 'object') {
350
+ if (effectiveSchema.required) {
351
+ for (const req of effectiveSchema.required) {
352
+ if (!(req in value)) errors.push(`${here}: missing required field "${req}"`);
353
+ }
354
+ }
355
+ const props = effectiveSchema.properties || {};
356
+ const allowAdditional = effectiveSchema.additionalProperties !== false;
357
+ for (const [k, v] of Object.entries(value)) {
358
+ if (k in props) {
359
+ errors.push(...validateAgainstSchema(v, props[k], `${here}.${k}`, rootSchema));
360
+ } else if (!allowAdditional) {
361
+ errors.push(`${here}: unexpected property "${k}"`);
362
+ }
363
+ }
364
+ }
365
+
366
+ return errors;
367
+ }
368
+
164
369
  /**
165
370
  * Public key fingerprint(s) of the DER-encoded SPKI public key,
166
371
  * base64-encoded. Emits both:
@@ -238,24 +443,44 @@ if (require.main === module) {
238
443
  if (result.no_key) process.exit(1);
239
444
 
240
445
  const total = Object.values(result).filter(Array.isArray).flat().length;
241
- // Compute + print the public key fingerprints so operators can pin
242
- // the key out-of-band. Without this, a swapped keys/public.pem
243
- // would still produce a "verified" message undetectable from the
244
- // exit code alone. Dual fingerprint (SHA-256 + SHA3-512) gives
245
- // ssh-keygen compatibility AND a SHA-3 family diversity hedge.
446
+ // S5 ordering: verdict line first, fingerprint banner after.
447
+ // An operator scanning `gh run watch` output should never see a
448
+ // fingerprint banner without first seeing whether the verdict
449
+ // was pass or fail. The previous order printed the success
450
+ // summary then the fingerprint; if verification was actually
451
+ // failing (TAMPERED / UNSIGNED / MISSING) the success line was
452
+ // never reached but the fingerprint had already been printed,
453
+ // which can read as "success" at a glance.
454
+ if (result.invalid.length > 0) {
455
+ console.error(`\n[verify] ${result.invalid.length}/${total} FAILED — TAMPERED: ${result.invalid.join(', ')}`);
456
+ } else if (result.missing_sig.length > 0) {
457
+ console.warn(`\n[verify] ${result.missing_sig.length}/${total} UNSIGNED: ${result.missing_sig.join(', ')}`);
458
+ } else if (result.missing_file.length > 0) {
459
+ console.error(`\n[verify] ${result.missing_file.length}/${total} MISSING: ${result.missing_file.join(', ')}`);
460
+ } else {
461
+ console.log(`\n[verify] All skills verified. ${result.valid.length}/${total} skills passed Ed25519 verification.`);
462
+ }
463
+
464
+ // Fingerprint banner comes AFTER the verdict.
246
465
  const pubKey = loadPublicKey();
247
466
  const fp = publicKeyFingerprint(pubKey);
248
- console.log(`\n[verify] ${result.valid.length}/${total} skills passed Ed25519 verification.`);
249
467
  console.log(`[verify] Public key: keys/public.pem`);
250
468
  console.log(`[verify] ${fp.sha256}`);
251
469
  console.log(`[verify] ${fp.sha3_512}`);
252
470
 
253
- if (result.invalid.length > 0) { console.error('[verify] TAMPERED:', result.invalid.join(', ')); process.exit(1); }
254
- if (result.missing_sig.length > 0) { console.warn('[verify] UNSIGNED:', result.missing_sig.join(', ')); process.exit(1); }
255
- if (result.missing_file.length > 0) { console.error('[verify] MISSING:', result.missing_file.join(', ')); process.exit(1); }
471
+ if (result.invalid.length > 0) process.exit(1);
472
+ if (result.missing_sig.length > 0) process.exit(1);
473
+ if (result.missing_file.length > 0) process.exit(1);
256
474
 
257
- console.log('[verify] All skills verified.');
258
475
  process.exit(0);
259
476
  }
260
477
 
261
- module.exports = { verifyAll, verifyOne, signAll };
478
+ module.exports = {
479
+ verifyAll,
480
+ verifyOne,
481
+ signAll,
482
+ normalize,
483
+ validateSkillPath,
484
+ loadManifestValidated,
485
+ validateAgainstSchema,
486
+ };
@@ -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-13T17:30:20.755Z",
3
+ "_generated_at": "2026-05-14T14:28:31.798Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [