Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/dryade_plugins_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand Down
11 changes: 7 additions & 4 deletions src/dryade_plugins_sdk/cli/pkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
96 changes: 96 additions & 0 deletions tests/test_package_signature.py
Original file line number Diff line number Diff line change
@@ -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))
Loading