""" 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()