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