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
This commit is contained in:
davida-ps
2026-02-12 17:49:34 +01:00
committed by GitHub
parent 331219eec3
commit 5ee8587b1e
24 changed files with 2970 additions and 153 deletions
+217 -10
View File
@@ -26,7 +26,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -174,10 +174,21 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Generate test signing key for dry-run
run: |
set -euo pipefail
echo "Generating temporary Ed25519 test key for dry-run validation"
umask 077
mkdir -p /tmp/test-signing
# Use Ed25519 to match production signing (not RSA)
openssl genpkey -algorithm ED25519 -out /tmp/test-signing/private.pem
openssl pkey -in /tmp/test-signing/private.pem -pubout -out /tmp/test-signing/public.pem
echo "TEST_SIGNING_KEY_DIR=/tmp/test-signing" >> $GITHUB_ENV
- name: Run release dry-run for changed skills
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -185,6 +196,83 @@ jobs:
run: |
set -euo pipefail
# Helper function to sign advisory artifacts with test key (dry-run only)
sign_advisory_artifacts() {
local skill_dir="$1"
local advisory_dir="${skill_dir}/advisories"
if [ ! -d "$advisory_dir" ] || [ ! -f "$advisory_dir/feed.json" ]; then
return 0
fi
echo " [Dry-run] Signing advisory artifacts with test key"
local key_file="$TEST_SIGNING_KEY_DIR/private.pem"
local pub_file="$TEST_SIGNING_KEY_DIR/public.pem"
local tmp_sig_bin
# Sign feed.json with Ed25519 (requires -rawin flag)
openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/feed.json" | \
openssl base64 -A > "$advisory_dir/feed.json.sig"
# Verify Ed25519 feed.json signature (requires -rawin flag)
tmp_sig_bin=$(mktemp)
openssl base64 -d -A -in "$advisory_dir/feed.json.sig" -out "$tmp_sig_bin"
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/feed.json" >/dev/null 2>&1; then
echo "::error file=${skill_dir}/advisories/feed.json.sig::Feed signature verification failed after signing"
rm -f "$tmp_sig_bin"
return 1
fi
rm -f "$tmp_sig_bin"
echo " [Dry-run] Verified feed.json signature"
# Generate checksums.json
local feed_sha=$(sha256sum "$advisory_dir/feed.json" | awk '{print $1}')
local feed_size=$(stat -c%s "$advisory_dir/feed.json" 2>/dev/null || stat -f%z "$advisory_dir/feed.json")
local feed_sig_sha=$(sha256sum "$advisory_dir/feed.json.sig" | awk '{print $1}')
local feed_sig_size=$(stat -c%s "$advisory_dir/feed.json.sig" 2>/dev/null || stat -f%z "$advisory_dir/feed.json.sig")
jq -n \
--arg schema_version "1" \
--arg algorithm "sha256" \
--arg version "test" \
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg feed_sha "$feed_sha" \
--argjson feed_size "$feed_size" \
--arg feed_sig_sha "$feed_sig_sha" \
--argjson feed_sig_size "$feed_sig_size" \
'{
schema_version: $schema_version,
algorithm: $algorithm,
version: $version,
generated_at: $generated,
files: {
"advisories/feed.json": {sha256: $feed_sha, size: $feed_size},
"advisories/feed.json.sig": {sha256: $feed_sig_sha, size: $feed_sig_size}
}
}' > "$advisory_dir/checksums.json"
# Sign checksums.json with Ed25519 (requires -rawin flag)
openssl pkeyutl -sign -rawin -inkey "$key_file" -in "$advisory_dir/checksums.json" | \
openssl base64 -A > "$advisory_dir/checksums.json.sig"
# Verify Ed25519 checksums.json signature (requires -rawin flag)
tmp_sig_bin=$(mktemp)
openssl base64 -d -A -in "$advisory_dir/checksums.json.sig" -out "$tmp_sig_bin"
if ! openssl pkeyutl -verify -rawin -pubin -inkey "$pub_file" -sigfile "$tmp_sig_bin" -in "$advisory_dir/checksums.json" >/dev/null 2>&1; then
echo "::error file=${skill_dir}/advisories/checksums.json.sig::Checksums signature verification failed after signing"
rm -f "$tmp_sig_bin"
return 1
fi
rm -f "$tmp_sig_bin"
echo " [Dry-run] Verified checksums.json signature"
# Copy public key
cp "$pub_file" "$advisory_dir/feed-signing-public.pem"
echo " [Dry-run] Advisory artifacts signed and verified with test key"
}
get_md_version() {
local md_file="$1"
awk '
@@ -263,6 +351,13 @@ jobs:
out_assets="${out_root}/release-assets"
mkdir -p "${out_assets}"
# --- Sign advisory artifacts if present (dry-run with test key) ---
if ! sign_advisory_artifacts "${skill_dir}"; then
failures=$((failures + 1))
echo "::endgroup::"
continue
fi
# --- Stage SBOM files preserving directory structure ---
staging_dir="$(mktemp -d)"
inner_dir="${staging_dir}/${skill_name}"
@@ -284,10 +379,28 @@ jobs:
cp "${json_path}" "${inner_dir}/skill.json"
# --- Remove test-only artifacts from staging (don't include in release zip) ---
# The test signatures/keys were needed for SBOM validation but shouldn't ship
if [ -d "${inner_dir}/advisories" ]; then
rm -f "${inner_dir}/advisories/feed.json.sig"
rm -f "${inner_dir}/advisories/checksums.json"
rm -f "${inner_dir}/advisories/checksums.json.sig"
rm -f "${inner_dir}/advisories/feed-signing-public.pem"
echo " [Dry-run] Removed test signatures from release staging"
fi
# --- Create zip preserving directory structure ---
zip_name="${skill_name}-v${version}.zip"
(cd "${staging_dir}" && zip -qr "${OLDPWD}/${out_assets}/${zip_name}" .)
# --- Clean up test artifacts from source directory ---
if [ -d "${skill_dir}/advisories" ]; then
rm -f "${skill_dir}/advisories/feed.json.sig"
rm -f "${skill_dir}/advisories/checksums.json"
rm -f "${skill_dir}/advisories/checksums.json.sig"
rm -f "${skill_dir}/advisories/feed-signing-public.pem"
fi
# --- Generate checksums.json via jq ---
files_json="{}"
while IFS= read -r file; do
@@ -402,7 +515,7 @@ jobs:
echo "Parsed tag: skill=${SKILL_NAME}, version=${VERSION}"
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate skill exists
run: |
@@ -472,10 +585,79 @@ jobs:
echo "publishable=${PUBLISHABLE}" >> $GITHUB_OUTPUT
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 20
- name: Sign embedded advisory feed and verify
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json
signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed.json.sig
public_key_output: skills/${{ steps.parse.outputs.skill_name }}/advisories/feed-signing-public.pem
- name: Generate embedded advisory checksums manifest
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
run: |
set -euo pipefail
ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
FEED_SHA=$(sha256sum "$ADVISORY_DIR/feed.json" | awk '{print $1}')
FEED_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json")
FEED_SIG_SHA=$(sha256sum "$ADVISORY_DIR/feed.json.sig" | awk '{print $1}')
FEED_SIG_SIZE=$(stat -c%s "$ADVISORY_DIR/feed.json.sig" 2>/dev/null || stat -f%z "$ADVISORY_DIR/feed.json.sig")
jq -n \
--arg schema_version "1" \
--arg algorithm "sha256" \
--arg version "${{ steps.parse.outputs.version }}" \
--arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg repo "${{ github.repository }}" \
--arg feed_sha "$FEED_SHA" \
--argjson feed_size "$FEED_SIZE" \
--arg feed_sig_sha "$FEED_SIG_SHA" \
--argjson feed_sig_size "$FEED_SIG_SIZE" \
'{
schema_version: $schema_version,
algorithm: $algorithm,
version: $version,
generated_at: $generated,
repository: $repo,
files: {
"advisories/feed.json": {
sha256: $feed_sha,
size: $feed_size,
path: "advisories/feed.json"
},
"advisories/feed.json.sig": {
sha256: $feed_sig_sha,
size: $feed_sig_size,
path: "advisories/feed.json.sig"
}
}
}' > "$ADVISORY_DIR/checksums.json"
echo "Generated $ADVISORY_DIR/checksums.json"
jq . "$ADVISORY_DIR/checksums.json"
- name: Sign embedded advisory checksums and verify
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json
signature_file: skills/${{ steps.parse.outputs.skill_name }}/advisories/checksums.json.sig
- name: Show embedded advisory signing outputs
if: hashFiles(format('skills/{0}/advisories/feed.json', steps.parse.outputs.skill_name)) != ''
run: |
ADVISORY_DIR="${{ steps.parse.outputs.skill_path }}/advisories"
echo "Successfully signed embedded advisory artifacts:"
ls -la "$ADVISORY_DIR"
- name: Install clawhub CLI
if: steps.publishable.outputs.publishable == 'true'
run: npm install -g clawhub
@@ -597,6 +779,20 @@ jobs:
echo "=== Release assets ==="
ls -la release-assets/
- name: Sign checksums and verify
uses: ./.github/actions/sign-and-verify
with:
private_key: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY }}
private_key_passphrase: ${{ secrets.CLAWSEC_SIGNING_PRIVATE_KEY_PASSPHRASE }}
input_file: release-assets/checksums.json
signature_file: release-assets/checksums.sig
public_key_output: release-assets/signing-public.pem
- name: Show signed release assets
run: |
echo "Signed and verified release-assets/checksums.json"
ls -la release-assets/
- name: Publish to ClawHub
if: steps.publishable.outputs.publishable == 'true' && env.CLAWHUB_TOKEN != ''
run: |
@@ -620,7 +816,7 @@ jobs:
--no-input
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: "${{ steps.parse.outputs.skill_name }} ${{ steps.parse.outputs.version }}"
tag_name: ${{ github.ref_name }}
@@ -637,22 +833,33 @@ jobs:
**Manual download with verification:**
```bash
# 1. Download the release archive and checksums
# 1. Download the release archive, checksums, and signing material
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
# 2. Verify archive checksum
# 2. Verify the checksums manifest signature (Ed25519)
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
# 3. Verify archive checksum from the signed manifest
echo "$(jq -r '.archive.sha256' checksums.json) ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip" | sha256sum -c
# 3. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
# 4. Extract (creates ${{ steps.parse.outputs.skill_name }}/ directory)
unzip ${{ steps.parse.outputs.skill_name }}-v${{ steps.parse.outputs.version }}.zip
```
### Verification
All files include SHA256 checksums in `checksums.json`:
`checksums.json` is cryptographically signed (`checksums.sig`) using the ClawSec CI signing key.
Verify the signature first, then trust hashes from `checksums.json`:
```bash
curl -sL https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json | jq .
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.json
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.sig
curl -sLO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/signing-public.pem
openssl base64 -d -A -in checksums.sig -out checksums.sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey signing-public.pem -sigfile checksums.sig.bin -in checksums.json
```
### Files