stackchain-lab-loop/tests/test_path_proof.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

321 lines
13 KiB
Python

"""
Executable replay/forgery gate tests for path-proof receipts (#28).
Every negative case asserts BOTH rejection and a specific reason. The suite is
deterministic: time is passed explicitly, the key registry and spent-nonce
registry are in-memory, and nothing is written to disk. It runs from a clean
checkout and leaves no generated artifacts.
Import shim: ``path_proof`` lives at the repo root (a sibling of ``tests/``),
so we add the repo root to ``sys.path``. ``path_proof`` is a normal underscore
package, so a plain import works (no importlib-by-path gymnastics needed).
"""
import copy
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from path_proof import ( # noqa: E402
SpentRegistry,
build_receipt,
verify_and_accept,
verify_receipt,
)
from path_proof.keys import make_demo_registry # noqa: E402
NOW = 1_750_000_000 # a fixed "now" in epoch seconds
ISSUED = NOW - 3600 # issued an hour ago
EXPIRES = NOW + 3600 # expires an hour from now
# Fixed sha256-shaped identifiers (64 hex chars) for deterministic fixtures.
REQ_SHA = "11" * 32
RESULT_SHA = "22" * 32
TOOL_TRACE_SHA = "33" * 32
POLICY_SHA = "44" * 32
EVIDENCE_URI = "sha256:" + "55" * 32
TARGET = "stackchain/stackchain-lab-loop#3"
def _registry():
"""Fresh demo registry: two trusted recorders, one revoked."""
return make_demo_registry()
def _receipt(**overrides):
"""Build a valid, fresh, trusted v0 receipt (defaults = pass case)."""
base = dict(
kind="gitea.issue.labels.replace",
target=TARGET,
request_sha256=REQ_SHA,
result_sha256=RESULT_SHA,
model_id="custom/qwen-3.8-27b",
model_invocation="recorder:forge/inv-1",
tool_trace_sha256=TOOL_TRACE_SHA,
policy_bundle_sha256=POLICY_SHA,
code_repo="stackchain/stackchain-lab-loop",
code_commit="a" * 40,
code_entrypoint="scripts/lab_loop.py",
evidence_uri=EVIDENCE_URI,
issuer="recorder:forge",
key_id="recorder:forge/key-1",
sig="c2lnbmF0dXJlLWJvdW5kLXRvLWNhbm9uaWNhbC1ieXRlcy0x",
action_id="act-0001",
nonce="nonce-0001",
issued_at=ISSUED,
expires_at=EXPIRES,
)
base.update(overrides)
return build_receipt(**base)
def _assert_rejected(result, *needles):
"""Assert the result was rejected AND each needle reason is present."""
self_ok = result.ok is False
assert self_ok, f"expected rejection but got ok={result.ok} reasons={result.reasons}"
for needle in needles:
assert any(needle in r for r in result.reasons), (
f"expected a reason containing {needle!r}, got {result.reasons}"
)
# ---------------------------------------------------------------------------
# Positive cases
# ---------------------------------------------------------------------------
class PositiveTests(unittest.TestCase):
def test_valid_receipt_passes(self):
result = verify_receipt(_receipt(), _registry(), NOW)
self.assertEqual(result.status, "verified")
self.assertTrue(result.ok)
self.assertEqual(result.reasons, [])
def test_valid_receipt_accepts_and_binds(self):
spent = SpentRegistry()
result = verify_and_accept(_receipt(), _registry(), spent, NOW)
self.assertTrue(result.ok)
self.assertIn("nonce-0001", spent.spent_nonces())
self.assertIn("act-0001", spent.spent_action_ids())
def test_expected_target_match_passes(self):
result = verify_receipt(_receipt(), _registry(), NOW, expected_target=TARGET)
self.assertTrue(result.ok, result.reasons)
def test_expected_result_match_passes(self):
result = verify_receipt(_receipt(), _registry(), NOW,
expected_result_sha256=RESULT_SHA)
self.assertTrue(result.ok, result.reasons)
# ---------------------------------------------------------------------------
# Replay / idempotency
# ---------------------------------------------------------------------------
class ReplayTests(unittest.TestCase):
def test_replayed_receipt_fails(self):
spent = SpentRegistry()
first = verify_and_accept(_receipt(), _registry(), spent, NOW)
self.assertTrue(first.ok, "first accept should pass")
replay = verify_and_accept(_receipt(), _registry(), spent, NOW)
_assert_rejected(replay, "replay")
self.assertFalse(replay.ok)
def test_replay_by_nonce_is_specific(self):
spent = SpentRegistry()
verify_and_accept(_receipt(nonce="n-A", action_id="act-A"), _registry(), spent, NOW)
# Same nonce, brand-new action_id -> still a replay on the nonce axis.
replay = verify_and_accept(_receipt(nonce="n-A", action_id="act-B"),
_registry(), spent, NOW)
_assert_rejected(replay, "nonce", "already spent")
def test_replay_by_action_id_is_specific(self):
spent = SpentRegistry()
verify_and_accept(_receipt(nonce="n-1", action_id="act-X"), _registry(), spent, NOW)
# Same action_id, fresh nonce -> replay on the action_id axis.
replay = verify_and_accept(_receipt(nonce="n-2", action_id="act-X"),
_registry(), spent, NOW)
_assert_rejected(replay, "action_id", "already spent")
def test_rejected_receipt_does_not_mark_spent(self):
spent = SpentRegistry()
bad = verify_and_accept(_receipt(expires_at=ISSUED - 10), _registry(), spent, NOW)
self.assertFalse(bad.ok, "expired receipt should be rejected")
self.assertEqual(len(spent), 0, "a rejected receipt must not consume a nonce")
def test_distinct_receipts_both_accept(self):
spent = SpentRegistry()
a = verify_and_accept(_receipt(nonce="n-1", action_id="act-1"), _registry(), spent, NOW)
b = verify_and_accept(_receipt(nonce="n-2", action_id="act-2"), _registry(), spent, NOW)
self.assertTrue(a.ok and b.ok, (a.reasons, b.reasons))
# ---------------------------------------------------------------------------
# Substitution / forgery
# ---------------------------------------------------------------------------
class SubstitutionTests(unittest.TestCase):
def test_result_substitution_fails(self):
forged = _receipt(result_sha256="ff" * 32)
result = verify_receipt(forged, _registry(), NOW,
expected_result_sha256=RESULT_SHA)
_assert_rejected(result, "substitution")
def test_target_substitution_fails(self):
forged = _receipt(target="stackchain/other-repo#999")
result = verify_receipt(forged, _registry(), NOW, expected_target=TARGET)
_assert_rejected(result, "target")
def test_request_hash_not_sha256_fails(self):
forged = _receipt(request_sha256="not-a-hash")
result = verify_receipt(forged, _registry(), NOW)
_assert_rejected(result, "request_sha256")
def test_missing_signature_fails(self):
forged = _receipt(sig="")
result = verify_receipt(forged, _registry(), NOW)
_assert_rejected(result, "signature")
def test_tampered_model_invocation_shape_still_bound(self):
# A receipt is a commitment: changing the bound invocation yields a
# different receipt the signer never attested. We can't cryptographically
# re-derive the sig in this dependency-free gate, but the contract
# requires a well-formed invocation to be present and non-empty.
forged = _receipt(model_invocation="")
result = verify_receipt(forged, _registry(), NOW)
_assert_rejected(result, "invocation")
class SignerTests(unittest.TestCase):
def test_unknown_signer_fails(self):
forged = _receipt(issuer="recorder:rogue", key_id="recorder:rogue/key-1")
result = verify_receipt(forged, _registry(), NOW)
_assert_rejected(result, "unknown signer")
def test_revoked_signer_fails(self):
forged = _receipt(issuer="recorder:old", key_id="recorder:old/key-1")
result = verify_receipt(forged, _registry(), NOW)
_assert_rejected(result, "revoked")
def test_known_signer_passes(self):
result = verify_receipt(_receipt(), _registry(), NOW)
self.assertTrue(result.ok, result.reasons)
def test_key_out_of_validity_fails(self):
reg = _registry()
reg.add_hex("recorder:forge", "recorder:forge/key-expired",
"dd" * 32, valid_until=NOW - 1)
forged = _receipt(key_id="recorder:forge/key-expired")
result = verify_receipt(forged, reg, NOW)
_assert_rejected(result, "out of validity")
# ---------------------------------------------------------------------------
# Freshness / expiry
# ---------------------------------------------------------------------------
class FreshnessTests(unittest.TestCase):
def test_expired_receipt_fails(self):
expired = _receipt(expires_at=NOW - 10)
result = verify_receipt(expired, _registry(), NOW)
_assert_rejected(result, "expired")
def test_not_yet_valid_fails(self):
future = _receipt(issued_at=NOW + 100)
result = verify_receipt(future, _registry(), NOW)
_assert_rejected(result, "not yet valid")
def test_invalid_window_fails(self):
bad = _receipt(issued_at=NOW + 10, expires_at=NOW + 1)
result = verify_receipt(bad, _registry(), NOW)
_assert_rejected(result, "validity window invalid")
def test_exactly_at_expiry_is_still_valid(self):
edge = _receipt(expires_at=NOW)
result = verify_receipt(edge, _registry(), NOW)
self.assertTrue(result.ok, result.reasons)
# ---------------------------------------------------------------------------
# Malformed structures (fail closed, no exceptions)
# ---------------------------------------------------------------------------
class MalformedTests(unittest.TestCase):
def _must_not_raise(self, payload):
# Returns the result; if this line raises, the test fails loudly.
return verify_receipt(payload, _registry(), NOW)
def test_non_object_receipt_fails_closed(self):
result = self._must_not_raise([1, 2, 3])
self.assertFalse(result.ok)
self.assertEqual(result.status, "malformed")
_assert_rejected(result, "must be a JSON object")
def test_wrong_version_fails_closed(self):
payload = _receipt()
payload["v"] = 1
result = self._must_not_raise(payload)
self.assertFalse(result.ok)
self.assertEqual(result.status, "malformed")
_assert_rejected(result, "version")
def test_missing_section_fails_closed(self):
payload = _receipt()
del payload["evidence"]
result = self._must_not_raise(payload)
self.assertFalse(result.ok)
self.assertEqual(result.status, "malformed")
_assert_rejected(result, "evidence")
def test_none_payload_fails_closed(self):
result = self._must_not_raise(None)
self.assertFalse(result.ok)
self.assertEqual(result.status, "malformed")
def test_non_int_issued_at_fails_closed(self):
result = self._must_not_raise(_receipt(issued_at="yesterday"))
self.assertFalse(result.ok)
_assert_rejected(result, "issued_at")
def test_bool_epoch_rejected(self):
# bool is a subclass of int; must not be accepted as an epoch value.
result = self._must_not_raise(_receipt(issued_at=True))
self.assertFalse(result.ok)
_assert_rejected(result, "issued_at")
def test_accept_path_also_fails_closed(self):
spent = SpentRegistry()
result = verify_and_accept("garbage", _registry(), spent, NOW)
self.assertFalse(result.ok)
self.assertEqual(len(spent), 0)
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
class DeterminismTests(unittest.TestCase):
def test_same_receipt_same_result_across_runs(self):
reg = _registry()
r1 = verify_receipt(_receipt(), reg, NOW)
r2 = verify_receipt(_receipt(), reg, NOW)
self.assertEqual(r1.status, r2.status)
self.assertEqual(r1.reasons, r2.reasons)
def test_time_is_injected_not_read(self):
# A receipt that is valid at NOW but expired at NOW+2h must flip
# purely because we pass a different `now`.
r_early = verify_receipt(_receipt(), _registry(), NOW)
r_late = verify_receipt(_receipt(), _registry(), NOW + 7200)
self.assertTrue(r_early.ok)
self.assertFalse(r_late.ok)
self.assertTrue(any("expired" in x for x in r_late.reasons))
if __name__ == "__main__":
unittest.main()