From 534b063113c36eb84a3a6a41648a205bb11e26e7 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 16 Feb 2026 22:25:31 +0100 Subject: [PATCH 1/4] feat: enhanced module manifest security and integrity (arch-06) Co-authored-by: Cursor --- .github/workflows/sign-modules.yml | 29 +++ CHANGELOG.md | 15 ++ README.md | 1 + docs/_layouts/default.html | 1 + docs/reference/module-security.md | 33 +++ pyproject.toml | 2 +- scripts/sign-module.sh | 19 ++ setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/models/module_package.py | 61 ++++++ src/specfact_cli/registry/crypto_validator.py | 128 +++++++++++ src/specfact_cli/registry/module_installer.py | 60 +++++ src/specfact_cli/registry/module_packages.py | 63 +++++- .../registry/test_crypto_validator.py | 91 ++++++++ .../registry/test_module_packages.py | 206 ++++++++++++++++++ .../registry/test_signing_artifacts.py | 55 +++++ 17 files changed, 764 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/sign-modules.yml create mode 100644 docs/reference/module-security.md create mode 100644 scripts/sign-module.sh create mode 100644 src/specfact_cli/registry/crypto_validator.py create mode 100644 src/specfact_cli/registry/module_installer.py create mode 100644 tests/unit/specfact_cli/registry/test_crypto_validator.py create mode 100644 tests/unit/specfact_cli/registry/test_signing_artifacts.py diff --git a/.github/workflows/sign-modules.yml b/.github/workflows/sign-modules.yml new file mode 100644 index 00000000..9c2c65a9 --- /dev/null +++ b/.github/workflows/sign-modules.yml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +# Sign module manifests for integrity (arch-06). Outputs checksums for manifest integrity fields. +name: Sign Modules + +on: + workflow_dispatch: {} + push: + branches: [main] + paths: + - "src/specfact_cli/modules/**/module-package.yaml" + - "modules/**/module-package.yaml" + +jobs: + sign: + name: Sign module manifests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Sign module manifests + run: | + for f in $(find . -name 'module-package.yaml' -not -path './.git/*' 2>/dev/null | head -20); do + if [ -f "scripts/sign-module.sh" ]; then + bash scripts/sign-module.sh "$f" || true + fi + done diff --git a/CHANGELOG.md b/CHANGELOG.md index 70032795..144233c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ All notable changes to this project will be documented in this file. --- +## [0.32.0] - 2026-02-16 + +### Added (0.32.0) + +- **Enhanced module manifest security and integrity** (OpenSpec change `arch-06-enhanced-manifest-security`, fixes [#208](https://github.com/nold-ai/specfact-cli/issues/208)) + - Publisher and integrity metadata in `module-package.yaml` (`publisher`, `integrity.checksum`, optional `integrity.signature`). + - Versioned dependency entries (`module_dependencies_versioned`, `pip_dependencies_versioned`) with name and version specifier. + - `crypto_validator`: checksum verification (sha256/sha384/sha512) and optional signature verification. + - Registration-time trust checks: manifest checksum verified before module load; failed trust skips that module only. + - `SPECFACT_ALLOW_UNSIGNED` and `allow_unsigned` parameter for explicit opt-in when using unsigned modules. + - Signing automation: `scripts/sign-module.sh` and `.github/workflows/sign-modules.yml` for checksum generation. + - Documentation: `docs/reference/module-security.md` and architecture updates for module trust and integrity lifecycle. + +--- + ## [0.31.0] - 2026-02-13 ### Added diff --git a/README.md b/README.md index 8013a996..616f09a6 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Contract-first module architecture highlights: - Registration tracks protocol operation coverage and schema compatibility metadata. - Bridge registry support allows module manifests to declare `service_bridges` converters (for example ADO/Jira/Linear/GitHub) loaded at lifecycle startup without direct core-to-module imports. - Protocol reporting classifies modules from effective runtime interfaces with a single aggregate summary (`Full/Partial/Legacy`). +- Module manifests support publisher and integrity metadata (arch-06) with optional checksum and signature verification at registration time. Why this matters: diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 498e1414..3dfb5256 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -174,6 +174,7 @@

  • Directory Structure
  • ProjectBundle Schema
  • Module Contracts
  • +
  • Module Security
  • Bridge Registry
  • Integrations Overview
  • diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md new file mode 100644 index 00000000..b7f347c3 --- /dev/null +++ b/docs/reference/module-security.md @@ -0,0 +1,33 @@ +--- +layout: default +title: Module Security +permalink: /reference/module-security/ +description: Trust model, checksum and signature verification, and integrity lifecycle for module packages. +--- + +# Module Security + +Module packages can carry **publisher** and **integrity** metadata so that installation and registration verify artifact trust before enabling a module. + +## Trust model + +- **Manifest metadata**: `module-package.yaml` may include `publisher` (name, email, attributes) and `integrity` (checksum, optional signature). +- **Checksum verification**: Before registration or install, the system verifies the manifest (or artifact) checksum when `integrity.checksum` is present. Supported algorithms: `sha256`, `sha384`, `sha512` in `algo:hex` format. +- **Signature verification**: If `integrity.signature` is set and trusted key material is configured, signature verification validates provenance. Without key material, only checksum is enforced and a warning is logged. +- **Unsigned modules**: Modules without `integrity` metadata are allowed (backward compatible). Set `SPECFACT_ALLOW_UNSIGNED=1` to document explicit opt-in when using strict policies. + +## Checksum flow + +1. Discovery reads `module-package.yaml` and parses `integrity.checksum`. +2. At registration time, the installer hashes the manifest content and compares it to the expected checksum. +3. On mismatch, the module is skipped and a security warning is logged. +4. Other modules continue to register; one failing trust does not block the rest. + +## Signing automation + +- **Script**: `scripts/sign-module.sh ` outputs a `sha256:` checksum suitable for the manifest `integrity.checksum` field. +- **CI**: `.github/workflows/sign-modules.yml` can run on demand or on push to `main` when module manifests change, to produce or validate checksums. + +## Versioned dependencies + +Manifest may declare versioned module and pip dependencies via `module_dependencies_versioned` and `pip_dependencies_versioned` (each entry: `name`, `version_specifier`). These are parsed and stored for installation-time resolution while keeping legacy `module_dependencies` / `pip_dependencies` lists backward compatible. diff --git a/pyproject.toml b/pyproject.toml index f9a5b64a..8f05a36c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.31.0" +version = "0.32.0" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/sign-module.sh b/scripts/sign-module.sh new file mode 100644 index 00000000..3ec8171a --- /dev/null +++ b/scripts/sign-module.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Sign module manifest for integrity (arch-06). Outputs checksum in algo:hex format for manifest integrity field. +set -euo pipefail +MANIFEST="${1:-}" +if [[ -z "$MANIFEST" || ! -f "$MANIFEST" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +# Produce sha256 checksum for manifest content (integrity.checksum format) +if command -v sha256sum &>/dev/null; then + SUM=$(sha256sum -b < "$MANIFEST" | awk '{print $1}') +elif command -v shasum &>/dev/null; then + SUM=$(shasum -a 256 -b < "$MANIFEST" | awk '{print $1}') +else + echo "No sha256sum/shasum found" >&2 + exit 1 +fi +echo "sha256:$SUM" +echo "checksum: sha256:$SUM" >&2 diff --git a/setup.py b/setup.py index 8d01d675..765e6ceb 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.31.0", + version="0.32.0", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 747c8b7d..ba7bf8be 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.31.0" +__version__ = "0.32.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 4944df2d..0a1abf56 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.31.0" +__version__ = "0.32.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/models/module_package.py b/src/specfact_cli/models/module_package.py index 5121c2a2..d4e721e7 100644 --- a/src/specfact_cli/models/module_package.py +++ b/src/specfact_cli/models/module_package.py @@ -9,9 +9,44 @@ from pydantic import BaseModel, Field, model_validator +CHECKSUM_ALGO_RE = re.compile(r"^sha256:[a-fA-F0-9]{64}$|^sha384:[a-fA-F0-9]{96}$|^sha512:[a-fA-F0-9]{128}$") CONVERTER_CLASS_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$") +@beartype +class PublisherInfo(BaseModel): + """Publisher identity from module manifest (arch-06).""" + + name: str = Field(..., description="Publisher display name") + email: str = Field(..., description="Publisher contact email") + attributes: dict[str, str] = Field(default_factory=dict, description="Optional publisher attributes") + + @model_validator(mode="after") + def _validate_non_empty(self) -> PublisherInfo: + if not self.name.strip(): + raise ValueError("Publisher name must not be empty") + if not self.email.strip(): + raise ValueError("Publisher email must not be empty") + return self + + +@beartype +class IntegrityInfo(BaseModel): + """Integrity metadata for module artifact verification (arch-06).""" + + checksum: str = Field(..., description="Checksum in algo:hex format (e.g. sha256:...)") + signature: str | None = Field(default=None, description="Optional detached signature (base64)") + + @model_validator(mode="after") + def _validate_checksum_format(self) -> IntegrityInfo: + """Validation SHALL ensure checksum format correctness.""" + if not CHECKSUM_ALGO_RE.match(self.checksum): + raise ValueError( + "integrity.checksum must be algo:hex (e.g. sha256:<64 hex chars>, sha384:<96>, sha512:<128>)" + ) + return self + + @beartype class ServiceBridgeMetadata(BaseModel): """Service bridge declaration from module package manifest.""" @@ -34,6 +69,22 @@ def _validate_bridge_metadata(self) -> ServiceBridgeMetadata: return self +@beartype +class VersionedModuleDependency(BaseModel): + """Versioned module dependency entry (arch-06).""" + + name: str = Field(..., description="Module package id") + version_specifier: str | None = Field(default=None, description="PEP 440 version specifier") + + +@beartype +class VersionedPipDependency(BaseModel): + """Versioned pip dependency entry (arch-06).""" + + name: str = Field(..., description="PyPI package name") + version_specifier: str | None = Field(default=None, description="PEP 440 version specifier") + + @beartype class ModulePackageMetadata(BaseModel): """Schema for a module package manifest.""" @@ -61,6 +112,16 @@ class ModulePackageMetadata(BaseModel): default_factory=list, description="Detected ModuleIOContract operations: import, export, sync, validate.", ) + publisher: PublisherInfo | None = Field(default=None, description="Publisher identity (arch-06)") + integrity: IntegrityInfo | None = Field(default=None, description="Integrity metadata (arch-06)") + module_dependencies_versioned: list[VersionedModuleDependency] = Field( + default_factory=list, + description="Versioned module dependency declarations (arch-06)", + ) + pip_dependencies_versioned: list[VersionedPipDependency] = Field( + default_factory=list, + description="Versioned pip dependency declarations (arch-06)", + ) service_bridges: list[ServiceBridgeMetadata] = Field( default_factory=list, description="Optional bridge declarations for converter registration.", diff --git a/src/specfact_cli/registry/crypto_validator.py b/src/specfact_cli/registry/crypto_validator.py new file mode 100644 index 00000000..de9edb07 --- /dev/null +++ b/src/specfact_cli/registry/crypto_validator.py @@ -0,0 +1,128 @@ +""" +Checksum and optional signature verification for module artifacts (arch-06). +""" + +from __future__ import annotations + +import base64 +import hashlib +from pathlib import Path + +from beartype import beartype +from icontract import require + + +_ArtifactInput = bytes | Path + + +def _algo_and_hex(expected_checksum: str) -> tuple[str, str]: + """Parse 'algo:hex' format. Raises ValueError if invalid.""" + if ":" not in expected_checksum or not expected_checksum.strip(): + raise ValueError("Expected checksum must be in algo:hex format (e.g. sha256:<64 hex chars>)") + algo, hex_part = expected_checksum.strip().split(":", 1) + algo = algo.lower() + if algo not in ("sha256", "sha384", "sha512"): + raise ValueError("Supported checksum algorithms: sha256, sha384, sha512") + if not hex_part or not all(c in "0123456789abcdefABCDEF" for c in hex_part): + raise ValueError("Checksum hex part must contain only hex digits") + expected_len = {"sha256": 64, "sha384": 96, "sha512": 128} + if len(hex_part) != expected_len[algo]: + raise ValueError(f"Checksum hex length for {algo} must be {expected_len[algo]}, got {len(hex_part)}") + return algo, hex_part + + +@beartype +@require(lambda expected_checksum: expected_checksum.strip() != "", "Expected checksum must not be empty") +def verify_checksum(artifact: _ArtifactInput, expected_checksum: str) -> bool: + """ + Verify artifact checksum against expected algo:hex value. + + Args: + artifact: Raw bytes or path to file. + expected_checksum: Expected value in format sha256:<64 hex>, sha384:<96>, or sha512:<128>. + + Returns: + True if the artifact's checksum matches. + + Raises: + ValueError: If format is invalid or checksum does not match. + """ + algo, expected_hex = _algo_and_hex(expected_checksum) + data = artifact.read_bytes() if isinstance(artifact, Path) else artifact + hasher = hashlib.new(algo) + hasher.update(data) + actual_hex = hasher.hexdigest() + if actual_hex.lower() != expected_hex.lower(): + raise ValueError(f"Checksum mismatch: computed {algo}:{actual_hex[:16]}... does not match expected") + return True + + +def _verify_signature_impl(artifact: bytes, signature_b64: str, public_key_pem: str) -> bool: + """ + Verify detached signature over artifact using public key. + Uses cryptography if available; otherwise raises. + """ + try: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes + except ImportError as e: + raise ValueError( + "Signature verification requires the 'cryptography' package. Install with: pip install cryptography" + ) from e + if not public_key_pem or not public_key_pem.strip(): + raise ValueError("Public key PEM must not be empty") + try: + key = serialization.load_pem_public_key(public_key_pem.encode()) + except Exception as e: + raise ValueError(f"Invalid public key PEM: {e}") from e + try: + sig_bytes = base64.b64decode(signature_b64, validate=True) + except Exception as e: + raise ValueError(f"Invalid base64 signature: {e}") from e + if not isinstance(key, PublicKeyTypes): + raise ValueError("Key must be a public key type") + # RSASSA-PKCS1v1_5 or PSS; Ed25519 uses different API. Try common path. + if hasattr(key, "key_size"): # RSA + try: + key.verify(sig_bytes, artifact, padding.PKCS1v15(), hashes.SHA256()) + return True + except InvalidSignature: + return False + if hasattr(key, "verify"): # Ed25519 etc + try: + key.verify(sig_bytes, artifact) + return True + except InvalidSignature: + return False + raise ValueError("Unsupported key type for signature verification") + + +@beartype +def verify_signature( + artifact: _ArtifactInput, + signature_b64: str, + public_key_pem: str, +) -> bool: + """ + Verify detached signature over artifact. + + Args: + artifact: Raw bytes or path to file. + signature_b64: Base64-encoded signature. + public_key_pem: PEM-encoded public key. + + Returns: + True if signature is valid. False if no signature to verify (empty). + Raises ValueError on missing key, invalid format, or verification failure. + """ + if not signature_b64 or not signature_b64.strip(): + return False + artifact_bytes = artifact.read_bytes() if isinstance(artifact, Path) else artifact + if not public_key_pem or not public_key_pem.strip(): + raise ValueError("Public key PEM is required for signature verification") + ok = _verify_signature_impl(artifact_bytes, signature_b64.strip(), public_key_pem.strip()) + if not ok: + raise ValueError("Signature verification failed: signature does not match artifact or key") + return True diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py new file mode 100644 index 00000000..53ede3ba --- /dev/null +++ b/src/specfact_cli/registry/module_installer.py @@ -0,0 +1,60 @@ +""" +Module artifact verification stages for installation and registration (arch-06). +""" + +from __future__ import annotations + +from pathlib import Path + +from beartype import beartype + +from specfact_cli.common import get_bridge_logger +from specfact_cli.models.module_package import ModulePackageMetadata +from specfact_cli.registry.crypto_validator import verify_checksum + + +@beartype +def verify_module_artifact( + package_dir: Path, + meta: ModulePackageMetadata, + allow_unsigned: bool = False, +) -> bool: + """ + Run integrity verification for a module artifact. Used at registration and install time. + + - If meta.integrity is set: verify checksum (and signature if present); return False on failure. + - If meta.integrity is not set and allow_unsigned: return True (allow with warning). + - If meta.integrity is not set and not allow_unsigned: return False (reject unsigned by default). + + Returns: + True if the module passes trust checks and may be registered/installed. + """ + logger = get_bridge_logger(__name__) + manifest_path = package_dir / "module-package.yaml" + if not manifest_path.exists(): + manifest_path = package_dir / "metadata.yaml" + if not manifest_path.exists(): + logger.warning("Module %s: No manifest file for integrity check (skipped)", meta.name) + return allow_unsigned + + if meta.integrity is None: + # Backward compatible: allow modules without integrity unless strict mode is added later. + if allow_unsigned: + logger.debug("Module %s: No integrity metadata; allowing (allow-unsigned)", meta.name) + return True + + try: + data = manifest_path.read_bytes() + verify_checksum(data, meta.integrity.checksum) + except ValueError as e: + logger.warning("Module %s: Integrity check failed: %s", meta.name, e) + return False + + if meta.integrity.signature: + # Signature verification would require key material (not in manifest). Allow with warning. + logger.warning( + "Module %s: Signature present but key material not configured; checksum-only verification", + meta.name, + ) + + return True diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index cbf560d1..2f1780bf 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -24,9 +24,17 @@ from specfact_cli import __version__ as cli_version from specfact_cli.common import get_bridge_logger -from specfact_cli.models.module_package import ModulePackageMetadata, ServiceBridgeMetadata +from specfact_cli.models.module_package import ( + IntegrityInfo, + ModulePackageMetadata, + PublisherInfo, + ServiceBridgeMetadata, + VersionedModuleDependency, + VersionedPipDependency, +) from specfact_cli.registry.bridge_registry import BridgeRegistry, SchemaConverter from specfact_cli.registry.metadata import CommandMetadata +from specfact_cli.registry.module_installer import verify_module_artifact from specfact_cli.registry.module_state import find_dependents, read_modules_state from specfact_cli.registry.registry import CommandRegistry from specfact_cli.runtime import is_debug_mode @@ -174,12 +182,52 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack command_help = None if isinstance(raw_help, dict): command_help = {str(k): str(v) for k, v in raw_help.items()} + publisher: PublisherInfo | None = None + if isinstance(raw.get("publisher"), dict): + pub = raw["publisher"] + if pub.get("name") and pub.get("email"): + publisher = PublisherInfo( + name=str(pub["name"]), + email=str(pub["email"]), + attributes={ + str(k): str(v) for k, v in pub.items() if k not in ("name", "email") and isinstance(v, str) + }, + ) + integrity: IntegrityInfo | None = None + if isinstance(raw.get("integrity"), dict): + integ = raw["integrity"] + if integ.get("checksum"): + integrity = IntegrityInfo( + checksum=str(integ["checksum"]), + signature=str(integ["signature"]) if integ.get("signature") else None, + ) + module_deps_versioned: list[VersionedModuleDependency] = [] + for entry in raw.get("module_dependencies_versioned") or []: + if isinstance(entry, dict) and entry.get("name"): + module_deps_versioned.append( + VersionedModuleDependency( + name=str(entry["name"]), + version_specifier=str(entry["version_specifier"]) + if entry.get("version_specifier") + else None, + ) + ) + pip_deps_versioned: list[VersionedPipDependency] = [] + for entry in raw.get("pip_dependencies_versioned") or []: + if isinstance(entry, dict) and entry.get("name"): + pip_deps_versioned.append( + VersionedPipDependency( + name=str(entry["name"]), + version_specifier=str(entry["version_specifier"]) + if entry.get("version_specifier") + else None, + ) + ) validated_service_bridges: list[ServiceBridgeMetadata] = [] for bridge_entry in raw.get("service_bridges", []) or []: try: validated_service_bridges.append(ServiceBridgeMetadata.model_validate(bridge_entry)) except Exception: - # Keep startup resilient: malformed bridge declarations are skipped later. continue meta = ModulePackageMetadata( name=str(raw["name"]), @@ -192,6 +240,10 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack tier=str(raw.get("tier", "community")), addon_id=str(raw["addon_id"]) if raw.get("addon_id") else None, schema_version=str(raw["schema_version"]) if raw.get("schema_version") is not None else None, + publisher=publisher, + integrity=integrity, + module_dependencies_versioned=module_deps_versioned, + pip_dependencies_versioned=pip_deps_versioned, service_bridges=validated_service_bridges, ) result.append((child, meta)) @@ -709,14 +761,18 @@ def merge_module_state( def register_module_package_commands( enable_ids: list[str] | None = None, disable_ids: list[str] | None = None, + allow_unsigned: bool | None = None, ) -> None: """ Discover module packages, merge with modules.json state, register only enabled packages' commands. Call after register_builtin_commands(). enable_ids/disable_ids from CLI (--enable-module/--disable-module). + allow_unsigned: If True, allow modules without integrity metadata. Default from SPECFACT_ALLOW_UNSIGNED env. """ enable_ids = enable_ids or [] disable_ids = disable_ids or [] + if allow_unsigned is None: + allow_unsigned = os.environ.get("SPECFACT_ALLOW_UNSIGNED", "").strip().lower() in ("1", "true", "yes") packages = discover_all_package_metadata() packages = sorted(packages, key=_package_sort_key) if not packages: @@ -745,6 +801,9 @@ def register_module_package_commands( if not deps_ok: skipped.append((meta.name, f"missing dependencies: {', '.join(missing)}")) continue + if not verify_module_artifact(package_dir, meta, allow_unsigned=allow_unsigned): + skipped.append((meta.name, "integrity/trust check failed")) + continue if not _check_schema_compatibility(meta.schema_version, CURRENT_PROJECT_SCHEMA_VERSION): skipped.append( ( diff --git a/tests/unit/specfact_cli/registry/test_crypto_validator.py b/tests/unit/specfact_cli/registry/test_crypto_validator.py new file mode 100644 index 00000000..9f1239a5 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_crypto_validator.py @@ -0,0 +1,91 @@ +""" +Tests for module artifact checksum and signature verification (arch-06, spec: module-security). +""" + +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest + +from specfact_cli.registry.crypto_validator import ( + verify_checksum, + verify_signature, +) + + +def test_checksum_verification_succeeds_when_values_match(): + """When artifact checksum matches expected, verification SHALL pass.""" + data = b"module artifact content" + expected = "sha256:" + "a" * 64 # will be replaced by actual hash in impl + # Use real hash: hashlib.sha256(data).hexdigest() + import hashlib + + expected = "sha256:" + hashlib.sha256(data).hexdigest() + assert verify_checksum(data, expected) is True + + +def test_checksum_verification_fails_when_values_mismatch(): + """When artifact checksum does not match expected, verification SHALL fail with security error.""" + data = b"module artifact content" + wrong_checksum = "sha256:" + "f" * 64 + with pytest.raises((ValueError, Exception)) as exc_info: + verify_checksum(data, wrong_checksum) + assert "checksum" in str(exc_info.value).lower() or "mismatch" in str(exc_info.value).lower() + + +def test_checksum_verification_from_path(tmp_path: Path): + """Verify checksum from file path.""" + f = tmp_path / "artifact.bin" + f.write_bytes(b"file content") + import hashlib + + expected = "sha256:" + hashlib.sha256(b"file content").hexdigest() + assert verify_checksum(f, expected) is True + + +def test_checksum_verification_rejects_invalid_expected_format(): + """Invalid expected checksum format SHALL raise.""" + with pytest.raises((ValueError, Exception)): + verify_checksum(b"x", "not-algo:hex") + + +def test_signature_verification_succeeds_with_trusted_key(monkeypatch): + """When manifest includes signature and trusted key, verification SHALL validate provenance.""" + artifact = b"signed payload" + sig_b64 = base64.b64encode(b"mock_sig").decode("ascii") + key_pem = "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----" + monkeypatch.setattr( + "specfact_cli.registry.crypto_validator._verify_signature_impl", + lambda _a, _s, _k: True, + ) + assert verify_signature(artifact, sig_b64, key_pem) is True + + +def test_signature_verification_fails_when_validation_fails(monkeypatch): + """When signature validation fails against trusted key, SHALL fail with explicit error.""" + artifact = b"tampered" + sig_b64 = base64.b64encode(b"bad_sig").decode("ascii") + key_pem = "-----BEGIN PUBLIC KEY-----\nmock\n-----END PUBLIC KEY-----" + monkeypatch.setattr( + "specfact_cli.registry.crypto_validator._verify_signature_impl", + lambda _a, _s, _k: False, + ) + with pytest.raises((ValueError, Exception)) as exc_info: + verify_signature(artifact, sig_b64, key_pem) + assert "signature" in str(exc_info.value).lower() + + +def test_signature_verification_handles_missing_key(): + """Missing key material SHALL raise explicit error.""" + with pytest.raises((ValueError, TypeError, Exception)): + verify_signature(b"data", "c2ln", "") + + +def test_signature_verification_handles_missing_signature(): + """Missing signature SHALL raise or return False with clear semantics.""" + key_pem = "-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----" + result = verify_signature(b"data", "", key_pem) + assert result is False or result is True # implementation may skip when no sig + # Or raise; either way we document behavior diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 126518df..9bd0a5bb 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -2,6 +2,7 @@ Tests for module packages (spec: module-packages). Discovery finds packages with metadata.yaml; package loader loads only that package; registry receives commands. +Arch-06: publisher/integrity metadata and versioned dependency models. """ from __future__ import annotations @@ -12,12 +13,20 @@ import pytest +from specfact_cli.models.module_package import ( + IntegrityInfo, + ModulePackageMetadata, + PublisherInfo, + VersionedModuleDependency, + VersionedPipDependency, +) from specfact_cli.registry import CommandRegistry from specfact_cli.registry.module_packages import ( ModulePackageMetadata, discover_package_metadata, get_modules_root, merge_module_state, + register_module_package_commands, ) from specfact_cli.registry.module_state import read_modules_state, write_modules_state @@ -83,6 +92,203 @@ def test_merge_module_state_disable_override(): assert enabled["m1"] is False +# --- Arch-06: manifest security metadata models (TDD) --- + + +def test_publisher_info_model_captures_name_email_and_attributes(): + """PublisherInfo SHALL capture name, email, and optional publisher attributes.""" + pub = PublisherInfo(name="Acme", email="publish@acme.example") + assert pub.name == "Acme" + assert pub.email == "publish@acme.example" + assert getattr(pub, "attributes", None) is None or isinstance(pub.attributes, dict) + pub_with_attr = PublisherInfo(name="X", email="x@y.z", attributes={"url": "https://acme.example"}) + assert pub_with_attr.attributes == {"url": "https://acme.example"} + + +def test_integrity_info_model_captures_checksum_and_optional_signature(): + """IntegrityInfo SHALL capture checksum and optional signature fields.""" + valid_sha256 = "sha256:" + "a" * 64 + integrity = IntegrityInfo(checksum=valid_sha256) + assert integrity.checksum == valid_sha256 + assert getattr(integrity, "signature", None) is None or isinstance(integrity.signature, (str, type(None))) + integrity_signed = IntegrityInfo(checksum=valid_sha256, signature="base64sig...") + assert integrity_signed.signature == "base64sig..." + + +def test_integrity_info_validates_checksum_format(): + """IntegrityInfo validation SHALL ensure checksum format correctness.""" + IntegrityInfo(checksum="sha256:" + "a" * 64) + with pytest.raises((ValueError, Exception)): + IntegrityInfo(checksum="invalid-no-algo") + + +def test_versioned_module_dependency_parsed(): + """Versioned module dependency SHALL store name and version specifier.""" + dep = VersionedModuleDependency(name="backlog-core", version_specifier=">=0.1.0,<1.0") + assert dep.name == "backlog-core" + assert dep.version_specifier == ">=0.1.0,<1.0" + + +def test_versioned_pip_dependency_parsed(): + """Versioned pip dependency SHALL preserve name and version for installation-time resolution.""" + dep = VersionedPipDependency(name="requests", version_specifier=">=2.28.0") + assert dep.name == "requests" + assert dep.version_specifier == ">=2.28.0" + + +def test_manifest_parsing_includes_publisher_and_integrity(tmp_path: Path): + """Manifest with publisher and integrity metadata SHALL be parsed and available.""" + (tmp_path / "secure_pkg").mkdir() + (tmp_path / "secure_pkg" / "module-package.yaml").write_text( + """ +name: secure_pkg +version: '0.1.0' +commands: [cmd] +publisher: + name: Publisher Inc + email: dev@pub.example +integrity: + checksum: sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef +""", + encoding="utf-8", + ) + (tmp_path / "secure_pkg" / "src").mkdir(parents=True) + result = discover_package_metadata(tmp_path) + assert len(result) == 1 + _pkg_dir, meta = result[0] + assert meta.publisher is not None + assert meta.publisher.name == "Publisher Inc" + assert meta.publisher.email == "dev@pub.example" + assert meta.integrity is not None + assert meta.integrity.checksum.startswith("sha256:") + + +def test_manifest_parsing_versioned_module_dependency(tmp_path: Path): + """Manifest declaring module dependency with version specifier SHALL store both values.""" + (tmp_path / "with_deps").mkdir() + (tmp_path / "with_deps" / "module-package.yaml").write_text( + """ +name: with_deps +version: '0.1.0' +commands: [c] +module_dependencies_versioned: + - name: other-module + version_specifier: ">=0.2.0" +""", + encoding="utf-8", + ) + (tmp_path / "with_deps" / "src").mkdir(parents=True) + result = discover_package_metadata(tmp_path) + assert len(result) == 1 + _pkg_dir, meta = result[0] + assert hasattr(meta, "module_dependencies_versioned") + assert len(meta.module_dependencies_versioned) == 1 + assert meta.module_dependencies_versioned[0].name == "other-module" + assert meta.module_dependencies_versioned[0].version_specifier == ">=0.2.0" + + +def test_manifest_parsing_versioned_pip_dependency(tmp_path: Path): + """Manifest declaring pip dependency with version specifier SHALL preserve for resolution.""" + (tmp_path / "pip_deps").mkdir() + (tmp_path / "pip_deps" / "module-package.yaml").write_text( + """ +name: pip_deps +version: '0.1.0' +commands: [c] +pip_dependencies_versioned: + - name: pyyaml + version_specifier: ">=6.0" +""", + encoding="utf-8", + ) + (tmp_path / "pip_deps" / "src").mkdir(parents=True) + result = discover_package_metadata(tmp_path) + assert len(result) == 1 + _pkg_dir, meta = result[0] + assert hasattr(meta, "pip_dependencies_versioned") + assert len(meta.pip_dependencies_versioned) == 1 + assert meta.pip_dependencies_versioned[0].name == "pyyaml" + assert meta.pip_dependencies_versioned[0].version_specifier == ">=6.0" + + +def test_manifest_legacy_without_publisher_integrity_loads_successfully(tmp_path: Path): + """Bundles without publisher/integrity (legacy) SHALL load successfully (backward compatibility).""" + (tmp_path / "legacy_pkg").mkdir() + (tmp_path / "legacy_pkg" / "module-package.yaml").write_text( + "name: legacy_pkg\nversion: '0.1.0'\ncommands: [x]\n", + encoding="utf-8", + ) + (tmp_path / "legacy_pkg" / "src").mkdir(parents=True) + result = discover_package_metadata(tmp_path) + assert len(result) == 1 + _pkg_dir, meta = result[0] + assert meta.name == "legacy_pkg" + assert meta.publisher is None + assert meta.integrity is None + + +# --- Arch-06: installer and lifecycle trust enforcement (TDD) --- + + +def test_trust_check_rejects_on_checksum_mismatch(monkeypatch, tmp_path: Path): + """When artifact checksum does not match expected, module SHALL be skipped at registration.""" + from specfact_cli.registry import module_installer + + (tmp_path / "pkg").mkdir() + (tmp_path / "pkg" / "module-package.yaml").write_text( + "name: pkg\nversion: '0.1.0'\ncommands: [c]\n", encoding="utf-8" + ) + + def fail_checksum(_data, _expected): + raise ValueError("Checksum mismatch") + + monkeypatch.setattr(module_installer, "verify_checksum", fail_checksum) + from specfact_cli.models.module_package import IntegrityInfo + + meta = ModulePackageMetadata( + name="bad_checksum_mod", + version="0.1.0", + commands=["c"], + integrity=IntegrityInfo(checksum="sha256:" + "a" * 64), + ) + result = module_installer.verify_module_artifact(tmp_path / "pkg", meta, allow_unsigned=False) + assert result is False + + +def test_allow_unsigned_allows_module_without_integrity(monkeypatch): + """When allow_unsigned is True, module without integrity metadata MAY be allowed.""" + from specfact_cli.registry import module_installer + + meta = ModulePackageMetadata(name="no_integrity", version="0.1.0", commands=["c"], integrity=None) + pkg_dir = Path(__file__).parent + result = module_installer.verify_module_artifact(pkg_dir, meta, allow_unsigned=True) + assert result is True + + +def test_unaffected_modules_register_when_one_fails_trust(monkeypatch, tmp_path: Path): + """When one module fails integrity verification, other valid modules SHALL continue registration.""" + from specfact_cli.registry import module_packages as mp + + for name, cmd in (("good", "good_cmd"), ("bad_trust", "bad_cmd")): + (tmp_path / name).mkdir() + (tmp_path / name / "module-package.yaml").write_text( + f"name: {name}\nversion: '0.1.0'\ncommands: [{cmd}]\n", encoding="utf-8" + ) + (tmp_path / name / "src").mkdir(parents=True) + (tmp_path / name / "src" / "app.py").write_text("app = None", encoding="utf-8") + + def verify_may_fail(_package_dir: Path, meta, allow_unsigned: bool = False): + return meta.name != "bad_trust" + + monkeypatch.setattr(mp, "verify_module_artifact", verify_may_fail) + monkeypatch.setattr(mp, "get_modules_root", lambda: tmp_path) + monkeypatch.setattr(mp, "read_modules_state", dict) + register_module_package_commands() + names = CommandRegistry.list_commands() + assert "good_cmd" in names + assert "bad_cmd" not in names + + def test_module_state_read_write(tmp_path: Path): """read_modules_state / write_modules_state roundtrip.""" os.environ["SPECFACT_REGISTRY_DIR"] = str(tmp_path) diff --git a/tests/unit/specfact_cli/registry/test_signing_artifacts.py b/tests/unit/specfact_cli/registry/test_signing_artifacts.py new file mode 100644 index 00000000..6aa0d1d7 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_signing_artifacts.py @@ -0,0 +1,55 @@ +""" +Tests for signing automation artifacts (arch-06): script and CI workflow. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[4] +SIGN_SCRIPT = REPO_ROOT / "scripts" / "sign-module.sh" +SIGN_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "sign-modules.yml" + + +def test_sign_module_script_exists(): + """Signing script scripts/sign-module.sh SHALL exist.""" + assert SIGN_SCRIPT.exists(), "scripts/sign-module.sh must exist for signing automation" + + +def test_sign_module_script_invocation_prints_or_produces_checksum(tmp_path: Path): + """Signing script invocation SHALL produce or emit checksum for manifest integrity.""" + if not SIGN_SCRIPT.exists(): + pytest.skip("sign-module.sh not present") + manifest = tmp_path / "module-package.yaml" + manifest.write_text("name: test\nversion: 0.1.0\ncommands: [c]\n", encoding="utf-8") + import subprocess + + result = subprocess.run( + ["bash", str(SIGN_SCRIPT), str(manifest)], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert result.returncode == 0 or result.stderr or result.stdout + if result.returncode == 0 and result.stdout: + assert "sha256:" in result.stdout or "checksum" in result.stdout.lower() + + +def test_sign_modules_workflow_exists(): + """CI workflow .github/workflows/sign-modules.yml SHALL exist.""" + assert SIGN_WORKFLOW.exists(), "sign-modules.yml workflow must exist" + + +def test_sign_modules_workflow_valid_yaml(): + """Sign-modules workflow file SHALL be valid YAML.""" + if not SIGN_WORKFLOW.exists(): + pytest.skip("workflow not present") + import yaml + + data = yaml.safe_load(SIGN_WORKFLOW.read_text(encoding="utf-8")) + assert data is not None + assert isinstance(data, dict) From 6efd45ff40da96adc52e3893d70c163ca79ee4cb Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 16 Feb 2026 23:01:27 +0100 Subject: [PATCH 2/4] fix: remove duplicate ModulePackageMetadata import (ruff F811) --- tests/unit/specfact_cli/registry/test_module_packages.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 9bd0a5bb..e1c5103e 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -22,7 +22,6 @@ ) from specfact_cli.registry import CommandRegistry from specfact_cli.registry.module_packages import ( - ModulePackageMetadata, discover_package_metadata, get_modules_root, merge_module_state, From f628d5ff75711774f2511202b204f260bc59cbdb Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 16 Feb 2026 23:23:30 +0100 Subject: [PATCH 3/4] Fix failed tests --- .../test_module_bridge_registration.py | 16 +++---- .../registry/test_module_packages.py | 44 ++++++++----------- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/tests/unit/registry/test_module_bridge_registration.py b/tests/unit/registry/test_module_bridge_registration.py index 11793d2a..93ab6f1d 100644 --- a/tests/unit/registry/test_module_bridge_registration.py +++ b/tests/unit/registry/test_module_bridge_registration.py @@ -34,11 +34,9 @@ def test_register_module_package_commands_registers_declared_bridges(monkeypatch registry = BridgeRegistry() converter_path = f"{__name__}._TestConverter" - monkeypatch.setattr( - module_packages, - "discover_package_metadata", - lambda _root: [(tmp_path, _metadata_with_bridges(converter_class=converter_path))], - ) + packages = [(tmp_path, _metadata_with_bridges(converter_class=converter_path))] + monkeypatch.setattr(module_packages, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(module_packages, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages, "read_modules_state", dict) monkeypatch.setattr(module_packages, "_make_package_loader", lambda *_args: object) monkeypatch.setattr(module_packages, "_load_package_module", lambda *_args: object()) @@ -53,11 +51,9 @@ def test_invalid_bridge_declaration_is_non_fatal(monkeypatch, tmp_path: Path) -> """Invalid bridge declarations should be skipped with warnings.""" CommandRegistry._clear_for_testing() registry = BridgeRegistry() - monkeypatch.setattr( - module_packages, - "discover_package_metadata", - lambda _root: [(tmp_path, _metadata_with_bridges(converter_class="invalid.path.MissingConverter"))], - ) + packages = [(tmp_path, _metadata_with_bridges(converter_class="invalid.path.MissingConverter"))] + monkeypatch.setattr(module_packages, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(module_packages, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages, "read_modules_state", dict) monkeypatch.setattr(module_packages, "_make_package_loader", lambda *_args: object) monkeypatch.setattr(module_packages, "_load_package_module", lambda *_args: object()) diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index e1c5103e..7f810bac 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -242,13 +242,12 @@ def fail_checksum(_data, _expected): raise ValueError("Checksum mismatch") monkeypatch.setattr(module_installer, "verify_checksum", fail_checksum) - from specfact_cli.models.module_package import IntegrityInfo meta = ModulePackageMetadata( name="bad_checksum_mod", version="0.1.0", commands=["c"], - integrity=IntegrityInfo(checksum="sha256:" + "a" * 64), + integrity={"checksum": "sha256:" + "a" * 64, "signature": None}, ) result = module_installer.verify_module_artifact(tmp_path / "pkg", meta, allow_unsigned=False) assert result is False @@ -348,7 +347,8 @@ def test_protocol_reporting_classifies_full_partial_legacy_from_static_source( (tmp_path / "partial", ModulePackageMetadata(name="partial", commands=[])), (tmp_path / "legacy", ModulePackageMetadata(name="legacy", commands=[])), ] - monkeypatch.setattr(module_packages_impl, "discover_package_metadata", lambda _root: metadata) + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", lambda: metadata) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) monkeypatch.setattr( module_packages_impl, @@ -375,11 +375,9 @@ def test_protocol_legacy_warning_emitted_once_per_module(monkeypatch, caplog, tm test_logger.propagate = True monkeypatch.setattr(module_packages_impl, "is_debug_mode", lambda: True) monkeypatch.setattr(module_packages_impl, "get_bridge_logger", lambda _name: test_logger) - monkeypatch.setattr( - module_packages_impl, - "discover_package_metadata", - lambda _root: [(tmp_path / "legacy", ModulePackageMetadata(name="legacy", commands=[]))], - ) + packages = [(tmp_path / "legacy", ModulePackageMetadata(name="legacy", commands=[]))] + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) monkeypatch.setattr(module_packages_impl, "_check_protocol_compliance_from_source", lambda *_args: []) @@ -399,11 +397,9 @@ def test_protocol_reporting_uses_static_source_operations(monkeypatch, caplog, t test_logger.propagate = True monkeypatch.setattr(module_packages_impl, "is_debug_mode", lambda: True) monkeypatch.setattr(module_packages_impl, "get_bridge_logger", lambda _name: test_logger) - monkeypatch.setattr( - module_packages_impl, - "discover_package_metadata", - lambda _root: [(tmp_path / "backlog", ModulePackageMetadata(name="backlog", commands=[]))], - ) + packages = [(tmp_path / "backlog", ModulePackageMetadata(name="backlog", commands=[]))] + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) monkeypatch.setattr(module_packages_impl, "_check_protocol_compliance_from_source", lambda *_args: ["import"]) @@ -439,14 +435,12 @@ def test_protocol_reporting_is_quiet_when_all_modules_are_fully_compliant(monkey test_logger.propagate = True monkeypatch.setattr(module_packages_impl, "is_debug_mode", lambda: False) monkeypatch.setattr(module_packages_impl, "get_bridge_logger", lambda _name: test_logger) - monkeypatch.setattr( - module_packages_impl, - "discover_package_metadata", - lambda _root: [ - (tmp_path / "full-a", ModulePackageMetadata(name="full-a", commands=[])), - (tmp_path / "full-b", ModulePackageMetadata(name="full-b", commands=[])), - ], - ) + packages = [ + (tmp_path / "full-a", ModulePackageMetadata(name="full-a", commands=[])), + (tmp_path / "full-b", ModulePackageMetadata(name="full-b", commands=[])), + ] + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) monkeypatch.setattr( module_packages_impl, @@ -468,11 +462,9 @@ def test_protocol_reporting_uses_user_friendly_messages_for_non_compliant_module monkeypatch.setattr(module_packages_impl, "is_debug_mode", lambda: False) monkeypatch.setattr(module_packages_impl, "print_warning", shown_messages.append) - monkeypatch.setattr( - module_packages_impl, - "discover_package_metadata", - lambda _root: [(tmp_path / "partial-a", ModulePackageMetadata(name="partial-a", commands=[]))], - ) + packages = [(tmp_path / "partial-a", ModulePackageMetadata(name="partial-a", commands=[]))] + monkeypatch.setattr(module_packages_impl, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(module_packages_impl, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) monkeypatch.setattr(module_packages_impl, "read_modules_state", dict) monkeypatch.setattr(module_packages_impl, "_check_protocol_compliance_from_source", lambda *_args: ["import"]) From 5fb53bec8c078a13d60538bcad1102d52606e62e Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 16 Feb 2026 23:39:22 +0100 Subject: [PATCH 4/4] Fix type-check errors --- .gitignore | 4 +++- src/specfact_cli/registry/crypto_validator.py | 12 ++++-------- .../specfact_cli/registry/test_module_packages.py | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 0adb289b..6165cc48 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,6 @@ harness_contracts.py # semgrep artifacts lang.json Language.ml -Language.mli \ No newline at end of file +Language.mli + +.artifacts diff --git a/src/specfact_cli/registry/crypto_validator.py b/src/specfact_cli/registry/crypto_validator.py index de9edb07..9393de63 100644 --- a/src/specfact_cli/registry/crypto_validator.py +++ b/src/specfact_cli/registry/crypto_validator.py @@ -65,8 +65,7 @@ def _verify_signature_impl(artifact: bytes, signature_b64: str, public_key_pem: try: from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import padding - from cryptography.hazmat.primitives.asymmetric.types import PublicKeyTypes + from cryptography.hazmat.primitives.asymmetric import ed25519, padding, rsa except ImportError as e: raise ValueError( "Signature verification requires the 'cryptography' package. Install with: pip install cryptography" @@ -81,22 +80,19 @@ def _verify_signature_impl(artifact: bytes, signature_b64: str, public_key_pem: sig_bytes = base64.b64decode(signature_b64, validate=True) except Exception as e: raise ValueError(f"Invalid base64 signature: {e}") from e - if not isinstance(key, PublicKeyTypes): - raise ValueError("Key must be a public key type") - # RSASSA-PKCS1v1_5 or PSS; Ed25519 uses different API. Try common path. - if hasattr(key, "key_size"): # RSA + if isinstance(key, rsa.RSAPublicKey): try: key.verify(sig_bytes, artifact, padding.PKCS1v15(), hashes.SHA256()) return True except InvalidSignature: return False - if hasattr(key, "verify"): # Ed25519 etc + if isinstance(key, ed25519.Ed25519PublicKey): try: key.verify(sig_bytes, artifact) return True except InvalidSignature: return False - raise ValueError("Unsupported key type for signature verification") + raise ValueError("Unsupported key type for signature verification (RSA or Ed25519 only)") @beartype diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 7f810bac..546c887d 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -247,7 +247,7 @@ def fail_checksum(_data, _expected): name="bad_checksum_mod", version="0.1.0", commands=["c"], - integrity={"checksum": "sha256:" + "a" * 64, "signature": None}, + integrity=IntegrityInfo(checksum="sha256:" + "a" * 64, signature=None), ) result = module_installer.verify_module_artifact(tmp_path / "pkg", meta, allow_unsigned=False) assert result is False