chore: initialize monorepo with project scaffolding, configs, and CI setup
This commit is contained in:
13
packages/py/aitbc-crypto/pyproject.toml
Normal file
13
packages/py/aitbc-crypto/pyproject.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "aitbc-crypto"
|
||||
version = "0.1.0"
|
||||
description = "AITBC cryptographic utilities"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pydantic>=2.7.0",
|
||||
"pynacl>=1.5.0"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
4
packages/py/aitbc-crypto/src/__init__.py
Normal file
4
packages/py/aitbc-crypto/src/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""AITBC crypto utilities package."""
|
||||
|
||||
from . import receipt # noqa: F401
|
||||
from . import signing # noqa: F401
|
||||
11
packages/py/aitbc-crypto/src/aitbc_crypto/__init__.py
Normal file
11
packages/py/aitbc-crypto/src/aitbc_crypto/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""AITBC cryptographic helpers for receipts."""
|
||||
|
||||
from .receipt import canonical_json, receipt_hash
|
||||
from .signing import ReceiptSigner, ReceiptVerifier
|
||||
|
||||
__all__ = [
|
||||
"canonical_json",
|
||||
"receipt_hash",
|
||||
"ReceiptSigner",
|
||||
"ReceiptVerifier",
|
||||
]
|
||||
23
packages/py/aitbc-crypto/src/aitbc_crypto/receipt.py
Normal file
23
packages/py/aitbc-crypto/src/aitbc_crypto/receipt.py
Normal file
@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import json
|
||||
from hashlib import sha256
|
||||
|
||||
|
||||
def canonical_json(receipt: Dict[str, Any]) -> str:
|
||||
def remove_none(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: remove_none(v) for k, v in obj.items() if v is not None}
|
||||
if isinstance(obj, list):
|
||||
return [remove_none(x) for x in obj if x is not None]
|
||||
return obj
|
||||
|
||||
cleaned = remove_none(receipt)
|
||||
return json.dumps(cleaned, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def receipt_hash(receipt: Dict[str, Any]) -> bytes:
|
||||
data = canonical_json(receipt).encode("utf-8")
|
||||
return sha256(data).digest()
|
||||
51
packages/py/aitbc-crypto/src/aitbc_crypto/signing.py
Normal file
51
packages/py/aitbc-crypto/src/aitbc_crypto/signing.py
Normal file
@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import base64
|
||||
|
||||
from nacl.signing import SigningKey, VerifyKey
|
||||
|
||||
from .receipt import canonical_json
|
||||
|
||||
|
||||
def _urlsafe_b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _urlsafe_b64decode(data: str) -> bytes:
|
||||
padding = '=' * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(data + padding)
|
||||
|
||||
|
||||
class ReceiptSigner:
|
||||
def __init__(self, signing_key: bytes):
|
||||
self._key = SigningKey(signing_key)
|
||||
|
||||
def sign(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
message = canonical_json(payload).encode("utf-8")
|
||||
signed = self._key.sign(message)
|
||||
return {
|
||||
"alg": "Ed25519",
|
||||
"key_id": _urlsafe_b64encode(self._key.verify_key.encode()),
|
||||
"sig": _urlsafe_b64encode(signed.signature),
|
||||
}
|
||||
|
||||
|
||||
class ReceiptVerifier:
|
||||
def __init__(self, verify_key: bytes):
|
||||
self._key = VerifyKey(verify_key)
|
||||
|
||||
def verify(self, payload: Dict[str, Any], signature: Dict[str, Any]) -> bool:
|
||||
if signature.get("alg") != "Ed25519":
|
||||
return False
|
||||
sig_field = signature.get("sig")
|
||||
if not isinstance(sig_field, str):
|
||||
return False
|
||||
message = canonical_json(payload).encode("utf-8")
|
||||
sig_bytes = _urlsafe_b64decode(sig_field)
|
||||
try:
|
||||
self._key.verify(message, sig_bytes)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
47
packages/py/aitbc-crypto/src/receipt.py
Normal file
47
packages/py/aitbc-crypto/src/receipt.py
Normal file
@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
|
||||
import json
|
||||
from hashlib import sha256
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Receipt(BaseModel):
|
||||
version: str
|
||||
receipt_id: str
|
||||
job_id: str
|
||||
provider: str
|
||||
client: str
|
||||
units: float
|
||||
unit_type: str
|
||||
started_at: int
|
||||
completed_at: int
|
||||
price: float | None = None
|
||||
model: str | None = None
|
||||
prompt_hash: str | None = None
|
||||
duration_ms: int | None = None
|
||||
artifact_hash: str | None = None
|
||||
coordinator_id: str | None = None
|
||||
nonce: str | None = None
|
||||
chain_id: int | None = None
|
||||
metadata: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
def canonical_json(receipt: Dict[str, Any]) -> str:
|
||||
def remove_none(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: remove_none(v) for k, v in obj.items() if v is not None}
|
||||
if isinstance(obj, list):
|
||||
return [remove_none(x) for x in obj if x is not None]
|
||||
return obj
|
||||
|
||||
cleaned = remove_none(receipt)
|
||||
return json.dumps(cleaned, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
|
||||
def receipt_hash(receipt: Dict[str, Any]) -> bytes:
|
||||
data = canonical_json(receipt).encode("utf-8")
|
||||
return sha256(data).digest()
|
||||
40
packages/py/aitbc-crypto/src/signing.py
Normal file
40
packages/py/aitbc-crypto/src/signing.py
Normal file
@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Any
|
||||
|
||||
import base64
|
||||
from hashlib import sha256
|
||||
|
||||
from nacl.signing import SigningKey, VerifyKey
|
||||
|
||||
from .receipt import canonical_json
|
||||
|
||||
|
||||
class ReceiptSigner:
|
||||
def __init__(self, signing_key: bytes):
|
||||
self._key = SigningKey(signing_key)
|
||||
|
||||
def sign(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
message = canonical_json(payload).encode("utf-8")
|
||||
signature = self._key.sign(message)
|
||||
return {
|
||||
"alg": "Ed25519",
|
||||
"key_id": base64.urlsafe_b64encode(self._key.verify_key.encode()).decode("utf-8").rstrip("="),
|
||||
"sig": base64.urlsafe_b64encode(signature.signature).decode("utf-8").rstrip("="),
|
||||
}
|
||||
|
||||
|
||||
class ReceiptVerifier:
|
||||
def __init__(self, verify_key: bytes):
|
||||
self._key = VerifyKey(verify_key)
|
||||
|
||||
def verify(self, payload: Dict[str, Any], signature: Dict[str, Any]) -> bool:
|
||||
if signature.get("alg") != "Ed25519":
|
||||
return False
|
||||
sig_bytes = base64.urlsafe_b64decode(signature["sig"] + "==")
|
||||
message = canonical_json(payload).encode("utf-8")
|
||||
try:
|
||||
self._key.verify(message, sig_bytes)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
45
packages/py/aitbc-crypto/tests/test_receipt_signing.py
Normal file
45
packages/py/aitbc-crypto/tests/test_receipt_signing.py
Normal file
@ -0,0 +1,45 @@
|
||||
import base64
|
||||
|
||||
from nacl.signing import SigningKey
|
||||
|
||||
from aitbc_crypto.receipt import canonical_json, receipt_hash
|
||||
from aitbc_crypto.signing import ReceiptSigner, ReceiptVerifier
|
||||
|
||||
|
||||
def test_canonical_json_orders_keys():
|
||||
payload = {
|
||||
"b": 2,
|
||||
"a": 1,
|
||||
"nested": {
|
||||
"y": None,
|
||||
"x": 5,
|
||||
},
|
||||
}
|
||||
json_str = canonical_json(payload)
|
||||
assert json_str == '{"a":1,"b":2,"nested":{"x":5}}'
|
||||
|
||||
|
||||
def test_receipt_sign_and_verify():
|
||||
payload = {
|
||||
"version": "1.0",
|
||||
"receipt_id": "rcpt-1",
|
||||
"job_id": "job-1",
|
||||
"provider": "miner-1",
|
||||
"client": "client-1",
|
||||
"units": 1.0,
|
||||
"unit_type": "gpu_seconds",
|
||||
"started_at": 1695720000,
|
||||
"completed_at": 1695720005,
|
||||
}
|
||||
|
||||
signing_key = SigningKey.generate()
|
||||
signer = ReceiptSigner(signing_key.encode())
|
||||
signature = signer.sign(payload)
|
||||
|
||||
verifier = ReceiptVerifier(signing_key.verify_key.encode())
|
||||
assert verifier.verify(payload, signature)
|
||||
|
||||
# tamper payload
|
||||
tampered = payload.copy()
|
||||
tampered["units"] = 2.0
|
||||
assert verifier.verify(tampered, signature) is False
|
||||
14
packages/py/aitbc-sdk/pyproject.toml
Normal file
14
packages/py/aitbc-sdk/pyproject.toml
Normal file
@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "aitbc-sdk"
|
||||
version = "0.1.0"
|
||||
description = "AITBC client SDK for interacting with coordinator services"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx>=0.27.0",
|
||||
"pydantic>=2.7.0",
|
||||
"aitbc-crypto @ file://../aitbc-crypto"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
17
packages/py/aitbc-sdk/src/aitbc_sdk/__init__.py
Normal file
17
packages/py/aitbc-sdk/src/aitbc_sdk/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""AITBC Python SDK utilities."""
|
||||
|
||||
from .receipts import (
|
||||
CoordinatorReceiptClient,
|
||||
ReceiptVerification,
|
||||
SignatureValidation,
|
||||
verify_receipt,
|
||||
verify_receipts,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CoordinatorReceiptClient",
|
||||
"ReceiptVerification",
|
||||
"SignatureValidation",
|
||||
"verify_receipt",
|
||||
"verify_receipts",
|
||||
]
|
||||
95
packages/py/aitbc-sdk/src/aitbc_sdk/receipts.py
Normal file
95
packages/py/aitbc-sdk/src/aitbc_sdk/receipts.py
Normal file
@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import httpx
|
||||
import base64
|
||||
|
||||
from aitbc_crypto.signing import ReceiptVerifier
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignatureValidation:
|
||||
key_id: str
|
||||
valid: bool
|
||||
algorithm: str = "Ed25519"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReceiptVerification:
|
||||
receipt: Dict[str, Any]
|
||||
miner_signature: SignatureValidation
|
||||
coordinator_attestations: List[SignatureValidation]
|
||||
|
||||
@property
|
||||
def verified(self) -> bool:
|
||||
if not self.miner_signature.valid:
|
||||
return False
|
||||
return all(att.valid for att in self.coordinator_attestations)
|
||||
|
||||
|
||||
class CoordinatorReceiptClient:
|
||||
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def _client(self) -> httpx.Client:
|
||||
return httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout,
|
||||
headers={"X-Api-Key": self.api_key},
|
||||
)
|
||||
|
||||
def fetch_latest(self, job_id: str) -> Optional[Dict[str, Any]]:
|
||||
with self._client() as client:
|
||||
resp = client.get(f"/v1/jobs/{job_id}/receipt")
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def fetch_history(self, job_id: str) -> List[Dict[str, Any]]:
|
||||
with self._client() as client:
|
||||
resp = client.get(f"/v1/jobs/{job_id}/receipts")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
raise ValueError("unexpected receipt history response shape")
|
||||
|
||||
|
||||
def _verify_signature(payload: Dict[str, Any], signature: Dict[str, Any]) -> SignatureValidation:
|
||||
key_id = signature.get("key_id", "")
|
||||
verifier = ReceiptVerifier(_decode_key(key_id))
|
||||
valid = verifier.verify(payload, signature)
|
||||
return SignatureValidation(key_id=key_id, valid=valid)
|
||||
|
||||
|
||||
def verify_receipt(receipt: Dict[str, Any]) -> ReceiptVerification:
|
||||
payload = {k: v for k, v in receipt.items() if k not in {"signature", "attestations"}}
|
||||
miner_sig = receipt.get("signature") or {}
|
||||
miner_validation = _verify_signature(payload, miner_sig)
|
||||
|
||||
attestations = receipt.get("attestations") or []
|
||||
att_validations = [
|
||||
_verify_signature(payload, att) for att in attestations if isinstance(att, dict)
|
||||
]
|
||||
|
||||
return ReceiptVerification(
|
||||
receipt=receipt,
|
||||
miner_signature=miner_validation,
|
||||
coordinator_attestations=att_validations,
|
||||
)
|
||||
|
||||
|
||||
def verify_receipts(receipts: Iterable[Dict[str, Any]]) -> List[ReceiptVerification]:
|
||||
return [verify_receipt(receipt) for receipt in receipts]
|
||||
|
||||
|
||||
def _decode_key(data: str) -> bytes:
|
||||
if not data:
|
||||
return b""
|
||||
padding = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(data + padding)
|
||||
141
packages/py/aitbc-sdk/tests/test_receipts.py
Normal file
141
packages/py/aitbc-sdk/tests/test_receipts.py
Normal file
@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
from nacl.signing import SigningKey
|
||||
|
||||
from aitbc_crypto.signing import ReceiptSigner
|
||||
|
||||
from aitbc_sdk.receipts import (
|
||||
CoordinatorReceiptClient,
|
||||
ReceiptVerification,
|
||||
verify_receipt,
|
||||
verify_receipts,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_payload() -> Dict[str, object]:
|
||||
return {
|
||||
"version": "1.0",
|
||||
"receipt_id": "rcpt-1",
|
||||
"job_id": "job-123",
|
||||
"provider": "miner-abc",
|
||||
"client": "client-xyz",
|
||||
"units": 1.0,
|
||||
"unit_type": "gpu_seconds",
|
||||
"price": 3.5,
|
||||
"started_at": 1700000000,
|
||||
"completed_at": 1700000005,
|
||||
"metadata": {
|
||||
"job_payload": {"task": "render"},
|
||||
"job_constraints": {},
|
||||
"result": {"duration": 5},
|
||||
"metrics": {"duration_ms": 5000},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sign_receipt(payload: Dict[str, object], key: SigningKey) -> Dict[str, object]:
|
||||
signer = ReceiptSigner(key.encode())
|
||||
receipt = dict(payload)
|
||||
receipt["signature"] = signer.sign(payload)
|
||||
return receipt
|
||||
|
||||
|
||||
def test_verify_receipt_success(sample_payload: Dict[str, object]) -> None:
|
||||
signing_key = SigningKey.generate()
|
||||
receipt = _sign_receipt(sample_payload, signing_key)
|
||||
|
||||
result = verify_receipt(receipt)
|
||||
assert isinstance(result, ReceiptVerification)
|
||||
assert result.miner_signature.valid is True
|
||||
assert result.verified is True
|
||||
|
||||
|
||||
def test_verify_receipt_failure(sample_payload: Dict[str, object]) -> None:
|
||||
signing_key = SigningKey.generate()
|
||||
receipt = _sign_receipt(sample_payload, signing_key)
|
||||
receipt["metadata"] = {"job_payload": {"task": "tampered"}}
|
||||
|
||||
result = verify_receipt(receipt)
|
||||
assert result.miner_signature.valid is False
|
||||
assert result.verified is False
|
||||
|
||||
|
||||
def test_verify_receipts_batch(sample_payload: Dict[str, object]) -> None:
|
||||
signing_key = SigningKey.generate()
|
||||
receipt = _sign_receipt(sample_payload, signing_key)
|
||||
|
||||
results = verify_receipts([receipt, receipt])
|
||||
assert len(results) == 2
|
||||
assert all(item.verified for item in results)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DummyResponse:
|
||||
status_code: int
|
||||
data: object
|
||||
|
||||
def json(self) -> object:
|
||||
return self.data
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
raise Exception(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
def __init__(self, responses: List[_DummyResponse]):
|
||||
self._responses = responses
|
||||
|
||||
def get(self, url: str, *args, **kwargs) -> _DummyResponse:
|
||||
if not self._responses:
|
||||
raise AssertionError("no more responses configured")
|
||||
return self._responses.pop(0)
|
||||
|
||||
def __enter__(self) -> "_DummyClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_coordinator_receipt_client_latest(monkeypatch, sample_payload: Dict[str, object]) -> None:
|
||||
signing_key = SigningKey.generate()
|
||||
receipt = _sign_receipt(sample_payload, signing_key)
|
||||
|
||||
def _mock_client(self) -> _DummyClient:
|
||||
return _DummyClient([_DummyResponse(200, receipt)])
|
||||
|
||||
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
|
||||
|
||||
client = CoordinatorReceiptClient("https://coordinator", "api")
|
||||
fetched = client.fetch_latest("job-123")
|
||||
assert fetched == receipt
|
||||
|
||||
|
||||
def test_coordinator_receipt_client_history(monkeypatch, sample_payload: Dict[str, object]) -> None:
|
||||
signing_key = SigningKey.generate()
|
||||
receipts = [_sign_receipt(sample_payload, signing_key)]
|
||||
|
||||
def _mock_client(self) -> _DummyClient:
|
||||
return _DummyClient([_DummyResponse(200, {"items": receipts})])
|
||||
|
||||
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
|
||||
|
||||
client = CoordinatorReceiptClient("https://coordinator", "api")
|
||||
history = client.fetch_history("job-123")
|
||||
assert history == receipts
|
||||
|
||||
|
||||
def test_coordinator_receipt_client_latest_404(monkeypatch) -> None:
|
||||
def _mock_client(self) -> _DummyClient:
|
||||
return _DummyClient([_DummyResponse(404, {})])
|
||||
|
||||
monkeypatch.setattr(CoordinatorReceiptClient, "_client", _mock_client)
|
||||
|
||||
client = CoordinatorReceiptClient("https://coordinator", "api")
|
||||
assert client.fetch_latest("job-missing") is None
|
||||
Reference in New Issue
Block a user