insumer-verify 1.9.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.
@@ -0,0 +1,15 @@
1
+ node_modules/
2
+ build/
3
+ *.tgz
4
+ .DS_Store
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+
9
+ # Python package (python/)
10
+ python/.venv/
11
+ python/dist/
12
+ python/build/
13
+ __pycache__/
14
+ *.egg-info/
15
+ .pytest_cache/
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.5
2
+ Name: insumer-verify
3
+ Version: 1.9.2
4
+ Summary: Verifier for InsumerAPI condition-based access attestations and wallet trust profiles. ECDSA P-256 signatures, condition hashes, block freshness, expiry, and the ML-DSA-65 post-quantum companion.
5
+ Project-URL: Homepage, https://insumermodel.com/developers/verification/
6
+ Project-URL: Repository, https://github.com/insumerapi/insumer-verify
7
+ Project-URL: Specification, https://insumermodel.com/state-attestation-spec/
8
+ Project-URL: Test vectors, https://insumermodel.com/.well-known/state-attestation-test-vectors.json
9
+ Project-URL: Changelog, https://insumermodel.com/developers/changelog/
10
+ Author: Douglas Borthwick
11
+ License: MIT
12
+ Keywords: agent-trust,ai-agents,attestation,blockchain,condition-based-access,ecdsa,insumer,ml-dsa,on-chain,post-quantum,token-gating,verification
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: cryptography>=41.0
22
+ Provides-Extra: pq
23
+ Requires-Dist: dilithium-py>=1.4; extra == 'pq'
24
+ Provides-Extra: test
25
+ Requires-Dist: dilithium-py>=1.4; extra == 'test'
26
+ Requires-Dist: pytest>=7; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # insumer-verify
30
+
31
+ Verifier for [InsumerAPI](https://insumermodel.com) condition-based access attestations and wallet trust profiles, in Python. ECDSA P-256 signatures, condition hashes, block freshness, expiry, and the ML-DSA-65 post-quantum companion, checked locally against the published JWKS.
32
+
33
+ This is the Python counterpart of the [`insumer-verify` npm package](https://www.npmjs.com/package/insumer-verify). Both implement the [State Attestation Specification](https://insumermodel.com/state-attestation-spec/) and both pass all 27 published [test vectors](https://insumermodel.com/.well-known/state-attestation-test-vectors.json), so a verdict from one can be reproduced with the other.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install insumer-verify
39
+ ```
40
+
41
+ The post-quantum companion needs an ML-DSA-65 implementation, which the standard library does not have:
42
+
43
+ ```bash
44
+ pip install "insumer-verify[pq]"
45
+ ```
46
+
47
+ Without it the companion is reported `unverifiable`. It is never silently passed and never silently failed.
48
+
49
+ Python 3.9 or later. The only required dependency is `cryptography`.
50
+
51
+ ## Get an API key
52
+
53
+ ```bash
54
+ curl -X POST https://api.insumermodel.com/v1/keys/create \
55
+ -H "Content-Type: application/json" \
56
+ -d '{"email":"you@example.com","appName":"my-app","tier":"free"}'
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```python
62
+ import requests
63
+ from insumer_verify import verify_attestation
64
+
65
+ res = requests.post(
66
+ "https://api.insumermodel.com/v1/attest",
67
+ headers={"X-API-Key": KEY},
68
+ json={
69
+ "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
70
+ "conditions": [{
71
+ "type": "token_balance",
72
+ "chainId": 1,
73
+ "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
74
+ "operator": "gte",
75
+ "threshold": "1",
76
+ }],
77
+ },
78
+ ).json()
79
+
80
+ result = verify_attestation(res, jwks_url="https://insumermodel.com/.well-known/jwks.json", max_age=3600)
81
+
82
+ if result["valid"] and res["data"]["attestation"]["pass"]:
83
+ grant_access()
84
+ ```
85
+
86
+ Pass the **full API response**, not `res["data"]` or the attestation object. The verifier reads the signature, `kid` and companion fields beside the attestation.
87
+
88
+ `valid` is true only when every check passed. Each check reports on its own under `result["checks"]`, so a failing artifact still tells you exactly what failed:
89
+
90
+ ```python
91
+ {
92
+ "valid": False,
93
+ "checks": {
94
+ "signature": {"passed": False, "reason": "Signature does not match payload"},
95
+ "conditionHashes": {"passed": True},
96
+ "freshness": {"passed": True},
97
+ "expiry": {"passed": True},
98
+ "pq": {"status": "verified", "passed": True, "kid": "insumer-attest-pq1"},
99
+ },
100
+ }
101
+ ```
102
+
103
+ ### JWT format
104
+
105
+ With `format: "jwt"` the API returns the attestation and two tokens beside it: `jwt` (ES256) and `pqJwt` (ML-DSA-65) over the same claims. Pass the whole response and all of it is verified and bound together; the tokens report under `checks["jwt"]`:
106
+
107
+ ```python
108
+ res = requests.post(API, headers=HEADERS, json={**body, "format": "jwt"}).json()
109
+ result = verify_attestation(res, jwks_url=JWKS)
110
+ result["checks"]["jwt"]["passed"] # the token is this attestation's, signed under the same kid
111
+ result["checks"]["jwt"]["pq"]["status"] # "verified": pqJwt carries exactly the same claims
112
+ ```
113
+
114
+ A bare token string works too. Its companion cannot travel inside it, so hand it over separately:
115
+
116
+ ```python
117
+ result = verify_attestation(res["data"]["jwt"], jwks_url=JWKS, pq_jwt=res["data"]["pqJwt"])
118
+ ```
119
+
120
+ ### Trust profiles
121
+
122
+ ```python
123
+ from insumer_verify import verify_trust_profile
124
+
125
+ res = requests.post("https://api.insumermodel.com/v1/trust", headers=HEADERS, json={"wallet": "0x..."}).json()
126
+ result = verify_trust_profile(res, jwks_url=JWKS, max_age=3600)
127
+ if result["valid"]:
128
+ render(result["trust"]) # the verified object, exactly as parsed; render this, not your own copy
129
+ ```
130
+
131
+ For `POST /v1/trust/batch`, call it once per entry of `data["results"]`.
132
+
133
+ Pass the object exactly as `json()` parsed it. Do not rebuild the `trust` object: the v1 scheme signs insertion-order JSON, so changing key order changes the signed bytes.
134
+
135
+ ### Keeping records for years
136
+
137
+ Keys are never removed from the published JWKS. Save a copy beside the artifacts you retain and verification needs no network at all:
138
+
139
+ ```python
140
+ result = verify_attestation(saved_response, jwks=saved_jwks) # nothing is fetched
141
+ ```
142
+
143
+ A supplied `jwks` takes precedence over `jwks_url` and is honoured exactly: a key the set does not hold fails the signature verdict with that reason, and nothing falls back to the built-in key.
144
+
145
+ ## Options
146
+
147
+ All options are keyword-only.
148
+
149
+ | Option | Type | Meaning |
150
+ |---|---|---|
151
+ | `max_age` | seconds | Freshness bound on each result's `blockTimestamp` (and on a trust profile's `profiledAt` and per-check anchors). Skipped when not set |
152
+ | `clock_skew` | seconds | Allowance on the freshness and expiry comparisons. Default 60; 0 disables |
153
+ | `jwks_url` | str | Fetch the key set from this URL and select the key by `kid` |
154
+ | `jwks` | dict | A key set you already hold. Nothing is fetched; takes precedence over `jwks_url` |
155
+ | `pq_jwt` | str | The `pqJwt` companion when verifying a bare JWT string |
156
+ | `pq_required_from` | ISO string or datetime | Your own cutoff: from this date, judged by your clock, an absent or unverifiable companion fails `valid`. The only option that can make a missing companion fail. A refuted companion always fails |
157
+ | `mode` | `"access"` or `"evidence"` | `access` (default) applies the cutoff. `evidence` never refuses for a missing companion and only reports; use it when reading an artifact after the fact |
158
+ | `pq_activated_at` | ISO string or datetime | The anchored key-binding time. When set, `checks["pq"]["existedAtIssuance"]` says whether a companion could have existed when the artifact was issued. Reporting only |
159
+
160
+ With neither `jwks` nor `jwks_url`, the built-in InsumerAPI P-256 key is used for the classical signature and the companion is `unverifiable` (its key must come from a key set).
161
+
162
+ ## What gets verified
163
+
164
+ | Check | What it does |
165
+ |---|---|
166
+ | **Signature** | ECDSA P-256 over the preimage the `kid` selects: v1 is bare `{id, pass, results, attestedAt}`; v2 is the domain tag plus canonical JSON with a `v: 2` member; trust v2 is the domain tag plus canonical JSON of the whole profile. A missing, unknown or wrong-artifact `kid` fails |
167
+ | **Condition hashes** | Recomputes SHA-256 of each `evaluatedCondition` (canonical JSON per the scheme) and compares to `conditionHash`. A result lacking either field fails at its index |
168
+ | **Freshness** | `blockTimestamp` age against `max_age` plus `clock_skew`. Optional |
169
+ | **Expiry** | Whether the window has elapsed, allowing `clock_skew` past `expiresAt`, with `expiresAt` bound to the signed `attestedAt`: at most 30 minutes later (5 for a delegation verdict) plus a fixed 60-second grace, so an edited future `expiresAt` cannot extend the window |
170
+ | **Post-quantum companion** | `pqSig` verified with ML-DSA-65 over the post-quantum domain tag plus the same classical preimage, key resolved by `pqKid`. In the JWT format, `pqJwt` is verified over its own `header.payload` and bound to `jwt` by the full claim set. Reported as `verified`, `refuted`, `absent` or `unverifiable` |
171
+ | **Tokens in the response** (`checks["jwt"]`) | Only when the response carries `data.jwt` beside `data.attestation`. The token is verified under the same `kid`, its condition hashes recomputed, its `jti`, `pass`, `results` and `exp` matched to the attestation, and its `pqJwt` companion reported under `checks["jwt"]["pq"]`. A failure fails `valid` |
172
+
173
+ Key selection is a verdict, not an escape. When a key set is in play and the response's `kid` selects no usable key in it, the signature check fails with that reason alone; the other checks need no key and still report their own results. Nothing is ever substituted for the key the signature claims.
174
+
175
+ A deliberately deep artifact (more than 128 nested containers) is refused as a failed check that names the refusal, never a crash and never a pass. A JSON document that deep will usually fail in `json.loads` before it reaches the verifier.
176
+
177
+ ## What `valid` does not tell you
178
+
179
+ `valid` means InsumerAPI issued the artifact, it has not been modified, and it is still current. It does not mean access should be granted. Before acting on it:
180
+
181
+ 1. **Check the verdict.** A validly signed attestation can say no. Read `pass`, or each `results[i]["met"]`.
182
+ 2. **Pin the conditions.** Compare every `results[i]["evaluatedCondition"]` (or its `conditionHash`) with the conditions your route requires. Never trust `label`: the caller writes it.
183
+ 3. **Bind the wallet.** The raw format signs no wallet for most condition types, so a raw attestation does not show whose wallet was read. Either call the API from your own server for a wallet the user has proved control of, or require `format: "jwt"` and match `sub` against that proven wallet.
184
+ 4. **Reject replays by id.** Remember each attestation `id` (JWT `jti`) until it expires and refuse it a second time. Never deduplicate on the signature bytes.
185
+ 5. **Treat only signed fields as evidence.** The signature covers `id`, `pass`, `results` and `attestedAt`. `passCount`, `failCount`, `expiresAt`, `ok` and `meta` are outside it.
186
+
187
+ ## Preimages and hashes, exactly as implemented
188
+
189
+ The bytes InsumerAPI signs are defined by what `JSON.stringify` emits in the issuer's runtime. Python's `json` module differs from it in corners that change those bytes, so this package reproduces the JavaScript serializer rather than approximating it: ECMAScript number formatting (`1e-7`, `1e+21`, integers above 2^53 as doubles), `Object.keys` property order (array-index keys first), key sorting by UTF-16 code units rather than code points, and the array-replacer semantics of the v1 condition hash. The serializer is tested against Node.js output when `node` is on the PATH.
190
+
191
+ - **Canonical JSON (v2).** Arrays keep their order; objects emit their keys sorted as JavaScript sorts them; no whitespace; applied at every level.
192
+ - **Attestation preimage.** `insumer-attest-v1`: `JSON.stringify({id, pass, results, attestedAt})`, results exactly as received. `insumer-attest-v2`: `"insumer.attestation.v2" + "\n" + canonical({v: 2, id, pass, results, attestedAt})`.
193
+ - **Trust preimage.** `insumer-attest-v1`: `JSON.stringify(trust)` as parsed. `insumer-trust-v2`: `"insumer.trust.v2" + "\n" + canonical(trust)`, `expiresAt` included, no `v` member.
194
+ - **Condition hash.** v1: `JSON.stringify(evaluatedCondition, sorted top-level keys)`. v2: canonical JSON. Both: `"0x" + hex(SHA-256(UTF-8 bytes))`.
195
+ - **Post-quantum companion.** `pqSig`: ML-DSA-65 (FIPS 204, pure mode, empty context) over `"insumer.attestation.pq1" + "\n" + <classical preimage>`; trust profiles use `"insumer.trust.pq1"`. The key is the RFC 9964 `AKP` entry under `pqKid`. `pqJwt`: a compact JWS with `alg: "ML-DSA-65"`, bound to the ES256 token by every claim.
196
+
197
+ `classical_attest_preimage`, `classical_trust_preimage`, `condition_hash` and `canonicalize` are exported so a third party can reproduce each step without the package.
198
+
199
+ ## Tests
200
+
201
+ ```bash
202
+ pip install "insumer-verify[test]"
203
+ python -m pytest
204
+ ```
205
+
206
+ The suite runs all 27 published vectors offline against a saved key set, ports the JavaScript package's offline and depth suites, and cross-checks the serializer against Node.js when available. Set `INSUMER_VERIFY_NETWORK=1` to run the vectors against the live JWKS URL instead. The live tests in `tests/test_live.py` run only when `INSUMER_API_KEY_V2` and `INSUMER_API_KEY_V1` are set; each call spends one credit (three for a trust profile).
207
+
208
+ ## Versioning
209
+
210
+ The Python package carries the version of the JavaScript release whose behaviour it matches. A Python-only fix adds a fourth component (`1.9.2.1`).
211
+
212
+ ## License
213
+
214
+ MIT
@@ -0,0 +1,186 @@
1
+ # insumer-verify
2
+
3
+ Verifier for [InsumerAPI](https://insumermodel.com) condition-based access attestations and wallet trust profiles, in Python. ECDSA P-256 signatures, condition hashes, block freshness, expiry, and the ML-DSA-65 post-quantum companion, checked locally against the published JWKS.
4
+
5
+ This is the Python counterpart of the [`insumer-verify` npm package](https://www.npmjs.com/package/insumer-verify). Both implement the [State Attestation Specification](https://insumermodel.com/state-attestation-spec/) and both pass all 27 published [test vectors](https://insumermodel.com/.well-known/state-attestation-test-vectors.json), so a verdict from one can be reproduced with the other.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install insumer-verify
11
+ ```
12
+
13
+ The post-quantum companion needs an ML-DSA-65 implementation, which the standard library does not have:
14
+
15
+ ```bash
16
+ pip install "insumer-verify[pq]"
17
+ ```
18
+
19
+ Without it the companion is reported `unverifiable`. It is never silently passed and never silently failed.
20
+
21
+ Python 3.9 or later. The only required dependency is `cryptography`.
22
+
23
+ ## Get an API key
24
+
25
+ ```bash
26
+ curl -X POST https://api.insumermodel.com/v1/keys/create \
27
+ -H "Content-Type: application/json" \
28
+ -d '{"email":"you@example.com","appName":"my-app","tier":"free"}'
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ import requests
35
+ from insumer_verify import verify_attestation
36
+
37
+ res = requests.post(
38
+ "https://api.insumermodel.com/v1/attest",
39
+ headers={"X-API-Key": KEY},
40
+ json={
41
+ "wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
42
+ "conditions": [{
43
+ "type": "token_balance",
44
+ "chainId": 1,
45
+ "contractAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
46
+ "operator": "gte",
47
+ "threshold": "1",
48
+ }],
49
+ },
50
+ ).json()
51
+
52
+ result = verify_attestation(res, jwks_url="https://insumermodel.com/.well-known/jwks.json", max_age=3600)
53
+
54
+ if result["valid"] and res["data"]["attestation"]["pass"]:
55
+ grant_access()
56
+ ```
57
+
58
+ Pass the **full API response**, not `res["data"]` or the attestation object. The verifier reads the signature, `kid` and companion fields beside the attestation.
59
+
60
+ `valid` is true only when every check passed. Each check reports on its own under `result["checks"]`, so a failing artifact still tells you exactly what failed:
61
+
62
+ ```python
63
+ {
64
+ "valid": False,
65
+ "checks": {
66
+ "signature": {"passed": False, "reason": "Signature does not match payload"},
67
+ "conditionHashes": {"passed": True},
68
+ "freshness": {"passed": True},
69
+ "expiry": {"passed": True},
70
+ "pq": {"status": "verified", "passed": True, "kid": "insumer-attest-pq1"},
71
+ },
72
+ }
73
+ ```
74
+
75
+ ### JWT format
76
+
77
+ With `format: "jwt"` the API returns the attestation and two tokens beside it: `jwt` (ES256) and `pqJwt` (ML-DSA-65) over the same claims. Pass the whole response and all of it is verified and bound together; the tokens report under `checks["jwt"]`:
78
+
79
+ ```python
80
+ res = requests.post(API, headers=HEADERS, json={**body, "format": "jwt"}).json()
81
+ result = verify_attestation(res, jwks_url=JWKS)
82
+ result["checks"]["jwt"]["passed"] # the token is this attestation's, signed under the same kid
83
+ result["checks"]["jwt"]["pq"]["status"] # "verified": pqJwt carries exactly the same claims
84
+ ```
85
+
86
+ A bare token string works too. Its companion cannot travel inside it, so hand it over separately:
87
+
88
+ ```python
89
+ result = verify_attestation(res["data"]["jwt"], jwks_url=JWKS, pq_jwt=res["data"]["pqJwt"])
90
+ ```
91
+
92
+ ### Trust profiles
93
+
94
+ ```python
95
+ from insumer_verify import verify_trust_profile
96
+
97
+ res = requests.post("https://api.insumermodel.com/v1/trust", headers=HEADERS, json={"wallet": "0x..."}).json()
98
+ result = verify_trust_profile(res, jwks_url=JWKS, max_age=3600)
99
+ if result["valid"]:
100
+ render(result["trust"]) # the verified object, exactly as parsed; render this, not your own copy
101
+ ```
102
+
103
+ For `POST /v1/trust/batch`, call it once per entry of `data["results"]`.
104
+
105
+ Pass the object exactly as `json()` parsed it. Do not rebuild the `trust` object: the v1 scheme signs insertion-order JSON, so changing key order changes the signed bytes.
106
+
107
+ ### Keeping records for years
108
+
109
+ Keys are never removed from the published JWKS. Save a copy beside the artifacts you retain and verification needs no network at all:
110
+
111
+ ```python
112
+ result = verify_attestation(saved_response, jwks=saved_jwks) # nothing is fetched
113
+ ```
114
+
115
+ A supplied `jwks` takes precedence over `jwks_url` and is honoured exactly: a key the set does not hold fails the signature verdict with that reason, and nothing falls back to the built-in key.
116
+
117
+ ## Options
118
+
119
+ All options are keyword-only.
120
+
121
+ | Option | Type | Meaning |
122
+ |---|---|---|
123
+ | `max_age` | seconds | Freshness bound on each result's `blockTimestamp` (and on a trust profile's `profiledAt` and per-check anchors). Skipped when not set |
124
+ | `clock_skew` | seconds | Allowance on the freshness and expiry comparisons. Default 60; 0 disables |
125
+ | `jwks_url` | str | Fetch the key set from this URL and select the key by `kid` |
126
+ | `jwks` | dict | A key set you already hold. Nothing is fetched; takes precedence over `jwks_url` |
127
+ | `pq_jwt` | str | The `pqJwt` companion when verifying a bare JWT string |
128
+ | `pq_required_from` | ISO string or datetime | Your own cutoff: from this date, judged by your clock, an absent or unverifiable companion fails `valid`. The only option that can make a missing companion fail. A refuted companion always fails |
129
+ | `mode` | `"access"` or `"evidence"` | `access` (default) applies the cutoff. `evidence` never refuses for a missing companion and only reports; use it when reading an artifact after the fact |
130
+ | `pq_activated_at` | ISO string or datetime | The anchored key-binding time. When set, `checks["pq"]["existedAtIssuance"]` says whether a companion could have existed when the artifact was issued. Reporting only |
131
+
132
+ With neither `jwks` nor `jwks_url`, the built-in InsumerAPI P-256 key is used for the classical signature and the companion is `unverifiable` (its key must come from a key set).
133
+
134
+ ## What gets verified
135
+
136
+ | Check | What it does |
137
+ |---|---|
138
+ | **Signature** | ECDSA P-256 over the preimage the `kid` selects: v1 is bare `{id, pass, results, attestedAt}`; v2 is the domain tag plus canonical JSON with a `v: 2` member; trust v2 is the domain tag plus canonical JSON of the whole profile. A missing, unknown or wrong-artifact `kid` fails |
139
+ | **Condition hashes** | Recomputes SHA-256 of each `evaluatedCondition` (canonical JSON per the scheme) and compares to `conditionHash`. A result lacking either field fails at its index |
140
+ | **Freshness** | `blockTimestamp` age against `max_age` plus `clock_skew`. Optional |
141
+ | **Expiry** | Whether the window has elapsed, allowing `clock_skew` past `expiresAt`, with `expiresAt` bound to the signed `attestedAt`: at most 30 minutes later (5 for a delegation verdict) plus a fixed 60-second grace, so an edited future `expiresAt` cannot extend the window |
142
+ | **Post-quantum companion** | `pqSig` verified with ML-DSA-65 over the post-quantum domain tag plus the same classical preimage, key resolved by `pqKid`. In the JWT format, `pqJwt` is verified over its own `header.payload` and bound to `jwt` by the full claim set. Reported as `verified`, `refuted`, `absent` or `unverifiable` |
143
+ | **Tokens in the response** (`checks["jwt"]`) | Only when the response carries `data.jwt` beside `data.attestation`. The token is verified under the same `kid`, its condition hashes recomputed, its `jti`, `pass`, `results` and `exp` matched to the attestation, and its `pqJwt` companion reported under `checks["jwt"]["pq"]`. A failure fails `valid` |
144
+
145
+ Key selection is a verdict, not an escape. When a key set is in play and the response's `kid` selects no usable key in it, the signature check fails with that reason alone; the other checks need no key and still report their own results. Nothing is ever substituted for the key the signature claims.
146
+
147
+ A deliberately deep artifact (more than 128 nested containers) is refused as a failed check that names the refusal, never a crash and never a pass. A JSON document that deep will usually fail in `json.loads` before it reaches the verifier.
148
+
149
+ ## What `valid` does not tell you
150
+
151
+ `valid` means InsumerAPI issued the artifact, it has not been modified, and it is still current. It does not mean access should be granted. Before acting on it:
152
+
153
+ 1. **Check the verdict.** A validly signed attestation can say no. Read `pass`, or each `results[i]["met"]`.
154
+ 2. **Pin the conditions.** Compare every `results[i]["evaluatedCondition"]` (or its `conditionHash`) with the conditions your route requires. Never trust `label`: the caller writes it.
155
+ 3. **Bind the wallet.** The raw format signs no wallet for most condition types, so a raw attestation does not show whose wallet was read. Either call the API from your own server for a wallet the user has proved control of, or require `format: "jwt"` and match `sub` against that proven wallet.
156
+ 4. **Reject replays by id.** Remember each attestation `id` (JWT `jti`) until it expires and refuse it a second time. Never deduplicate on the signature bytes.
157
+ 5. **Treat only signed fields as evidence.** The signature covers `id`, `pass`, `results` and `attestedAt`. `passCount`, `failCount`, `expiresAt`, `ok` and `meta` are outside it.
158
+
159
+ ## Preimages and hashes, exactly as implemented
160
+
161
+ The bytes InsumerAPI signs are defined by what `JSON.stringify` emits in the issuer's runtime. Python's `json` module differs from it in corners that change those bytes, so this package reproduces the JavaScript serializer rather than approximating it: ECMAScript number formatting (`1e-7`, `1e+21`, integers above 2^53 as doubles), `Object.keys` property order (array-index keys first), key sorting by UTF-16 code units rather than code points, and the array-replacer semantics of the v1 condition hash. The serializer is tested against Node.js output when `node` is on the PATH.
162
+
163
+ - **Canonical JSON (v2).** Arrays keep their order; objects emit their keys sorted as JavaScript sorts them; no whitespace; applied at every level.
164
+ - **Attestation preimage.** `insumer-attest-v1`: `JSON.stringify({id, pass, results, attestedAt})`, results exactly as received. `insumer-attest-v2`: `"insumer.attestation.v2" + "\n" + canonical({v: 2, id, pass, results, attestedAt})`.
165
+ - **Trust preimage.** `insumer-attest-v1`: `JSON.stringify(trust)` as parsed. `insumer-trust-v2`: `"insumer.trust.v2" + "\n" + canonical(trust)`, `expiresAt` included, no `v` member.
166
+ - **Condition hash.** v1: `JSON.stringify(evaluatedCondition, sorted top-level keys)`. v2: canonical JSON. Both: `"0x" + hex(SHA-256(UTF-8 bytes))`.
167
+ - **Post-quantum companion.** `pqSig`: ML-DSA-65 (FIPS 204, pure mode, empty context) over `"insumer.attestation.pq1" + "\n" + <classical preimage>`; trust profiles use `"insumer.trust.pq1"`. The key is the RFC 9964 `AKP` entry under `pqKid`. `pqJwt`: a compact JWS with `alg: "ML-DSA-65"`, bound to the ES256 token by every claim.
168
+
169
+ `classical_attest_preimage`, `classical_trust_preimage`, `condition_hash` and `canonicalize` are exported so a third party can reproduce each step without the package.
170
+
171
+ ## Tests
172
+
173
+ ```bash
174
+ pip install "insumer-verify[test]"
175
+ python -m pytest
176
+ ```
177
+
178
+ The suite runs all 27 published vectors offline against a saved key set, ports the JavaScript package's offline and depth suites, and cross-checks the serializer against Node.js when available. Set `INSUMER_VERIFY_NETWORK=1` to run the vectors against the live JWKS URL instead. The live tests in `tests/test_live.py` run only when `INSUMER_API_KEY_V2` and `INSUMER_API_KEY_V1` are set; each call spends one credit (three for a trust profile).
179
+
180
+ ## Versioning
181
+
182
+ The Python package carries the version of the JavaScript release whose behaviour it matches. A Python-only fix adds a fourth component (`1.9.2.1`).
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "insumer-verify"
7
+ version = "1.9.2"
8
+ description = "Verifier for InsumerAPI condition-based access attestations and wallet trust profiles. ECDSA P-256 signatures, condition hashes, block freshness, expiry, and the ML-DSA-65 post-quantum companion."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Douglas Borthwick" }]
13
+ keywords = [
14
+ "condition-based-access",
15
+ "insumer",
16
+ "attestation",
17
+ "ecdsa",
18
+ "ml-dsa",
19
+ "post-quantum",
20
+ "verification",
21
+ "blockchain",
22
+ "on-chain",
23
+ "ai-agents",
24
+ "token-gating",
25
+ "agent-trust",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 5 - Production/Stable",
29
+ "Intended Audience :: Developers",
30
+ "License :: OSI Approved :: MIT License",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3 :: Only",
33
+ "Topic :: Security :: Cryptography",
34
+ "Typing :: Typed",
35
+ ]
36
+ dependencies = ["cryptography>=41.0"]
37
+
38
+ [project.optional-dependencies]
39
+ pq = ["dilithium-py>=1.4"]
40
+ test = ["pytest>=7", "dilithium-py>=1.4"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://insumermodel.com/developers/verification/"
44
+ Repository = "https://github.com/insumerapi/insumer-verify"
45
+ Specification = "https://insumermodel.com/state-attestation-spec/"
46
+ "Test vectors" = "https://insumermodel.com/.well-known/state-attestation-test-vectors.json"
47
+ Changelog = "https://insumermodel.com/developers/changelog/"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/insumer_verify"]
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["src/insumer_verify", "tests", "README.md", "pyproject.toml"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
@@ -0,0 +1,43 @@
1
+ """insumer-verify: verifier for InsumerAPI attestations and wallet trust profiles.
2
+
3
+ from insumer_verify import verify_attestation, verify_trust_profile
4
+
5
+ result = verify_attestation(response_json, jwks_url="https://insumermodel.com/.well-known/jwks.json")
6
+ if result["valid"] and response_json["data"]["attestation"]["pass"]:
7
+ ...
8
+
9
+ Five independent verdicts on an attestation (signature, condition hashes,
10
+ freshness, expiry, post-quantum companion), four on a trust profile. The
11
+ Python and JavaScript packages implement the same specification and pass the
12
+ same published test vectors.
13
+ """
14
+
15
+ from ._jsjson import MAX_CANONICAL_DEPTH, CanonicalDepthError, canonicalize
16
+ from ._keys import DEFAULT_JWKS_URL, PUBLIC_KEY_JWK
17
+ from .verify import (
18
+ DEFAULT_CLOCK_SKEW_SECONDS,
19
+ EXPIRY_BINDING_GRACE_MS,
20
+ classical_attest_preimage,
21
+ classical_trust_preimage,
22
+ condition_hash,
23
+ verify_attestation,
24
+ verify_trust_profile,
25
+ )
26
+
27
+ __version__ = "1.9.2"
28
+
29
+ __all__ = [
30
+ "verify_attestation",
31
+ "verify_trust_profile",
32
+ "classical_attest_preimage",
33
+ "classical_trust_preimage",
34
+ "condition_hash",
35
+ "canonicalize",
36
+ "CanonicalDepthError",
37
+ "DEFAULT_CLOCK_SKEW_SECONDS",
38
+ "EXPIRY_BINDING_GRACE_MS",
39
+ "MAX_CANONICAL_DEPTH",
40
+ "DEFAULT_JWKS_URL",
41
+ "PUBLIC_KEY_JWK",
42
+ "__version__",
43
+ ]