137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""Durable, replayable and tamper-evident evidence closure."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import json
|
|
from typing import Any, Mapping, TYPE_CHECKING
|
|
|
|
from .errors import EvidenceIncomplete
|
|
from .models import sha256_json
|
|
|
|
if TYPE_CHECKING:
|
|
from .store import StateStore
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class EvidenceEvent:
|
|
event_type: str
|
|
task_id: str
|
|
payload: Mapping[str, Any]
|
|
previous_digest: str | None
|
|
created_at: datetime
|
|
|
|
def event_digest(self) -> str:
|
|
return sha256_json(
|
|
{
|
|
"event_type": self.event_type,
|
|
"task_id": self.task_id,
|
|
"payload": dict(self.payload),
|
|
"previous_digest": self.previous_digest,
|
|
"created_at": self.created_at.astimezone(timezone.utc).isoformat(),
|
|
}
|
|
)
|
|
|
|
|
|
class EvidenceChain:
|
|
"""Append-only chain optionally backed by the Candidate state database."""
|
|
|
|
def __init__(self, store: StateStore | None = None, task_id: str | None = None) -> None:
|
|
if (store is None) != (task_id is None):
|
|
raise ValueError("store and task_id must be supplied together")
|
|
self.store = store
|
|
self.task_id = task_id
|
|
self._records: list[tuple[EvidenceEvent, str]] = []
|
|
if store is not None and task_id is not None:
|
|
self._load()
|
|
|
|
def _load(self) -> None:
|
|
assert self.store is not None and self.task_id is not None
|
|
self._records = []
|
|
for row in self.store.list_events(self.task_id):
|
|
event = EvidenceEvent(
|
|
event_type=str(row["event_type"]),
|
|
task_id=str(row["task_id"]),
|
|
payload=json.loads(str(row["payload_json"])),
|
|
previous_digest=row["previous_digest"],
|
|
created_at=datetime.fromisoformat(str(row["created_at"])),
|
|
)
|
|
self._records.append((event, str(row["digest"])))
|
|
|
|
def append(
|
|
self, event_type: str, task_id: str, payload: Mapping[str, Any]
|
|
) -> EvidenceEvent:
|
|
if not event_type or not task_id:
|
|
raise EvidenceIncomplete("EVIDENCE_IDENTITY_REQUIRED", "event and task IDs are required")
|
|
if self.task_id is not None and task_id != self.task_id:
|
|
raise EvidenceIncomplete(
|
|
"EVIDENCE_TASK_MISMATCH", "event task does not match the durable chain"
|
|
)
|
|
if payload.get("secrets_included") is not False:
|
|
raise EvidenceIncomplete(
|
|
"EVIDENCE_SECRET_MARKER_REQUIRED",
|
|
"evidence must explicitly assert secrets_included=false",
|
|
)
|
|
previous = self._records[-1][1] if self._records else None
|
|
event = EvidenceEvent(
|
|
event_type=event_type,
|
|
task_id=task_id,
|
|
payload=dict(payload),
|
|
previous_digest=previous,
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
event_digest = event.event_digest()
|
|
if self.store is not None:
|
|
self.store.append_event(
|
|
task_id,
|
|
event_type,
|
|
payload,
|
|
previous,
|
|
event_digest,
|
|
event.created_at,
|
|
)
|
|
self._records.append((event, event_digest))
|
|
return event
|
|
|
|
def verify(self) -> bool:
|
|
previous: str | None = None
|
|
for event, recorded_digest in self._records:
|
|
if event.previous_digest != previous or event.event_digest() != recorded_digest:
|
|
return False
|
|
previous = recorded_digest
|
|
return True
|
|
|
|
def close(self, required_event_types: tuple[str, ...]) -> str:
|
|
if not self._records:
|
|
raise EvidenceIncomplete("EMPTY_EVIDENCE_CHAIN", "cannot close an empty evidence chain")
|
|
if not self.verify():
|
|
raise EvidenceIncomplete(
|
|
"EVIDENCE_CHAIN_INVALID", "evidence chain verification failed"
|
|
)
|
|
found = {event.event_type for event, _ in self._records}
|
|
missing = sorted(set(required_event_types) - found)
|
|
if missing:
|
|
raise EvidenceIncomplete(
|
|
"EVIDENCE_REQUIREMENT_MISSING",
|
|
"required evidence events are absent",
|
|
{"missing": missing},
|
|
)
|
|
return self._records[-1][1]
|
|
|
|
def closure_digest(self) -> str:
|
|
return self.close(())
|
|
|
|
def export(self) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"event_type": event.event_type,
|
|
"task_id": event.task_id,
|
|
"payload": dict(event.payload),
|
|
"previous_digest": event.previous_digest,
|
|
"digest": recorded_digest,
|
|
"created_at": event.created_at.isoformat(),
|
|
}
|
|
for event, recorded_digest in self._records
|
|
]
|