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.
- {bindable-0.1.0 → bindable-0.1.2}/PKG-INFO +2 -2
- {bindable-0.1.0 → bindable-0.1.2}/bindable/_canonical_record.py +8 -8
- bindable-0.1.2/bindable/_version.py +1 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/client.py +9 -4
- {bindable-0.1.0 → bindable-0.1.2}/pyproject.toml +1 -1
- {bindable-0.1.0 → bindable-0.1.2}/tests/crypto/vectors.json +1 -1
- bindable-0.1.2/tests/fixtures/KEY_PROVENANCE.md +7 -0
- bindable-0.1.2/tests/fixtures/bindable-test-rc4-2026-08.pem +4 -0
- bindable-0.1.2/tests/fixtures/receipt_public_blocked_rc4.json +1 -0
- bindable-0.1.2/tests/fixtures/receipt_public_released_rc4.json +1 -0
- {bindable-0.1.0 → bindable-0.1.2}/tests/test_canonical_vectors.py +6 -6
- {bindable-0.1.0 → bindable-0.1.2}/tests/test_client.py +6 -4
- bindable-0.1.2/tests/test_verify_receipt.py +184 -0
- {bindable-0.1.0 → bindable-0.1.2}/tests/test_version.py +1 -1
- bindable-0.1.0/bindable/_version.py +0 -1
- bindable-0.1.0/tests/fixtures/receipt_public_key.pem +0 -4
- bindable-0.1.0/tests/fixtures/receipt_released.json +0 -30
- bindable-0.1.0/tests/test_verify_receipt.py +0 -165
- {bindable-0.1.0 → bindable-0.1.2}/.gitignore +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/LICENSE +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/NOTICE +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/README.md +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/__init__.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/__init__.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/canonical.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/hash.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/keys.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/crypto/sign.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/errors.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/py.typed +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/bindable/verify.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/tests/__init__.py +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/tests/crypto/sign_vectors.json +0 -0
- {bindable-0.1.0 → bindable-0.1.2}/tests/test_signature_verify.py +0 -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/
|
|
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,
|
|
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,
|
|
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,
|
|
50
|
-
(derivable from canonical
|
|
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/
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
95
|
+
f"{self._base_url}/v1/review",
|
|
91
96
|
json=body,
|
|
92
97
|
headers={
|
|
93
98
|
"Authorization": f"Bearer {self._token}",
|
|
@@ -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 @@
|
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
79
|
+
"proposal_digest": "f" * 64,
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
_EXPECTED_KEYS = {
|
|
83
83
|
"action_type",
|
|
84
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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" / "
|
|
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/
|
|
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/
|
|
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]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|