Skip to content

fix(signing)!: Ed25519-only registry signing verified against the pinned key - #48

Open
Patel230 wants to merge 6 commits into
mainfrom
fix/registry-signing
Open

Patel230 wants to merge 6 commits into
mainfrom
fix/registry-signing

Conversation

@Patel230

Copy link
Copy Markdown
Contributor

Summary

Makes the published skill registry's signature mean something. The rolling registry-latest release currently carries an hmac-sha256 signature made with the literal dev-fallback-key from the workflow file, so anyone can forge it. This PR:

  • signs registry.json with Ed25519 only (HMAC and SKILLS_SIGNING_KEY removed from tools/sign_manifest.py);
  • commits the pinned public key as keys/registry-ed25519.pub (the key Rho pins, spec §1.10);
  • makes publish-registry.yml fail when SKILLS_ED25519_PRIVATE_KEY is unset, and verify its own signature with the committed public key before uploading;
  • splits the workflow into a secret-free read-only build job and a sign-and-publish job that installs only a hash-locked cryptography stack; pins every action to a commit SHA; adds a non-cancelling concurrency group; validates with the zero-warning budget;
  • documents the §6 registry-signature contract in docs/REGISTRY.md.

It also carries the owner's uncommitted work-in-progress fix to the workflow (first commit, unchanged).

Merge note: the SKILLS_ED25519_PRIVATE_KEY secret already exists on this repo (checked with gh api .../actions/secrets: the only secret listed). Merging touches tools/**, so the publish workflow runs on merge and replaces the forgeable asset. If the secret does not correspond to keys/registry-ed25519.pub, the new verify step fails the run before anything is uploaded; that is intended.

Findings addressed

  • F101 (critical): forgeable published signature. Reproduced: the published registry-signature.json is hmac-sha256, its sha256 matches the published bytes, and HMAC-SHA256('dev-fallback-key', sha256) equals the published signature. Fixed with Ed25519-only signing, the committed pinned key, fail-if-unset, and verify-before-upload. The new verify --signature-file rejects the currently published asset (algorithm must be 'ed25519', got 'hmac-sha256').
  • F248 (high): unpinned actions and pip dependencies in the only job with the key and contents: write. Actions are SHA-pinned; dependencies install from tools/requirements.lock / tools/requirements-sign.lock with --require-hashes --no-deps; the key-holding job installs only cryptography/cffi/pycparser; pip install --upgrade pip is gone; persist-credentials: false. CI now also runs pip-audit on both lock files.
  • F109 (publish-registry part): SHA pins, concurrency: {group: publish-registry, cancel-in-progress: false}, --warning-budget, and path triggers for keys/**, manifest-schema.toml and the workflow file. (The ci.yml github.event.before guard is in the companion docs/tooling PR.)
  • F115 (part): docs/REGISTRY.md said the registry goes "to the CDN"; it goes to the registry-latest GitHub release.

Not reproduced / deferred

  • The signature format is now final for Rho (RH-extensions verifies it). Rho must ship the pinned key before, or together with, the first Ed25519-signed registry it relies on. Old Rho builds do not check signatures at all, so nothing breaks for them.

Verification

Run on this branch (macOS, Python 3.14 venv; CI uses 3.11):

Command Result
make lint pass (All checks passed!)
bash scripts/check-consumer-boundaries.sh pass
python -m compileall -q categories pass
python tools/validate_skill.py --all --warning-budget tools/validation_warning_budget.json pass: 14011/14011, budget matches
python tools/update_registry.py && python tools/update_registry.py --check pass (14011 indexed, current)
python tools/check_version_sync.py pass
python tools/sync_marketplace.py --check pass
python tools/check_references.py pass
python tools/check_self_contained.py pass
python tools/check_secrets.py --strict pass
python tools/check_shell_commands.py --strict pass
python -m pytest tests/ -q --cov --cov-fail-under=88 pass: 417 passed, coverage 89.85%
make package-check pass
actionlint 1.7.7 .github/workflows/publish-registry.yml clean. The two SC2086 infos it reports on ci.yml are pre-existing.
pip-audit --require-hashes -r tools/requirements.lock -r tools/requirements-sign.lock No known vulnerabilities found
pip install --require-hashes --no-deps -r tools/requirements.lock in a clean Python 3.11 venv installs; pip check clean; validate_skill.py runs

Signing simulation with a throwaway keypair (never the real key): the unset-secret branch exits 1 with the ::error:: message. sign registry.json --ed25519 emits {target: "registry.json", algorithm: "ed25519", sha256 (64 hex, equal to shasum -a 256), signature (128 hex)}. verify --signature-file ... --key keys/registry-ed25519.pub fails for the throwaway signature (as it must) and passes with the throwaway public key. OpenSSL 3.6 pkeyutl -verify -rawin independently verifies the signature over the 64 ASCII hex bytes.

New tests: tests/test_sign_manifest.py covers the contract: ASCII-hex message, tamper cases, pinned-key value, and no private key in keys/. tests/test_workflows.py covers SHA-pinned actions in every workflow, secret scoping, fail-if-unset, the step order sign < verify < upload < publish, hash-locked installs, and lock-file integrity.

Follow-ups

  • Rho (RH-extensions) pins the same key and verifies registry-signature.json fail-closed; both sides test with throwaway keys.
  • After merge, confirm that the first Publish Skill Registry run on main passes the verify step, and check the new asset: gh release download registry-latest -p registry-signature.json, then jq .algorithm should print "ed25519".
  • Consider enabling GitHub private vulnerability reporting (currently disabled) and Dependabot for tools/*.lock.

🤖 Generated with Claude Code

Patel230 and others added 6 commits September 27, 2026 03:34
publish-registry.yml fell back to the literal 'dev-fallback-key' when no
signing secret was configured, so the published registry-signature.json
was an HMAC anyone could recompute from the public workflow file.
Remove the fallback so an unconfigured secret fails the job instead of
shipping a meaningless signature.

This is the repository owner's in-progress change, committed as-is; the
follow-up commits make Ed25519 the only scheme.

Refs: F101
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c key

Remove the shared-secret HMAC-SHA256 scheme and SKILLS_SIGNING_KEY from
tools/sign_manifest.py: anyone able to verify an HMAC signature could also
forge one, so it gave consumers no integrity guarantee. Ed25519 is now the
only scheme; `--ed25519` stays accepted as a no-op because it is the
documented publish command.

Commit the pinned public key as keys/registry-ed25519.pub (the key Rho
pins; the private half lives only in the SKILLS_ED25519_PRIVATE_KEY
Actions secret). Add `verify --signature-file`, which implements the
verifier side of the registry contract: algorithm must be ed25519, target
must match, sha256 must equal the digest of the exact bytes, and the
signature must verify over the 64-byte ASCII hex digest. `verify` falls
back to the committed key when no key is supplied. The signature JSON's
`target` is now the file's base name.

Tests use throwaway keypairs only and pin the committed key's value so a
key change cannot slip past Rho's pinned copy.

BREAKING CHANGE: `sign`/`verify` no longer accept HMAC secrets or
SKILLS_SIGNING_KEY; published signatures use algorithm "ed25519".

Refs: F101
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/requirements.txt lists pyyaml, rich and cryptography with no
versions or hashes, and publish-registry.yml installed it (after an
unpinned `pip install --upgrade pip`) in the same job that received the
signing key. A compromised PyPI release could have signed a malicious
registry or exfiltrated the key.

Add uv-generated, hash-locked install sets (Python 3.11, manylinux):
- tools/requirements.lock: the full tools/requirements.txt closure for
  the build job (validate + generate registry).
- tools/requirements-sign.lock (from requirements-sign.in): only
  cryptography, cffi and pycparser, for the job that holds the key.

Both install with `pip install --require-hashes --no-deps`; verified in a
clean Python 3.11 venv (pip check clean, validate_skill.py runs).

Refs: F248
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Harden publish-registry.yml, the only workflow with contents:write and
the signing secret:

- Ed25519 only: the HMAC branch and SKILLS_SIGNING_KEY are gone, and the
  sign step fails with an error when SKILLS_ED25519_PRIVATE_KEY is unset.
- Before anything is uploaded, verify registry-signature.json with the
  committed keys/registry-ed25519.pub (algorithm, target, sha256 and
  signature), so a wrong or rotated secret fails the run instead of
  breaking every Rho client.
- Split into a secret-free, read-only build job (validate + generate)
  and a sign-and-publish job that installs only the hash-locked
  cryptography stack; both use `pip install --require-hashes --no-deps`.
- Pin checkout, setup-python, upload-artifact and download-artifact to
  commit SHAs (the same SHAs the other workflows use), set
  persist-credentials: false, and drop `pip install --upgrade pip`.
- Add a non-cancelling `publish-registry` concurrency group so two quick
  merges cannot publish a registry/signature pair from different commits.
- Validate with the zero-warning budget like ci.yml, and trigger on
  keys/**, manifest-schema.toml and the workflow file too.

tests/test_workflows.py pins these properties (SHA-pinned actions in every
workflow, step order sign < verify < upload < publish, secret scoping, and
exact-pin + hash coverage of both lock files). actionlint 1.7.7 is clean
for this workflow.

Refs: F101, F109, F248
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The registry publish job now installs tools/requirements.lock and
tools/requirements-sign.lock; audit those exact, hash-pinned versions in
the required CI job too (locally: "No known vulnerabilities found").

Refs: F248
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/REGISTRY.md said the registry is published "to the CDN"; it is
published to the rolling `registry-latest` GitHub release. Document the
two asset URLs, the pinned public key, the exact registry-signature.json
format (message = the 64 ASCII bytes of the hex SHA-256), the fail-closed
verification steps clients must follow, how to verify with this repo's
tool or plain OpenSSL 3 (commands tested with a throwaway key), and key
rotation ordering with Rho's pinned copy. Record the change in the
CHANGELOG Security section, including that previously published
hmac-sha256 signatures must not be trusted.

Refs: F101, F115
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant