bindable 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {bindable-0.1.0 → bindable-0.1.2}/PKG-INFO +2 -2
  2. {bindable-0.1.0 → bindable-0.1.2}/bindable/_canonical_record.py +8 -8
  3. bindable-0.1.2/bindable/_version.py +1 -0
  4. {bindable-0.1.0 → bindable-0.1.2}/bindable/client.py +9 -4
  5. {bindable-0.1.0 → bindable-0.1.2}/pyproject.toml +1 -1
  6. {bindable-0.1.0 → bindable-0.1.2}/tests/crypto/vectors.json +1 -1
  7. bindable-0.1.2/tests/fixtures/KEY_PROVENANCE.md +7 -0
  8. bindable-0.1.2/tests/fixtures/bindable-test-rc4-2026-08.pem +4 -0
  9. bindable-0.1.2/tests/fixtures/receipt_public_blocked_rc4.json +1 -0
  10. bindable-0.1.2/tests/fixtures/receipt_public_released_rc4.json +1 -0
  11. {bindable-0.1.0 → bindable-0.1.2}/tests/test_canonical_vectors.py +6 -6
  12. {bindable-0.1.0 → bindable-0.1.2}/tests/test_client.py +6 -4
  13. bindable-0.1.2/tests/test_verify_receipt.py +184 -0
  14. {bindable-0.1.0 → bindable-0.1.2}/tests/test_version.py +1 -1
  15. bindable-0.1.0/bindable/_version.py +0 -1
  16. bindable-0.1.0/tests/fixtures/receipt_public_key.pem +0 -4
  17. bindable-0.1.0/tests/fixtures/receipt_released.json +0 -30
  18. bindable-0.1.0/tests/test_verify_receipt.py +0 -165
  19. {bindable-0.1.0 → bindable-0.1.2}/.gitignore +0 -0
  20. {bindable-0.1.0 → bindable-0.1.2}/LICENSE +0 -0
  21. {bindable-0.1.0 → bindable-0.1.2}/NOTICE +0 -0
  22. {bindable-0.1.0 → bindable-0.1.2}/README.md +0 -0
  23. {bindable-0.1.0 → bindable-0.1.2}/bindable/__init__.py +0 -0
  24. {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/__init__.py +0 -0
  25. {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/canonical.py +0 -0
  26. {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/hash.py +0 -0
  27. {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/keys.py +0 -0
  28. {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/sign.py +0 -0
  29. {bindable-0.1.0 → bindable-0.1.2}/bindable/errors.py +0 -0
  30. {bindable-0.1.0 → bindable-0.1.2}/bindable/py.typed +0 -0
  31. {bindable-0.1.0 → bindable-0.1.2}/bindable/verify.py +0 -0
  32. {bindable-0.1.0 → bindable-0.1.2}/tests/__init__.py +0 -0
  33. {bindable-0.1.0 → bindable-0.1.2}/tests/crypto/sign_vectors.json +0 -0
  34. {bindable-0.1.0 → bindable-0.1.2}/tests/test_signature_verify.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bindable
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python client for the hosted Bindable Action Gate, with offline Receipt verification
5
5
  Author: Bindable AI LLC
6
6
  License-Expression: Apache-2.0
@@ -14,7 +14,7 @@ verified_at handling (de-pydantic departure, behaviour-preserving):
14
14
  In the engine, ``Receipt.verified_at`` is a ``datetime`` and the
15
15
  builder calls ``_serialize_wire_datetime`` to produce the wire
16
16
  string (YYYY-MM-DDTHH:MM:SS.sssZ). In the thin client the receipt
17
- arrives from /v1/decide as JSON, so ``verified_at`` is ALREADY the
17
+ arrives from /v1/review as JSON, so ``verified_at`` is ALREADY the
18
18
  wire string. It is used verbatim — passing it back through a
19
19
  datetime serializer would be a double-serialization bug. No
20
20
  ``datetime`` object can reach this path, so ``_serialize_wire_datetime``
@@ -38,20 +38,20 @@ def _canonical_hash_input(receipt: Mapping[str, Any]) -> dict[str, Any]:
38
38
  """Return the canonical hash input dict per MVP spec §12.3.
39
39
 
40
40
  Included fields (canonical_json sorts alphabetically):
41
- action_type, claim_payload, contract_id, contract_version,
41
+ action_type, proposal_payload, contract_id, contract_version,
42
42
  counterparty_identity, failed_rule, nonce,
43
43
  persistent_agent_id, previous_hash, proposed_action_id,
44
44
  referenced_evidence, sponsor_identity,
45
- sponsor_identity_context, verification_outcome, verified_at.
45
+ sponsor_identity_context, verdict, verified_at.
46
46
 
47
47
  Excluded per §12.3:
48
48
  receipt_id, signature, signing_key_id, signed_at,
49
- chain_hash (self-reference), reason_detail, claim_digest
50
- (derivable from canonical claim_payload).
49
+ chain_hash (self-reference), reason_detail, proposal_digest
50
+ (derivable from canonical proposal_payload).
51
51
 
52
52
  Args:
53
53
  receipt: A receipt as a plain mapping (e.g. parsed JSON from
54
- /v1/decide). All 15 included fields must be present;
54
+ /v1/review). All 15 included fields must be present;
55
55
  nullable fields carry ``None``. ``verified_at`` must be
56
56
  the wire-format string already.
57
57
 
@@ -61,7 +61,7 @@ def _canonical_hash_input(receipt: Mapping[str, Any]) -> dict[str, Any]:
61
61
  """
62
62
  return {
63
63
  "action_type": receipt["action_type"],
64
- "claim_payload": receipt["claim_payload"],
64
+ "proposal_payload": receipt["proposal_payload"],
65
65
  "contract_id": receipt["contract_id"],
66
66
  "contract_version": receipt["contract_version"],
67
67
  "counterparty_identity": receipt["counterparty_identity"],
@@ -73,6 +73,6 @@ def _canonical_hash_input(receipt: Mapping[str, Any]) -> dict[str, Any]:
73
73
  "referenced_evidence": receipt["referenced_evidence"],
74
74
  "sponsor_identity": receipt["sponsor_identity"],
75
75
  "sponsor_identity_context": receipt["sponsor_identity_context"],
76
- "verification_outcome": receipt["verification_outcome"],
76
+ "verdict": receipt["verdict"],
77
77
  "verified_at": receipt["verified_at"],
78
78
  }
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -1,6 +1,6 @@
1
1
  """Transport client for the hosted Bindable Action Gate.
2
2
 
3
- ``ActionGate.decide()`` submits a Proposal to ``POST /v1/decide`` and returns
3
+ ``ActionGate.decide()`` submits a Proposal to ``POST /v1/review`` and returns
4
4
  the hosted Decision (the Outcome dict) verbatim. The client is pure transport:
5
5
  it does NOT evaluate, canonicalize, or decide anything locally — all
6
6
  proof-rule evaluation happens on the hosted gate. To verify the returned
@@ -24,7 +24,7 @@ from bindable.errors import BindableAPIError
24
24
  class ActionGate:
25
25
  """Thin, token-authenticated httpx client for the hosted Bindable Action Gate.
26
26
 
27
- Transports a Proposal to the hosted ``/v1/decide`` endpoint and returns the
27
+ Transports a Proposal to the hosted ``/v1/review`` endpoint and returns the
28
28
  Decision verbatim as a dict. The client performs no local evaluation,
29
29
  canonicalization, or decision-making; offline receipt verification is a
30
30
  separate concern handled by ``bindable.verify_receipt``.
@@ -55,13 +55,18 @@ class ActionGate:
55
55
  proposed_action_id: str,
56
56
  nonce: str | None = None,
57
57
  ) -> dict[str, Any]:
58
- """Submit a Proposal to ``/v1/decide`` and return the hosted Outcome dict.
58
+ """Submit a Proposal to ``/v1/review`` and return the hosted Outcome dict.
59
59
 
60
60
  Sends exactly the five wire fields (persistent_agent_id, action_type,
61
61
  payload, proposed_action_id, nonce) with bearer-token auth. The Outcome
62
62
  is returned verbatim; its ``receipt`` field (when present) can be passed
63
63
  to ``verify_receipt`` for offline verification.
64
64
 
65
+ The endpoint moved from ``/v1/decide`` to ``/v1/review`` in 0.1.2. The
66
+ platform keeps BOTH paths live for a deprecation window, so an older
67
+ client pinned to ``/v1/decide`` keeps working; new callers get
68
+ ``/v1/review``.
69
+
65
70
  Args:
66
71
  persistent_agent_id: The stable agent identity.
67
72
  action_type: The action being proposed.
@@ -87,7 +92,7 @@ class ActionGate:
87
92
  "nonce": nonce,
88
93
  }
89
94
  response = self._client.post(
90
- f"{self._base_url}/v1/decide",
95
+ f"{self._base_url}/v1/review",
91
96
  json=body,
92
97
  headers={
93
98
  "Authorization": f"Bearer {self._token}",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bindable"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  requires-python = ">=3.10"
5
5
  description = "Python client for the hosted Bindable Action Gate, with offline Receipt verification"
6
6
  readme = "README.md"
@@ -72,7 +72,7 @@
72
72
  },
73
73
  {
74
74
  "name": "production_shape_receipt",
75
- "description": "Approximates the receipt record shape from Interlock MVP Spec v0.1.2 §6.2.",
75
+ "description": "LEGACY SHAPE (pre-rc3 claim_* wire). Approximates the receipt record shape from Interlock MVP Spec v0.1.2 §6.2. Pins canonicalization math only; the rc3 wire uses proposal_* vocabulary.",
76
76
  "record": {
77
77
  "claim_payload": {
78
78
  "po_number": "PO-44821",
@@ -0,0 +1,7 @@
1
+ Key provenance: `bindable-test-rc4-2026-08.pem` is the public half of a keypair generated by the engine (0.1.0rc4) when the two receipts in this directory were minted through its own decide path, for the verdict-form cutover. The private half was never written anywhere and is not needed to verify.
2
+
3
+ These fixtures replace the retired `*_rc3.json` pair, which were real Public-landscape production receipts signed by `bindable-public-2026-07` under the pre-cutover `verification_outcome` canonical form. That form no longer exists, so those receipts could not be carried forward: hand-editing them would invalidate their `chain_hash` and signature, and re-signing them with the production key is impossible here — its private half is Key-Vault-injected and never leaves the platform. `bindable-public-2026-07.pem` (recovered previously via ECDSA public-key recovery from those receipts) was retired with them.
4
+
5
+ What the fixtures prove is unchanged: a receipt minted by the engine under the current signed canonical form verifies offline through this client's hand-maintained port, chain hash and signature both. Only the signing key's provenance changed — production-minted to engine-minted-with-a-test-key.
6
+
7
+ Worth doing once the Public landscape runs rc4: re-mint this pair from real production output and restore end-to-end production provenance. Authoritative distribution of landscape verification keys will move to a read-only platform endpoint.
@@ -0,0 +1,4 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENYh+RQNDS48qBicGl9FfgStbcPNk
3
+ 2ElW2e4VUjnFHokPHj8eXL82eCzw+jwEGwEoDZmP9OdcUTkI8xsdPeWyzQ==
4
+ -----END PUBLIC KEY-----
@@ -0,0 +1 @@
1
+ {"receipt_id": "1d8ef197c63c4a4fbb4f9523d25f4e88", "proposal_digest": "01d3430e4b046b20fb7f2e0de22d3bab5febaae1832601020109c55b81d73b64", "proposal_payload": {"matter_id": "MER-2026-014", "call_cost_usd": 126, "records_requested": 400, "purpose": "full-market data sweep"}, "contract_id": "demo-diligence-spend-public-founding", "contract_version": "1.0.0", "sponsor_identity": "did:web:bindbl.ai:sponsor:b4047dfe7c4244dda32a3c8b4a02fc8e", "sponsor_identity_context": "ctx:public", "counterparty_identity": null, "persistent_agent_id": "did:web:bindbl.ai:agent:9dd08b5846564a1fbe674b1834fb887d", "action_type": "paid_data_call", "proposed_action_id": "pf-blocked-public-founding", "verdict": "failed", "failed_rule": "r-2", "reason_detail": "126 lte 58 not satisfied", "referenced_evidence": ["demo-budget-public-founding"], "verified_at": "2026-08-21T14:52:16.607Z", "nonce": "d61480f760d63f14d1350c21573d1a8b", "chain_hash": "485d281c0590813d2fd943114004280cbadf95c56bb9f2c8caba22f26c8ac1a2", "previous_hash": null, "signature": "3045022022f79c8897615256b53d3364db8f743eb524541a766329674e25b0ca1eba3d83022100b057d504c0cdb43474b091aa03f72a0bf18f8d11e24f05fab40baa2e497d40c1", "signing_key_id": "bindable-test-rc4-2026-08", "signed_at": "2026-08-21T14:52:16.607Z", "intervention_artifacts": null}
@@ -0,0 +1 @@
1
+ {"receipt_id": "ac2c3b3e7a6c4d59b972fa297a9f4821", "proposal_digest": "f35eb78be1073174427cf7884bccecbf7a2c8b776ecb932939a2219fa5d17c16", "proposal_payload": {"matter_id": "MER-2026-014", "call_cost_usd": 12, "records_requested": 40, "purpose": "comparable-transactions pull"}, "contract_id": "demo-diligence-spend-public-founding", "contract_version": "1.0.0", "sponsor_identity": "did:web:bindbl.ai:sponsor:b4047dfe7c4244dda32a3c8b4a02fc8e", "sponsor_identity_context": "ctx:public", "counterparty_identity": null, "persistent_agent_id": "did:web:bindbl.ai:agent:9dd08b5846564a1fbe674b1834fb887d", "action_type": "paid_data_call", "proposed_action_id": "pf-released-public-founding", "verdict": "passed", "failed_rule": null, "reason_detail": null, "referenced_evidence": ["demo-budget-public-founding"], "verified_at": "2026-08-21T14:52:16.604Z", "nonce": "b38265567729c89b020e6afe8a420f70", "chain_hash": "d7449f9b6874f2774ab4902dbe18ae2093de6a4cd223ab53802d39aace2a38e4", "previous_hash": null, "signature": "3045022014974d1ae715e5f16fdc2307d82cff116414ca4ac176d5566e2f72d7385cf580022100e56c5a09184c0fe57c32e5a400c55ac59d18c35d34ffd1b95c8bf2e3f4e7c86d", "signing_key_id": "bindable-test-rc4-2026-08", "signed_at": "2026-08-21T14:52:16.607Z", "intervention_artifacts": null}
@@ -55,7 +55,7 @@ def test_canonical_json_rejects_nested_float() -> None:
55
55
  _FULL_RECEIPT: dict[str, Any] = {
56
56
  # 15 included fields
57
57
  "action_type": "release_payment",
58
- "claim_payload": {"po_number": "PO-44821", "invoice_amount_cents": 12500000},
58
+ "proposal_payload": {"po_number": "PO-44821", "invoice_amount_cents": 12500000},
59
59
  "contract_id": "contract-acme-xyz-invoice",
60
60
  "contract_version": "1.0.0",
61
61
  "counterparty_identity": "acme-vendor",
@@ -67,7 +67,7 @@ _FULL_RECEIPT: dict[str, Any] = {
67
67
  "referenced_evidence": ["audit-001", "audit-002"],
68
68
  "sponsor_identity": "sponsor-001",
69
69
  "sponsor_identity_context": "treasury",
70
- "verification_outcome": "passed",
70
+ "verdict": "passed",
71
71
  "verified_at": "2026-05-19T14:00:00.000Z",
72
72
  # excluded fields that must NOT leak into the canonical input
73
73
  "receipt_id": "receipt-zzz",
@@ -76,12 +76,12 @@ _FULL_RECEIPT: dict[str, Any] = {
76
76
  "signed_at": "2026-05-19T14:00:01.000Z",
77
77
  "chain_hash": "0" * 64,
78
78
  "reason_detail": "all good",
79
- "claim_digest": "f" * 64,
79
+ "proposal_digest": "f" * 64,
80
80
  }
81
81
 
82
82
  _EXPECTED_KEYS = {
83
83
  "action_type",
84
- "claim_payload",
84
+ "proposal_payload",
85
85
  "contract_id",
86
86
  "contract_version",
87
87
  "counterparty_identity",
@@ -93,7 +93,7 @@ _EXPECTED_KEYS = {
93
93
  "referenced_evidence",
94
94
  "sponsor_identity",
95
95
  "sponsor_identity_context",
96
- "verification_outcome",
96
+ "verdict",
97
97
  "verified_at",
98
98
  }
99
99
 
@@ -115,7 +115,7 @@ def test_canonical_hash_input_excludes_signature_and_chain_fields() -> None:
115
115
  "signed_at",
116
116
  "chain_hash",
117
117
  "reason_detail",
118
- "claim_digest",
118
+ "proposal_digest",
119
119
  ):
120
120
  assert excluded not in result
121
121
 
@@ -21,7 +21,7 @@ _TOKEN = "test-token"
21
21
 
22
22
  _OUTCOME_RELEASED: dict[str, Any] = {
23
23
  "status": "released",
24
- "verification_outcome": "passed",
24
+ "verdict": "passed",
25
25
  "reason": None,
26
26
  "reason_code": None,
27
27
  "receipt": None,
@@ -29,7 +29,9 @@ _OUTCOME_RELEASED: dict[str, Any] = {
29
29
  }
30
30
 
31
31
  _FIXTURE_RECEIPT: dict[str, Any] = json.loads(
32
- (Path(__file__).parent / "fixtures" / "receipt_released.json").read_text(encoding="utf-8")
32
+ (Path(__file__).parent / "fixtures" / "receipt_public_released_rc4.json").read_text(
33
+ encoding="utf-8"
34
+ )
33
35
  )
34
36
 
35
37
 
@@ -62,7 +64,7 @@ def test_decide_posts_five_field_body_with_bearer() -> None:
62
64
  )
63
65
 
64
66
  assert captured["method"] == "POST"
65
- assert captured["url"] == "https://gate.example.com/v1/decide"
67
+ assert captured["url"] == "https://gate.example.com/v1/review"
66
68
  assert captured["auth"] == "Bearer test-token"
67
69
  assert captured["content_type"] == "application/json"
68
70
  # EXACTLY the 5 frozen wire fields, nothing more.
@@ -93,7 +95,7 @@ def test_base_url_trailing_slash_stripped() -> None:
93
95
  proposed_action_id="p",
94
96
  )
95
97
 
96
- assert captured["url"] == "https://gate.example.com/v1/decide"
98
+ assert captured["url"] == "https://gate.example.com/v1/review"
97
99
 
98
100
 
99
101
  def test_nonce_auto_generated_when_omitted() -> None:
@@ -0,0 +1,184 @@
1
+ """verify_receipt — end-to-end gate against real engine-minted receipts.
2
+
3
+ The fixtures (tests/fixtures/receipt_public_released_rc4.json and
4
+ receipt_public_blocked_rc4.json) are REAL receipts minted and ECDSA-signed
5
+ by the engine @ 0.1.0rc4 through its own decide path, under the verdict
6
+ canonical form (public half at tests/fixtures/bindable-test-rc4-2026-08.pem).
7
+ The chain-hash gates below prove the de-pydantic thin-client builder
8
+ reproduces the engine's chain_hash byte-for-byte. If one fails, the hand
9
+ port has drifted from the engine's signed form — that failure is the desync
10
+ alarm: STOP and report; never edit the fixtures.
11
+
12
+ They are minted with a test signing key rather than the production
13
+ bindable-public-2026-07 key, whose private half lives in Key Vault. What
14
+ they prove is unchanged: the ported canonical form and the engine's agree
15
+ byte-for-byte.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import pytest
26
+
27
+ from bindable import ReceiptVerification, load_public_key, verify_receipt
28
+ from bindable._canonical_record import _canonical_hash_input
29
+ from bindable.crypto import canonical_sha256
30
+
31
+ _FIX = Path(__file__).parent / "fixtures"
32
+ _RELEASED: dict[str, Any] = json.loads(
33
+ (_FIX / "receipt_public_released_rc4.json").read_text(encoding="utf-8")
34
+ )
35
+ _BLOCKED: dict[str, Any] = json.loads(
36
+ (_FIX / "receipt_public_blocked_rc4.json").read_text(encoding="utf-8")
37
+ )
38
+ _PUBLIC_KEY_PEM_BYTES = (_FIX / "bindable-test-rc4-2026-08.pem").read_bytes()
39
+ _PUBLIC_KEY_PEM_STR = _PUBLIC_KEY_PEM_BYTES.decode("utf-8")
40
+
41
+ # A valid P-256 key that did NOT sign these receipts (from the sign vectors).
42
+ _WRONG_KEY_PEM = json.loads(
43
+ (Path(__file__).parent / "crypto" / "sign_vectors.json").read_text(encoding="utf-8")
44
+ )[0]["public_key_pem"]
45
+
46
+ _BOTH = pytest.mark.parametrize(
47
+ "fixture", [_RELEASED, _BLOCKED], ids=["released", "blocked"]
48
+ )
49
+
50
+
51
+ def _receipt(fixture: dict[str, Any]) -> dict[str, Any]:
52
+ return copy.deepcopy(fixture)
53
+
54
+
55
+ # ─── GATE: builder reproduces the engine chain_hash ────────────────────
56
+
57
+
58
+ @_BOTH
59
+ def test_chain_hash_gate_builder_matches_fixture(fixture: dict[str, Any]) -> None:
60
+ """canonical_sha256(_canonical_hash_input(receipt)) == receipt['chain_hash']."""
61
+ recomputed = canonical_sha256(_canonical_hash_input(_receipt(fixture)))
62
+ assert recomputed == fixture["chain_hash"], (
63
+ f"Builder diverged from the engine: recomputed {recomputed!r} != pinned "
64
+ f"{fixture['chain_hash']!r}. STOP — do NOT edit the fixture."
65
+ )
66
+
67
+
68
+ # ─── Happy path (key as PEM str and as loaded object) ──────────────────
69
+
70
+
71
+ @_BOTH
72
+ def test_valid_with_pem_string(fixture: dict[str, Any]) -> None:
73
+ result = verify_receipt(_receipt(fixture), public_key=_PUBLIC_KEY_PEM_STR)
74
+ assert result.valid is True
75
+ assert result.chain_hash_valid is True
76
+ assert result.signature_valid is True
77
+ assert result.reason is None
78
+
79
+
80
+ @_BOTH
81
+ def test_valid_with_loaded_public_key_object(fixture: dict[str, Any]) -> None:
82
+ pub = load_public_key(_PUBLIC_KEY_PEM_BYTES)
83
+ result = verify_receipt(_receipt(fixture), public_key=pub)
84
+ assert result.valid is True
85
+ assert result.chain_hash_valid is True
86
+ assert result.signature_valid is True
87
+ assert result.reason is None
88
+
89
+
90
+ # ─── Tamper and wrong-key paths ────────────────────────────────────────
91
+
92
+
93
+ @_BOTH
94
+ def test_tampered_canonical_field_breaks_chain(fixture: dict[str, Any]) -> None:
95
+ r = _receipt(fixture)
96
+ r["proposal_payload"] = {"matter_id": "MER-EVIL", "call_cost_usd": 999_999}
97
+ result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
98
+ assert result.valid is False
99
+ assert result.chain_hash_valid is False
100
+ assert result.reason == "chain_hash mismatch"
101
+
102
+
103
+ def test_tampered_signature_fails_signature_check() -> None:
104
+ r = _receipt(_RELEASED)
105
+ # Flip the last hex digit of the DER signature.
106
+ last = r["signature"][-1]
107
+ r["signature"] = r["signature"][:-1] + ("0" if last != "0" else "1")
108
+ result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
109
+ assert result.valid is False
110
+ assert result.chain_hash_valid is True
111
+ assert result.signature_valid is False
112
+
113
+
114
+ def test_wrong_key_fails_signature_check() -> None:
115
+ result = verify_receipt(_receipt(_RELEASED), public_key=_WRONG_KEY_PEM)
116
+ assert result.valid is False
117
+ assert result.chain_hash_valid is True
118
+ assert result.signature_valid is False
119
+ assert result.reason == "signature invalid"
120
+
121
+
122
+ # ─── Signature-optional matrix ─────────────────────────────────────────
123
+
124
+
125
+ @_BOTH
126
+ def test_no_key_skips_signature(fixture: dict[str, Any]) -> None:
127
+ result = verify_receipt(_receipt(fixture)) # public_key=None
128
+ assert result.valid is True
129
+ assert result.chain_hash_valid is True
130
+ assert result.signature_valid is None
131
+ assert result.reason is None
132
+
133
+
134
+ def test_key_set_but_no_signature_field() -> None:
135
+ r = _receipt(_RELEASED)
136
+ r["signature"] = None
137
+ result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
138
+ assert result.valid is False
139
+ assert result.chain_hash_valid is True
140
+ assert result.signature_valid is False
141
+
142
+
143
+ def test_invalid_hex_signature_is_handled() -> None:
144
+ r = _receipt(_RELEASED)
145
+ r["signature"] = "zz-not-hex"
146
+ result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
147
+ assert result.valid is False
148
+ assert result.chain_hash_valid is True
149
+ assert result.signature_valid is False
150
+ assert "error" in (result.reason or "").lower()
151
+
152
+
153
+ # ─── Robustness: verify_receipt never raises ───────────────────────────
154
+
155
+
156
+ def test_empty_dict_never_raises() -> None:
157
+ result = verify_receipt({})
158
+ assert isinstance(result, ReceiptVerification)
159
+ assert result.valid is False
160
+ assert result.reason is not None
161
+
162
+
163
+ def test_garbage_dict_never_raises() -> None:
164
+ result = verify_receipt({"chain_hash": 123, "proposal_payload": object()}) # type: ignore[dict-item]
165
+ assert isinstance(result, ReceiptVerification)
166
+ assert result.valid is False
167
+ assert result.reason is not None
168
+
169
+
170
+ def test_missing_chain_hash_never_raises() -> None:
171
+ r = _receipt(_RELEASED)
172
+ r.pop("chain_hash")
173
+ result = verify_receipt(r)
174
+ assert result.valid is False
175
+ assert result.chain_hash_valid is False
176
+ assert result.reason is not None
177
+
178
+
179
+ def test_receipt_verification_is_frozen() -> None:
180
+ rv = verify_receipt(_receipt(_RELEASED))
181
+ import dataclasses
182
+
183
+ with pytest.raises(dataclasses.FrozenInstanceError):
184
+ rv.valid = False # type: ignore[misc]
@@ -2,4 +2,4 @@ import bindable
2
2
 
3
3
 
4
4
  def test_version() -> None:
5
- assert bindable.__version__ == "0.1.0"
5
+ assert bindable.__version__ == "0.1.2"
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,4 +0,0 @@
1
- -----BEGIN PUBLIC KEY-----
2
- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa5xwHbysJvcU00yrqTfM7jGYk/87
3
- cypa/O3b/j6Iy6n+rpJRwDT2VTrbli1n4xoWZ3fXszM/pUzWRVIQt56fTg==
4
- -----END PUBLIC KEY-----
@@ -1,30 +0,0 @@
1
- {
2
- "receipt_id": "rcpt_test_001",
3
- "claim_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
4
- "claim_payload": {
5
- "po_number": "PO-001",
6
- "amount": 1000
7
- },
8
- "contract_id": "ctr_001",
9
- "contract_version": "1.0.0",
10
- "sponsor_identity": "testco",
11
- "sponsor_identity_context": "ctx:testco:procurement",
12
- "counterparty_identity": "supplier-a",
13
- "persistent_agent_id": "agent_testco",
14
- "action_type": "send_invoice",
15
- "proposed_action_id": "claim_001",
16
- "verification_outcome": "passed",
17
- "failed_rule": null,
18
- "reason_detail": null,
19
- "referenced_evidence": [
20
- "evidence-001"
21
- ],
22
- "verified_at": "2026-05-25T14:22:05.123Z",
23
- "nonce": "abc123def456",
24
- "chain_hash": "fa37feb6c264ae089c44a2667eddfa91492712b425c2e5c60091797051e90ca6",
25
- "previous_hash": null,
26
- "signature": "304502202ff0c4527fa3be3208732971e75ac939edb486da248107ffab6f10faa7ef0baa02210096dec776e158633c9e1f88dd5f7e184d0461a7574d6772a8eae89661ea1921d2",
27
- "signing_key_id": "test_signer",
28
- "signed_at": null,
29
- "intervention_artifacts": null
30
- }
@@ -1,165 +0,0 @@
1
- """verify_receipt — end-to-end gate against a real engine-minted receipt.
2
-
3
- The fixture (tests/fixtures/receipt_released.json + receipt_public_key.pem) was
4
- minted FROM the engine @ 5f129a6 via its own Interlock construction path. The
5
- chain-hash gate below proves the de-pydantic thin-client builder reproduces the
6
- engine's chain_hash byte-for-byte. If it fails, the builder diverged — STOP and
7
- report; never edit the fixture.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import copy
13
- import json
14
- from pathlib import Path
15
- from typing import Any
16
-
17
- from bindable import ReceiptVerification, load_public_key, verify_receipt
18
- from bindable._canonical_record import _canonical_hash_input
19
- from bindable.crypto import canonical_sha256
20
-
21
- _FIX = Path(__file__).parent / "fixtures"
22
- _RECEIPT: dict[str, Any] = json.loads(
23
- (_FIX / "receipt_released.json").read_text(encoding="utf-8")
24
- )
25
- _PUBLIC_KEY_PEM_BYTES = (_FIX / "receipt_public_key.pem").read_bytes()
26
- _PUBLIC_KEY_PEM_STR = _PUBLIC_KEY_PEM_BYTES.decode("utf-8")
27
-
28
- # A valid P-256 key that did NOT sign this receipt (from the sign vectors).
29
- _WRONG_KEY_PEM = json.loads(
30
- (Path(__file__).parent / "crypto" / "sign_vectors.json").read_text(encoding="utf-8")
31
- )[0]["public_key_pem"]
32
-
33
-
34
- def _receipt() -> dict[str, Any]:
35
- return copy.deepcopy(_RECEIPT)
36
-
37
-
38
- # ─── GATE: builder reproduces the engine chain_hash ────────────────────
39
-
40
-
41
- def test_chain_hash_gate_builder_matches_fixture() -> None:
42
- """canonical_sha256(_canonical_hash_input(receipt)) == receipt['chain_hash']."""
43
- recomputed = canonical_sha256(_canonical_hash_input(_receipt()))
44
- assert recomputed == _RECEIPT["chain_hash"], (
45
- f"Builder diverged from the engine: recomputed {recomputed!r} != pinned "
46
- f"{_RECEIPT['chain_hash']!r}. STOP — do NOT edit the fixture."
47
- )
48
-
49
-
50
- # ─── Happy path (key as PEM str and as loaded object) ──────────────────
51
-
52
-
53
- def test_valid_with_pem_string() -> None:
54
- result = verify_receipt(_receipt(), public_key=_PUBLIC_KEY_PEM_STR)
55
- assert isinstance(result, ReceiptVerification)
56
- assert result.valid is True
57
- assert result.chain_hash_valid is True
58
- assert result.signature_valid is True
59
- assert result.reason is None
60
-
61
-
62
- def test_valid_with_loaded_public_key_object() -> None:
63
- pub = load_public_key(_PUBLIC_KEY_PEM_BYTES)
64
- result = verify_receipt(_receipt(), public_key=pub)
65
- assert result.valid is True
66
- assert result.signature_valid is True
67
-
68
-
69
- # ─── Tamper detection ──────────────────────────────────────────────────
70
-
71
-
72
- def test_tampered_canonical_field_breaks_chain() -> None:
73
- r = _receipt()
74
- r["claim_payload"] = {"po_number": "PO-EVIL", "amount": 999_999}
75
- result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
76
- assert result.chain_hash_valid is False
77
- assert result.valid is False
78
- assert "chain_hash" in (result.reason or "")
79
-
80
-
81
- def test_tampered_signature_fails_signature_check() -> None:
82
- r = _receipt()
83
- sig = bytearray(bytes.fromhex(r["signature"]))
84
- sig[-1] ^= 0x01
85
- r["signature"] = bytes(sig).hex()
86
- result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
87
- assert result.chain_hash_valid is True
88
- assert result.signature_valid is False
89
- assert result.valid is False
90
-
91
-
92
- def test_wrong_key_fails_signature_check() -> None:
93
- result = verify_receipt(_receipt(), public_key=_WRONG_KEY_PEM)
94
- assert result.chain_hash_valid is True
95
- assert result.signature_valid is False
96
- assert result.valid is False
97
- assert result.reason == "signature invalid"
98
-
99
-
100
- # ─── 4-state matrix corners ────────────────────────────────────────────
101
-
102
-
103
- def test_no_key_skips_signature() -> None:
104
- result = verify_receipt(_receipt()) # public_key=None
105
- assert result.signature_valid is None
106
- assert result.chain_hash_valid is True
107
- assert result.valid is True
108
- assert result.reason is None
109
-
110
-
111
- def test_key_set_but_no_signature_field() -> None:
112
- r = _receipt()
113
- r.pop("signature")
114
- result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
115
- assert result.signature_valid is False
116
- assert result.chain_hash_valid is True
117
- assert result.valid is False
118
- assert result.reason == "receipt has no signature to verify"
119
-
120
-
121
- def test_invalid_hex_signature_is_handled() -> None:
122
- r = _receipt()
123
- r["signature"] = "zzz_not_hex_garbage_zzz"
124
- result = verify_receipt(r, public_key=_PUBLIC_KEY_PEM_STR)
125
- assert result.valid is False
126
- assert result.signature_valid is False
127
- assert "error" in (result.reason or "").lower()
128
-
129
-
130
- # ─── Never-raises contract ─────────────────────────────────────────────
131
-
132
-
133
- def test_empty_dict_never_raises() -> None:
134
- result = verify_receipt({})
135
- assert isinstance(result, ReceiptVerification)
136
- assert result.valid is False
137
- assert result.reason is not None
138
-
139
-
140
- def test_garbage_dict_never_raises() -> None:
141
- result = verify_receipt({"chain_hash": 123, "claim_payload": object()}) # type: ignore[dict-item]
142
- assert isinstance(result, ReceiptVerification)
143
- assert result.valid is False
144
- assert result.reason is not None
145
-
146
-
147
- def test_missing_chain_hash_never_raises() -> None:
148
- r = _receipt()
149
- r.pop("chain_hash")
150
- result = verify_receipt(r)
151
- assert result.valid is False
152
- assert result.chain_hash_valid is False
153
- assert result.reason is not None
154
-
155
-
156
- def test_receipt_verification_is_frozen() -> None:
157
- rv = verify_receipt(_receipt())
158
- import dataclasses
159
-
160
- try:
161
- rv.valid = False # type: ignore[misc]
162
- except dataclasses.FrozenInstanceError:
163
- pass
164
- else: # pragma: no cover - frozen dataclass must reject assignment
165
- raise AssertionError("ReceiptVerification must be frozen")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes