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
29 changes: 29 additions & 0 deletions .github/workflows/sign-modules.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,6 @@ harness_contracts.py
# semgrep artifacts
lang.json
Language.ml
Language.mli
Language.mli

.artifacts
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.1] - 2026-02-16

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions docs/_layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ <h2 class="docs-sidebar-title">
<li><a href="{{ '/directory-structure/' | relative_url }}">Directory Structure</a></li>
<li><a href="{{ '/reference/projectbundle-schema/' | relative_url }}">ProjectBundle Schema</a></li>
<li><a href="{{ '/reference/module-contracts/' | relative_url }}">Module Contracts</a></li>
<li><a href="{{ '/reference/module-security/' | relative_url }}">Module Security</a></li>
<li><a href="{{ '/reference/bridge-registry/' | relative_url }}">Bridge Registry</a></li>
<li><a href="{{ '/guides/integrations-overview/' | relative_url }}">Integrations Overview</a></li>
</ul>
Expand Down
33 changes: 33 additions & 0 deletions docs/reference/module-security.md
Original file line number Diff line number Diff line change
@@ -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 <path-to-module-package.yaml>` 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.
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 = "specfact-cli"
version = "0.31.1"
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"
Expand Down
19 changes: 19 additions & 0 deletions scripts/sign-module.sh
Original file line number Diff line number Diff line change
@@ -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 <path-to-module-package.yaml>" >&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
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
if __name__ == "__main__":
_setup = setup(
name="specfact-cli",
version="0.31.1",
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."
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"""

# Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py
__version__ = "0.31.1"
__version__ = "0.32.0"
2 changes: 1 addition & 1 deletion src/specfact_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
- Supporting agile ceremonies and team workflows
"""

__version__ = "0.31.1"
__version__ = "0.32.0"

__all__ = ["__version__"]
61 changes: 61 additions & 0 deletions src/specfact_cli/models/module_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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.",
Expand Down
124 changes: 124 additions & 0 deletions src/specfact_cli/registry/crypto_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""
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 ed25519, padding, rsa
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 isinstance(key, rsa.RSAPublicKey):
try:
key.verify(sig_bytes, artifact, padding.PKCS1v15(), hashes.SHA256())
return True
except InvalidSignature:
return False
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 (RSA or Ed25519 only)")


@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
Loading
Loading