build(agent): c3po#b883b4 iteration

This commit is contained in:
agent-b883b4bc188823a2 2026-04-25 21:40:46 +02:00
parent 8fe081d762
commit 7085a05ae7
5 changed files with 136 additions and 4 deletions

View File

@ -8,6 +8,7 @@ It provides:
- a versioned schema registry - a versioned schema registry
- append-only SQLite ledger storage - append-only SQLite ledger storage
- tamper-evident hash chaining - tamper-evident hash chaining
- detached Ed25519 signing for build contracts
- delta-sync packets for partial connectivity - delta-sync packets for partial connectivity
- anchor records for external attestation - anchor records for external attestation
- CI context adapters for GitHub Actions, GitLab CI, and CircleCI - 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 init-ledger buildledger.sqlite
python -m buildledger append-sample buildledger.sqlite python -m buildledger append-sample buildledger.sqlite
python -m buildledger verify 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 ## Testing
@ -59,4 +62,4 @@ python -m buildledger verify buildledger.sqlite
## Status ## 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.

View File

@ -39,6 +39,15 @@ def build_parser() -> argparse.ArgumentParser:
gh = sub.add_parser("from-github") gh = sub.add_parser("from-github")
gh.add_argument("context_json") 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 return parser
@ -87,6 +96,26 @@ def main(argv: list[str] | None = None) -> int:
print(contract.model_dump_json(indent=2)) print(contract.model_dump_json(indent=2))
return 0 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 return 1

View File

@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import base64
import binascii
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any, Literal 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 from pydantic import BaseModel, ConfigDict, Field, field_validator
@ -149,8 +154,54 @@ class BuildContract(StrictModel):
signature: str | None = None signature: str | None = None
notes: list[str] = Field(default_factory=list) notes: list[str] = Field(default_factory=list)
def canonical_payload(self) -> dict[str, Any]: def canonical_payload(self, *, include_signature: bool = False) -> dict[str, Any]:
return self.model_dump(mode="python") 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): class LedgerEntry(StrictModel):

View File

@ -8,7 +8,7 @@ version = "0.1.0"
description = "BuildLedger captures, verifies, and syncs reproducible build provenance across registries." description = "BuildLedger captures, verifies, and syncs reproducible build provenance across registries."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["pydantic>=2.7,<3"] dependencies = ["pydantic>=2.7,<3", "cryptography>=42,<45"]
[project.optional-dependencies] [project.optional-dependencies]
test = ["pytest>=8", "build>=1.2"] test = ["pytest>=8", "build>=1.2"]

View File

@ -4,9 +4,12 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from buildledger.adapters import GitHubActionsAdapter from buildledger.adapters import GitHubActionsAdapter
from buildledger.anchors import AnchorStore from buildledger.anchors import AnchorStore
from buildledger.cli import main as cli_main
from buildledger.ledger import SQLiteLedger from buildledger.ledger import SQLiteLedger
from buildledger.models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, ReproManifest from buildledger.models import Artifact, BuildContract, BuildEnvironment, BuildLog, BuildObject, DependencyGraph, DependencyNode, ReproManifest
from buildledger.schemas import SchemaRegistry 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: def test_schema_registry_validates_contract() -> None:
registry = SchemaRegistry.with_defaults() registry = SchemaRegistry.with_defaults()
contract = make_contract() contract = make_contract()
loaded = registry.validate("BuildContract", "1.0", contract.model_dump(mode="python")) loaded = registry.validate("BuildContract", "1.0", contract.model_dump(mode="python"))
assert isinstance(loaded, BuildContract)
assert loaded.contract_id == "contract-1" assert loaded.contract_id == "contract-1"
assert registry.manifest()["BuildContract"] == ["1.0"] 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.build_object.commit == "abc123"
assert contract.environment.variables["env.PYTHONHASHSEED"] == "0" assert contract.environment.variables["env.PYTHONHASHSEED"] == "0"
assert contract.manifest.run_count == 2 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