bindable 0.0.1__tar.gz → 0.1.0__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/.gitignore +8 -0
- {bindable-0.0.1 → bindable-0.1.0}/LICENSE +2 -2
- bindable-0.1.0/NOTICE +3 -0
- bindable-0.1.0/PKG-INFO +38 -0
- bindable-0.1.0/README.md +20 -0
- bindable-0.1.0/bindable/__init__.py +22 -0
- bindable-0.1.0/bindable/_canonical_record.py +78 -0
- bindable-0.1.0/bindable/_version.py +1 -0
- bindable-0.1.0/bindable/client.py +130 -0
- bindable-0.1.0/bindable/crypto/__init__.py +57 -0
- bindable-0.1.0/bindable/crypto/canonical.py +250 -0
- bindable-0.1.0/bindable/crypto/hash.py +42 -0
- bindable-0.1.0/bindable/crypto/keys.py +90 -0
- bindable-0.1.0/bindable/crypto/sign.py +90 -0
- bindable-0.1.0/bindable/errors.py +18 -0
- bindable-0.1.0/bindable/py.typed +0 -0
- bindable-0.1.0/bindable/verify.py +251 -0
- bindable-0.1.0/pyproject.toml +23 -0
- bindable-0.1.0/tests/__init__.py +0 -0
- bindable-0.1.0/tests/crypto/sign_vectors.json +35 -0
- bindable-0.1.0/tests/crypto/vectors.json +94 -0
- bindable-0.1.0/tests/fixtures/receipt_public_key.pem +4 -0
- bindable-0.1.0/tests/fixtures/receipt_released.json +30 -0
- bindable-0.1.0/tests/test_canonical_vectors.py +133 -0
- bindable-0.1.0/tests/test_client.py +193 -0
- bindable-0.1.0/tests/test_signature_verify.py +79 -0
- bindable-0.1.0/tests/test_verify_receipt.py +165 -0
- bindable-0.1.0/tests/test_version.py +5 -0
- bindable-0.0.1/.github/workflows/release.yml +0 -51
- bindable-0.0.1/.gitignore +0 -218
- bindable-0.0.1/PKG-INFO +0 -45
- bindable-0.0.1/README.md +0 -23
- bindable-0.0.1/bindable/__init__.py +0 -6
- bindable-0.0.1/bindable/_version.py +0 -3
- bindable-0.0.1/bindable/core.py +0 -43
- bindable-0.0.1/pyproject.toml +0 -28
- bindable-0.0.1/tests/test_import.py +0 -11
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Apache License
|
|
1
|
+
Apache License
|
|
2
2
|
Version 2.0, January 2004
|
|
3
3
|
http://www.apache.org/licenses/
|
|
4
4
|
|
|
@@ -186,7 +186,7 @@ Apache License
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright
|
|
189
|
+
Copyright 2026 Bindable AI LLC
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
bindable-0.1.0/NOTICE
ADDED
bindable-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bindable
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the hosted Bindable Action Gate, with offline Receipt verification
|
|
5
|
+
Author: Bindable AI LLC
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
License-File: NOTICE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: cryptography>=42
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
14
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# bindable
|
|
20
|
+
|
|
21
|
+
Bindable Python client — propose, decide, and verify accountable agent actions against the hosted Bindable Action Gate. Offline receipt verification included.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install bindable
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import bindable
|
|
33
|
+
|
|
34
|
+
# Submit a Proposal to the Bindable Action Gate, receive a Decision,
|
|
35
|
+
# record the Outcome, then verify the returned Receipt offline.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Status: pre-release (0.1.0.dev0)
|
bindable-0.1.0/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# bindable
|
|
2
|
+
|
|
3
|
+
Bindable Python client — propose, decide, and verify accountable agent actions against the hosted Bindable Action Gate. Offline receipt verification included.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install bindable
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import bindable
|
|
15
|
+
|
|
16
|
+
# Submit a Proposal to the Bindable Action Gate, receive a Decision,
|
|
17
|
+
# record the Outcome, then verify the returned Receipt offline.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Status: pre-release (0.1.0.dev0)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Bindable — Python client for the hosted Bindable Action Gate.
|
|
2
|
+
|
|
3
|
+
Submit a Proposal to the Bindable Action Gate via ``ActionGate.decide()``,
|
|
4
|
+
receive a Decision (the Outcome), and verify the returned Receipt offline with
|
|
5
|
+
``verify_receipt``. The client is pure transport; all proof-rule evaluation
|
|
6
|
+
happens on the hosted gate, and receipt verification is fully offline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .client import ActionGate
|
|
11
|
+
from .crypto import load_public_key
|
|
12
|
+
from .errors import BindableAPIError
|
|
13
|
+
from .verify import ReceiptVerification, verify_receipt
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ActionGate",
|
|
17
|
+
"BindableAPIError",
|
|
18
|
+
"ReceiptVerification",
|
|
19
|
+
"__version__",
|
|
20
|
+
"load_public_key",
|
|
21
|
+
"verify_receipt",
|
|
22
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Canonical hash-input serialization for receipts (thin client).
|
|
2
|
+
|
|
3
|
+
Byte-faithful port of the engine's ``_canonical_hash_input``
|
|
4
|
+
(bindable-interlock-engine @ 5f129a6), adapted to read a receipt
|
|
5
|
+
delivered as a plain JSON dict from the hosted Bindable Action Gate
|
|
6
|
+
rather than as a pydantic ``Receipt``. The 15 included fields, the
|
|
7
|
+
§12.3 exclusions, and the field set match the engine EXACTLY so that
|
|
8
|
+
``canonical_sha256`` of this output is byte-identical across the
|
|
9
|
+
hosted gate and any offline verifier.
|
|
10
|
+
|
|
11
|
+
This module is NOT part of the public API.
|
|
12
|
+
|
|
13
|
+
verified_at handling (de-pydantic departure, behaviour-preserving):
|
|
14
|
+
In the engine, ``Receipt.verified_at`` is a ``datetime`` and the
|
|
15
|
+
builder calls ``_serialize_wire_datetime`` to produce the wire
|
|
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
|
|
18
|
+
wire string. It is used verbatim — passing it back through a
|
|
19
|
+
datetime serializer would be a double-serialization bug. No
|
|
20
|
+
``datetime`` object can reach this path, so ``_serialize_wire_datetime``
|
|
21
|
+
is not ported here. The vectors.json production-shape record
|
|
22
|
+
(verified_at = "2026-05-19T14:00:00.000Z" hashed verbatim) confirms
|
|
23
|
+
the wire string is what the hash commits to.
|
|
24
|
+
|
|
25
|
+
Pure and deterministic: given a receipt mapping, returns a new dict
|
|
26
|
+
of wire-form fields with no I/O and no mutation. Canonical ordering
|
|
27
|
+
is applied downstream by ``canonical_json`` (alphabetical key sort),
|
|
28
|
+
so the literal order here is illustrative only.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _canonical_hash_input(receipt: Mapping[str, Any]) -> dict[str, Any]:
|
|
38
|
+
"""Return the canonical hash input dict per MVP spec §12.3.
|
|
39
|
+
|
|
40
|
+
Included fields (canonical_json sorts alphabetically):
|
|
41
|
+
action_type, claim_payload, contract_id, contract_version,
|
|
42
|
+
counterparty_identity, failed_rule, nonce,
|
|
43
|
+
persistent_agent_id, previous_hash, proposed_action_id,
|
|
44
|
+
referenced_evidence, sponsor_identity,
|
|
45
|
+
sponsor_identity_context, verification_outcome, verified_at.
|
|
46
|
+
|
|
47
|
+
Excluded per §12.3:
|
|
48
|
+
receipt_id, signature, signing_key_id, signed_at,
|
|
49
|
+
chain_hash (self-reference), reason_detail, claim_digest
|
|
50
|
+
(derivable from canonical claim_payload).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
receipt: A receipt as a plain mapping (e.g. parsed JSON from
|
|
54
|
+
/v1/decide). All 15 included fields must be present;
|
|
55
|
+
nullable fields carry ``None``. ``verified_at`` must be
|
|
56
|
+
the wire-format string already.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A dict of exactly the 15 canonical-hash-input fields, ready
|
|
60
|
+
to feed into ``canonical_sha256``.
|
|
61
|
+
"""
|
|
62
|
+
return {
|
|
63
|
+
"action_type": receipt["action_type"],
|
|
64
|
+
"claim_payload": receipt["claim_payload"],
|
|
65
|
+
"contract_id": receipt["contract_id"],
|
|
66
|
+
"contract_version": receipt["contract_version"],
|
|
67
|
+
"counterparty_identity": receipt["counterparty_identity"],
|
|
68
|
+
"failed_rule": receipt["failed_rule"],
|
|
69
|
+
"nonce": receipt["nonce"],
|
|
70
|
+
"persistent_agent_id": receipt["persistent_agent_id"],
|
|
71
|
+
"previous_hash": receipt["previous_hash"],
|
|
72
|
+
"proposed_action_id": receipt["proposed_action_id"],
|
|
73
|
+
"referenced_evidence": receipt["referenced_evidence"],
|
|
74
|
+
"sponsor_identity": receipt["sponsor_identity"],
|
|
75
|
+
"sponsor_identity_context": receipt["sponsor_identity_context"],
|
|
76
|
+
"verification_outcome": receipt["verification_outcome"],
|
|
77
|
+
"verified_at": receipt["verified_at"],
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Transport client for the hosted Bindable Action Gate.
|
|
2
|
+
|
|
3
|
+
``ActionGate.decide()`` submits a Proposal to ``POST /v1/decide`` and returns
|
|
4
|
+
the hosted Decision (the Outcome dict) verbatim. The client is pure transport:
|
|
5
|
+
it does NOT evaluate, canonicalize, or decide anything locally — all
|
|
6
|
+
proof-rule evaluation happens on the hosted gate. To verify the returned
|
|
7
|
+
receipt offline, hand ``outcome["receipt"]`` to ``bindable.verify_receipt``.
|
|
8
|
+
|
|
9
|
+
Runtime dependency: httpx only (plus cryptography via the verify path).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import secrets
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from bindable.errors import BindableAPIError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ActionGate:
|
|
25
|
+
"""Thin, token-authenticated httpx client for the hosted Bindable Action Gate.
|
|
26
|
+
|
|
27
|
+
Transports a Proposal to the hosted ``/v1/decide`` endpoint and returns the
|
|
28
|
+
Decision verbatim as a dict. The client performs no local evaluation,
|
|
29
|
+
canonicalization, or decision-making; offline receipt verification is a
|
|
30
|
+
separate concern handled by ``bindable.verify_receipt``.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
with ActionGate(base_url="https://gate.example.com", token="...") as gate:
|
|
34
|
+
outcome = gate.decide(
|
|
35
|
+
persistent_agent_id="agent_1",
|
|
36
|
+
action_type="send_invoice",
|
|
37
|
+
payload={"po_number": "PO-001", "amount": 1000},
|
|
38
|
+
proposed_action_id="claim_1",
|
|
39
|
+
)
|
|
40
|
+
# Offline verification of the returned receipt:
|
|
41
|
+
result = verify_receipt(outcome["receipt"])
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, base_url: str, token: str, timeout: float = 30.0) -> None:
|
|
45
|
+
self._base_url = base_url.rstrip("/")
|
|
46
|
+
self._token = token
|
|
47
|
+
self._client = httpx.Client(timeout=timeout)
|
|
48
|
+
|
|
49
|
+
def decide(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
persistent_agent_id: str,
|
|
53
|
+
action_type: str,
|
|
54
|
+
payload: Mapping[str, Any],
|
|
55
|
+
proposed_action_id: str,
|
|
56
|
+
nonce: str | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Submit a Proposal to ``/v1/decide`` and return the hosted Outcome dict.
|
|
59
|
+
|
|
60
|
+
Sends exactly the five wire fields (persistent_agent_id, action_type,
|
|
61
|
+
payload, proposed_action_id, nonce) with bearer-token auth. The Outcome
|
|
62
|
+
is returned verbatim; its ``receipt`` field (when present) can be passed
|
|
63
|
+
to ``verify_receipt`` for offline verification.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
persistent_agent_id: The stable agent identity.
|
|
67
|
+
action_type: The action being proposed.
|
|
68
|
+
payload: The action payload (sent as the ``payload`` field).
|
|
69
|
+
proposed_action_id: The caller's id for this proposed action.
|
|
70
|
+
nonce: Replay-protection nonce; a fresh ``secrets.token_hex(16)``
|
|
71
|
+
is generated when omitted.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The parsed Outcome JSON object as a dict.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
BindableAPIError: If the gate returns a non-2xx response. The
|
|
78
|
+
error carries ``.status`` and the ``detail`` from the JSON body.
|
|
79
|
+
"""
|
|
80
|
+
if nonce is None:
|
|
81
|
+
nonce = secrets.token_hex(16)
|
|
82
|
+
body = {
|
|
83
|
+
"persistent_agent_id": persistent_agent_id,
|
|
84
|
+
"action_type": action_type,
|
|
85
|
+
"payload": dict(payload),
|
|
86
|
+
"proposed_action_id": proposed_action_id,
|
|
87
|
+
"nonce": nonce,
|
|
88
|
+
}
|
|
89
|
+
response = self._client.post(
|
|
90
|
+
f"{self._base_url}/v1/decide",
|
|
91
|
+
json=body,
|
|
92
|
+
headers={
|
|
93
|
+
"Authorization": f"Bearer {self._token}",
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
if not response.is_success:
|
|
98
|
+
raise BindableAPIError(response.status_code, _extract_detail(response))
|
|
99
|
+
result: dict[str, Any] = response.json()
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
"""Close the underlying httpx.Client."""
|
|
104
|
+
self._client.close()
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> ActionGate:
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(
|
|
110
|
+
self,
|
|
111
|
+
exc_type: type[BaseException] | None,
|
|
112
|
+
exc: BaseException | None,
|
|
113
|
+
tb: TracebackType | None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_detail(response: httpx.Response) -> str:
|
|
119
|
+
"""Pull the ``detail`` from an error JSON body, falling back to text.
|
|
120
|
+
|
|
121
|
+
The gate returns errors as ``{"detail": ...}``. ``detail`` may be a string
|
|
122
|
+
(401/403) or a structured value (422); it is stringified either way.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
body = response.json()
|
|
126
|
+
except Exception: # noqa: BLE001 - non-JSON error body; fall back to text
|
|
127
|
+
return response.text or response.reason_phrase
|
|
128
|
+
if isinstance(body, dict) and "detail" in body:
|
|
129
|
+
return str(body["detail"])
|
|
130
|
+
return str(body)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Cryptographic primitives for the Bindable thin client.
|
|
2
|
+
|
|
3
|
+
This subpackage provides the deterministic, offline-verifiable
|
|
4
|
+
primitives used to recompute a receipt's chain_hash and to verify its
|
|
5
|
+
ECDSA P-256 signature. Per D6 these live under ``bindable.crypto`` and
|
|
6
|
+
are NOT re-exported at the top-level ``bindable`` namespace (except the
|
|
7
|
+
high-level ``verify_receipt`` / ``ReceiptVerification`` / ``load_public_key``
|
|
8
|
+
surface exposed by ``bindable`` directly).
|
|
9
|
+
|
|
10
|
+
Surface (stdlib + cryptography — no pydantic):
|
|
11
|
+
|
|
12
|
+
Type aliases:
|
|
13
|
+
PublicKey # alias for cryptography EllipticCurvePublicKey
|
|
14
|
+
|
|
15
|
+
Hashing (pure, stdlib only):
|
|
16
|
+
sha256_hex(data: bytes) -> str
|
|
17
|
+
canonicalize(value: Any) -> Any
|
|
18
|
+
canonical_json(record: Mapping[str, Any]) -> bytes
|
|
19
|
+
canonical_sha256(record: Mapping[str, Any]) -> str
|
|
20
|
+
|
|
21
|
+
Key loading (cryptography library):
|
|
22
|
+
load_public_key(pem_bytes: bytes) -> PublicKey
|
|
23
|
+
|
|
24
|
+
Signature verification (cryptography library):
|
|
25
|
+
verify(public_key, record, signature) -> bool
|
|
26
|
+
|
|
27
|
+
This is a VERIFY-ONLY surface: there is no signing, key generation, or
|
|
28
|
+
private-key handling client-side.
|
|
29
|
+
|
|
30
|
+
PATENT-CRITICAL: these primitives produce the canonical bytes a
|
|
31
|
+
receipt's chain_hash commits to and verify the signature over them.
|
|
32
|
+
The hashing primitives use only the Python standard library; key
|
|
33
|
+
loading and signature verification depend on the cryptography library,
|
|
34
|
+
which is pinned in [project] dependencies. All primitives operate
|
|
35
|
+
without network access.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from bindable.crypto.canonical import (
|
|
41
|
+
canonical_json,
|
|
42
|
+
canonical_sha256,
|
|
43
|
+
canonicalize,
|
|
44
|
+
)
|
|
45
|
+
from bindable.crypto.hash import sha256_hex
|
|
46
|
+
from bindable.crypto.keys import PublicKey, load_public_key
|
|
47
|
+
from bindable.crypto.sign import verify
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"PublicKey",
|
|
51
|
+
"canonical_json",
|
|
52
|
+
"canonical_sha256",
|
|
53
|
+
"canonicalize",
|
|
54
|
+
"load_public_key",
|
|
55
|
+
"sha256_hex",
|
|
56
|
+
"verify",
|
|
57
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Canonical JSON serialization and hashing per RFC 8785 (JCS).
|
|
2
|
+
|
|
3
|
+
RFC 8785 (JSON Canonicalization Scheme) defines a deterministic
|
|
4
|
+
byte-level representation of a JSON value. Two equivalent JSON
|
|
5
|
+
values always serialize to identical bytes, which makes them
|
|
6
|
+
suitable for cryptographic hashing.
|
|
7
|
+
|
|
8
|
+
This module implements the subset of RFC 8785 that the Bindable
|
|
9
|
+
SDK requires for v0.1.0 receipts:
|
|
10
|
+
|
|
11
|
+
* Object keys are sorted lexicographically by UTF-16 code units.
|
|
12
|
+
* No whitespace between tokens.
|
|
13
|
+
* Strings are UTF-8 encoded with the RFC 8785 escape rules.
|
|
14
|
+
* Booleans and null serialize as ``true``, ``false``, ``null``.
|
|
15
|
+
* Integers (including booleans coerced via int subclassing) serialize
|
|
16
|
+
as their decimal representation.
|
|
17
|
+
|
|
18
|
+
DEPARTURE FROM RFC 8785 — by design, locked decision D3:
|
|
19
|
+
* Python floats are REJECTED with TypeError. RFC 8785 specifies
|
|
20
|
+
IEEE-754 shortest-form serialization for non-integer numbers;
|
|
21
|
+
we forbid them entirely at the canonicalization boundary
|
|
22
|
+
because float drift across implementations is the dominant
|
|
23
|
+
source of cross-language hash divergence in financial systems.
|
|
24
|
+
Callers MUST convert decimal quantities to integers (e.g.,
|
|
25
|
+
dollars * 100 = cents) or strings before hashing.
|
|
26
|
+
|
|
27
|
+
PATENT-CRITICAL: this module produces the canonical bytes that feed
|
|
28
|
+
into every sponsor-scoped verification receipt's chain_hash. Any
|
|
29
|
+
change to canonicalization behavior is a wire-format break and
|
|
30
|
+
MUST be accompanied by a major version bump.
|
|
31
|
+
|
|
32
|
+
Per Interlock MVP Spec v0.1.2 §6.2, canonical_sha256(record) is the
|
|
33
|
+
function used to derive a receipt's chain_hash.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Mapping
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from bindable.crypto.hash import sha256_hex
|
|
42
|
+
|
|
43
|
+
# Characters that must be escaped in JSON strings per RFC 8259 §7
|
|
44
|
+
# and RFC 8785 §3.2.2.2. Control characters (U+0000 through U+001F)
|
|
45
|
+
# are escaped as \uXXXX. Quote and backslash get short-form escapes.
|
|
46
|
+
_SHORT_ESCAPES = {
|
|
47
|
+
0x08: "\\b",
|
|
48
|
+
0x09: "\\t",
|
|
49
|
+
0x0A: "\\n",
|
|
50
|
+
0x0C: "\\f",
|
|
51
|
+
0x0D: "\\r",
|
|
52
|
+
0x22: '\\"',
|
|
53
|
+
0x5C: "\\\\",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def canonicalize(value: Any) -> Any:
|
|
58
|
+
"""Return a structurally-canonical copy of ``value``.
|
|
59
|
+
|
|
60
|
+
Recursively sorts object (dict) keys. Leaves arrays in their
|
|
61
|
+
original order (RFC 8785 §3.2.3 — array element order is
|
|
62
|
+
semantically significant in JSON and is preserved).
|
|
63
|
+
|
|
64
|
+
This is a structural pass only. The byte-level canonicalization
|
|
65
|
+
happens in ``canonical_json``.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
value: Any JSON-serializable value (dict, list, str, int,
|
|
69
|
+
bool, None). Float values raise in canonical_json, not here.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A structurally-canonical copy of ``value`` with all
|
|
73
|
+
dict keys sorted lexicographically (by UTF-16 code units,
|
|
74
|
+
which for ASCII keys is the same as Python's default
|
|
75
|
+
string sort).
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> canonicalize({"b": 1, "a": 2})
|
|
79
|
+
{'a': 2, 'b': 1}
|
|
80
|
+
|
|
81
|
+
>>> canonicalize({"z": {"y": 1, "x": 2}})
|
|
82
|
+
{'z': {'x': 2, 'y': 1}}
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
# Validate keys before sorting. _utf16_sort_key encodes via UTF-16-BE,
|
|
86
|
+
# which requires string keys. Validation is duplicated in _encode as
|
|
87
|
+
# defense-in-depth for direct internal callers.
|
|
88
|
+
for key in value:
|
|
89
|
+
if not isinstance(key, str):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"Object keys must be strings in canonical JSON, "
|
|
92
|
+
f"got {type(key).__name__} (value: {key!r})."
|
|
93
|
+
)
|
|
94
|
+
# Sort keys per RFC 8785 §3.2.3: UTF-16 code-unit order.
|
|
95
|
+
# For pure ASCII keys this matches Python's default string
|
|
96
|
+
# ordering. For non-ASCII keys, see _utf16_sort_key.
|
|
97
|
+
return {k: canonicalize(value[k]) for k in sorted(value, key=_utf16_sort_key)}
|
|
98
|
+
if isinstance(value, list):
|
|
99
|
+
return [canonicalize(item) for item in value]
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def canonical_json(record: Mapping[str, Any]) -> bytes:
|
|
104
|
+
"""Return the canonical JSON bytes of ``record`` per RFC 8785.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
record: A JSON-serializable mapping. Nested dicts, lists,
|
|
108
|
+
strings, integers, booleans, and None are supported.
|
|
109
|
+
Floats are rejected (see TypeError below).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The UTF-8 encoded canonical JSON bytes. Suitable for hashing
|
|
113
|
+
with ``sha256_hex`` or for comparing two records for byte
|
|
114
|
+
equality.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
TypeError: If the record (or any nested value) contains a
|
|
118
|
+
float, a non-string dict key, or an unsupported type.
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
>>> canonical_json({"b": 1, "a": 2})
|
|
122
|
+
b'{"a":2,"b":1}'
|
|
123
|
+
|
|
124
|
+
>>> canonical_json({"hello": "world"})
|
|
125
|
+
b'{"hello":"world"}'
|
|
126
|
+
"""
|
|
127
|
+
return _encode(canonicalize(record)).encode("utf-8")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def canonical_sha256(record: Mapping[str, Any]) -> str:
|
|
131
|
+
"""Return the SHA-256 hex digest of the canonical JSON form of ``record``.
|
|
132
|
+
|
|
133
|
+
This is the primary function used by the Interlock to derive a
|
|
134
|
+
receipt's chain_hash. Two records that are semantically equal
|
|
135
|
+
(same keys, same values, regardless of input ordering) always
|
|
136
|
+
produce the same hash.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
record: A JSON-serializable mapping (same constraints as
|
|
140
|
+
``canonical_json``).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The 64-character lowercase hexadecimal SHA-256 digest of
|
|
144
|
+
the canonical JSON bytes.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
TypeError: If the record contains a disallowed value
|
|
148
|
+
(see ``canonical_json``).
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
>>> canonical_sha256({"b": 1, "a": 2})
|
|
152
|
+
'43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777'
|
|
153
|
+
|
|
154
|
+
>>> # Key order does not affect the hash:
|
|
155
|
+
>>> canonical_sha256({"a": 2, "b": 1}) == canonical_sha256({"b": 1, "a": 2})
|
|
156
|
+
True
|
|
157
|
+
"""
|
|
158
|
+
return sha256_hex(canonical_json(record))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ─── Internal helpers ──────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _utf16_sort_key(s: str) -> bytes:
|
|
165
|
+
"""Return a sort key approximating UTF-16 code-unit order for ``s``.
|
|
166
|
+
|
|
167
|
+
RFC 8785 §3.2.3 mandates sorting object keys by their UTF-16
|
|
168
|
+
code-unit sequences. For ASCII keys this is identical to Python's
|
|
169
|
+
default Unicode codepoint order. For non-BMP characters (above
|
|
170
|
+
U+FFFF), Python's codepoint order and UTF-16 code-unit order
|
|
171
|
+
diverge because non-BMP characters are represented in UTF-16 as
|
|
172
|
+
surrogate pairs.
|
|
173
|
+
|
|
174
|
+
Encoding the string to UTF-16-BE and treating the bytes as the
|
|
175
|
+
sort key gives RFC-8785-correct ordering for both BMP and non-BMP
|
|
176
|
+
characters.
|
|
177
|
+
"""
|
|
178
|
+
return s.encode("utf-16-be")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _encode(value: Any) -> str:
|
|
182
|
+
"""Serialize a canonical value to a JSON string per RFC 8785.
|
|
183
|
+
|
|
184
|
+
Pre-condition: ``value`` has already been through ``canonicalize``,
|
|
185
|
+
so dicts have sorted keys.
|
|
186
|
+
"""
|
|
187
|
+
if value is None:
|
|
188
|
+
return "null"
|
|
189
|
+
if value is True:
|
|
190
|
+
return "true"
|
|
191
|
+
if value is False:
|
|
192
|
+
return "false"
|
|
193
|
+
if isinstance(value, bool):
|
|
194
|
+
# Defensive: covered by the True/False checks above, but
|
|
195
|
+
# guard against bool subclasses.
|
|
196
|
+
return "true" if value else "false"
|
|
197
|
+
if isinstance(value, int):
|
|
198
|
+
# bool is a subclass of int in Python; the bool checks above
|
|
199
|
+
# short-circuit before we reach this branch.
|
|
200
|
+
return str(value)
|
|
201
|
+
if isinstance(value, float):
|
|
202
|
+
raise TypeError(
|
|
203
|
+
"Float values are not allowed in canonical JSON. "
|
|
204
|
+
"RFC 8785 specifies IEEE-754 shortest-form serialization, "
|
|
205
|
+
"but float drift across implementations is the dominant "
|
|
206
|
+
"source of cross-language hash divergence. Convert decimal "
|
|
207
|
+
"quantities to integers (e.g., dollars * 100 = cents) or "
|
|
208
|
+
"to strings before hashing. See docs/VOCABULARY.md §8 and "
|
|
209
|
+
"the canonical.py module docstring for rationale."
|
|
210
|
+
)
|
|
211
|
+
if isinstance(value, str):
|
|
212
|
+
return _encode_string(value)
|
|
213
|
+
if isinstance(value, list):
|
|
214
|
+
return "[" + ",".join(_encode(item) for item in value) + "]"
|
|
215
|
+
if isinstance(value, dict):
|
|
216
|
+
parts = []
|
|
217
|
+
for key in value:
|
|
218
|
+
if not isinstance(key, str):
|
|
219
|
+
raise TypeError(
|
|
220
|
+
f"Object keys must be strings in canonical JSON, "
|
|
221
|
+
f"got {type(key).__name__} (value: {key!r})."
|
|
222
|
+
)
|
|
223
|
+
parts.append(_encode_string(key) + ":" + _encode(value[key]))
|
|
224
|
+
return "{" + ",".join(parts) + "}"
|
|
225
|
+
raise TypeError(
|
|
226
|
+
f"Unsupported type in canonical JSON: {type(value).__name__}. "
|
|
227
|
+
f"Allowed: dict (str keys), list, str, int, bool, None."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _encode_string(s: str) -> str:
|
|
232
|
+
"""Encode a string per RFC 8785 §3.2.2.2.
|
|
233
|
+
|
|
234
|
+
Wraps the string in double quotes and escapes characters per the
|
|
235
|
+
JSON serialization rules. Non-ASCII characters are passed through
|
|
236
|
+
as UTF-8 (no \\uXXXX escapes for codepoints above 0x1F).
|
|
237
|
+
"""
|
|
238
|
+
out = ['"']
|
|
239
|
+
for ch in s:
|
|
240
|
+
cp = ord(ch)
|
|
241
|
+
if cp in _SHORT_ESCAPES:
|
|
242
|
+
out.append(_SHORT_ESCAPES[cp])
|
|
243
|
+
elif cp < 0x20:
|
|
244
|
+
# Control characters not in _SHORT_ESCAPES → \uXXXX.
|
|
245
|
+
out.append(f"\\u{cp:04x}")
|
|
246
|
+
else:
|
|
247
|
+
# Printable characters (including non-ASCII) pass through.
|
|
248
|
+
out.append(ch)
|
|
249
|
+
out.append('"')
|
|
250
|
+
return "".join(out)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""SHA-256 hashing primitive.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over hashlib.sha256 that returns a lowercase hexadecimal
|
|
4
|
+
digest string. Used as the terminal step of canonical_sha256 and as
|
|
5
|
+
a building block for receipt verification.
|
|
6
|
+
|
|
7
|
+
PATENT-CRITICAL: this function is part of the offline-verifiable
|
|
8
|
+
receipt path. Per Interlock MVP Spec v0.1.2 §5.2 invariant I7,
|
|
9
|
+
verify_receipt(...) must work offline using only stdlib hashlib.
|
|
10
|
+
This module satisfies that requirement.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sha256_hex(data: bytes) -> str:
|
|
19
|
+
"""Return the SHA-256 digest of ``data`` as a lowercase hex string.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
data: The bytes to hash. Must be of type ``bytes``.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The 64-character lowercase hexadecimal SHA-256 digest.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
TypeError: If ``data`` is not of type ``bytes``.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> sha256_hex(b"hello")
|
|
32
|
+
'2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
|
|
33
|
+
|
|
34
|
+
>>> sha256_hex(b"")
|
|
35
|
+
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
36
|
+
"""
|
|
37
|
+
if not isinstance(data, bytes):
|
|
38
|
+
raise TypeError(
|
|
39
|
+
f"sha256_hex requires bytes input, got {type(data).__name__}. "
|
|
40
|
+
f"Encode strings to bytes (e.g., text.encode('utf-8')) before hashing."
|
|
41
|
+
)
|
|
42
|
+
return hashlib.sha256(data).hexdigest()
|