vouchwell 0.5.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.
Files changed (33) hide show
  1. vouchwell-0.5.0/PKG-INFO +199 -0
  2. vouchwell-0.5.0/README.md +171 -0
  3. vouchwell-0.5.0/pyproject.toml +51 -0
  4. vouchwell-0.5.0/setup.cfg +4 -0
  5. vouchwell-0.5.0/src/vouchwell/__init__.py +82 -0
  6. vouchwell-0.5.0/src/vouchwell/canonical.py +139 -0
  7. vouchwell-0.5.0/src/vouchwell/checkpoint.py +100 -0
  8. vouchwell-0.5.0/src/vouchwell/hashing.py +72 -0
  9. vouchwell-0.5.0/src/vouchwell/integrations/__init__.py +26 -0
  10. vouchwell-0.5.0/src/vouchwell/integrations/langchain.py +93 -0
  11. vouchwell-0.5.0/src/vouchwell/integrations/openai_agents.py +55 -0
  12. vouchwell-0.5.0/src/vouchwell/keys.py +84 -0
  13. vouchwell-0.5.0/src/vouchwell/log.py +453 -0
  14. vouchwell-0.5.0/src/vouchwell/merkle.py +200 -0
  15. vouchwell-0.5.0/src/vouchwell/py.typed +0 -0
  16. vouchwell-0.5.0/src/vouchwell/receipt.py +200 -0
  17. vouchwell-0.5.0/src/vouchwell/record.py +210 -0
  18. vouchwell-0.5.0/src/vouchwell/redact.py +126 -0
  19. vouchwell-0.5.0/src/vouchwell/remote.py +134 -0
  20. vouchwell-0.5.0/src/vouchwell/unfinished.py +91 -0
  21. vouchwell-0.5.0/src/vouchwell.egg-info/PKG-INFO +199 -0
  22. vouchwell-0.5.0/src/vouchwell.egg-info/SOURCES.txt +31 -0
  23. vouchwell-0.5.0/src/vouchwell.egg-info/dependency_links.txt +1 -0
  24. vouchwell-0.5.0/src/vouchwell.egg-info/requires.txt +14 -0
  25. vouchwell-0.5.0/src/vouchwell.egg-info/top_level.txt +1 -0
  26. vouchwell-0.5.0/tests/test_core.py +149 -0
  27. vouchwell-0.5.0/tests/test_integrations.py +135 -0
  28. vouchwell-0.5.0/tests/test_interop.py +153 -0
  29. vouchwell-0.5.0/tests/test_log.py +236 -0
  30. vouchwell-0.5.0/tests/test_record.py +104 -0
  31. vouchwell-0.5.0/tests/test_redact.py +19 -0
  32. vouchwell-0.5.0/tests/test_remote.py +46 -0
  33. vouchwell-0.5.0/tests/test_unfinished.py +75 -0
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: vouchwell
3
+ Version: 0.5.0
4
+ Summary: Tamper-evident receipts for AI agent actions. Python SDK, wire-compatible with the vouchwell CLI.
5
+ Author: IdeaboticsAI
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/vouchwell/vouchwell
8
+ Project-URL: Source, https://github.com/vouchwell/vouchwell/tree/main/sdk/python
9
+ Project-URL: Issues, https://github.com/vouchwell/vouchwell/issues
10
+ Keywords: ai-agents,audit,transparency-log,merkle,ed25519,eu-ai-act
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security :: Cryptography
16
+ Classifier: Topic :: System :: Logging
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: cryptography>=41
20
+ Provides-Extra: langchain
21
+ Requires-Dist: langchain-core>=0.3; extra == "langchain"
22
+ Provides-Extra: openai-agents
23
+ Requires-Dist: openai-agents>=0.2; extra == "openai-agents"
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7; extra == "test"
26
+ Requires-Dist: langchain-core>=0.3; python_version >= "3.10" and extra == "test"
27
+ Requires-Dist: openai-agents>=0.2; python_version >= "3.10" and extra == "test"
28
+
29
+ # vouchwell (Python)
30
+
31
+ Tamper-evident receipts for what your AI agents do, from Python. This is the
32
+ Python SDK for [Vouchwell](https://github.com/vouchwell/vouchwell), and it
33
+ writes exactly the format the `vouchwell` CLI reads:
34
+
35
+ - a log written here passes `vw verify`;
36
+ - a bundle exported here passes `vw check`;
37
+ - logs and bundles written by the JavaScript side open and verify here.
38
+
39
+ CI runs both directions against the real CLI on every commit.
40
+
41
+ ```bash
42
+ pip install "vouchwell @ git+https://github.com/vouchwell/vouchwell#subdirectory=sdk/python"
43
+ ```
44
+
45
+ That installs straight from this repository. The package is not on PyPI yet;
46
+ once it is, `pip install vouchwell` will do the same.
47
+
48
+ Python 3.9+. The one dependency is `cryptography`, for Ed25519.
49
+
50
+ ## Record an agent's tool calls
51
+
52
+ ```python
53
+ from vouchwell import ProofLog, Recorder
54
+
55
+ log = ProofLog.open_or_create(".vouchwell")
56
+ rec = Recorder(log, agent="support-bot", principal="ops@acme.com", namespace="stripe")
57
+
58
+ @rec.tool("refund", metrics=lambda order_id, amount_cents: {"amount_usd": amount_cents / 100})
59
+ def refund(order_id: str, amount_cents: int):
60
+ return stripe.Refund.create(payment_intent=order_id, amount=amount_cents)
61
+
62
+ refund("pi_123", 4200)
63
+ ```
64
+
65
+ Each call leaves two signed receipts:
66
+
67
+ 1. **An intent receipt, before the call runs.** A crash mid-call still leaves
68
+ evidence it was attempted.
69
+ 2. **An outcome receipt, when it returns or raises**, linked to the intent.
70
+
71
+ Async functions work the same. So does a context manager:
72
+
73
+ ```python
74
+ with rec.call("crm.update", params={"id": 42}) as call:
75
+ call.result = crm.update(42)
76
+ ```
77
+
78
+ Arguments and results are **committed to, not stored**. A receipt holds a
79
+ salted hash plus a preview with secrets and personal data masked (API keys,
80
+ card numbers, emails and so on). The salts stay in the log's private
81
+ `salts.jsonl`, so you can later prove what a value was:
82
+ `log.reveal(seq, "params", value)`. Or you can destroy the salts, and the value
83
+ becomes unprovable forever while the receipt still verifies:
84
+ `log.shred(predicate)`.
85
+
86
+ ### Check calls before they run
87
+
88
+ ```python
89
+ def decide(target, params):
90
+ if target.endswith(".delete"):
91
+ return {"outcome": "deny", "rules": ["no-deletes"], "reason": "agents may not delete"}
92
+ return {"outcome": "allow", "policy": "v3"}
93
+
94
+ rec = Recorder(log, agent="support-bot", principal="ops@acme.com", decide=decide)
95
+ ```
96
+
97
+ A refused call never runs. It gets one receipt, and `PolicyDenied` is raised.
98
+ An `escalate` outcome is a denial here, recorded as `policy:no-approver`,
99
+ because nobody in the process can approve it.
100
+
101
+ The full policy language (budgets, rate limits, escalation to a person, Slack
102
+ approvals, monitor mode) lives in the `vw proxy` MCP proxy and in the
103
+ JavaScript `Recorder` in `@vouchwell/core`. For an agent that uses MCP, wrap
104
+ its servers with the proxy instead. A Python port of the policy engine is
105
+ planned.
106
+
107
+ ### Use it with LangChain or the OpenAI Agents SDK
108
+
109
+ Wrap the agent's tools once; use the wrapped ones in their place. Each call is
110
+ checked with `decide`, recorded as intent and outcome, and a refused call
111
+ never runs: the model is told why in the tool's reply, as the MCP proxy does,
112
+ instead of the agent crashing.
113
+
114
+ ```python
115
+ from vouchwell.integrations.langchain import record_tools # pip install "vouchwell[langchain]"
116
+ agent = create_react_agent(model, record_tools(rec, [search, refund]))
117
+
118
+ from vouchwell.integrations.openai_agents import record_tools # pip install "vouchwell[openai-agents]"
119
+ agent = Agent(name="Support", tools=record_tools(rec, [lookup_order, refund]))
120
+ ```
121
+
122
+ Hosted OpenAI tools (web search and the like) run on OpenAI's side, so they
123
+ are passed through unrecorded. Both adapters need Python 3.10+, and are tested
124
+ in CI against the real packages.
125
+
126
+ ### Find calls that never finished
127
+
128
+ Each call's intent is on disk before the tool runs. If the process is killed
129
+ while a call is out, the intent has no outcome, and that is worth knowing:
130
+
131
+ ```python
132
+ from vouchwell import find_unfinished
133
+
134
+ found = find_unfinished(log.entries)
135
+ for u in found["unfinished"]:
136
+ print(u["seq"], u["ts"], u["target"], u["principal"])
137
+ ```
138
+
139
+ `inFlight` holds calls from the last five minutes that may still be running,
140
+ and `abandoned` the ones a recorder gave up on while shutting down. `vw verify`
141
+ reports the same list, and `vw verify --fail-on-unfinished` exits 3 when there
142
+ is one.
143
+
144
+ ## Hand over evidence
145
+
146
+ ```python
147
+ import json
148
+ from vouchwell import verify_bundle
149
+
150
+ log.checkpoint() # sign the current state
151
+ open("evidence.json", "w").write(json.dumps(log.bundle()))
152
+
153
+ # Anyone can verify it, here or with `vw check evidence.json`:
154
+ verify_bundle(json.load(open("evidence.json")))
155
+ # -> {"ok": True, "issues": [], "checked": 12}
156
+ ```
157
+
158
+ To require an independent witness, pin its key. Get the key from somewhere the
159
+ bundle can't influence:
160
+
161
+ ```python
162
+ verify_bundle(bundle, min_witnesses=1, trusted_witnesses={"pw1…": "<public key>"})
163
+ ```
164
+
165
+ `vw report` (from the npm CLI) turns a log written here into an evidence pack
166
+ for auditors.
167
+
168
+ ## Ship to a hub
169
+
170
+ ```python
171
+ from vouchwell import push
172
+
173
+ push(log, "https://hub.acme.com", token=os.environ["VOUCHWELL_TOKEN"], name="support-bot")
174
+ ```
175
+
176
+ It sends whatever the hub doesn't have yet, and is safe to call again. Plain
177
+ `http://` is refused unless the hub is on this machine, so the API key never
178
+ crosses a network unencrypted.
179
+
180
+ ## What's here
181
+
182
+ | Module | |
183
+ | --- | --- |
184
+ | `canonical` | RFC 8785 canonical JSON: UTF-16 key order, ECMAScript number formatting |
185
+ | `merkle` | RFC 6962 trees, inclusion and consistency proofs |
186
+ | `unfinished` | `find_unfinished`: calls sent and never answered |
187
+ | `integrations` | `langchain.record_tools`, `openai_agents.record_tools` |
188
+ | `keys` | Ed25519 identities, canonical base64url |
189
+ | `receipt` | Build, seal, sign and verify receipts |
190
+ | `checkpoint` | Signed tree heads, witness countersignatures |
191
+ | `log` | `ProofLog`, the on-disk log the CLI shares, and `verify_bundle` |
192
+ | `record` | `Recorder`, for Python agents |
193
+ | `remote` | `push` to a hub |
194
+
195
+ Tested against the RFC 6962 Certificate Transparency reference tree and the
196
+ RFC 8032 Ed25519 vectors, plus byte-level canonicalization parity with the
197
+ JavaScript implementation over hundreds of awkward values.
198
+
199
+ License: Apache-2.0.
@@ -0,0 +1,171 @@
1
+ # vouchwell (Python)
2
+
3
+ Tamper-evident receipts for what your AI agents do, from Python. This is the
4
+ Python SDK for [Vouchwell](https://github.com/vouchwell/vouchwell), and it
5
+ writes exactly the format the `vouchwell` CLI reads:
6
+
7
+ - a log written here passes `vw verify`;
8
+ - a bundle exported here passes `vw check`;
9
+ - logs and bundles written by the JavaScript side open and verify here.
10
+
11
+ CI runs both directions against the real CLI on every commit.
12
+
13
+ ```bash
14
+ pip install "vouchwell @ git+https://github.com/vouchwell/vouchwell#subdirectory=sdk/python"
15
+ ```
16
+
17
+ That installs straight from this repository. The package is not on PyPI yet;
18
+ once it is, `pip install vouchwell` will do the same.
19
+
20
+ Python 3.9+. The one dependency is `cryptography`, for Ed25519.
21
+
22
+ ## Record an agent's tool calls
23
+
24
+ ```python
25
+ from vouchwell import ProofLog, Recorder
26
+
27
+ log = ProofLog.open_or_create(".vouchwell")
28
+ rec = Recorder(log, agent="support-bot", principal="ops@acme.com", namespace="stripe")
29
+
30
+ @rec.tool("refund", metrics=lambda order_id, amount_cents: {"amount_usd": amount_cents / 100})
31
+ def refund(order_id: str, amount_cents: int):
32
+ return stripe.Refund.create(payment_intent=order_id, amount=amount_cents)
33
+
34
+ refund("pi_123", 4200)
35
+ ```
36
+
37
+ Each call leaves two signed receipts:
38
+
39
+ 1. **An intent receipt, before the call runs.** A crash mid-call still leaves
40
+ evidence it was attempted.
41
+ 2. **An outcome receipt, when it returns or raises**, linked to the intent.
42
+
43
+ Async functions work the same. So does a context manager:
44
+
45
+ ```python
46
+ with rec.call("crm.update", params={"id": 42}) as call:
47
+ call.result = crm.update(42)
48
+ ```
49
+
50
+ Arguments and results are **committed to, not stored**. A receipt holds a
51
+ salted hash plus a preview with secrets and personal data masked (API keys,
52
+ card numbers, emails and so on). The salts stay in the log's private
53
+ `salts.jsonl`, so you can later prove what a value was:
54
+ `log.reveal(seq, "params", value)`. Or you can destroy the salts, and the value
55
+ becomes unprovable forever while the receipt still verifies:
56
+ `log.shred(predicate)`.
57
+
58
+ ### Check calls before they run
59
+
60
+ ```python
61
+ def decide(target, params):
62
+ if target.endswith(".delete"):
63
+ return {"outcome": "deny", "rules": ["no-deletes"], "reason": "agents may not delete"}
64
+ return {"outcome": "allow", "policy": "v3"}
65
+
66
+ rec = Recorder(log, agent="support-bot", principal="ops@acme.com", decide=decide)
67
+ ```
68
+
69
+ A refused call never runs. It gets one receipt, and `PolicyDenied` is raised.
70
+ An `escalate` outcome is a denial here, recorded as `policy:no-approver`,
71
+ because nobody in the process can approve it.
72
+
73
+ The full policy language (budgets, rate limits, escalation to a person, Slack
74
+ approvals, monitor mode) lives in the `vw proxy` MCP proxy and in the
75
+ JavaScript `Recorder` in `@vouchwell/core`. For an agent that uses MCP, wrap
76
+ its servers with the proxy instead. A Python port of the policy engine is
77
+ planned.
78
+
79
+ ### Use it with LangChain or the OpenAI Agents SDK
80
+
81
+ Wrap the agent's tools once; use the wrapped ones in their place. Each call is
82
+ checked with `decide`, recorded as intent and outcome, and a refused call
83
+ never runs: the model is told why in the tool's reply, as the MCP proxy does,
84
+ instead of the agent crashing.
85
+
86
+ ```python
87
+ from vouchwell.integrations.langchain import record_tools # pip install "vouchwell[langchain]"
88
+ agent = create_react_agent(model, record_tools(rec, [search, refund]))
89
+
90
+ from vouchwell.integrations.openai_agents import record_tools # pip install "vouchwell[openai-agents]"
91
+ agent = Agent(name="Support", tools=record_tools(rec, [lookup_order, refund]))
92
+ ```
93
+
94
+ Hosted OpenAI tools (web search and the like) run on OpenAI's side, so they
95
+ are passed through unrecorded. Both adapters need Python 3.10+, and are tested
96
+ in CI against the real packages.
97
+
98
+ ### Find calls that never finished
99
+
100
+ Each call's intent is on disk before the tool runs. If the process is killed
101
+ while a call is out, the intent has no outcome, and that is worth knowing:
102
+
103
+ ```python
104
+ from vouchwell import find_unfinished
105
+
106
+ found = find_unfinished(log.entries)
107
+ for u in found["unfinished"]:
108
+ print(u["seq"], u["ts"], u["target"], u["principal"])
109
+ ```
110
+
111
+ `inFlight` holds calls from the last five minutes that may still be running,
112
+ and `abandoned` the ones a recorder gave up on while shutting down. `vw verify`
113
+ reports the same list, and `vw verify --fail-on-unfinished` exits 3 when there
114
+ is one.
115
+
116
+ ## Hand over evidence
117
+
118
+ ```python
119
+ import json
120
+ from vouchwell import verify_bundle
121
+
122
+ log.checkpoint() # sign the current state
123
+ open("evidence.json", "w").write(json.dumps(log.bundle()))
124
+
125
+ # Anyone can verify it, here or with `vw check evidence.json`:
126
+ verify_bundle(json.load(open("evidence.json")))
127
+ # -> {"ok": True, "issues": [], "checked": 12}
128
+ ```
129
+
130
+ To require an independent witness, pin its key. Get the key from somewhere the
131
+ bundle can't influence:
132
+
133
+ ```python
134
+ verify_bundle(bundle, min_witnesses=1, trusted_witnesses={"pw1…": "<public key>"})
135
+ ```
136
+
137
+ `vw report` (from the npm CLI) turns a log written here into an evidence pack
138
+ for auditors.
139
+
140
+ ## Ship to a hub
141
+
142
+ ```python
143
+ from vouchwell import push
144
+
145
+ push(log, "https://hub.acme.com", token=os.environ["VOUCHWELL_TOKEN"], name="support-bot")
146
+ ```
147
+
148
+ It sends whatever the hub doesn't have yet, and is safe to call again. Plain
149
+ `http://` is refused unless the hub is on this machine, so the API key never
150
+ crosses a network unencrypted.
151
+
152
+ ## What's here
153
+
154
+ | Module | |
155
+ | --- | --- |
156
+ | `canonical` | RFC 8785 canonical JSON: UTF-16 key order, ECMAScript number formatting |
157
+ | `merkle` | RFC 6962 trees, inclusion and consistency proofs |
158
+ | `unfinished` | `find_unfinished`: calls sent and never answered |
159
+ | `integrations` | `langchain.record_tools`, `openai_agents.record_tools` |
160
+ | `keys` | Ed25519 identities, canonical base64url |
161
+ | `receipt` | Build, seal, sign and verify receipts |
162
+ | `checkpoint` | Signed tree heads, witness countersignatures |
163
+ | `log` | `ProofLog`, the on-disk log the CLI shares, and `verify_bundle` |
164
+ | `record` | `Recorder`, for Python agents |
165
+ | `remote` | `push` to a hub |
166
+
167
+ Tested against the RFC 6962 Certificate Transparency reference tree and the
168
+ RFC 8032 Ed25519 vectors, plus byte-level canonicalization parity with the
169
+ JavaScript implementation over hundreds of awkward values.
170
+
171
+ License: Apache-2.0.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vouchwell"
7
+ version = "0.5.0"
8
+ description = "Tamper-evident receipts for AI agent actions. Python SDK, wire-compatible with the vouchwell CLI."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "IdeaboticsAI" }]
13
+ keywords = ["ai-agents", "audit", "transparency-log", "merkle", "ed25519", "eu-ai-act"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Security :: Cryptography",
20
+ "Topic :: System :: Logging",
21
+ ]
22
+ # One dependency, for Ed25519: the standard library has none, and signature
23
+ # code is the last thing a security product should write itself.
24
+ dependencies = ["cryptography>=41"]
25
+
26
+ [project.optional-dependencies]
27
+ # The framework adapters import their framework only when used; these extras
28
+ # install it alongside. Both frameworks need Python 3.10+.
29
+ langchain = ["langchain-core>=0.3"]
30
+ openai-agents = ["openai-agents>=0.2"]
31
+ test = [
32
+ "pytest>=7",
33
+ # So CI runs the adapters against the real frameworks where they install.
34
+ "langchain-core>=0.3; python_version >= '3.10'",
35
+ "openai-agents>=0.2; python_version >= '3.10'",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/vouchwell/vouchwell"
40
+ Source = "https://github.com/vouchwell/vouchwell/tree/main/sdk/python"
41
+ Issues = "https://github.com/vouchwell/vouchwell/issues"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.setuptools.package-data]
47
+ vouchwell = ["py.typed"]
48
+
49
+ [tool.pytest.ini_options]
50
+ pythonpath = ["src"]
51
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,82 @@
1
+ """Vouchwell for Python: tamper-evident receipts for what AI agents do.
2
+
3
+ Wire-compatible with the ``vouchwell`` npm packages: a log written here
4
+ passes ``vw verify``, a bundle exported here passes ``vw check``, and logs
5
+ and bundles written by the JavaScript side open and verify here.
6
+ """
7
+
8
+ __version__ = "0.5.0"
9
+
10
+ from .canonical import canonical_bytes, canonicalize
11
+ from .checkpoint import build_checkpoint, checkpoint_digest, cosign, sign_checkpoint, verify_checkpoint
12
+ from .keys import Identity, generate_identity, identity_from_pem, identity_from_public_key, key_id_for, sign, verify
13
+ from .log import ProofLog, verify_bundle
14
+ from .merkle import (
15
+ MerkleTree,
16
+ consistency_proof,
17
+ inclusion_proof,
18
+ leaf_hash,
19
+ merkle_root,
20
+ node_hash,
21
+ verify_consistency,
22
+ verify_inclusion,
23
+ )
24
+ from .record import PolicyDenied, Recorder
25
+ from .redact import redact
26
+ from .receipt import (
27
+ GENESIS_PREV,
28
+ build_receipt,
29
+ entry_hash,
30
+ open_seal,
31
+ receipt_digest,
32
+ seal,
33
+ sign_receipt,
34
+ verify_chain,
35
+ verify_receipt,
36
+ )
37
+ from .remote import HubError, push
38
+ from .unfinished import DEFAULT_GRACE_MS, find_unfinished
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "DEFAULT_GRACE_MS",
43
+ "GENESIS_PREV",
44
+ "HubError",
45
+ "Identity",
46
+ "MerkleTree",
47
+ "PolicyDenied",
48
+ "ProofLog",
49
+ "Recorder",
50
+ "build_checkpoint",
51
+ "build_receipt",
52
+ "canonical_bytes",
53
+ "canonicalize",
54
+ "checkpoint_digest",
55
+ "consistency_proof",
56
+ "cosign",
57
+ "entry_hash",
58
+ "find_unfinished",
59
+ "generate_identity",
60
+ "identity_from_pem",
61
+ "identity_from_public_key",
62
+ "inclusion_proof",
63
+ "key_id_for",
64
+ "leaf_hash",
65
+ "merkle_root",
66
+ "node_hash",
67
+ "open_seal",
68
+ "push",
69
+ "receipt_digest",
70
+ "redact",
71
+ "seal",
72
+ "sign",
73
+ "sign_checkpoint",
74
+ "sign_receipt",
75
+ "verify",
76
+ "verify_bundle",
77
+ "verify_chain",
78
+ "verify_checkpoint",
79
+ "verify_consistency",
80
+ "verify_inclusion",
81
+ "verify_receipt",
82
+ ]
@@ -0,0 +1,139 @@
1
+ """RFC 8785 (JCS) canonical JSON, byte-identical to packages/core/src/canonical.js.
2
+
3
+ Two implementations must produce the same bytes for the same receipt, or a
4
+ receipt signed in Python would not verify in JavaScript. Three details decide
5
+ that, and each is matched here exactly:
6
+
7
+ * object keys sort by UTF-16 code unit, not by code point;
8
+ * control characters escape as lowercase ``\\u00xx``, with the short forms for
9
+ ``\\b \\t \\n \\f \\r`` and ``"`` and ``\\``, and everything else is literal;
10
+ * numbers are written the way ECMAScript's ``Number::toString`` writes them,
11
+ with ``-0`` as ``0``, and NaN and infinities refused.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from decimal import Decimal
18
+ from typing import Any
19
+
20
+ __all__ = ["canonicalize", "canonical_bytes"]
21
+
22
+ _ESCAPES = {
23
+ 0x08: "\\b",
24
+ 0x09: "\\t",
25
+ 0x0A: "\\n",
26
+ 0x0C: "\\f",
27
+ 0x0D: "\\r",
28
+ 0x22: '\\"',
29
+ 0x5C: "\\\\",
30
+ }
31
+
32
+ #: The largest integer every JSON implementation represents exactly.
33
+ _MAX_SAFE_INTEGER = 2**53 - 1
34
+
35
+
36
+ def _encode_string(s: str) -> str:
37
+ out = ['"']
38
+ for ch in s:
39
+ cp = ord(ch)
40
+ esc = _ESCAPES.get(cp)
41
+ if esc is not None:
42
+ out.append(esc)
43
+ elif cp < 0x20:
44
+ out.append("\\u%04x" % cp)
45
+ elif 0xD800 <= cp <= 0xDFFF:
46
+ # A lone surrogate has no UTF-8 form. JavaScript's encoder writes
47
+ # U+FFFD in its place, so the bytes hashed there are these.
48
+ out.append("�")
49
+ else:
50
+ out.append(ch)
51
+ out.append('"')
52
+ return "".join(out)
53
+
54
+
55
+ def _es_number(x: float) -> str:
56
+ """ECMAScript Number::toString for a finite, non-zero float.
57
+
58
+ Python's ``repr`` already finds the shortest digit string that round-trips,
59
+ which is what ECMAScript uses too; only the layout differs.
60
+ """
61
+ sign = "-" if x < 0 else ""
62
+ d = Decimal(repr(abs(x)))
63
+ _, digits, exponent = d.as_tuple()
64
+ s = "".join(map(str, digits)).rstrip("0")
65
+ exponent += len(digits) - len(s) # account for the zeros just stripped
66
+ k = len(s)
67
+ n = exponent + k # the value is 0.s × 10^n
68
+
69
+ if k <= n <= 21:
70
+ body = s + "0" * (n - k)
71
+ elif 0 < n <= 21:
72
+ body = s[:n] + "." + s[n:]
73
+ elif -6 < n <= 0:
74
+ body = "0." + "0" * (-n) + s
75
+ else:
76
+ e = n - 1
77
+ exp = ("+" if e > 0 else "-") + str(abs(e))
78
+ body = (s if k == 1 else s[0] + "." + s[1:]) + "e" + exp
79
+ return sign + body
80
+
81
+
82
+ def _encode_number(n: Any) -> str:
83
+ if isinstance(n, bool): # bool is an int subclass; never a number here
84
+ raise TypeError("booleans are not numbers")
85
+ if isinstance(n, int):
86
+ if abs(n) <= _MAX_SAFE_INTEGER:
87
+ return str(n)
88
+ # Beyond 2**53 a JSON number is a double everywhere else, so it is
89
+ # treated as one here: the same value JavaScript would parse and print.
90
+ try:
91
+ n = float(n)
92
+ except OverflowError:
93
+ raise TypeError(f"cannot canonicalize {n}: too large for a JSON number") from None
94
+ if not math.isfinite(n):
95
+ raise TypeError(f"cannot canonicalize non-finite number: {n}")
96
+ if n == 0:
97
+ return "0"
98
+ if n.is_integer() and abs(n) <= _MAX_SAFE_INTEGER:
99
+ return str(int(n))
100
+ # Everything else, including whole numbers above 2**53, which ECMAScript
101
+ # prints as shortest round-trip digits padded with zeros, not exactly.
102
+ return _es_number(n)
103
+
104
+
105
+ def _sort_key(key: str) -> bytes:
106
+ # Big-endian UTF-16 bytes compare in the same order as UTF-16 code units,
107
+ # which is how JavaScript's default sort orders strings.
108
+ return key.encode("utf-16-be", "surrogatepass")
109
+
110
+
111
+ def canonicalize(value: Any) -> str:
112
+ """The RFC 8785 canonical form of a JSON-compatible value."""
113
+ if value is None:
114
+ return "null"
115
+ if value is True:
116
+ return "true"
117
+ if value is False:
118
+ return "false"
119
+ if isinstance(value, (int, float)):
120
+ return _encode_number(value)
121
+ if isinstance(value, str):
122
+ return _encode_string(value)
123
+ if isinstance(value, (list, tuple)):
124
+ return "[" + ",".join(canonicalize(v) for v in value) + "]"
125
+ if isinstance(value, dict):
126
+ for k in value:
127
+ if not isinstance(k, str):
128
+ raise TypeError(f"object keys must be strings, got {type(k).__name__}")
129
+ parts = [
130
+ _encode_string(k) + ":" + canonicalize(value[k])
131
+ for k in sorted(value, key=_sort_key)
132
+ ]
133
+ return "{" + ",".join(parts) + "}"
134
+ raise TypeError(f"cannot canonicalize {type(value).__name__}")
135
+
136
+
137
+ def canonical_bytes(value: Any) -> bytes:
138
+ """Canonical form as UTF-8: what actually gets hashed and signed."""
139
+ return canonicalize(value).encode("utf-8")