From 4ebee25297817993843048351ccda047510b946b Mon Sep 17 00:00:00 2001 From: Dryade Date: Tue, 26 May 2026 20:08:57 +0200 Subject: [PATCH] fix(cli): sign the manifest last so the .dryadepkg signature verifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dryade plugin package signed the manifest before adding the sbom field, leaving it outside the signed canonical bytes — the Ed25519 signature on the produced .dryadepkg failed to verify and the package would be rejected at load. Sign last, after hashes + contract_version + sbom are set, so the signature covers the final manifest. Adds a signature round-trip regression test (the gap that let this ship). Co-Authored-By: Dammerzone --- CHANGELOG.md | 15 +++++ pyproject.toml | 2 +- src/dryade_plugins_sdk/__init__.py | 2 +- src/dryade_plugins_sdk/cli/pkg.py | 11 ++-- tests/test_package_signature.py | 96 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 tests/test_package_signature.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 881a90a..07a1f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ labeled PRs. _release-drafter manages this section on every PR merge — do not edit by hand._ +## [1.1.4] — 2026-05-26 + +### Fixed + +- **`dryade plugin package` produced an unverifiable signature.** The manifest + was signed before the `sbom` field was added, leaving it outside the signed + bytes, so the Ed25519 signature on the `.dryadepkg` failed to verify. Signing + now happens last, after every content field (hashes, contract version, sbom) + is in place, so the signature covers the final manifest. + +### Added + +- `tests/test_package_signature.py` — verifies the signature on a produced + `.dryadepkg` validates against the author key and covers the `sbom` field. + ## [1.1.3] — 2026-05-26 ### Changed diff --git a/pyproject.toml b/pyproject.toml index eee4e54..d3a4746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dryade-plugins-sdk" -version = "1.1.3" +version = "1.1.4" description = "Dryade plugin SDK — Protocol contracts and author tooling primitives" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dryade_plugins_sdk/__init__.py b/src/dryade_plugins_sdk/__init__.py index 555b08f..b912a11 100644 --- a/src/dryade_plugins_sdk/__init__.py +++ b/src/dryade_plugins_sdk/__init__.py @@ -52,7 +52,7 @@ verify_plugin_hash, ) -__version__ = "1.1.3" +__version__ = "1.1.4" __contract_version__ = 4 # SHA-256 + SHA3-256 dual hash __all__ = [ diff --git a/src/dryade_plugins_sdk/cli/pkg.py b/src/dryade_plugins_sdk/cli/pkg.py index e736a03..0db8007 100644 --- a/src/dryade_plugins_sdk/cli/pkg.py +++ b/src/dryade_plugins_sdk/cli/pkg.py @@ -106,10 +106,6 @@ def build_dryadepkg(plugin_dir: Path, output_dir: Path) -> Path: manifest["plugin_hash_sha3_256"] = sha3_256_hex manifest["contract_version"] = CONTRACT_VERSION - priv = load_author_private_key() - sig_hex = sign_manifest(manifest, priv) - manifest["signature"] = sig_hex - name = manifest["name"] version = manifest["version"] output_dir.mkdir(parents=True, exist_ok=True) @@ -131,6 +127,13 @@ def build_dryadepkg(plugin_dir: Path, output_dir: Path) -> Path: # unpacking sbom.cdx.json. manifest["sbom"] = sbom_source + # Sign LAST — after every content field (hashes, contract_version, sbom) + # is in place — so the Ed25519 signature covers the final manifest. Signing + # before adding `sbom` would leave that field outside the signed canonical + # bytes, and the signature would fail to verify on the produced package. + priv = load_author_private_key() + manifest["signature"] = sign_manifest(manifest, priv) + # Write the re-signed manifest to a temp file so we can tar-add it under # the canonical arcname while leaving the on-disk dryade.json untouched. with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as tmp: diff --git a/tests/test_package_signature.py b/tests/test_package_signature.py new file mode 100644 index 0000000..ce8dfd1 --- /dev/null +++ b/tests/test_package_signature.py @@ -0,0 +1,96 @@ +"""The signature on a produced .dryadepkg must verify against the author key. + +Regression net for the packaging bug where the manifest was signed *before* +the ``sbom`` field was added, leaving that field outside the signed canonical +bytes so the signature failed to verify on the produced package. The signature +must cover the FINAL manifest — every content field included. +""" + +from __future__ import annotations + +import json +import tarfile +from pathlib import Path + +import pytest +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from dryade_plugins_sdk.packaging import get_canonical_bytes + + +@pytest.fixture +def plugin_dir(tmp_path: Path) -> Path: + pdir = tmp_path / "sigproof" + pdir.mkdir() + (pdir / "dryade.json").write_text( + json.dumps( + { + "manifest_version": "2.0", + "name": "sigproof", + "version": "0.1.0", + "description": "Signature test fixture.", + "core_version_constraint": ">=1.0.0,<2.0.0", + "required_tier": "starter", + }, + indent=2, + ) + ) + (pdir / "__init__.py").write_text("from .plugin import plugin\n") + (pdir / "plugin.py").write_text( + "class Plugin:\n" + " name='sigproof'\n" + " version='0.1.0'\n" + " description='proof'\n" + " core_version_constraint='>=1.0.0,<2.0.0'\n" + " def register(self, r): pass\n" + "plugin = Plugin()\n" + ) + (pdir / "pyproject.toml").write_text( + '[project]\nname = "sigproof"\nversion = "0.1.0"\nrequires-python = ">=3.11"\n' + ) + return pdir + + +def _packaged_manifest(monkeypatch, plugin_dir: Path, tmp_path: Path) -> tuple[dict, str]: + """Package the plugin in an isolated $HOME; return (manifest, pubkey_hex).""" + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + from dryade_plugins_sdk.cli import keys + + _, pub_hex = keys.generate_author_keypair() + + from dryade_plugins_sdk.cli.pkg import build_dryadepkg + + pkg_path = build_dryadepkg(plugin_dir, tmp_path / "dist") + with tarfile.open(pkg_path, "r:gz") as tf: + member = tf.extractfile("dryade.json") + assert member is not None + manifest = json.loads(member.read().decode("utf-8")) + return manifest, pub_hex + + +def test_packaged_signature_verifies(monkeypatch, plugin_dir, tmp_path): + """The Ed25519 signature in the .dryadepkg verifies against the author key.""" + manifest, pub_hex = _packaged_manifest(monkeypatch, plugin_dir, tmp_path) + + assert manifest.get("signature"), "package has no signature" + pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex)) + # Must not raise — the signature covers the final manifest. + pub.verify(bytes.fromhex(manifest["signature"]), get_canonical_bytes(manifest)) + + +def test_signature_covers_sbom_field(monkeypatch, plugin_dir, tmp_path): + """The signed bytes include `sbom` — proving signing happens after it is set.""" + manifest, pub_hex = _packaged_manifest(monkeypatch, plugin_dir, tmp_path) + assert "sbom" in manifest, "manifest should carry the sbom-source field" + + pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex)) + sig = bytes.fromhex(manifest["signature"]) + + # Flipping the sbom value must invalidate the signature (it is in scope). + tampered = dict(manifest) + tampered["sbom"] = "cyclonedx-py" if manifest["sbom"] != "cyclonedx-py" else "minimal-shim" + with pytest.raises(InvalidSignature): + pub.verify(sig, get_canonical_bytes(tampered))