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
321 lines
13 KiB
Python
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()
|