bindable 0.1.4__tar.gz → 0.2.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.2.0/CHANGELOG.md +59 -0
- bindable-0.2.0/PKG-INFO +144 -0
- bindable-0.2.0/README.md +126 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/__init__.py +32 -2
- bindable-0.2.0/bindable/__main__.py +7 -0
- bindable-0.2.0/bindable/_canonical_trackpoint.py +53 -0
- bindable-0.2.0/bindable/_forms.py +455 -0
- bindable-0.2.0/bindable/_version.py +1 -0
- bindable-0.2.0/bindable/cli.py +165 -0
- bindable-0.2.0/bindable/verify.py +685 -0
- {bindable-0.1.4 → bindable-0.2.0}/pyproject.toml +4 -1
- bindable-0.2.0/tests/conftest.py +32 -0
- bindable-0.2.0/tests/forms_pin/SOURCE.json +205 -0
- bindable-0.2.0/tests/forms_pin/__init__.py +0 -0
- bindable-0.2.0/tests/forms_pin/refresh.py +144 -0
- bindable-0.2.0/tests/forms_pin/specs.json +906 -0
- bindable-0.2.0/tests/forms_pin/test-key.public.json +6 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/gate-authority-signed.json +35 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/integers-and-booleans.json +35 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/key-order.json +25 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/nulls.json +17 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/principal-association-signed.json +33 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/receipt-signed.json +133 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/sorted-lists.json +61 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/string-escaping.json +28 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/canonical/trackpoint-signed.json +51 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/contract_version.json +29 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/evidence.json +24 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/gate_authority.json +34 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/governing_set.json +59 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/principal_association.json +32 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/proposal.json +21 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/receipt.json +142 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/scope_definition.json +21 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/sponsor_context.json +12 -0
- bindable-0.2.0/tests/forms_pin/vectors/v4/trackpoint.json +50 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/agent_record.json +14 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/authority_event.json +15 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/binding.json +14 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/contract_version.json +29 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/evidence.json +24 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/gate_authority.json +34 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/governing_set.json +59 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/principal_association.json +32 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/proposal.json +21 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/receipt.json +142 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/scope_definition.json +21 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/sponsor_context.json +12 -0
- bindable-0.2.0/tests/forms_pin/vectors/v5/trackpoint.json +50 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/agent_record.json +14 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/authority_event.json +15 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/binding.json +14 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/contract_version.json +29 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/evidence.json +24 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/gate_authority.json +34 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/governing_set.json +59 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/principal_association.json +46 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/principal_authorization.json +20 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/principal_naming.json +12 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/proposal.json +21 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/receipt.json +142 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/scope_definition.json +21 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/sponsor_context.json +12 -0
- bindable-0.2.0/tests/forms_pin/vectors/v6/trackpoint.json +50 -0
- bindable-0.2.0/tests/test_forms_equality.py +226 -0
- bindable-0.2.0/tests/test_offline_bundle_and_cli.py +304 -0
- bindable-0.2.0/tests/test_trackpoint_vectors.py +90 -0
- bindable-0.2.0/tests/test_verify_current_forms.py +318 -0
- bindable-0.2.0/tests/test_version.py +15 -0
- bindable-0.2.0/tests/vectors/trackpoint_vectors.json +189 -0
- bindable-0.1.4/PKG-INFO +0 -38
- bindable-0.1.4/README.md +0 -20
- bindable-0.1.4/bindable/_version.py +0 -1
- bindable-0.1.4/bindable/verify.py +0 -251
- bindable-0.1.4/tests/test_version.py +0 -5
- {bindable-0.1.4 → bindable-0.2.0}/.gitignore +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/LICENSE +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/NOTICE +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/_canonical_record.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/client.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/crypto/__init__.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/crypto/canonical.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/crypto/hash.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/crypto/keys.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/crypto/sign.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/decision.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/errors.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/bindable/py.typed +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/__init__.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/crypto/sign_vectors.json +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/crypto/vectors.json +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/fixtures/KEY_PROVENANCE.md +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/fixtures/bindable-test-rc4-2026-08.pem +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/fixtures/receipt_public_blocked_rc4.json +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/fixtures/receipt_public_released_rc4.json +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/test_canonical_vectors.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/test_client.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/test_decision_from.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/test_signature_verify.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/test_stage.py +0 -0
- {bindable-0.1.4 → bindable-0.2.0}/tests/test_verify_receipt.py +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `verify_receipt` and `verify_trackpoint` verify the Receipts and Trackpoints
|
|
8
|
+
Bindable issues today, form `v5`, and the previous form `v4`, offline. A
|
|
9
|
+
record names its form in its `form` field. The digest is recomputed from
|
|
10
|
+
the covered fields and compared with the one the record declares, and the
|
|
11
|
+
signature is checked over that digest with Bindable's public key.
|
|
12
|
+
- `ReceiptVerification` and `TrackpointVerification` gain `form`, the form
|
|
13
|
+
the record was checked as, and `hash_valid`, the same fact as
|
|
14
|
+
`chain_hash_valid` under its current name. Existing fields are unchanged.
|
|
15
|
+
- A record that is not the form it claims to be (an unknown field, a missing
|
|
16
|
+
field, a null where none is allowed, a float, an unknown form version)
|
|
17
|
+
returns `valid=False` with a reason. It never raises.
|
|
18
|
+
- The test suite proves the canonical bytes, digests and signature checks
|
|
19
|
+
equal to Bindable's committed test vectors for every form, byte for byte.
|
|
20
|
+
The vectors are a pinned copy (`tests/forms_pin`), and a test fails if the
|
|
21
|
+
copy drifts.
|
|
22
|
+
- Form `v6` (Bindable's forms v0.4.0, not yet issued): its Receipt and
|
|
23
|
+
Trackpoint are v5's with only the form value changed, and are verified the
|
|
24
|
+
same way; the pinned
|
|
25
|
+
vectors are now those of forms v0.4.0, with v4 and v5 unchanged.
|
|
26
|
+
- `verify_receipt(receipt, public_key)` and
|
|
27
|
+
`verify_trackpoint(trackpoint, public_key)`: the key may be given as the
|
|
28
|
+
second argument as well as by name.
|
|
29
|
+
- The file the Portal's "Download Receipt" saves (the Receipt, the public key
|
|
30
|
+
it names and its Trackpoint) is accepted as it is: `verify_receipt(file)`
|
|
31
|
+
checks the Receipt under the file's own key, and the new
|
|
32
|
+
`verify_bundle(file, public_key=None)` returns a `BundleVerification` for
|
|
33
|
+
the Receipt, the Trackpoint, and whether the Trackpoint names the Receipt
|
|
34
|
+
(`link_valid`). A file whose key is not the one the Receipt names fails.
|
|
35
|
+
- `bindable verify <file>` (and `python -m bindable verify <file>`): the same
|
|
36
|
+
checks from the command line, for a downloaded file or for a bare Receipt or
|
|
37
|
+
Trackpoint with `--key`; `--json` prints the result; exit 0, 1 or 2.
|
|
38
|
+
- README: "Verify a Receipt offline", with the downloaded file first.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `import bindable` no longer imports the HTTP client: `ActionGate` and
|
|
43
|
+
`STAGES` are loaded on first use, so the verify path imports no network
|
|
44
|
+
module. `from bindable import ActionGate` works as before.
|
|
45
|
+
- The test suite runs with sockets refused, and proves in a fresh interpreter
|
|
46
|
+
that verifying a file imports no network module and opens no socket.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- `bindable.__version__` now matches the package version. 0.1.5 reported
|
|
51
|
+
`0.1.4`.
|
|
52
|
+
|
|
53
|
+
### Unchanged
|
|
54
|
+
|
|
55
|
+
- `verify_receipt(receipt, public_key=key)` keeps working.
|
|
56
|
+
- Receipts and Trackpoints without a `form` field (the 0.1.x layouts) are
|
|
57
|
+
verified exactly as before.
|
|
58
|
+
- No new dependency. Verification uses the Python standard library and
|
|
59
|
+
`cryptography` only, and makes no network call.
|
bindable-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bindable
|
|
3
|
+
Version: 0.2.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
|
+
## Verify a Receipt offline
|
|
39
|
+
|
|
40
|
+
Every action Bindable reviews leaves a Receipt: a signed record of what was
|
|
41
|
+
proposed, what was decided and why. Anyone who holds a Receipt can check it on
|
|
42
|
+
their own machine. No network connection is needed, and Bindable is never
|
|
43
|
+
contacted.
|
|
44
|
+
|
|
45
|
+
### From a downloaded Receipt file
|
|
46
|
+
|
|
47
|
+
On a Receipt's page in the Portal, **Download Receipt** saves one file,
|
|
48
|
+
`bindable-receipt-<receipt_id>.json`, holding the Receipt, the public key it
|
|
49
|
+
was signed under and, once your Gate has reported, its Trackpoint. Check it
|
|
50
|
+
with no connection at all:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
pip install bindable
|
|
54
|
+
bindable verify bindable-receipt-<receipt_id>.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
It prints whether the Receipt and its Trackpoint verify, and that the
|
|
58
|
+
Trackpoint names this Receipt, and exits `0` when everything does, `1` when
|
|
59
|
+
something does not, and `2` when the file cannot be read. Add `--json` for a
|
|
60
|
+
result a program can read, or `--key <pem file>` to check against a key you
|
|
61
|
+
hold rather than the one in the file. The key in the file shows the file
|
|
62
|
+
agrees with itself; to show the Receipt is Bindable's, compare that key with
|
|
63
|
+
the one Bindable publishes at `GET /v1/keys/{signing_key_id}`, or pass it with
|
|
64
|
+
`--key`.
|
|
65
|
+
|
|
66
|
+
In Python, the same file is accepted as it is, by `verify_receipt(document)`
|
|
67
|
+
for the Receipt alone, or by `verify_bundle(document)` for the Receipt, its
|
|
68
|
+
Trackpoint and the link between them:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import json
|
|
72
|
+
from bindable import verify_bundle
|
|
73
|
+
|
|
74
|
+
result = verify_bundle(json.load(open("bindable-receipt-<receipt_id>.json")))
|
|
75
|
+
print("Verified." if result.valid else f"Not verified: {result.reason}")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### From a Receipt and a key
|
|
79
|
+
|
|
80
|
+
You need two things:
|
|
81
|
+
|
|
82
|
+
1. **The Receipt**, as JSON. Your Gate returns it with each decision. You can
|
|
83
|
+
also fetch a Receipt's public form by its ID from
|
|
84
|
+
`GET /v1/receipts/{receipt_id}/public`, which needs no credential.
|
|
85
|
+
2. **Bindable's public key** for the key named in the Receipt's
|
|
86
|
+
`signing_key_id`. Fetch it once from `GET /v1/keys/{signing_key_id}` (no
|
|
87
|
+
credential needed) and keep it. Retired keys stay published, so an old
|
|
88
|
+
Receipt can always be checked.
|
|
89
|
+
|
|
90
|
+
Then:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import json
|
|
94
|
+
from bindable import verify_receipt
|
|
95
|
+
|
|
96
|
+
receipt = json.load(open("receipt.json"))
|
|
97
|
+
public_key = open("bindable-public-key.pem").read()
|
|
98
|
+
|
|
99
|
+
result = verify_receipt(receipt, public_key)
|
|
100
|
+
|
|
101
|
+
if result.valid:
|
|
102
|
+
print("Genuine and unchanged.")
|
|
103
|
+
else:
|
|
104
|
+
print("Do not rely on this Receipt:", result.reason)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
What the answer tells you:
|
|
108
|
+
|
|
109
|
+
| Field | Meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `valid` | `True` only when both checks below pass. Rely on the Receipt only then. |
|
|
112
|
+
| `hash_valid` | The Receipt's contents are exactly what was recorded. If even one character of a covered field changed, this is `False`. |
|
|
113
|
+
| `signature_valid` | Bindable's key signed this Receipt. `False` for a different key or a broken signature. `None` if you gave no key. |
|
|
114
|
+
| `form` | Which version of the Receipt layout was checked, for example `"v5"`. |
|
|
115
|
+
| `reason` | A short explanation when `valid` is `False`. Meant for people to read, not for programs to parse. |
|
|
116
|
+
|
|
117
|
+
`verify_receipt` never raises an exception for a bad or incomplete Receipt.
|
|
118
|
+
It returns `valid=False` with a reason.
|
|
119
|
+
|
|
120
|
+
Some fields travel beside a Receipt but are not covered by its signature:
|
|
121
|
+
`reason_detail`, `proposal_payload`, `principal_disclosed`, `principal_id`
|
|
122
|
+
and `release_token`. Changing them does not change the result, so do not treat
|
|
123
|
+
them as signed evidence.
|
|
124
|
+
|
|
125
|
+
A Trackpoint, the record of what your Gate did after the decision, is
|
|
126
|
+
checked the same way with `verify_trackpoint`. If you give a key and the
|
|
127
|
+
Trackpoint carries no signature, the answer is `valid=False`, never a pass on
|
|
128
|
+
the contents alone.
|
|
129
|
+
|
|
130
|
+
Without a key, both functions check the contents only. Anyone can compute that
|
|
131
|
+
check, so it shows a record is internally consistent, not that Bindable made
|
|
132
|
+
it. Give the key whenever you need to know where a record came from.
|
|
133
|
+
|
|
134
|
+
**Which Receipts this checks.** Forms `v4` and `v5`, the layouts Bindable
|
|
135
|
+
has issued, and `v6`, the next, defined and ready to check. Receipts from the
|
|
136
|
+
0.1.x releases, which have no `form` field, are still checked the way 0.1.x
|
|
137
|
+
checked them.
|
|
138
|
+
|
|
139
|
+
The checks use only the Python standard library and the `cryptography`
|
|
140
|
+
package, and import nothing that can reach a network: the HTTP client this
|
|
141
|
+
library also ships is loaded only when you use `ActionGate`. This library
|
|
142
|
+
contains no signing code and no private key.
|
|
143
|
+
|
|
144
|
+
Status: pre-release
|
bindable-0.2.0/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
## Verify a Receipt offline
|
|
21
|
+
|
|
22
|
+
Every action Bindable reviews leaves a Receipt: a signed record of what was
|
|
23
|
+
proposed, what was decided and why. Anyone who holds a Receipt can check it on
|
|
24
|
+
their own machine. No network connection is needed, and Bindable is never
|
|
25
|
+
contacted.
|
|
26
|
+
|
|
27
|
+
### From a downloaded Receipt file
|
|
28
|
+
|
|
29
|
+
On a Receipt's page in the Portal, **Download Receipt** saves one file,
|
|
30
|
+
`bindable-receipt-<receipt_id>.json`, holding the Receipt, the public key it
|
|
31
|
+
was signed under and, once your Gate has reported, its Trackpoint. Check it
|
|
32
|
+
with no connection at all:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install bindable
|
|
36
|
+
bindable verify bindable-receipt-<receipt_id>.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It prints whether the Receipt and its Trackpoint verify, and that the
|
|
40
|
+
Trackpoint names this Receipt, and exits `0` when everything does, `1` when
|
|
41
|
+
something does not, and `2` when the file cannot be read. Add `--json` for a
|
|
42
|
+
result a program can read, or `--key <pem file>` to check against a key you
|
|
43
|
+
hold rather than the one in the file. The key in the file shows the file
|
|
44
|
+
agrees with itself; to show the Receipt is Bindable's, compare that key with
|
|
45
|
+
the one Bindable publishes at `GET /v1/keys/{signing_key_id}`, or pass it with
|
|
46
|
+
`--key`.
|
|
47
|
+
|
|
48
|
+
In Python, the same file is accepted as it is, by `verify_receipt(document)`
|
|
49
|
+
for the Receipt alone, or by `verify_bundle(document)` for the Receipt, its
|
|
50
|
+
Trackpoint and the link between them:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import json
|
|
54
|
+
from bindable import verify_bundle
|
|
55
|
+
|
|
56
|
+
result = verify_bundle(json.load(open("bindable-receipt-<receipt_id>.json")))
|
|
57
|
+
print("Verified." if result.valid else f"Not verified: {result.reason}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### From a Receipt and a key
|
|
61
|
+
|
|
62
|
+
You need two things:
|
|
63
|
+
|
|
64
|
+
1. **The Receipt**, as JSON. Your Gate returns it with each decision. You can
|
|
65
|
+
also fetch a Receipt's public form by its ID from
|
|
66
|
+
`GET /v1/receipts/{receipt_id}/public`, which needs no credential.
|
|
67
|
+
2. **Bindable's public key** for the key named in the Receipt's
|
|
68
|
+
`signing_key_id`. Fetch it once from `GET /v1/keys/{signing_key_id}` (no
|
|
69
|
+
credential needed) and keep it. Retired keys stay published, so an old
|
|
70
|
+
Receipt can always be checked.
|
|
71
|
+
|
|
72
|
+
Then:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import json
|
|
76
|
+
from bindable import verify_receipt
|
|
77
|
+
|
|
78
|
+
receipt = json.load(open("receipt.json"))
|
|
79
|
+
public_key = open("bindable-public-key.pem").read()
|
|
80
|
+
|
|
81
|
+
result = verify_receipt(receipt, public_key)
|
|
82
|
+
|
|
83
|
+
if result.valid:
|
|
84
|
+
print("Genuine and unchanged.")
|
|
85
|
+
else:
|
|
86
|
+
print("Do not rely on this Receipt:", result.reason)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
What the answer tells you:
|
|
90
|
+
|
|
91
|
+
| Field | Meaning |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `valid` | `True` only when both checks below pass. Rely on the Receipt only then. |
|
|
94
|
+
| `hash_valid` | The Receipt's contents are exactly what was recorded. If even one character of a covered field changed, this is `False`. |
|
|
95
|
+
| `signature_valid` | Bindable's key signed this Receipt. `False` for a different key or a broken signature. `None` if you gave no key. |
|
|
96
|
+
| `form` | Which version of the Receipt layout was checked, for example `"v5"`. |
|
|
97
|
+
| `reason` | A short explanation when `valid` is `False`. Meant for people to read, not for programs to parse. |
|
|
98
|
+
|
|
99
|
+
`verify_receipt` never raises an exception for a bad or incomplete Receipt.
|
|
100
|
+
It returns `valid=False` with a reason.
|
|
101
|
+
|
|
102
|
+
Some fields travel beside a Receipt but are not covered by its signature:
|
|
103
|
+
`reason_detail`, `proposal_payload`, `principal_disclosed`, `principal_id`
|
|
104
|
+
and `release_token`. Changing them does not change the result, so do not treat
|
|
105
|
+
them as signed evidence.
|
|
106
|
+
|
|
107
|
+
A Trackpoint, the record of what your Gate did after the decision, is
|
|
108
|
+
checked the same way with `verify_trackpoint`. If you give a key and the
|
|
109
|
+
Trackpoint carries no signature, the answer is `valid=False`, never a pass on
|
|
110
|
+
the contents alone.
|
|
111
|
+
|
|
112
|
+
Without a key, both functions check the contents only. Anyone can compute that
|
|
113
|
+
check, so it shows a record is internally consistent, not that Bindable made
|
|
114
|
+
it. Give the key whenever you need to know where a record came from.
|
|
115
|
+
|
|
116
|
+
**Which Receipts this checks.** Forms `v4` and `v5`, the layouts Bindable
|
|
117
|
+
has issued, and `v6`, the next, defined and ready to check. Receipts from the
|
|
118
|
+
0.1.x releases, which have no `form` field, are still checked the way 0.1.x
|
|
119
|
+
checked them.
|
|
120
|
+
|
|
121
|
+
The checks use only the Python standard library and the `cryptography`
|
|
122
|
+
package, and import nothing that can reach a network: the HTTP client this
|
|
123
|
+
library also ships is loaded only when you use `ActionGate`. This library
|
|
124
|
+
contains no signing code and no private key.
|
|
125
|
+
|
|
126
|
+
Status: pre-release
|
|
@@ -10,14 +10,40 @@ is fully offline.
|
|
|
10
10
|
Review resolves the governing Contract by stage. ``decide()`` takes an optional
|
|
11
11
|
``stage`` — one of ``STAGES`` — and omits the field entirely when it is not
|
|
12
12
|
given, leaving the platform's ``development`` default in force.
|
|
13
|
+
|
|
14
|
+
Offline verification imports nothing that can reach a network. The transport
|
|
15
|
+
client, which does, is imported only when ``ActionGate`` or ``STAGES`` is first
|
|
16
|
+
used, so ``from bindable import verify_receipt`` and ``bindable verify`` load
|
|
17
|
+
no HTTP library at all.
|
|
13
18
|
"""
|
|
14
19
|
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
15
22
|
from ._version import __version__
|
|
16
|
-
from .client import STAGES, ActionGate
|
|
17
23
|
from .crypto import load_public_key
|
|
18
24
|
from .decision import BLOCKED, RELEASED, decision_from
|
|
19
25
|
from .errors import BindableAPIError
|
|
20
|
-
from .verify import
|
|
26
|
+
from .verify import (
|
|
27
|
+
BundleVerification,
|
|
28
|
+
ReceiptVerification,
|
|
29
|
+
TrackpointVerification,
|
|
30
|
+
verify_bundle,
|
|
31
|
+
verify_receipt,
|
|
32
|
+
verify_trackpoint,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .client import STAGES, ActionGate
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __getattr__(name: str) -> Any:
|
|
40
|
+
"""The transport client, imported on first use."""
|
|
41
|
+
if name in ("ActionGate", "STAGES"):
|
|
42
|
+
from . import client
|
|
43
|
+
|
|
44
|
+
return getattr(client, name)
|
|
45
|
+
raise AttributeError(f"module 'bindable' has no attribute {name!r}")
|
|
46
|
+
|
|
21
47
|
|
|
22
48
|
__all__ = [
|
|
23
49
|
"BLOCKED",
|
|
@@ -25,9 +51,13 @@ __all__ = [
|
|
|
25
51
|
"STAGES",
|
|
26
52
|
"ActionGate",
|
|
27
53
|
"BindableAPIError",
|
|
54
|
+
"BundleVerification",
|
|
28
55
|
"ReceiptVerification",
|
|
56
|
+
"TrackpointVerification",
|
|
29
57
|
"__version__",
|
|
30
58
|
"decision_from",
|
|
31
59
|
"load_public_key",
|
|
60
|
+
"verify_bundle",
|
|
32
61
|
"verify_receipt",
|
|
62
|
+
"verify_trackpoint",
|
|
33
63
|
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Canonical hash-input serialization for trackpoints (form v3).
|
|
2
|
+
|
|
3
|
+
Single source of truth for the canonical input over which a trackpoint's
|
|
4
|
+
chain_hash is computed, and — on production — what its signature covers.
|
|
5
|
+
``verify_trackpoint`` reconstructs this input to verify offline; the platform
|
|
6
|
+
constructs it when appending. Both must be byte-identical.
|
|
7
|
+
|
|
8
|
+
FIFTEEN FIELDS, form v3 (2026-09-03). Form v2 hashed thirteen: ``gate_runner``
|
|
9
|
+
and ``stage`` were excluded only because rows predated the columns, and
|
|
10
|
+
including them then would have split the chain at an undocumented instant. Both
|
|
11
|
+
chains restarted at genesis on 2026-09-03 with zero rows, so that reason is gone
|
|
12
|
+
and both fields are now covered.
|
|
13
|
+
|
|
14
|
+
``chain_hash`` is NOT in this input — it is the OUTPUT. A signature covers this
|
|
15
|
+
canonical record, which is equivalent to signing the chain_hash by ECDSA's
|
|
16
|
+
standard construction.
|
|
17
|
+
|
|
18
|
+
Pure and deterministic: no I/O, no mutation. Keys are sorted by RFC 8785 at
|
|
19
|
+
serialization, so the order written here is for readers, not for the digest.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Mapping
|
|
25
|
+
|
|
26
|
+
#: The fifteen hashed field names, in the order a reader should think about them.
|
|
27
|
+
CANONICAL_FIELDS: tuple[str, ...] = (
|
|
28
|
+
"trackpoint_id",
|
|
29
|
+
"receipt_id",
|
|
30
|
+
"persistent_agent_id",
|
|
31
|
+
"principal_id",
|
|
32
|
+
"outcome",
|
|
33
|
+
"action_result",
|
|
34
|
+
"proposed_at",
|
|
35
|
+
"decided_at",
|
|
36
|
+
"enforced_at",
|
|
37
|
+
"gate_version",
|
|
38
|
+
"gate_kind",
|
|
39
|
+
"gate_runner",
|
|
40
|
+
"stage",
|
|
41
|
+
"environment",
|
|
42
|
+
"previous_hash",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _canonical_trackpoint_input(trackpoint: Mapping[str, Any]) -> dict[str, Any]:
|
|
47
|
+
"""Return the canonical hash input for one trackpoint.
|
|
48
|
+
|
|
49
|
+
Timestamps are hashed as the wire strings the platform wrote
|
|
50
|
+
(ISO 8601 UTC, millisecond precision, ``Z`` suffix). They are taken
|
|
51
|
+
verbatim: a verifier must not reformat a value it is checking.
|
|
52
|
+
"""
|
|
53
|
+
return {name: trackpoint.get(name) for name in CANONICAL_FIELDS}
|