diff --git a/README.md b/README.md index 73915cc..ab9daaa 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ It provides: - a versioned schema registry - append-only SQLite ledger storage - tamper-evident hash chaining +- detached Ed25519 signing for build contracts - delta-sync packets for partial connectivity - anchor records for external attestation - CI context adapters for GitHub Actions, GitLab CI, and CircleCI @@ -49,6 +50,8 @@ python -m buildledger schema-manifest python -m buildledger init-ledger buildledger.sqlite python -m buildledger append-sample buildledger.sqlite python -m buildledger verify buildledger.sqlite +python -m buildledger sign-contract contract.json private-key.pem --output signed-contract.json +python -m buildledger verify-contract signed-contract.json public-key.pem ``` ## Testing @@ -59,4 +62,4 @@ python -m buildledger verify buildledger.sqlite ## Status -This repository currently implements the core ledger, schema, sync, anchor, and CI-adapter foundation. Registry publishing adapters and full distributed integrations can be added on top of this base. +This repository currently implements the core ledger, schema, sync, anchor, CI-adapter, and contract-signing foundation. Registry publishing adapters and full distributed integrations can be added on top of this base. diff --git a/buildledger/cli.py b/buildledger/cli.py index 69c292d..3f78c48 100644 --- a/buildledger/cli.py +++ b/buildledger/cli.py @@ -39,6 +39,15 @@ def build_parser() -> argparse.ArgumentParser: gh = sub.add_parser("from-github") gh.add_argument("context_json") + sign = sub.add_parser("sign-contract") + sign.add_argument("contract_json") + sign.add_argument("private_key_pem") + sign.add_argument("--output") + + verify_contract = sub.add_parser("verify-contract") + verify_contract.add_argument("contract_json") + verify_contract.add_argument("public_key_pem") + return parser @@ -87,6 +96,26 @@ def main(argv: list[str] | None = None) -> int: print(contract.model_dump_json(indent=2)) return 0 + if args.command == "sign-contract": + from .models import BuildContract + + contract = BuildContract.model_validate(json.loads(Path(args.contract_json).read_text(encoding="utf-8"))) + signed = contract.sign(Path(args.private_key_pem).read_text(encoding="utf-8")) + output = signed.model_dump_json(indent=2) + if args.output: + Path(args.output).write_text(output + "\n", encoding="utf-8") + else: + print(output) + return 0 + + if args.command == "verify-contract": + from .models import BuildContract + + contract = BuildContract.model_validate(json.loads(Path(args.contract_json).read_text(encoding="utf-8"))) + ok = contract.verify_signature(Path(args.public_key_pem).read_text(encoding="utf-8")) + print(json.dumps({"ok": ok}, indent=2)) + return 0 if ok else 1 + return 1 diff --git a/buildledger/models.py b/buildledger/models.py index 04f812e..8faa3b7 100644 --- a/buildledger/models.py +++ b/buildledger/models.py @@ -1,11 +1,16 @@ from __future__ import annotations import hashlib +import base64 +import binascii import json from datetime import datetime, timezone from enum import Enum from typing import Any, Literal +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -149,8 +154,54 @@ class BuildContract(StrictModel): signature: str | None = None notes: list[str] = Field(default_factory=list) - def canonical_payload(self) -> dict[str, Any]: - return self.model_dump(mode="python") + def canonical_payload(self, *, include_signature: bool = False) -> dict[str, Any]: + payload = self.model_dump(mode="python") + if not include_signature: + payload.pop("signature", None) + return payload + + def signing_bytes(self) -> bytes: + return canonical_json(self.canonical_payload()).encode("utf-8") + + def sign( + self, + private_key: Ed25519PrivateKey | bytes | str, + ) -> "BuildContract": + key = _load_private_key(private_key) + signature = base64.b64encode(key.sign(self.signing_bytes())).decode("ascii") + return self.model_copy(update={"signature": signature}) + + def verify_signature(self, public_key: Ed25519PublicKey | bytes | str) -> bool: + if self.signature is None: + return False + key = _load_public_key(public_key) + try: + key.verify(base64.b64decode(self.signature), self.signing_bytes()) + except (ValueError, TypeError, binascii.Error, InvalidSignature): + return False + return True + + +def _load_private_key(private_key: Ed25519PrivateKey | bytes | str) -> Ed25519PrivateKey: + if isinstance(private_key, Ed25519PrivateKey): + return private_key + if isinstance(private_key, str): + private_key = private_key.encode("utf-8") + key = load_pem_private_key(private_key, password=None) + if not isinstance(key, Ed25519PrivateKey): + raise TypeError("expected an Ed25519 private key") + return key + + +def _load_public_key(public_key: Ed25519PublicKey | bytes | str) -> Ed25519PublicKey: + if isinstance(public_key, Ed25519PublicKey): + return public_key + if isinstance(public_key, str): + public_key = public_key.encode("utf-8") + key = load_pem_public_key(public_key) + if not isinstance(key, Ed25519PublicKey): + raise TypeError("expected an Ed25519 public key") + return key class LedgerEntry(StrictModel): diff --git a/pyproject.toml b/pyproject.toml index a2aea88..4713498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "BuildLedger captures, verifies, and syncs reproducible build provenance across registries." readme = "README.md" requires-python = ">=3.11" -dependencies = ["pydantic>=2.7,<3"] +dependencies = ["pydantic>=2.7,<3", "cryptography>=42,<45"] [project.optional-dependencies] test = ["pytest>=8", "build>=1.2"] diff --git a/tests/test_buildledger.py b/tests/test_buildledger.py index c5fcdc8..1a9164a 100644 --- a/tests/test_buildledger.py +++ b/tests/test_buildledger.py @@ -4,9 +4,12 @@ import json from pathlib import Path import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from buildledger.adapters import GitHubActionsAdapter from buildledger.anchors import AnchorStore +from buildledger.cli import main as cli_main from buildledger.ledger import SQLiteLedger from buildledger.models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, ReproManifest from buildledger.schemas import SchemaRegistry @@ -51,10 +54,34 @@ def make_contract() -> BuildContract: ) +def write_ed25519_keypair(tmp_path: Path) -> tuple[Path, Path]: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key() + private_key_path = tmp_path / "private.pem" + public_key_path = tmp_path / "public.pem" + private_key_path.write_text( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8"), + encoding="utf-8", + ) + public_key_path.write_text( + public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8"), + encoding="utf-8", + ) + return private_key_path, public_key_path + + def test_schema_registry_validates_contract() -> None: registry = SchemaRegistry.with_defaults() contract = make_contract() loaded = registry.validate("BuildContract", "1.0", contract.model_dump(mode="python")) + assert isinstance(loaded, BuildContract) assert loaded.contract_id == "contract-1" assert registry.manifest()["BuildContract"] == ["1.0"] @@ -108,3 +135,25 @@ def test_ci_adapter_generates_contract() -> None: assert contract.build_object.commit == "abc123" assert contract.environment.variables["env.PYTHONHASHSEED"] == "0" assert contract.manifest.run_count == 2 + + +def test_contract_signing_and_cli_round_trip(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + private_key_path, public_key_path = write_ed25519_keypair(tmp_path) + contract = make_contract() + + signed = contract.sign(private_key_path.read_text(encoding="utf-8")) + assert signed.signature is not None + assert signed.verify_signature(public_key_path.read_text(encoding="utf-8")) + + tampered = signed.model_copy(update={"notes": ["tampered"]}) + assert not tampered.verify_signature(public_key_path.read_text(encoding="utf-8")) + + contract_path = tmp_path / "contract.json" + signed_path = tmp_path / "signed.json" + contract_path.write_text(contract.model_dump_json(indent=2) + "\n", encoding="utf-8") + + assert cli_main(["sign-contract", str(contract_path), str(private_key_path), "--output", str(signed_path)]) == 0 + assert cli_main(["verify-contract", str(signed_path), str(public_key_path)]) == 0 + + out = capsys.readouterr().out + assert json.loads(out)["ok"] is True