@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.
- package/CHANGELOG.md +174 -31
- package/README.md +1 -1
- package/bin/exceptd.js +378 -50
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +26 -5
- package/data/playbooks/containers.json +23 -4
- package/data/playbooks/cred-stores.json +18 -3
- package/data/playbooks/crypto-codebase.json +18 -3
- package/data/playbooks/crypto.json +12 -2
- package/data/playbooks/framework.json +15 -3
- package/data/playbooks/hardening.json +21 -4
- package/data/playbooks/kernel.json +10 -2
- package/data/playbooks/mcp.json +15 -3
- package/data/playbooks/runtime.json +17 -3
- package/data/playbooks/sbom.json +16 -3
- package/data/playbooks/secrets.json +30 -5
- package/lib/auto-discovery.js +96 -10
- package/lib/playbook-runner.js +188 -32
- package/lib/prefetch.js +62 -15
- package/lib/refresh-external.js +27 -0
- package/lib/refresh-network.js +91 -3
- package/lib/schemas/playbook.schema.json +7 -1
- package/lib/sign.js +171 -2
- package/lib/verify.js +171 -2
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +44 -40
- package/orchestrator/scheduler.js +10 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/predeploy.js +5 -0
- package/scripts/verify-shipped-tarball.js +89 -0
package/lib/refresh-network.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
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
|
};
|
package/manifest-snapshot.json
CHANGED
|
@@ -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-
|
|
3
|
+
"_generated_at": "2026-05-14T18:49:21.239Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|
package/manifest-snapshot.sha256
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
7e10f4b0b1c6cfa096e35ca28cdd8a95c19e51537cdd3e22628aecb87c72db1b manifest-snapshot.json
|