From a19afd4e5e8198318fe59affe6f26a84a9541cae Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:34:10 +0530 Subject: [PATCH 1/6] fix(ci): drop the forgeable dev-fallback registry signing key 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 --- .github/workflows/publish-registry.yml | 14 ++++++++++---- CHANGELOG.md | 9 +++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml index 478694a2c..45229e6c8 100644 --- a/.github/workflows/publish-registry.yml +++ b/.github/workflows/publish-registry.yml @@ -40,11 +40,17 @@ jobs: - name: Sign registry manifest # Ed25519 (asymmetric) is preferred when the SKILLS_ED25519_PRIVATE_KEY # secret is configured (generate once with: python tools/sign_manifest.py - # keygen). Pin the matching public key for verifiers. Until that secret - # exists, fall back to the legacy shared-secret HMAC scheme so CI keeps - # working — migration follow-up: make Ed25519 mandatory. + # keygen). Pin the matching public key for verifiers. + # + # There is deliberately NO fallback key. This step previously fell back + # to the literal 'dev-fallback-key', which published a + # registry-signature.json that looked authoritative but was forgeable by + # anyone who read this file. sign_manifest.py already exits non-zero + # when no key is supplied, so omitting the fallback makes an unconfigured + # secret fail the release loudly instead of shipping a meaningless + # signature. env: - SIGNING_KEY: ${{ secrets.SKILLS_SIGNING_KEY || 'dev-fallback-key' }} + SIGNING_KEY: ${{ secrets.SKILLS_SIGNING_KEY }} SKILLS_ED25519_PRIVATE_KEY: ${{ secrets.SKILLS_ED25519_PRIVATE_KEY }} run: | if [ -n "$SKILLS_ED25519_PRIVATE_KEY" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ee932bf..148905092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - CI/CD workflows for PR checks - 14,015+ community skill packages across 27 categories +### Security +- `publish-registry.yml` no longer falls back to the literal `dev-fallback-key` + when `SKILLS_ED25519_PRIVATE_KEY` / `SKILLS_SIGNING_KEY` are unconfigured. It + previously published a `registry-signature.json` that looked authoritative but + was forgeable by anyone who could read the workflow. `tools/sign_manifest.py` + already exited non-zero without a key, so removing the fallback makes an + unconfigured secret fail the release loudly instead of shipping a meaningless + signature. + ## [0.1.0] - 2026-05-26 ### Changed From b4db29da1c69d2c329f35529716c20f8d5c64c47 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:37:25 +0530 Subject: [PATCH 2/6] feat(signing)!: sign the registry with Ed25519 only and pin the public 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 --- keys/registry-ed25519.pub | 3 + tests/test_sign_manifest.py | 385 +++++++++++++++++++++++++----------- tools/sign_manifest.py | 245 +++++++++++++++-------- 3 files changed, 439 insertions(+), 194 deletions(-) create mode 100644 keys/registry-ed25519.pub diff --git a/keys/registry-ed25519.pub b/keys/registry-ed25519.pub new file mode 100644 index 000000000..f2cc1146d --- /dev/null +++ b/keys/registry-ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAr9I2NG1Sih9Mu04/eOA8FmJhczSLBYiXeLAl1rqusQU= +-----END PUBLIC KEY----- diff --git a/tests/test_sign_manifest.py b/tests/test_sign_manifest.py index 18219110d..a47cb3dfd 100644 --- a/tests/test_sign_manifest.py +++ b/tests/test_sign_manifest.py @@ -1,9 +1,12 @@ -"""Tests for tools/sign_manifest.py.""" +"""Tests for tools/sign_manifest.py (Ed25519-only registry signing). + +Every test uses a throwaway keypair generated in-process; the real private key +only exists as the SKILLS_ED25519_PRIVATE_KEY GitHub Actions secret. +""" from __future__ import annotations import hashlib -import hmac import json import sys from pathlib import Path @@ -12,25 +15,32 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools")) +import sign_manifest from sign_manifest import ( + PINNED_PUBLIC_KEY_PATH, + build_signature_document, compute_content_hash, generate_ed25519_keypair, load_key_material, resolve_key, - sign_hash, sign_hash_ed25519, - verify_hash, + target_digest, verify_hash_ed25519, + verify_signature_document, write_ed25519_keypair, ) -TOOLS_DIR = Path(__file__).resolve().parent.parent / "tools" +# The public key Rho pins (graycode-eco spec, 2026-09-27). If this changes, Rho's +# pinned copy must change in the same release, so the test fails loudly. +EXPECTED_PINNED_PUBLIC_KEY = ( + "-----BEGIN PUBLIC KEY-----\n" + "MCowBQYDK2VwAyEAr9I2NG1Sih9Mu04/eOA8FmJhczSLBYiXeLAl1rqusQU=\n" + "-----END PUBLIC KEY-----\n" +) def run_cli(*argv: str): """Invoke sign_manifest's CLI in-process; returns SystemExit code (or None).""" - import sign_manifest - old_argv = sys.argv sys.argv = ["sign_manifest.py", *argv] try: @@ -42,9 +52,26 @@ def run_cli(*argv: str): return None +@pytest.fixture +def keypair(tmp_path): + private_out = tmp_path / "throwaway-private.pem" + public_out = tmp_path / "throwaway-public.pem" + private_pem, public_pem = write_ed25519_keypair(private_out, public_out) + return private_out, public_out, private_pem, public_pem + + +@pytest.fixture(autouse=True) +def _no_key_env(monkeypatch): + for env in ("SKILLS_SIGNING_KEY", "SKILLS_ED25519_PRIVATE_KEY", "SKILLS_ED25519_PUBLIC_KEY"): + monkeypatch.delenv(env, raising=False) + + +# --------------------------------------------------------------------------- +# Hashing +# --------------------------------------------------------------------------- + + def test_compute_content_hash_single_file(tmp_path): - # compute_content_hash operates on a directory; a single-file dir - # must match the manual per-file hash (rel path + content). f = tmp_path / "sample.txt" f.write_text("hello world\n") expected = hashlib.sha256() @@ -54,71 +81,62 @@ def test_compute_content_hash_single_file(tmp_path): def test_compute_content_hash_empty_dir_returns_empty_digest(tmp_path): - # An empty (or nonexistent) directory yields the SHA256 of nothing. assert compute_content_hash(tmp_path) == hashlib.sha256().hexdigest() def test_compute_content_hash_is_deterministic(tmp_path): (tmp_path / "b.txt").write_text("bbb") (tmp_path / "a.txt").write_text("aaa") - # Hash must be deterministic regardless of directory traversal order. first = compute_content_hash(tmp_path) - second = compute_content_hash(tmp_path) - assert first == second - assert len(first) == 64 # SHA-256 hex digest length. - - -def test_sign_and_verify_round_trip(): - secret = "my-secret-key" - digest = "abc123def456" - signature = sign_hash(digest, secret) - assert verify_hash(digest, signature, secret) - assert not verify_hash("tampered", signature, secret) - + assert first == compute_content_hash(tmp_path) + assert len(first) == 64 -def test_verify_wrong_secret_fails(): - digest = "deadbeef" - sig = sign_hash(digest, "secret-a") - assert not verify_hash(digest, sig, "secret-b") - -def test_signature_is_deterministic(): - # Same inputs must produce the same signature (HMAC is deterministic). - sig1 = sign_hash("digest", "key") - sig2 = sign_hash("digest", "key") - assert sig1 == sig2 - # And it must match a manual HMAC computation. - expected = hmac.new(b"key", b"digest", hashlib.sha256).hexdigest() - assert sig1 == expected +def test_target_digest_file_dir_and_missing(tmp_path): + f = tmp_path / "registry.json" + f.write_bytes(b'{"skills": []}') + assert target_digest(f) == hashlib.sha256(b'{"skills": []}').hexdigest() + assert target_digest(tmp_path) == compute_content_hash(tmp_path) + assert target_digest(tmp_path / "missing.json") is None # --------------------------------------------------------------------------- -# Ed25519 (asymmetric) scheme +# Ed25519 primitives # --------------------------------------------------------------------------- def test_ed25519_keygen_returns_pem_pair(): private_pem, public_pem = generate_ed25519_keypair() assert "-----BEGIN PRIVATE KEY-----" in private_pem - assert "-----END PRIVATE KEY-----" in private_pem assert "-----BEGIN PUBLIC KEY-----" in public_pem - assert "-----END PUBLIC KEY-----" in public_pem def test_ed25519_sign_and_verify_round_trip(): private_pem, public_pem = generate_ed25519_keypair() - digest = "abc123def456" - signature = sign_hash_ed25519(digest, private_pem) - assert verify_hash_ed25519(digest, signature, public_pem) + signature = sign_hash_ed25519("abc123def456", private_pem) + assert verify_hash_ed25519("abc123def456", signature, public_pem) assert not verify_hash_ed25519("tampered", signature, public_pem) +def test_signed_message_is_ascii_hex_digest_not_raw_bytes(): + # Contract: the message is the 64 ASCII bytes of the lowercase hex digest. + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import serialization + + private_pem, public_pem = generate_ed25519_keypair() + digest = hashlib.sha256(b"registry bytes").hexdigest() + signature = bytes.fromhex(sign_hash_ed25519(digest, private_pem)) + public_key = serialization.load_pem_public_key(public_pem.encode()) + public_key.verify(signature, digest.encode("ascii")) # raises if wrong + with pytest.raises(InvalidSignature): + public_key.verify(signature, bytes.fromhex(digest)) + assert len(signature) == 64 + + def test_ed25519_wrong_public_key_fails(): private_a, _ = generate_ed25519_keypair() _, public_b = generate_ed25519_keypair() sig = sign_hash_ed25519("deadbeef", private_a) - # A signature from keypair A must not verify against keypair B — unlike - # the legacy shared-secret scheme, verifiers cannot forge signatures. assert not verify_hash_ed25519("deadbeef", sig, public_b) @@ -132,103 +150,212 @@ def test_ed25519_malformed_public_key_fails(): def test_ed25519_sign_rejects_non_pem_private_key(): - # An HMAC-style shared secret is not a valid PEM private key. + # A former HMAC shared secret is not a valid Ed25519 private key. with pytest.raises(ValueError): sign_hash_ed25519("digest", "my-shared-secret") -def test_write_ed25519_keypair_writes_files(tmp_path): - private_out = tmp_path / "signing-private.pem" - public_out = tmp_path / "signing-public.pem" - private_pem, public_pem = write_ed25519_keypair(private_out, public_out) +def test_hmac_scheme_is_gone(): + assert not hasattr(sign_manifest, "sign_hash") + assert not hasattr(sign_manifest, "verify_hash") + assert not hasattr(sign_manifest, "HMAC_KEY_ENV") + + +# --------------------------------------------------------------------------- +# Pinned public key +# --------------------------------------------------------------------------- + + +def test_pinned_public_key_is_committed_and_matches_rho_pin(): + assert PINNED_PUBLIC_KEY_PATH == sign_manifest.REPO_ROOT / "keys" / "registry-ed25519.pub" + assert PINNED_PUBLIC_KEY_PATH.read_text(encoding="utf-8") == EXPECTED_PINNED_PUBLIC_KEY + + +def test_pinned_public_key_is_an_ed25519_public_key(): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + key = serialization.load_pem_public_key(EXPECTED_PINNED_PUBLIC_KEY.encode()) + assert isinstance(key, Ed25519PublicKey) + + +def test_no_private_key_material_is_committed(): + repo = sign_manifest.REPO_ROOT + for path in (repo / "keys").iterdir(): + assert "PRIVATE KEY" not in path.read_text(encoding="utf-8"), path + + +# --------------------------------------------------------------------------- +# Signature documents (the registry contract) +# --------------------------------------------------------------------------- + + +def _signed(tmp_path, private_pem, payload=b'{"version": 1, "skills": []}\n'): + target = tmp_path / "registry.json" + target.write_bytes(payload) + digest = hashlib.sha256(payload).hexdigest() + document = build_signature_document(target, digest, sign_hash_ed25519(digest, private_pem)) + return target, document + + +def test_build_signature_document_shape(tmp_path, keypair): + _, _, private_pem, _ = keypair + target, document = _signed(tmp_path, private_pem) + assert set(document) == {"target", "sha256", "algorithm", "signature"} + assert document["target"] == "registry.json" + assert document["algorithm"] == "ed25519" + assert document["sha256"] == hashlib.sha256(target.read_bytes()).hexdigest() + assert len(bytes.fromhex(document["signature"])) == 64 + + +def test_verify_signature_document_accepts_valid(tmp_path, keypair): + _, _, private_pem, public_pem = keypair + target, document = _signed(tmp_path, private_pem) + assert verify_signature_document(target, document, public_pem) == [] + + +@pytest.mark.parametrize( + "mutate, expected", + [ + (lambda d: d.update(algorithm="hmac-sha256"), "algorithm must be 'ed25519'"), + (lambda d: d.update(target="other.json"), "target must be 'registry.json'"), + (lambda d: d.update(sha256="0" * 64), "sha256 mismatch"), + (lambda d: d.update(sha256="ABC"), "sha256 must be a lowercase"), + (lambda d: d.update(signature=""), "signature must be a non-empty"), + (lambda d: d.update(signature="00" * 64), "does not verify"), + ], +) +def test_verify_signature_document_rejects_tampering(tmp_path, keypair, mutate, expected): + _, _, private_pem, public_pem = keypair + target, document = _signed(tmp_path, private_pem) + mutate(document) + problems = verify_signature_document(target, document, public_pem) + assert any(expected in p for p in problems), problems + + +def test_verify_signature_document_rejects_modified_registry(tmp_path, keypair): + _, _, private_pem, public_pem = keypair + target, document = _signed(tmp_path, private_pem) + target.write_bytes(b'{"version": 1, "skills": [{"name": "evil"}]}\n') + problems = verify_signature_document(target, document, public_pem) + assert any("sha256 mismatch" in p for p in problems) + + +def test_verify_signature_document_rejects_other_signer(tmp_path, keypair): + _, _, private_pem, _ = keypair + _, other_public = generate_ed25519_keypair() + target, document = _signed(tmp_path, private_pem) + problems = verify_signature_document(target, document, other_public) + assert problems == ["Ed25519 signature does not verify with the given public key"] + + +def test_verify_signature_document_rejects_non_object(tmp_path): + assert verify_signature_document(tmp_path, ["x"], "pem") == [ + "signature document must be a JSON object" + ] + + +def test_verify_signature_document_missing_target(tmp_path, keypair): + _, _, private_pem, public_pem = keypair + target, document = _signed(tmp_path, private_pem) + target.unlink() + assert any("does not exist" in p for p in verify_signature_document(target, document, public_pem)) + + +# --------------------------------------------------------------------------- +# Key resolution +# --------------------------------------------------------------------------- + + +def test_write_ed25519_keypair_writes_files(keypair): + private_out, public_out, private_pem, public_pem = keypair assert private_out.read_text(encoding="utf-8") == private_pem assert public_out.read_text(encoding="utf-8") == public_pem - # The private key file must only be readable by its owner. assert (private_out.stat().st_mode & 0o777) == 0o600 - # And the written pair must actually work for sign/verify. - sig = sign_hash_ed25519("digest", private_pem) - assert verify_hash_ed25519("digest", sig, public_pem) + assert verify_hash_ed25519("digest", sign_hash_ed25519("digest", private_pem), public_pem) -def test_load_key_material_accepts_pem_file_path(tmp_path): - _, public_pem = generate_ed25519_keypair() - key_file = tmp_path / "public.pem" - key_file.write_text(public_pem, encoding="utf-8") - # Literal PEM is passed through; a path to a PEM file is read from disk. +def test_load_key_material_accepts_pem_file_path(keypair): + _, public_out, _, public_pem = keypair assert load_key_material(public_pem) == public_pem - assert load_key_material(str(key_file)) == public_pem - # Anything else is returned unchanged (key parsing fails later if invalid). + assert load_key_material(str(public_out)) == public_pem assert load_key_material("raw-secret") == "raw-secret" def test_resolve_key_env_fallback(monkeypatch): monkeypatch.setenv("SKILLS_ED25519_PRIVATE_KEY", "env-value") assert resolve_key(None, "SKILLS_ED25519_PRIVATE_KEY", "test") == "env-value" - # An explicit CLI value wins over the environment. assert resolve_key("cli-value", "SKILLS_ED25519_PRIVATE_KEY", "test") == "cli-value" -def test_resolve_key_missing_exits(monkeypatch): - monkeypatch.delenv("SKILLS_ED25519_PRIVATE_KEY", raising=False) +def test_resolve_key_missing_exits(): with pytest.raises(SystemExit) as excinfo: resolve_key(None, "SKILLS_ED25519_PRIVATE_KEY", "test") assert excinfo.value.code == 2 -def test_cli_ed25519_sign_verify_end_to_end(tmp_path, capsys): - # Full CLI round trip: keygen writes key files, sign uses the private - # key file path, verify accepts the pinned public key file path. - private_out = tmp_path / "signing-private.pem" - public_out = tmp_path / "signing-public.pem" - run_cli("keygen", "--private-out", str(private_out), "--public-out", str(public_out)) - capsys.readouterr() # drain keygen output - assert private_out.read_text(encoding="utf-8").startswith("-----BEGIN PRIVATE KEY-----") - assert public_out.read_text(encoding="utf-8").startswith("-----BEGIN PUBLIC KEY-----") +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def test_cli_sign_verify_end_to_end(tmp_path, capsys, keypair): + private_out, public_out, _, _ = keypair target = tmp_path / "registry.json" target.write_text('{"skills": []}', encoding="utf-8") + # `--ed25519` stays accepted: it is the documented publish command. run_cli("sign", str(target), "--ed25519", "--key", str(private_out)) result = json.loads(capsys.readouterr().out) - assert result["algorithm"] == "ed25519" - assert result["sha256"] == hashlib.sha256(b'{"skills": []}').hexdigest() + assert result == { + "target": "registry.json", + "sha256": hashlib.sha256(b'{"skills": []}').hexdigest(), + "algorithm": "ed25519", + "signature": result["signature"], + } + + run_cli("verify", str(target), "--key", str(public_out), "--signature", result["signature"]) + assert "OK" in capsys.readouterr().out - run_cli("verify", str(target), "--ed25519", "--key", str(public_out), - "--signature", result["signature"]) + sig_file = tmp_path / "registry-signature.json" + sig_file.write_text(json.dumps(result), encoding="utf-8") + code = run_cli("verify", str(target), "--key", str(public_out), "--signature-file", str(sig_file)) + assert code == 0 assert "OK" in capsys.readouterr().out - # A tampered digest must fail verification with a non-zero exit code. target.write_text('{"skills": ["tampered"]}', encoding="utf-8") - code = run_cli("verify", str(target), "--ed25519", "--key", str(public_out), - "--signature", result["signature"]) + code = run_cli("verify", str(target), "--key", str(public_out), "--signature", result["signature"]) assert code == 1 assert "FAIL" in capsys.readouterr().err + code = run_cli("verify", str(target), "--key", str(public_out), "--signature-file", str(sig_file)) + assert code == 1 + assert "sha256 mismatch" in capsys.readouterr().err -def test_cli_hmac_sign_verify_still_works(tmp_path, capsys): - # The legacy shared-secret scheme remains the default and unchanged. +def test_cli_sign_uses_private_key_env(tmp_path, capsys, monkeypatch, keypair): + _, _, private_pem, public_pem = keypair + monkeypatch.setenv("SKILLS_ED25519_PRIVATE_KEY", private_pem) target = tmp_path / "registry.json" target.write_text("{}", encoding="utf-8") - run_cli("sign", str(target), "--key", "dev-fallback-key") - result = json.loads(capsys.readouterr().out) - assert result["algorithm"] == "hmac-sha256" - # Legacy scheme: HMAC over the SHA-256 hex digest of the file contents. - digest = hashlib.sha256(b"{}").hexdigest() - expected = hmac.new(b"dev-fallback-key", digest.encode("utf-8"), hashlib.sha256).hexdigest() - assert result["sha256"] == digest - assert result["signature"] == expected - run_cli("verify", str(target), "--key", "dev-fallback-key", "--signature", expected) - assert "OK" in capsys.readouterr().out + assert run_cli("sign", str(target)) is None + document = json.loads(capsys.readouterr().out) + assert verify_signature_document(target, document, public_pem) == [] -def test_cli_keygen_without_files_prints_json_keypair(capsys): - run_cli("keygen") - captured = capsys.readouterr() - result = json.loads(captured.out) - assert result["algorithm"] == "ed25519" - assert result["private_key"].startswith("-----BEGIN PRIVATE KEY-----") - assert result["public_key"].startswith("-----BEGIN PUBLIC KEY-----") - assert "Never commit the private key" in captured.err +def test_cli_sign_without_key_fails_closed(tmp_path, capsys): + target = tmp_path / "registry.json" + target.write_text("{}", encoding="utf-8") + code = run_cli("sign", str(target), "--ed25519") + assert code == 2 + assert "SKILLS_ED25519_PRIVATE_KEY" in capsys.readouterr().err + + +def test_cli_sign_rejects_legacy_hmac_secret(tmp_path, capsys): + target = tmp_path / "registry.json" + target.write_text("{}", encoding="utf-8") + code = run_cli("sign", str(target), "--key", "dev-fallback-key") + assert code == 2 + assert "invalid Ed25519 private key" in capsys.readouterr().err def test_cli_sign_missing_target_exits_nonzero(tmp_path, capsys): @@ -237,34 +364,60 @@ def test_cli_sign_missing_target_exits_nonzero(tmp_path, capsys): assert "does not exist" in capsys.readouterr().err -def test_cli_verify_hmac_mismatch_exits_nonzero(tmp_path, capsys): - target = tmp_path / "registry.json" - target.write_text("{}", encoding="utf-8") - code = run_cli("verify", str(target), "--key", "k", "--signature", "00" * 32) +def test_cli_verify_defaults_to_pinned_public_key(tmp_path, capsys, monkeypatch, keypair): + # With no --key and no env var, verify uses the committed pinned key; a + # signature from any other key (here: a throwaway key) must be rejected. + _, _, private_pem, _ = keypair + target, document = _signed(tmp_path, private_pem) + sig_file = tmp_path / "registry-signature.json" + sig_file.write_text(json.dumps(document), encoding="utf-8") + code = run_cli("verify", str(target), "--signature-file", str(sig_file)) assert code == 1 - assert "FAIL" in capsys.readouterr().err + assert "does not verify" in capsys.readouterr().err + # Point the pinned path at the throwaway public key to prove it is used. + _, public_out, _, _ = keypair + monkeypatch.setattr(sign_manifest, "PINNED_PUBLIC_KEY_PATH", public_out) + assert run_cli("verify", str(target), "--signature-file", str(sig_file)) == 0 -def test_cli_verify_ed25519_uses_public_key_env(tmp_path, capsys, monkeypatch): - # Verification can be keyed purely from the pinned public key env var. - _, public_pem = generate_ed25519_keypair() + +def test_cli_verify_uses_public_key_env(tmp_path, capsys, monkeypatch, keypair): + _, _, _, public_pem = keypair monkeypatch.setenv("SKILLS_ED25519_PUBLIC_KEY", public_pem) target = tmp_path / "registry.json" target.write_text("{}", encoding="utf-8") - digest = hashlib.sha256(b"{}").hexdigest() - # Sign with a different (wrong) key so verification must fail. wrong_private, _ = generate_ed25519_keypair() - wrong_sig = sign_hash_ed25519(digest, wrong_private) - code = run_cli("verify", str(target), "--ed25519", "--signature", wrong_sig) + wrong_sig = sign_hash_ed25519(hashlib.sha256(b"{}").hexdigest(), wrong_private) + code = run_cli("verify", str(target), "--signature", wrong_sig) assert code == 1 assert "FAIL" in capsys.readouterr().err -def test_cli_missing_key_exits_with_usage_error(tmp_path, capsys, monkeypatch): - for env in ("SKILLS_SIGNING_KEY", "SKILLS_ED25519_PRIVATE_KEY", "SKILLS_ED25519_PUBLIC_KEY"): - monkeypatch.delenv(env, raising=False) +def test_cli_verify_missing_pinned_key_exits_2(tmp_path, capsys, monkeypatch): + monkeypatch.setattr(sign_manifest, "PINNED_PUBLIC_KEY_PATH", tmp_path / "absent.pub") target = tmp_path / "registry.json" target.write_text("{}", encoding="utf-8") - code = run_cli("sign", str(target)) + code = run_cli("verify", str(target), "--signature", "00") assert code == 2 - assert "SKILLS_SIGNING_KEY" in capsys.readouterr().err + assert "no key provided" in capsys.readouterr().err + + +def test_cli_verify_unreadable_signature_file(tmp_path, capsys, keypair): + _, public_out, _, _ = keypair + target = tmp_path / "registry.json" + target.write_text("{}", encoding="utf-8") + bad = tmp_path / "registry-signature.json" + bad.write_text("not json", encoding="utf-8") + code = run_cli("verify", str(target), "--key", str(public_out), "--signature-file", str(bad)) + assert code == 1 + assert "cannot read" in capsys.readouterr().err + + +def test_cli_keygen_without_files_prints_json_keypair(capsys): + run_cli("keygen") + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["algorithm"] == "ed25519" + assert result["private_key"].startswith("-----BEGIN PRIVATE KEY-----") + assert result["public_key"].startswith("-----BEGIN PUBLIC KEY-----") + assert "Never commit the private key" in captured.err diff --git a/tools/sign_manifest.py b/tools/sign_manifest.py index 025800e49..6834c20a2 100755 --- a/tools/sign_manifest.py +++ b/tools/sign_manifest.py @@ -1,26 +1,34 @@ #!/usr/bin/env python3 """ -Sign and verify community skill packages and registry manifests. - -Two signature schemes are supported: - -* HMAC-SHA256 (default, legacy): a symmetric shared-secret signature over the - SHA-256 digest of a skill directory or manifest file. Anyone who holds the - shared secret can forge signatures, so this scheme only proves possession - of the secret, not per-origin provenance. -* Ed25519 (``--ed25519``): an asymmetric signature over the same digest. The - private key signs; verifiers only need the pinned public key and cannot - forge signatures. Generate a keypair with the ``keygen`` subcommand, keep - the private key in CI secrets, and commit or otherwise pin the public key - for verifiers. - -Key material may be passed via ``--key`` (literal PEM for Ed25519, secret -string for HMAC, or a path to a PEM file for Ed25519) or through the -``SKILLS_SIGNING_KEY`` (HMAC), ``SKILLS_ED25519_PRIVATE_KEY`` (Ed25519 sign) -and ``SKILLS_ED25519_PUBLIC_KEY`` (Ed25519 verify) environment variables. - -Ed25519 requires the optional ``cryptography`` package; the legacy HMAC path -uses only the standard library. +Sign and verify the skill registry (and skill directories) with Ed25519. + +Ed25519 is the only supported scheme. The private key signs; verifiers only +need the pinned public key and cannot forge signatures. The public key that +verifiers pin is committed at ``keys/registry-ed25519.pub``; the matching +private key lives only in the ``SKILLS_ED25519_PRIVATE_KEY`` GitHub Actions +secret. (The former shared-secret HMAC-SHA256 scheme was removed: anyone who +could verify an HMAC signature could also forge one.) + +Registry signature contract (produced by publish-registry.yml, verified by +Rho before it trusts the index): + +* ``sha256`` is the lowercase hex SHA-256 digest of the exact bytes of the + signed file. +* The signed message is the 64 ASCII bytes of that hex digest (not the raw + 32-byte digest). +* ``signature`` is the hex-encoded 64-byte Ed25519 signature. +* ``sign`` prints ``{"target", "sha256", "algorithm": "ed25519", "signature"}`` + as JSON; ``target`` is the signed file's base name (``registry.json``). + +A verifier must require ``algorithm == "ed25519"``, recompute the digest of the +downloaded bytes and compare it with ``sha256``, then verify ``signature`` over +the hex digest with the pinned public key. Any failure means the registry must +not be used. + +Key material may be passed via ``--key`` (literal PEM or a path to a PEM +file) or through ``SKILLS_ED25519_PRIVATE_KEY`` (sign) and +``SKILLS_ED25519_PUBLIC_KEY`` (verify). ``verify`` falls back to the committed +public key when neither is given. Requires the ``cryptography`` package. """ from __future__ import annotations @@ -28,17 +36,22 @@ import argparse import contextlib import hashlib -import hmac import json import os +import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent -HMAC_KEY_ENV = "SKILLS_SIGNING_KEY" +ALGORITHM = "ed25519" ED25519_PRIVATE_KEY_ENV = "SKILLS_ED25519_PRIVATE_KEY" ED25519_PUBLIC_KEY_ENV = "SKILLS_ED25519_PUBLIC_KEY" +# The public key Rho pins. Committed so anyone can verify a published registry. +PINNED_PUBLIC_KEY_PATH = REPO_ROOT / "keys" / "registry-ed25519.pub" + +_HEX_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") + def compute_content_hash(skill_dir: Path) -> str: """Compute deterministic SHA256 digest of all files in a skill directory.""" @@ -50,17 +63,21 @@ def compute_content_hash(skill_dir: Path) -> str: hasher.update(file_path.read_bytes()) return hasher.hexdigest() -def sign_hash(digest: str, secret: str) -> str: - """Sign a SHA256 digest with a secret key using HMAC-SHA256.""" - return hmac.new(secret.encode("utf-8"), digest.encode("utf-8"), hashlib.sha256).hexdigest() -def verify_hash(digest: str, signature: str, secret: str) -> bool: - """Verify an HMAC-SHA256 signature for a given digest.""" - expected = sign_hash(digest, secret) - return hmac.compare_digest(expected, signature) +def target_digest(target: Path) -> str | None: + """Return the hex SHA-256 digest of a file's bytes or a skill directory. + + Returns None when the target does not exist. + """ + if target.is_dir(): + return compute_content_hash(target) + if target.is_file(): + return hashlib.sha256(target.read_bytes()).hexdigest() + return None + def load_key_material(value: str) -> str: - """Resolve a --key value that is either literal PEM/key text or a path to a file.""" + """Resolve a --key value that is either literal PEM text or a path to a PEM file.""" if "-----BEGIN" in value: return value path = Path(value) @@ -68,6 +85,7 @@ def load_key_material(value: str) -> str: return path.read_text(encoding="utf-8") return value + def generate_ed25519_keypair() -> tuple[str, str]: """Generate an Ed25519 keypair and return (private_pem, public_pem). @@ -89,8 +107,9 @@ def generate_ed25519_keypair() -> tuple[str, str]: ).decode("utf-8") return private_pem, public_pem + def sign_hash_ed25519(digest: str, private_key_pem: str) -> str: - """Sign a SHA256 digest with an Ed25519 private key; returns hex-encoded signature.""" + """Sign a hex digest (as ASCII bytes) with an Ed25519 private key; returns hex.""" from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -99,6 +118,7 @@ def sign_hash_ed25519(digest: str, private_key_pem: str) -> str: raise ValueError("key is not an Ed25519 private key") return key.sign(digest.encode("utf-8")).hex() + def verify_hash_ed25519(digest: str, signature: str, public_key_pem: str) -> bool: """Verify a hex-encoded Ed25519 signature for a digest against a public key.""" from cryptography.exceptions import InvalidSignature @@ -114,6 +134,52 @@ def verify_hash_ed25519(digest: str, signature: str, public_key_pem: str) -> boo except (InvalidSignature, ValueError, TypeError): return False + +def build_signature_document(target: Path, digest: str, signature: str) -> dict[str, str]: + """Return the signature document published next to the signed target.""" + return { + "target": target.resolve().name, + "sha256": digest, + "algorithm": ALGORITHM, + "signature": signature, + } + + +def verify_signature_document(target: Path, document: object, public_key_pem: str) -> list[str]: + """Check a signature document against the target bytes and a public key. + + Implements the verifier side of the registry signature contract. Returns + a list of human-readable problems; an empty list means the signature is + valid for exactly these bytes. + """ + if not isinstance(document, dict): + return ["signature document must be a JSON object"] + problems: list[str] = [] + algorithm = document.get("algorithm") + if algorithm != ALGORITHM: + problems.append(f"algorithm must be {ALGORITHM!r}, got {algorithm!r}") + expected_target = target.resolve().name + if document.get("target") != expected_target: + problems.append( + f"target must be {expected_target!r}, got {document.get('target')!r}" + ) + claimed = document.get("sha256") + if not isinstance(claimed, str) or not _HEX_DIGEST_RE.match(claimed): + problems.append("sha256 must be a lowercase 64-character hex digest") + claimed = None + actual = target_digest(target) + if actual is None: + problems.append(f"{target} does not exist") + elif claimed is not None and claimed != actual: + problems.append(f"sha256 mismatch: document says {claimed}, file hashes to {actual}") + signature = document.get("signature") + if not isinstance(signature, str) or not signature: + problems.append("signature must be a non-empty hex string") + elif actual is not None and not verify_hash_ed25519(actual, signature, public_key_pem): + problems.append("Ed25519 signature does not verify with the given public key") + return problems + + def write_ed25519_keypair(private_out: Path | None, public_out: Path | None) -> tuple[str, str]: """Generate an Ed25519 keypair and optionally write the PEMs to files. @@ -129,6 +195,7 @@ def write_ed25519_keypair(private_out: Path | None, public_out: Path | None) -> public_out.write_text(public_pem, encoding="utf-8") return private_pem, public_pem + def resolve_key(cli_value: str | None, env_name: str, purpose: str) -> str: """Fall back to an environment variable when --key is omitted.""" value = cli_value or os.environ.get(env_name) @@ -137,11 +204,37 @@ def resolve_key(cli_value: str | None, env_name: str, purpose: str) -> str: sys.exit(2) return value + +def resolve_public_key(cli_value: str | None) -> str: + """Return the verification key: --key, then the env var, then the pinned key.""" + value = cli_value or os.environ.get(ED25519_PUBLIC_KEY_ENV) + if value: + return load_key_material(value) + try: + return PINNED_PUBLIC_KEY_PATH.read_text(encoding="utf-8") + except OSError: + print( + f"Error: no key provided for Ed25519 verification; pass --key, set " + f"{ED25519_PUBLIC_KEY_ENV}, or restore {PINNED_PUBLIC_KEY_PATH.name}", + file=sys.stderr, + ) + sys.exit(2) + + +def _require_digest(target: Path) -> str: + digest = target_digest(target) + if digest is None: + print(f"Error: {target} does not exist", file=sys.stderr) + sys.exit(1) + return digest + + def main() -> None: - parser = argparse.ArgumentParser(description="Sign or verify Graycode community skills.") + parser = argparse.ArgumentParser( + description="Sign or verify GrayCode Skills registry manifests with Ed25519." + ) subparsers = parser.add_subparsers(dest="command", required=True) - # Keygen command keygen_parser = subparsers.add_parser("keygen", help="Generate an Ed25519 signing keypair.") keygen_parser.add_argument( "--private-out", type=Path, default=None, @@ -150,76 +243,72 @@ def main() -> None: "--public-out", type=Path, default=None, help="Optional path to write the public key PEM to (safe to commit/pin).") - # Sign command sign_parser = subparsers.add_parser("sign", help="Sign a skill directory or manifest.") sign_parser.add_argument("target", type=Path, help="Path to skill directory or registry.json") - sign_parser.add_argument("--key", default=None, help="HMAC secret or Ed25519 private key PEM (or PEM file path)") + sign_parser.add_argument( + "--key", default=None, + help=f"Ed25519 private key PEM or PEM file path (default: ${ED25519_PRIVATE_KEY_ENV})") sign_parser.add_argument( "--ed25519", action="store_true", - help="Sign with Ed25519 instead of legacy HMAC-SHA256 (requires the cryptography package).") + help="Accepted for compatibility; Ed25519 is the only scheme.") - # Verify command verify_parser = subparsers.add_parser("verify", help="Verify a signed skill package or manifest.") verify_parser.add_argument("target", type=Path, help="Path to skill directory or registry.json") - verify_parser.add_argument("--signature", required=True, help="Expected signature") - verify_parser.add_argument("--key", default=None, help="HMAC secret or Ed25519 public key PEM (or PEM file path)") + source = verify_parser.add_mutually_exclusive_group(required=True) + source.add_argument("--signature", help="Expected hex signature over the target's digest") + source.add_argument( + "--signature-file", type=Path, + help="Signature JSON printed by 'sign'; also checks algorithm, target and sha256") + verify_parser.add_argument( + "--key", default=None, + help=(f"Ed25519 public key PEM or PEM file path (default: ${ED25519_PUBLIC_KEY_ENV}, " + "then keys/registry-ed25519.pub)")) verify_parser.add_argument( "--ed25519", action="store_true", - help="Verify an Ed25519 signature instead of legacy HMAC-SHA256 (requires the cryptography package).") + help="Accepted for compatibility; Ed25519 is the only scheme.") args = parser.parse_args() if args.command == "keygen": private_pem, public_pem = write_ed25519_keypair(args.private_out, args.public_out) - print(json.dumps({"algorithm": "ed25519", "private_key": private_pem, "public_key": public_pem}, indent=2)) + print(json.dumps({"algorithm": ALGORITHM, "private_key": private_pem, "public_key": public_pem}, indent=2)) print( - "Keep the private key in CI secrets (SKILLS_ED25519_PRIVATE_KEY); " + f"Keep the private key in CI secrets ({ED25519_PRIVATE_KEY_ENV}); " "pin the public key for verifiers. Never commit the private key.", file=sys.stderr, ) elif args.command == "sign": - if args.target.is_dir(): - digest = compute_content_hash(args.target) - elif args.target.is_file(): - digest = hashlib.sha256(args.target.read_bytes()).hexdigest() - else: - print(f"Error: {args.target} does not exist", file=sys.stderr) - sys.exit(1) - - if args.ed25519: - key = load_key_material( - resolve_key(args.key, ED25519_PRIVATE_KEY_ENV, "Ed25519 signing")) + digest = _require_digest(args.target) + key = load_key_material(resolve_key(args.key, ED25519_PRIVATE_KEY_ENV, "Ed25519 signing")) + try: sig = sign_hash_ed25519(digest, key) - algorithm = "ed25519" - else: - key = resolve_key(args.key, HMAC_KEY_ENV, "HMAC signing") - sig = sign_hash(digest, key) - algorithm = "hmac-sha256" - print(json.dumps({"target": str(args.target), "sha256": digest, "algorithm": algorithm, "signature": sig}, indent=2)) + except (ValueError, TypeError) as exc: + print(f"Error: invalid Ed25519 private key: {exc}", file=sys.stderr) + sys.exit(2) + print(json.dumps(build_signature_document(args.target, digest, sig), indent=2)) elif args.command == "verify": - if args.target.is_dir(): - digest = compute_content_hash(args.target) - elif args.target.is_file(): - digest = hashlib.sha256(args.target.read_bytes()).hexdigest() + public_key = resolve_public_key(args.key) + if args.signature_file is not None: + try: + document = json.loads(args.signature_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"FAIL: cannot read {args.signature_file}: {exc}", file=sys.stderr) + sys.exit(1) + problems = verify_signature_document(args.target, document, public_key) else: - print(f"Error: {args.target} does not exist", file=sys.stderr) + digest = _require_digest(args.target) + problems = [] if verify_hash_ed25519(digest, args.signature, public_key) else [ + "Ed25519 signature does not verify with the given public key" + ] + if problems: + for problem in problems: + print(f"FAIL: {problem}", file=sys.stderr) sys.exit(1) + print("OK: Signature verified successfully.") + sys.exit(0) - if args.ed25519: - key = load_key_material( - resolve_key(args.key, ED25519_PUBLIC_KEY_ENV, "Ed25519 verification")) - valid = verify_hash_ed25519(digest, args.signature, key) - else: - key = resolve_key(args.key, HMAC_KEY_ENV, "HMAC verification") - valid = verify_hash(digest, args.signature, key) - if valid: - print("OK: Signature verified successfully.") - sys.exit(0) - else: - print("FAIL: Signature mismatch!", file=sys.stderr) - sys.exit(1) if __name__ == "__main__": main() From e27b2872ebf2e2f3a0a740acefb3d6ee1bcb3604 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:40:17 +0530 Subject: [PATCH 3/6] build(deps): add hash-locked requirement sets for registry publishing 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 --- tools/requirements-sign.in | 5 + tools/requirements-sign.lock | 159 ++++++++++++++++++++++ tools/requirements.lock | 250 +++++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 tools/requirements-sign.in create mode 100644 tools/requirements-sign.lock create mode 100644 tools/requirements.lock diff --git a/tools/requirements-sign.in b/tools/requirements-sign.in new file mode 100644 index 000000000..7e4b9deba --- /dev/null +++ b/tools/requirements-sign.in @@ -0,0 +1,5 @@ +# Minimal dependency set for the registry signing job in +# .github/workflows/publish-registry.yml (the only job that sees the signing key). +# Keep it to the cryptography package; regenerate the lock with the command in +# tools/requirements-sign.lock. +cryptography diff --git a/tools/requirements-sign.lock b/tools/requirements-sign.lock new file mode 100644 index 000000000..12ce046be --- /dev/null +++ b/tools/requirements-sign.lock @@ -0,0 +1,159 @@ +# Hash-locked install set for .github/workflows/publish-registry.yml. +# Generated from tools/requirements-sign.in; do not edit by hand. Regenerate with: +# uv pip compile --generate-hashes --python-version 3.11 \ +# --python-platform x86_64-manylinux_2_28 --no-header tools/requirements-sign.in -o tools/requirements-sign.lock +# Install with: pip install --require-hashes --no-deps -r tools/requirements-sign.lock +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via -r tools/requirements-sign.in +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi diff --git a/tools/requirements.lock b/tools/requirements.lock new file mode 100644 index 000000000..dd6c58ec6 --- /dev/null +++ b/tools/requirements.lock @@ -0,0 +1,250 @@ +# Hash-locked install set for .github/workflows/publish-registry.yml. +# Generated from tools/requirements.txt; do not edit by hand. Regenerate with: +# uv pip compile --generate-hashes --python-version 3.11 \ +# --python-platform x86_64-manylinux_2_28 --no-header tools/requirements.txt -o tools/requirements.lock +# Install with: pip install --require-hashes --no-deps -r tools/requirements.lock +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via -r tools/requirements.txt +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via rich +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via rich +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via -r tools/requirements.txt +rich==15.0.0 \ + --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \ + --hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36 + # via -r tools/requirements.txt From eecbe31b25a5d4782b118449543451498044a8e8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:40:17 +0530 Subject: [PATCH 4/6] ci(publish): Ed25519-only signing verified against the pinned key 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 --- .github/workflows/publish-registry.yml | 124 +++++++++++++----- tests/test_workflows.py | 168 +++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 tests/test_workflows.py diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml index 45229e6c8..d74ef6f3b 100644 --- a/.github/workflows/publish-registry.yml +++ b/.github/workflows/publish-registry.yml @@ -7,71 +7,127 @@ on: paths: - 'categories/**' - 'tools/**' + - 'keys/**' + - 'manifest-schema.toml' + - '.github/workflows/publish-registry.yml' workflow_dispatch: +# Two quick merges must not race on `gh release upload --clobber` and leave a +# registry.json / registry-signature.json pair from different commits on the +# rolling release. Queue runs (never cancel one mid-upload) so the newest main +# always publishes last. +concurrency: + group: publish-registry + cancel-in-progress: false + +permissions: + contents: read + jobs: - build-and-publish: + # Builds registry.json without any secret and without write access. The + # validator and generator need pyyaml/rich, which never run next to the + # signing key. + build: + name: Build registry runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.11' - cache: 'pip' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f tools/requirements.txt ]; then pip install -r tools/requirements.txt; fi + - name: Install hash-locked dependencies + run: python -m pip install --require-hashes --no-deps -r tools/requirements.lock - - name: Validate skills - run: | + - name: Validate skills (zero-warning gate) + run: >- python tools/validate_skill.py --all + --warning-budget tools/validation_warning_budget.json - - name: Generate registry artifact - run: | - python tools/update_registry.py - - - name: Sign registry manifest - # Ed25519 (asymmetric) is preferred when the SKILLS_ED25519_PRIVATE_KEY - # secret is configured (generate once with: python tools/sign_manifest.py - # keygen). Pin the matching public key for verifiers. - # - # There is deliberately NO fallback key. This step previously fell back - # to the literal 'dev-fallback-key', which published a - # registry-signature.json that looked authoritative but was forgeable by - # anyone who read this file. sign_manifest.py already exits non-zero - # when no key is supplied, so omitting the fallback makes an unconfigured - # secret fail the release loudly instead of shipping a meaningless - # signature. + - name: Generate registry + run: python tools/update_registry.py + + - name: Hand the unsigned registry to the signing job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: registry-unsigned + path: registry.json + if-no-files-found: error + retention-days: 1 + + # The only job that sees SKILLS_ED25519_PRIVATE_KEY or holds contents:write. + # It installs nothing but the hash-locked cryptography stack. + sign-and-publish: + name: Sign, verify and publish registry + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.11' + + - name: Install hash-locked signing dependencies + run: python -m pip install --require-hashes --no-deps -r tools/requirements-sign.lock + + - name: Download unsigned registry + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: registry-unsigned + path: . + + - name: Sign registry (Ed25519) + # Ed25519 is the only scheme. There is deliberately no fallback: an + # unset secret fails the run instead of publishing an unsigned or + # forgeable registry-signature.json (the old shared-secret path fell + # back to a literal key anyone could read in this file). env: - SIGNING_KEY: ${{ secrets.SKILLS_SIGNING_KEY }} SKILLS_ED25519_PRIVATE_KEY: ${{ secrets.SKILLS_ED25519_PRIVATE_KEY }} run: | - if [ -n "$SKILLS_ED25519_PRIVATE_KEY" ]; then - python tools/sign_manifest.py sign registry.json --ed25519 > registry-signature.json - else - python tools/sign_manifest.py sign registry.json --key "$SIGNING_KEY" > registry-signature.json + if [ -z "${SKILLS_ED25519_PRIVATE_KEY}" ]; then + echo "::error::SKILLS_ED25519_PRIVATE_KEY is not set; refusing to publish an unsigned registry." + exit 1 fi + python tools/sign_manifest.py sign registry.json --ed25519 > registry-signature.json cat registry-signature.json + - name: Verify signature with the committed public key + # Proves the secret matches keys/registry-ed25519.pub (the key Rho + # pins) before anything is uploaded; a mismatched or rotated secret + # fails here instead of breaking every client. + run: >- + python tools/sign_manifest.py verify registry.json + --signature-file registry-signature.json + --key keys/registry-ed25519.pub + - name: Upload registry artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: skill-registry path: | registry.json registry-signature.json + if-no-files-found: error retention-days: 90 - name: Publish registry to the rolling release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} run: | # One moving release holds the current registry. The Actions # artifact above is retained separately for 90-day forensics. @@ -81,7 +137,7 @@ jobs: if ! gh release view registry-latest >/dev/null 2>&1; then gh release create registry-latest \ --title "Skill registry (rolling)" \ - --notes "Generated registry.json for the current main. Updated automatically; do not delete." \ + --notes "Generated registry.json for the current main, signed with the Ed25519 key in keys/registry-ed25519.pub. Updated automatically; do not delete." \ --latest=false fi gh release upload registry-latest \ diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 000000000..ac2d48a82 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,168 @@ +"""Guards for the GitHub Actions workflows and the publish job's lock files. + +These pin down supply-chain and signing properties that a YAML edit could +silently undo: every action is pinned to a commit SHA, the registry is signed +with Ed25519 only, the signature is verified with the committed public key +before upload, and the job that holds the signing key installs only +hash-locked dependencies. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +WORKFLOWS = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) +PUBLISH = REPO_ROOT / ".github" / "workflows" / "publish-registry.yml" +SHA_PINNED_USES = re.compile(r"^[\w.-]+/[\w./-]+@[0-9a-f]{40}$") + + +def _load(path: Path) -> dict: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + # PyYAML (YAML 1.1) parses the bare `on:` key as boolean True. + if True in data: + data["on"] = data.pop(True) + return data + + +def _steps(job: dict) -> list[dict]: + return job.get("steps", []) + + +def _step_index(steps: list[dict], predicate) -> int: + for index, step in enumerate(steps): + if predicate(step): + return index + raise AssertionError("step not found") + + +@pytest.mark.parametrize("workflow", WORKFLOWS, ids=lambda p: p.name) +def test_every_action_is_pinned_to_a_commit_sha(workflow: Path): + for job_name, job in _load(workflow)["jobs"].items(): + for step in _steps(job): + uses = step.get("uses") + if uses is None or uses.startswith("./"): + continue + assert SHA_PINNED_USES.match(uses), f"{workflow.name}:{job_name}: unpinned action {uses!r}" + + +def test_publish_registry_has_a_non_cancelling_concurrency_group(): + concurrency = _load(PUBLISH)["concurrency"] + assert concurrency["group"] == "publish-registry" + assert concurrency["cancel-in-progress"] is False + + +def test_publish_registry_triggers_cover_its_inputs(): + paths = _load(PUBLISH)["on"]["push"]["paths"] + for required in ( + "categories/**", + "tools/**", + "keys/**", + "manifest-schema.toml", + ".github/workflows/publish-registry.yml", + ): + assert required in paths + + +def test_publish_registry_is_ed25519_only(): + text = PUBLISH.read_text(encoding="utf-8") + for forbidden in ("SKILLS_SIGNING_KEY", "dev-fallback", "hmac", "HMAC", "SIGNING_KEY:"): + assert forbidden not in text, forbidden + + +def test_only_the_signing_job_sees_the_secret_or_can_write(): + jobs = _load(PUBLISH)["jobs"] + assert jobs["build"]["permissions"] == {"contents": "read"} + assert "secrets." not in yaml.safe_dump(jobs["build"]) + sign = jobs["sign-and-publish"] + assert sign["needs"] == "build" + assert sign["permissions"] == {"contents": "write"} + secret_steps = [s for s in _steps(sign) if "SKILLS_ED25519_PRIVATE_KEY" in yaml.safe_dump(s)] + assert len(secret_steps) == 1 + assert secret_steps[0]["env"] == { + "SKILLS_ED25519_PRIVATE_KEY": "${{ secrets.SKILLS_ED25519_PRIVATE_KEY }}" + } + + +def test_signing_fails_when_the_secret_is_unset(): + sign_steps = _steps(_load(PUBLISH)["jobs"]["sign-and-publish"]) + step = sign_steps[_step_index(sign_steps, lambda s: "sign registry.json" in s.get("run", ""))] + script = step["run"] + assert 'if [ -z "${SKILLS_ED25519_PRIVATE_KEY}" ]' in script + assert "exit 1" in script + assert "sign_manifest.py sign registry.json --ed25519" in script + + +def test_signature_is_verified_with_the_committed_key_before_upload(): + steps = _steps(_load(PUBLISH)["jobs"]["sign-and-publish"]) + sign = _step_index(steps, lambda s: "sign registry.json" in s.get("run", "")) + verify = _step_index(steps, lambda s: "sign_manifest.py verify" in s.get("run", "")) + upload = _step_index(steps, lambda s: "upload-artifact" in s.get("uses", "")) + publish = _step_index(steps, lambda s: "gh release upload" in s.get("run", "")) + assert sign < verify < upload < publish + verify_run = steps[verify]["run"] + assert "--signature-file registry-signature.json" in verify_run + assert "--key keys/registry-ed25519.pub" in verify_run + assert (REPO_ROOT / "keys" / "registry-ed25519.pub").is_file() + + +def test_publish_installs_only_hash_locked_dependencies(): + jobs = _load(PUBLISH)["jobs"] + expected = {"build": "tools/requirements.lock", "sign-and-publish": "tools/requirements-sign.lock"} + for job_name, lock in expected.items(): + installs = [s["run"] for s in _steps(jobs[job_name]) if "pip install" in s.get("run", "")] + assert installs == [f"python -m pip install --require-hashes --no-deps -r {lock}"] + + +def test_publish_validation_uses_the_warning_budget(): + runs = " ".join(s.get("run", "") for s in _steps(_load(PUBLISH)["jobs"]["build"])) + assert "validate_skill.py --all --warning-budget tools/validation_warning_budget.json" in runs + + +def _locked_packages(lock: Path) -> dict[str, list[str]]: + """Map package name -> hashes for a `uv pip compile --generate-hashes` file.""" + packages: dict[str, list[str]] = {} + current = None + for raw in lock.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("--hash=sha256:"): + assert current is not None, f"{lock.name}: hash before any requirement" + packages[current].append(line.split(":", 1)[1].rstrip(" \\")) + continue + match = re.match(r"^([A-Za-z0-9_.-]+)==[0-9][^ ]*( \\)?$", line) + assert match, f"{lock.name}: not an exact pin: {raw!r}" + current = match.group(1).lower().replace("_", "-") + packages[current] = [] + return packages + + +@pytest.mark.parametrize("lock_name", ["requirements.lock", "requirements-sign.lock"]) +def test_lock_files_pin_exact_versions_with_hashes(lock_name: str): + packages = _locked_packages(REPO_ROOT / "tools" / lock_name) + assert packages + for name, hashes in packages.items(): + assert hashes, f"{lock_name}: {name} has no --hash" + assert all(re.fullmatch(r"[0-9a-f]{64}", h) for h in hashes), name + + +def test_requirements_lock_covers_requirements_txt(): + wanted = { + line.strip().lower() + for line in (REPO_ROOT / "tools" / "requirements.txt").read_text().splitlines() + if line.strip() and not line.startswith("#") + } + assert wanted <= set(_locked_packages(REPO_ROOT / "tools" / "requirements.lock")) + + +def test_signing_lock_is_only_the_cryptography_stack(): + assert set(_locked_packages(REPO_ROOT / "tools" / "requirements-sign.lock")) == { + "cryptography", + "cffi", + "pycparser", + } From 6858770d24758f5d44bec17fa0bb9a2ef12da271 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:40:40 +0530 Subject: [PATCH 5/6] ci: audit the publish lock files with pip-audit 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 --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2ffdf924..3dce9be25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: run: python3 tools/check_shell_commands.py --strict - name: Dependency security audit run: pip-audit -r tools/requirements.txt + - name: Dependency security audit (publish lock files) + # The hash-locked sets publish-registry.yml installs, including the + # signing job's cryptography stack. + run: pip-audit --require-hashes -r tools/requirements.lock -r tools/requirements-sign.lock # ------------------------------------------------------------------------- # Sandbox containment check for changed skill scripts. Deliberately scoped From 6295927ae212ebe5462462399cbbfba44b003c5a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:41:26 +0530 Subject: [PATCH 6/6] docs(registry): document the Ed25519 registry signature contract 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 --- CHANGELOG.md | 14 ++++++++ docs/REGISTRY.md | 90 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 148905092..7851d95f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,20 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) already exited non-zero without a key, so removing the fallback makes an unconfigured secret fail the release loudly instead of shipping a meaningless signature. +- The registry is now signed with **Ed25519 only**. The HMAC-SHA256 scheme and + `SKILLS_SIGNING_KEY` were removed from `tools/sign_manifest.py`, and the + pinned public key is committed at `keys/registry-ed25519.pub`. The publish + job fails when `SKILLS_ED25519_PRIVATE_KEY` is unset and verifies its own + signature with the committed key before uploading. Registries published + before this change carry a forgeable `hmac-sha256` signature; clients must + not trust them. **Breaking:** `sign`/`verify` reject HMAC secrets. +- `publish-registry.yml` pins every action to a commit SHA, installs only + hash-locked dependencies (`tools/requirements.lock`, + `tools/requirements-sign.lock`), keeps the signing key in a separate job + that installs nothing but the `cryptography` stack, and serialises runs + with a `publish-registry` concurrency group. +- `verify --signature-file` checks a published `registry-signature.json` + end to end (algorithm, target, SHA-256, signature). See `docs/REGISTRY.md`. ## [0.1.0] - 2026-05-26 diff --git a/docs/REGISTRY.md b/docs/REGISTRY.md index 41f7e017c..2c9ba3202 100644 --- a/docs/REGISTRY.md +++ b/docs/REGISTRY.md @@ -1,6 +1,7 @@ # Skill Registry -`registry.json` is a **generated artifact** — it is NOT committed to git. +`registry.json` is a **generated artifact**. It is not committed to git; the +source of truth is the `SKILL.md` files under `categories/`. ## Generate locally @@ -8,10 +9,91 @@ python tools/update_registry.py ``` -## In CI +## Published registry -The registry is generated fresh in CI before publishing to the CDN. It is not stored in the repository. +On every push to `main` that touches `categories/`, `tools/`, `keys/`, +`manifest-schema.toml` or the workflow itself, +[`publish-registry.yml`](../.github/workflows/publish-registry.yml) validates the +corpus, regenerates `registry.json`, signs it, verifies the signature, and +uploads both files to the rolling GitHub release `registry-latest`: + +- `https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry.json` +- `https://github.com/GrayCodeAI/graycode-skills/releases/download/registry-latest/registry-signature.json` + +This is the index Rho reads for `rho skills search`, `info` and `trending`. There +is no CDN; the GitHub release is the distribution point. Each run also keeps +both files as a 90-day Actions artifact (`skill-registry`) for forensics. + +## Signature + +The registry is signed with **Ed25519 only**. The public key is committed at +[`keys/registry-ed25519.pub`](../keys/registry-ed25519.pub) and pinned by Rho: + +``` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAr9I2NG1Sih9Mu04/eOA8FmJhczSLBYiXeLAl1rqusQU= +-----END PUBLIC KEY----- +``` + +The private key exists only as the `SKILLS_ED25519_PRIVATE_KEY` GitHub Actions +secret. If that secret is unset the publish job fails; it never falls back to +another scheme or an unsigned upload. + +`registry-signature.json` is the JSON printed by +`python tools/sign_manifest.py sign registry.json --ed25519`: + +```json +{ + "target": "registry.json", + "sha256": "", + "algorithm": "ed25519", + "signature": "" +} +``` + +The signed message is the 64 ASCII bytes of the lowercase hex digest, not the +raw 32-byte digest. + +### Verifying + +A client must: + +1. download both files; +2. require `algorithm == "ed25519"`; +3. recompute the SHA-256 of the downloaded `registry.json` bytes and require it + to equal `sha256`; +4. verify `signature` over the hex digest with the pinned public key; +5. refuse to use the index if any step fails (no unsigned fallback). + +With this repository checked out: + +```bash +python tools/sign_manifest.py verify registry.json \ + --signature-file registry-signature.json +``` + +`verify` uses `keys/registry-ed25519.pub` unless `--key` or +`SKILLS_ED25519_PUBLIC_KEY` is given. With OpenSSL 3 only: + +```bash +jq -r .signature registry-signature.json | xxd -r -p > registry.sig +printf '%s' "$(shasum -a 256 registry.json | cut -c1-64)" > registry.sha256 +openssl pkeyutl -verify -pubin -inkey keys/registry-ed25519.pub \ + -rawin -in registry.sha256 -sigfile registry.sig +``` + +The publish workflow runs the same `verify` against the committed key before +it uploads anything. + +### Rotating the key + +1. `python tools/sign_manifest.py keygen --private-out --public-out keys/registry-ed25519.pub` +2. Store the private PEM as the `SKILLS_ED25519_PRIVATE_KEY` secret; never commit it. +3. Update the expected key in `tests/test_sign_manifest.py` and ship the new + pinned key in a Rho release before, or together with, the first registry + signed by the new key. Old clients reject registries signed by a key they + do not pin. ## Why not in git? -At 4+ MB, committing `registry.json` creates excessive diff noise and slows clones. The source of truth is the individual `SKILL.md` files under `categories/`. +At about 6 MB, committing `registry.json` would add diff noise and slow clones.