Files
clawsec/skills/clawsec-suite/test/feed_verification.test.mjs
T
davida-ps 5ee8587b1e Integration/signing work (#20)
* ci: sign advisory feed and checksums in workflows

* feat(clawsec-suite): add verifier-side signature and checksum enforcement

Implements cryptographic verification for advisory feed loading:

- Ed25519 detached signature verification for feed.json
- Supports raw base64 and JSON-wrapped signature formats
- Pinned public key at advisories/feed-signing-public.pem

- SHA-256 checksum manifest (checksums.json) verification
- Signed checksums.json.sig prevents partial artifact substitution
- Verifies feed.json, feed.json.sig, and public key against manifest

- Remote feed: returns null on verification failure (triggers fallback)
- Local feed: throws on verification failure (hard fail)
- No silent bypass of verification

- CLAWSEC_ALLOW_UNSIGNED_FEED=1 temporarily bypasses verification
- Warning logged when bypass mode is enabled
- Intended for transition period only

- guarded_skill_install without --version matches any advisory for skill
- Encourages explicit version specification

- scripts/sign_detached_ed25519.mjs - signing utility
- scripts/verify_detached_ed25519.mjs - verification utility
- scripts/generate_checksums_json.mjs - checksum manifest generator
- test/feed_verification.test.mjs - 14 verification tests
- test/guarded_install.test.mjs - 6 install flow tests

- hooks/.../lib/feed.mjs - full rewrite with verification
- hooks/.../handler.ts - verification options integration
- scripts/guarded_skill_install.mjs - verification integration
- skill.json - v0.0.9, new SBOM entries, openssl requirement
- SKILL.md - signed install flow, env vars documentation
- HOOK.md - new environment variables
- ci.yml - added verification test job

Refs: fail-closed verification, Ed25519 signatures, checksum manifests

* fix: update action versions in CI workflows for improved stability

* chore(clawsec-suite): bump version to 0.0.10

* feat: enhance security measures in asset deployment and add changelog for version history

* feat: add dry-run signing for advisory artifacts and generate checksums

* fix: enhance error handling in loadRemoteFeed for security policy violations

* feat: implement Ed25519 signing and verification for advisory artifacts and checksums

* feat: implement signing and verification for advisory artifacts and checksums in workflows

* feat: update dry-run signing key generation to use Ed25519 algorithm

* feat: update Ed25519 signing and verification to use -rawin flag for compatibility

* feat: add public key copying to advisory directory and implement safe basename extraction for URLs

* feat: remove Product Hunt promotion section from README and Home page
2026-02-12 18:49:34 +02:00

569 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Feed verification tests for clawsec-suite.
*
* Tests cover:
* - Signature verification success/failure/tampered cases
* - Checksum manifest verification success/failure/tampered cases
* - Fail-closed behavior when signatures are missing/invalid
* - Temporary compatibility flag behavior
*
* Run: node skills/clawsec-suite/test/feed_verification.test.mjs
*/
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");
// Dynamic import to ensure we test the actual module
const { verifySignedPayload, loadLocalFeed, isValidFeedPayload } = await import(
`${LIB_PATH}/feed.mjs`
);
let tempDir;
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount++;
console.log(`${name}`);
}
function fail(name, error) {
failCount++;
console.error(`${name}`);
console.error(` ${String(error)}`);
}
function generateEd25519KeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
return { publicKeyPem, privateKeyPem };
}
function signPayload(data, privateKeyPem) {
const privateKey = crypto.createPrivateKey(privateKeyPem);
const signature = crypto.sign(null, Buffer.from(data, "utf8"), privateKey);
return signature.toString("base64");
}
function createValidFeed() {
return JSON.stringify(
{
version: "1.0.0",
updated: "2026-02-08T12:00:00Z",
advisories: [
{
id: "TEST-001",
severity: "high",
affected: ["test-skill@1.0.0"],
},
],
},
null,
2,
);
}
function createChecksumManifest(files) {
const checksums = {};
for (const [name, content] of Object.entries(files)) {
checksums[name] = crypto.createHash("sha256").update(content).digest("hex");
}
return JSON.stringify(
{
schema_version: "1.0",
algorithm: "sha256",
files: checksums,
},
null,
2,
);
}
async function setupTestDir() {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawsec-test-"));
}
async function cleanupTestDir() {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
// -----------------------------------------------------------------------------
// Test: verifySignedPayload - valid signature
// -----------------------------------------------------------------------------
async function testVerifySignedPayload_ValidSignature() {
const testName = "verifySignedPayload: valid signature passes";
try {
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
const payload = "test payload content";
const signature = signPayload(payload, privateKeyPem);
const result = verifySignedPayload(payload, signature, publicKeyPem);
if (result === true) {
pass(testName);
} else {
fail(testName, "Expected true, got false");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: verifySignedPayload - invalid signature
// -----------------------------------------------------------------------------
async function testVerifySignedPayload_InvalidSignature() {
const testName = "verifySignedPayload: invalid signature fails";
try {
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
const payload = "test payload content";
const signature = signPayload(payload, privateKeyPem);
// Tamper with payload
const tamperedPayload = "TAMPERED payload content";
const result = verifySignedPayload(tamperedPayload, signature, publicKeyPem);
if (result === false) {
pass(testName);
} else {
fail(testName, "Expected false for tampered payload, got true");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: verifySignedPayload - wrong key
// -----------------------------------------------------------------------------
async function testVerifySignedPayload_WrongKey() {
const testName = "verifySignedPayload: wrong key fails";
try {
const keyPair1 = generateEd25519KeyPair();
const keyPair2 = generateEd25519KeyPair();
const payload = "test payload content";
const signature = signPayload(payload, keyPair1.privateKeyPem);
// Verify with different public key
const result = verifySignedPayload(payload, signature, keyPair2.publicKeyPem);
if (result === false) {
pass(testName);
} else {
fail(testName, "Expected false for wrong key, got true");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: verifySignedPayload - malformed signature
// -----------------------------------------------------------------------------
async function testVerifySignedPayload_MalformedSignature() {
const testName = "verifySignedPayload: malformed signature fails";
try {
const { publicKeyPem } = generateEd25519KeyPair();
const payload = "test payload content";
const result = verifySignedPayload(payload, "not-valid-base64!!!", publicKeyPem);
if (result === false) {
pass(testName);
} else {
fail(testName, "Expected false for malformed signature, got true");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: verifySignedPayload - empty signature
// -----------------------------------------------------------------------------
async function testVerifySignedPayload_EmptySignature() {
const testName = "verifySignedPayload: empty signature fails";
try {
const { publicKeyPem } = generateEd25519KeyPair();
const payload = "test payload content";
const result = verifySignedPayload(payload, "", publicKeyPem);
if (result === false) {
pass(testName);
} else {
fail(testName, "Expected false for empty signature, got true");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: verifySignedPayload - JSON-wrapped signature format
// -----------------------------------------------------------------------------
async function testVerifySignedPayload_JsonWrappedSignature() {
const testName = "verifySignedPayload: JSON-wrapped signature passes";
try {
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
const payload = "test payload content";
const signatureBase64 = signPayload(payload, privateKeyPem);
const jsonWrapped = JSON.stringify({ signature: signatureBase64 });
const result = verifySignedPayload(payload, jsonWrapped, publicKeyPem);
if (result === true) {
pass(testName);
} else {
fail(testName, "Expected true for JSON-wrapped signature, got false");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: loadLocalFeed - valid signed feed
// -----------------------------------------------------------------------------
async function testLoadLocalFeed_ValidSignedFeed() {
const testName = "loadLocalFeed: valid signed feed loads successfully";
try {
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
const feedContent = createValidFeed();
const feedSignature = signPayload(feedContent, privateKeyPem);
// Create checksum manifest
const checksumManifest = createChecksumManifest({
"feed.json": feedContent,
"feed.json.sig": feedSignature + "\n",
"feed-signing-public.pem": publicKeyPem,
});
const checksumSignature = signPayload(checksumManifest, privateKeyPem);
// Write files
const feedPath = path.join(tempDir, "feed.json");
const sigPath = path.join(tempDir, "feed.json.sig");
const checksumPath = path.join(tempDir, "checksums.json");
const checksumSigPath = path.join(tempDir, "checksums.json.sig");
const keyPath = path.join(tempDir, "feed-signing-public.pem");
await fs.writeFile(feedPath, feedContent);
await fs.writeFile(sigPath, feedSignature + "\n");
await fs.writeFile(checksumPath, checksumManifest);
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
await fs.writeFile(keyPath, publicKeyPem);
const feed = await loadLocalFeed(feedPath, {
signaturePath: sigPath,
checksumsPath: checksumPath,
checksumsSignaturePath: checksumSigPath,
publicKeyPem,
verifyChecksumManifest: true,
checksumPublicKeyEntry: "feed-signing-public.pem",
});
if (feed && feed.version === "1.0.0" && feed.advisories.length === 1) {
pass(testName);
} else {
fail(testName, "Feed did not load with expected content");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: loadLocalFeed - tampered feed fails (fail-closed)
// -----------------------------------------------------------------------------
async function testLoadLocalFeed_TamperedFeedFails() {
const testName = "loadLocalFeed: tampered feed fails (fail-closed)";
try {
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
const feedContent = createValidFeed();
const feedSignature = signPayload(feedContent, privateKeyPem);
// Tamper with feed after signing
const tamperedFeed = feedContent.replace("TEST-001", "TAMPERED-001");
const feedPath = path.join(tempDir, "tampered-feed.json");
const sigPath = path.join(tempDir, "tampered-feed.json.sig");
await fs.writeFile(feedPath, tamperedFeed);
await fs.writeFile(sigPath, feedSignature + "\n");
let didFail = false;
try {
await loadLocalFeed(feedPath, {
signaturePath: sigPath,
publicKeyPem,
verifyChecksumManifest: false,
});
} catch {
didFail = true;
}
if (didFail) {
pass(testName);
} else {
fail(testName, "Expected failure for tampered feed, but it loaded");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: loadLocalFeed - missing signature fails (fail-closed)
// -----------------------------------------------------------------------------
async function testLoadLocalFeed_MissingSignatureFails() {
const testName = "loadLocalFeed: missing signature fails (fail-closed)";
try {
const { publicKeyPem } = generateEd25519KeyPair();
const feedContent = createValidFeed();
const feedPath = path.join(tempDir, "nosig-feed.json");
const sigPath = path.join(tempDir, "nosig-feed.json.sig");
await fs.writeFile(feedPath, feedContent);
// Don't write signature file
let didFail = false;
try {
await loadLocalFeed(feedPath, {
signaturePath: sigPath,
publicKeyPem,
verifyChecksumManifest: false,
});
} catch {
didFail = true;
}
if (didFail) {
pass(testName);
} else {
fail(testName, "Expected failure for missing signature, but it loaded");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: loadLocalFeed - allowUnsigned bypasses verification
// -----------------------------------------------------------------------------
async function testLoadLocalFeed_AllowUnsignedBypasses() {
const testName = "loadLocalFeed: allowUnsigned=true bypasses verification";
try {
const feedContent = createValidFeed();
const feedPath = path.join(tempDir, "unsigned-feed.json");
await fs.writeFile(feedPath, feedContent);
const feed = await loadLocalFeed(feedPath, {
allowUnsigned: true,
verifyChecksumManifest: false,
});
if (feed && feed.version === "1.0.0") {
pass(testName);
} else {
fail(testName, "Feed did not load with allowUnsigned=true");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: loadLocalFeed - checksum mismatch fails
// -----------------------------------------------------------------------------
async function testLoadLocalFeed_ChecksumMismatchFails() {
const testName = "loadLocalFeed: checksum mismatch fails";
try {
const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair();
const feedContent = createValidFeed();
const feedSignature = signPayload(feedContent, privateKeyPem);
// Create checksum manifest with WRONG hash
const badChecksumManifest = JSON.stringify(
{
schema_version: "1.0",
algorithm: "sha256",
files: {
"feed.json": "0".repeat(64), // Wrong hash
"feed.json.sig":
crypto.createHash("sha256").update(feedSignature + "\n").digest("hex"),
},
},
null,
2,
);
const checksumSignature = signPayload(badChecksumManifest, privateKeyPem);
const feedPath = path.join(tempDir, "badcs-feed.json");
const sigPath = path.join(tempDir, "badcs-feed.json.sig");
const checksumPath = path.join(tempDir, "badcs-checksums.json");
const checksumSigPath = path.join(tempDir, "badcs-checksums.json.sig");
await fs.writeFile(feedPath, feedContent);
await fs.writeFile(sigPath, feedSignature + "\n");
await fs.writeFile(checksumPath, badChecksumManifest);
await fs.writeFile(checksumSigPath, checksumSignature + "\n");
let didFail = false;
try {
await loadLocalFeed(feedPath, {
signaturePath: sigPath,
checksumsPath: checksumPath,
checksumsSignaturePath: checksumSigPath,
publicKeyPem,
verifyChecksumManifest: true,
});
} catch {
didFail = true;
}
if (didFail) {
pass(testName);
} else {
fail(testName, "Expected failure for checksum mismatch, but it loaded");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: isValidFeedPayload - valid feed
// -----------------------------------------------------------------------------
async function testIsValidFeedPayload_Valid() {
const testName = "isValidFeedPayload: valid feed passes";
try {
const feed = {
version: "1.0.0",
advisories: [
{
id: "TEST-001",
severity: "high",
affected: ["test-skill@1.0.0"],
},
],
};
if (isValidFeedPayload(feed)) {
pass(testName);
} else {
fail(testName, "Expected valid feed to pass validation");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: isValidFeedPayload - missing version fails
// -----------------------------------------------------------------------------
async function testIsValidFeedPayload_MissingVersion() {
const testName = "isValidFeedPayload: missing version fails";
try {
const feed = {
advisories: [
{
id: "TEST-001",
severity: "high",
affected: [],
},
],
};
if (!isValidFeedPayload(feed)) {
pass(testName);
} else {
fail(testName, "Expected feed without version to fail validation");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Test: isValidFeedPayload - advisory missing id fails
// -----------------------------------------------------------------------------
async function testIsValidFeedPayload_AdvisoryMissingId() {
const testName = "isValidFeedPayload: advisory missing id fails";
try {
const feed = {
version: "1.0.0",
advisories: [
{
severity: "high",
affected: [],
},
],
};
if (!isValidFeedPayload(feed)) {
pass(testName);
} else {
fail(testName, "Expected advisory without id to fail validation");
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
async function runTests() {
console.log("=== ClawSec Feed Verification Tests ===\n");
await setupTestDir();
try {
// Signature verification tests
await testVerifySignedPayload_ValidSignature();
await testVerifySignedPayload_InvalidSignature();
await testVerifySignedPayload_WrongKey();
await testVerifySignedPayload_MalformedSignature();
await testVerifySignedPayload_EmptySignature();
await testVerifySignedPayload_JsonWrappedSignature();
// Local feed loading tests
await testLoadLocalFeed_ValidSignedFeed();
await testLoadLocalFeed_TamperedFeedFails();
await testLoadLocalFeed_MissingSignatureFails();
await testLoadLocalFeed_AllowUnsignedBypasses();
await testLoadLocalFeed_ChecksumMismatchFails();
// Feed payload validation tests
await testIsValidFeedPayload_Valid();
await testIsValidFeedPayload_MissingVersion();
await testIsValidFeedPayload_AdvisoryMissingId();
} finally {
await cleanupTestDir();
}
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runTests().catch((error) => {
console.error("Test runner failed:", error);
process.exit(1);
});