stackchain-lab-loop/path_proof/keys.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

85 lines
2.8 KiB
Python

"""
Trusted-signer key registry for path-proof receipts.
Dependency-free. Keys are Ed25519 public keys (raw 32-byte bytes) or their
hex encoding. Verification here is a *simulated* cryptographic check: the
signature must equal the canonical commitment bound to the key id. In a
production recorder the signature would be a real Ed25519 signature and the
key a verified public key from a hardware/service-isolated key store; the
shape and the trust decision (known vs unknown vs revoked) are identical.
The trust decision is what the gate enforces:
- ``known``: (issuer, key_id) is present and not revoked
- ``unknown``: not present
- ``revoked``: present but revoked
Everything is deterministic and in-memory so tests are reproducible from a
clean checkout with no generated artifacts.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional, Tuple
@dataclass(frozen=True)
class SignerKey:
"""A trusted runtime-recorder signing identity.
``public_key`` is the raw Ed25519 public key bytes (32 bytes) or hex text.
``valid_until`` is an explicit epoch-seconds bound (``None`` = no bound).
"""
issuer: str
key_id: str
public_key: str
valid_until: Optional[int] = None
revoked: bool = False
def is_trusted(self, now: int) -> bool:
if self.revoked:
return False
if self.valid_until is not None and now > self.valid_until:
return False
return True
class KeyRegistry:
"""In-memory registry of trusted signer keys, keyed by (issuer, key_id)."""
def __init__(self) -> None:
self._keys: Dict[Tuple[str, str], SignerKey] = {}
def add(self, key: SignerKey) -> None:
self._keys[(key.issuer, key.key_id)] = key
def add_hex(self, issuer: str, key_id: str, public_key_hex: str,
valid_until: Optional[int] = None, revoked: bool = False) -> SignerKey:
key = SignerKey(issuer=issuer, key_id=key_id, public_key=public_key_hex,
valid_until=valid_until, revoked=revoked)
self.add(key)
return key
def resolve(self, issuer: str, key_id: str) -> Optional[SignerKey]:
return self._keys.get((issuer, key_id))
def __len__(self) -> int:
return len(self._keys)
def __contains__(self, item: Tuple[str, str]) -> bool:
return item in self._keys
def make_demo_registry() -> KeyRegistry:
"""A deterministic demo registry: two trusted recorders, one revoked."""
reg = KeyRegistry()
reg.add_hex("recorder:forge", "recorder:forge/key-1", "aa" * 32)
reg.add_hex("recorder:gateway", "recorder:gateway/key-1", "bb" * 32)
reg.add_hex("recorder:old", "recorder:old/key-1", "cc" * 32, revoked=True)
return reg
# Backwards-compatible alias used by the public package surface.
keys = make_demo_registry()