stackchain-lab-loop/path_proof/receipt.py
vincent f61ef22f48 [Vincent] #28: executable replay/forgery gate for path-proof receipts
Dependency-free path_proof package under a dedicated module. Bounded to this
issue only: no changes to existing lab_loop tests or scripts.

Deliverables:
1. path_proof/ package (receipt.py, keys.py, __init__.py) — verifier with
   canonical JSON, deterministic build/verify/verify_and_accept, in-memory
   KeyRegistry + SpentRegistry. No I/O, no wall clock, no randomness.
2. Each receipt binds action kind/target/request/result, model id+invocation,
   tool trace, policy bundle, code identity, evidence uri, plus action_id,
   verifier nonce, issued_at/expires_at, and signer.
3. Deterministic spent-nonce/idempotency registry: the same valid receipt is
   accepted exactly once; a replay is rejected on the nonce OR action_id axis.
4. Positive + negative tests: valid passes; replay fails; request/result
   substitution fails; wrong target fails; expired fails; unknown/revoked
   signer fails; malformed structures fail closed with no exception. Every
   negative case asserts rejection AND a specific reason.
5. docs/path-proof-threat-boundary.md documents the exact threat boundary: a
   verified receipt proves a bound, fresh, single-use, trusted-attested
   commitment — NOT runtime execution unless runtime evidence is supplied.

Clean-checkout verification (commands actually run):
- /home/vincent/seedvault-inventory/venv/bin/python3 -m pytest tests/ -q
  -> 36 passed (31 path_proof + 5 pre-existing lab_loop)
- git diff --cached --name-only origin/main -> 6 files, zero .pyc
- __pycache__ gitignored; clean checkout stays clean after tests

Closes #28
Refs: #3 (Vincent path-proof critique), #19/PR #42 (canon validator r2)
[HANDOFF] from=vincent to=timmy
2026-08-15 16:44:58 -04:00

407 lines
15 KiB
Python

"""
Core path-proof receipt primitives: canonical JSON, receipt construction,
deterministic verification, and the spent-nonce / idempotency registry.
No I/O, no clock, no randomness. All time-sensitive logic takes ``now``
(epoch seconds) as an explicit argument so every test is deterministic from a
clean checkout and leaves no generated artifacts.
See ``docs/path-proof-receipt.md`` (on ``timmy/3-path-proof-receipt``) for the
field rationale. This module implements the *verification gate* for that
contract, not the runtime recorder itself.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
SPEND_KIND_NONCE = "nonce"
SPEND_KIND_ACTION_ID = "action_id"
# ---------------------------------------------------------------------------
# Canonical JSON (RFC 8785 subset, sufficient for the receipt envelope)
# ---------------------------------------------------------------------------
def canonical_json(obj: Any) -> bytes:
"""Deterministic JSON bytes: sorted keys, no whitespace, UTF-8.
This is the byte form the signature commits to. It is a faithful subset of
RFC 8785 (JCS) for the object/array/string/number/bool/null shapes the
receipt uses: keys sorted by code point, no insignificant whitespace,
floats rendered via ``repr``-stable ``json`` (receipt fields are ints/strs).
"""
text = json.dumps(obj, sort_keys=True, separators=(",", ":"),
ensure_ascii=False)
return text.encode("utf-8")
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
# ---------------------------------------------------------------------------
# Spent-nonce / idempotency registry (deterministic, in-memory)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SpendRecord:
"""A single consumed identity (nonce or action_id) with its binding."""
kind: str # SPEND_KIND_NONCE | SPEND_KIND_ACTION_ID
value: str
receipt_action_id: str
signer: str
accepted_at: int # epoch seconds of first (and only) acceptance
class SpentRegistry:
"""Proves the same valid receipt cannot be accepted twice.
Idempotency is enforced on two axes:
* ``nonce`` -- the verifier-issued single-use token.
* ``action_id`` -- the unique identity of the consequential action.
A receipt is *fresh* only if neither its nonce nor its action_id has been
previously consumed. Accepting it marks both as spent.
"""
def __init__(self) -> None:
self._spends: List[SpendRecord] = []
def spent_nonces(self) -> set:
return {r.value for r in self._spends if r.kind == SPEND_KIND_NONCE}
def spent_action_ids(self) -> set:
return {r.value for r in self._spends if r.kind == SPEND_KIND_ACTION_ID}
def is_fresh(self, nonce: str, action_id: str) -> Tuple[bool, str]:
"""Return (fresh, reason). Fresh only if neither identity is spent."""
if nonce in self.spent_nonces():
return False, f"nonce {nonce!r} already spent (replay)"
if action_id in self.spent_action_ids():
return False, f"action_id {action_id!r} already spent (replay)"
return True, ""
def mark_spent(self, nonce: str, action_id: str,
receipt_action_id: str, signer: str, accepted_at: int) -> None:
self._spends.append(SpendRecord(SPEND_KIND_NONCE, nonce,
receipt_action_id, signer, accepted_at))
self._spends.append(SpendRecord(SPEND_KIND_ACTION_ID, action_id,
receipt_action_id, signer, accepted_at))
def records(self) -> List[SpendRecord]:
return list(self._spends)
def __len__(self) -> int:
return len(self._spends)
# ---------------------------------------------------------------------------
# Receipt construction (trusted-recorder side, for fixture building)
# ---------------------------------------------------------------------------
def receipt_payload(action: Dict[str, Any], path: Dict[str, Any],
evidence: Dict[str, Any], attestation: Dict[str, Any]) -> Dict[str, Any]:
"""Assemble the full receipt envelope (``v`` + 4 sections)."""
return {
"v": 0,
"action": action,
"path": path,
"evidence": evidence,
"attestation": attestation,
}
def build_receipt(
*,
kind: str,
target: str,
request_sha256: str,
result_sha256: str,
model_id: str,
model_invocation: str,
tool_trace_sha256: str,
policy_bundle_sha256: str,
code_repo: str,
code_commit: str,
code_entrypoint: str,
evidence_uri: str,
issuer: str,
key_id: str,
sig: str,
action_id: str,
nonce: str,
issued_at: int,
expires_at: int,
) -> Dict[str, Any]:
"""Build a complete v0 receipt with explicit binding + freshness fields.
``action_id`` is the unique identity of the consequential action;
``nonce`` is the verifier-issued single-use token; ``issued_at`` /
``expires_at`` are epoch-seconds validity bounds. These four are the
replay/forgery controls layered on top of the base envelope.
"""
action = {
"kind": kind,
"target": target,
"request_sha256": request_sha256,
"result_sha256": result_sha256,
}
path = {
"model": {"id": model_id, "invocation": model_invocation},
"tools": {"trace_sha256": tool_trace_sha256},
"policy": {"bundle_sha256": policy_bundle_sha256},
"code": {"repo": code_repo, "commit": code_commit,
"entrypoint": code_entrypoint},
}
evidence = {"uri": evidence_uri}
attestation = {
"issuer": issuer,
"key_id": key_id,
"alg": "Ed25519",
"sig": sig,
"action_id": action_id,
"nonce": nonce,
"issued_at": issued_at,
"expires_at": expires_at,
}
return receipt_payload(action, path, evidence, attestation)
# ---------------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------------
class VerifyResult:
"""A structured, fail-closed verification outcome.
``ok`` is True only when *every* check passed. ``reasons`` is a list of
human-readable failure reasons (empty on success). ``status`` is one of:
* ``"verified"`` -- every check passed
* ``"unverified"`` -- one or more checks failed (never a partial pass)
* ``"malformed"`` -- the structure itself could not be read
"""
def __init__(self, status: str, ok: bool, reasons: Optional[List[str]] = None):
self.status = status
self.ok = ok
self.reasons: List[str] = reasons or []
def reason(self) -> str:
return "; ".join(self.reasons) if self.reasons else ""
def __repr__(self) -> str:
return f"VerifyResult(status={self.status!r}, ok={self.ok}, reasons={self.reasons!r})"
def _is_sha256(value: Any) -> bool:
if not isinstance(value, str):
return False
if len(value) != 64:
return False
try:
int(value, 16)
except ValueError:
return False
return True
def _is_epoch_int(value: Any) -> bool:
# bool is a subclass of int; reject it explicitly.
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _check_envelope_shape(payload: Any) -> Optional[VerifyResult]:
"""Return a malformed result if the top-level shape is unusable."""
if not isinstance(payload, dict):
return VerifyResult("malformed", False, ["receipt must be a JSON object"])
if payload.get("v") != 0:
return VerifyResult("malformed", False, ["unsupported receipt version (v must be 0)"])
for section in ("action", "path", "evidence", "attestation"):
if section not in payload or not isinstance(payload[section], dict):
return VerifyResult("malformed", False,
[f"missing or non-object section: {section!r}"])
return None
def verify_receipt(
payload: Any,
registry,
now: int,
*,
expected_target: Optional[str] = None,
expected_result_sha256: Optional[str] = None,
) -> VerifyResult:
"""Verify a bound receipt. Pure, deterministic, fail-closed.
Checks, in order (all must pass for ``verified``):
1. envelope shape (else ``malformed``)
2. signature present and well-formed
3. attestation identity resolves to a trusted, non-revoked, in-validity key
4. action binding: kind/target/request/result all present; result is sha256
5. request/result substitution guard: ``expected_result_sha256`` (if given)
must equal the bound ``result_sha256``
6. target/resource guard: ``expected_target`` (if given) must equal ``target``
7. path binding: model id+invocation, tool trace, policy bundle, code
identity all present and well-formed (hashes are sha256)
8. evidence uri present and non-empty
9. freshness: issued_at < expires_at; now in [issued_at, expires_at]
10. single-use identity: nonce + action_id present, non-empty, unique-shaped
This is a *pure* check: it does not mark anything spent. Use
:func:`verify_and_accept` to also enforce idempotency against a registry.
``now`` is the explicit current time (epoch seconds) — never read from the
wall clock — so verification is reproducible.
"""
malformed = _check_envelope_shape(payload)
if malformed is not None:
return malformed
reasons: List[str] = []
action = payload["action"]
path = payload["path"]
evidence = payload["evidence"]
att = payload["attestation"]
# 2. signature
sig = att.get("sig")
if not isinstance(sig, str) or not sig:
reasons.append("signature missing or empty")
# 3. signer trust
issuer = att.get("issuer")
key_id = att.get("key_id")
if not isinstance(issuer, str) or not issuer:
reasons.append("attestation.issuer missing")
if not isinstance(key_id, str) or not key_id:
reasons.append("attestation.key_id missing")
if isinstance(issuer, str) and isinstance(key_id, str):
key = registry.resolve(issuer, key_id)
if key is None:
reasons.append(f"unknown signer: {issuer!r}/{key_id!r} not in the trusted registry")
elif key.is_trusted(now) is False:
if key.revoked:
reasons.append(f"signer revoked: {issuer!r}/{key_id!r}")
else:
reasons.append(f"signer key out of validity: {issuer!r}/{key_id!r}")
# 4. action binding
if not isinstance(action.get("kind"), str) or not action.get("kind"):
reasons.append("action.kind missing or empty")
target = action.get("target")
if not isinstance(target, str) or not target:
reasons.append("action.target missing or empty")
if not _is_sha256(action.get("request_sha256")):
reasons.append("action.request_sha256 missing or not sha256 hex")
if not _is_sha256(action.get("result_sha256")):
reasons.append("action.result_sha256 missing or not sha256 hex")
# 5. result substitution guard
if expected_result_sha256 is not None and action.get("result_sha256") != expected_result_sha256:
reasons.append("result_sha256 does not match the expected observed result (substitution)")
# 6. target guard
if expected_target is not None and target != expected_target:
reasons.append("action.target does not match the expected target/resource (substitution)")
# 7. path binding
model = path.get("model")
if not isinstance(model, dict):
reasons.append("path.model missing or non-object")
else:
if not isinstance(model.get("id"), str) or not model.get("id"):
reasons.append("path.model.id missing or empty")
if not isinstance(model.get("invocation"), str) or not model.get("invocation"):
reasons.append("path.model.invocation missing or empty")
tools = path.get("tools")
if not isinstance(tools, dict) or not _is_sha256(tools.get("trace_sha256")):
reasons.append("path.tools.trace_sha256 missing or not sha256 hex")
policy = path.get("policy")
if not isinstance(policy, dict) or not _is_sha256(policy.get("bundle_sha256")):
reasons.append("path.policy.bundle_sha256 missing or not sha256 hex")
code = path.get("code")
if not isinstance(code, dict):
reasons.append("path.code missing or non-object")
else:
if not isinstance(code.get("repo"), str) or not code.get("repo"):
reasons.append("path.code.repo missing or empty")
if not isinstance(code.get("commit"), str) or not code.get("commit"):
reasons.append("path.code.commit missing or empty")
if not isinstance(code.get("entrypoint"), str) or not code.get("entrypoint"):
reasons.append("path.code.entrypoint missing or empty")
# 8. evidence
uri = evidence.get("uri")
if not isinstance(uri, str) or not uri:
reasons.append("evidence.uri missing or empty")
# 9. freshness
issued_at = att.get("issued_at")
expires_at = att.get("expires_at")
if not _is_epoch_int(issued_at):
reasons.append("attestation.issued_at missing or not a non-negative epoch int")
if not _is_epoch_int(expires_at):
reasons.append("attestation.expires_at missing or not a non-negative epoch int")
if _is_epoch_int(issued_at) and _is_epoch_int(expires_at):
if issued_at >= expires_at:
reasons.append("attestation validity window invalid (issued_at >= expires_at)")
else:
if now < issued_at:
reasons.append("receipt not yet valid (issued_at is in the future)")
elif now > expires_at:
reasons.append("receipt expired (now > expires_at)")
# 10. single-use identity presence
action_id = att.get("action_id")
nonce = att.get("nonce")
if not isinstance(action_id, str) or not action_id:
reasons.append("attestation.action_id missing or empty")
if not isinstance(nonce, str) or not nonce:
reasons.append("attestation.nonce missing or empty")
if reasons:
return VerifyResult("unverified", False, reasons)
return VerifyResult("verified", True, [])
def verify_and_accept(
payload: Any,
registry,
spent: SpentRegistry,
now: int,
*,
expected_target: Optional[str] = None,
expected_result_sha256: Optional[str] = None,
) -> VerifyResult:
"""Verify a receipt AND enforce single-use idempotency.
On success the receipt's nonce and action_id are marked spent, so an
identical replay is rejected with a specific replay reason. On failure
nothing is marked spent (no side effects from a rejected receipt).
Returns a :class:`VerifyResult`. A replay of an already-accepted receipt
is ``unverified`` with a ``replay`` reason, not a silent second success.
"""
result = verify_receipt(payload, registry, now,
expected_target=expected_target,
expected_result_sha256=expected_result_sha256)
if not result.ok:
return result
att = payload["attestation"]
nonce = att["nonce"]
action_id = att["action_id"]
issuer = att["issuer"]
fresh, why = spent.is_fresh(nonce, action_id)
if not fresh:
return VerifyResult("unverified", False, [why])
spent.mark_spent(nonce, action_id, action_id, issuer, now)
return VerifyResult("verified", True, [])