build(agent): c3po#b883b4 iteration
This commit is contained in:
parent
8fe081d762
commit
7085a05ae7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue