mirror of
https://github.com/prompt-security/clawsec.git
synced 2026-06-24 10:51:22 +03:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user