ainra 0.4.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.
ainra-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: ainra
3
+ Version: 0.4.0
4
+ Summary: Independent Python implementation of the AINRA agent-passport verifier (verify-only, offline, fail-closed). The fourth column of the conformance differential.
5
+ Author: AINRA contributors
6
+ License-Expression: Apache-2.0 OR MIT
7
+ Project-URL: Homepage, https://ainra.org
8
+ Project-URL: Live, https://ainra.vercel.app/
9
+ Project-URL: Source, https://github.com/JacobJandon/ainra
10
+ Keywords: ainra,agent-identity,verifier,passport,post-quantum
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Security :: Cryptography
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: cryptography>=44
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7; extra == "test"
20
+
21
+ <!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->
22
+ # ainra — Python verifier (independent implementation #4)
23
+
24
+ A verify-only, offline, fail-closed, zero-telemetry Python implementation of the
25
+ AINRA agent-passport verifier. It is written **independently** from the AINRA
26
+ specification (`docs/AINRA_I_The_Standard.md`, `docs/AINRA_Master_Technical_Specification_v1.md`)
27
+ and the CC0 conformance vectors — **not** transliterated from the Rust core or
28
+ the TypeScript SDK. It joins the conformance differential as a **fourth column**:
29
+ core ↔ sdk ↔ P0 ↔ **py**, agreeing byte-for-byte on every vector's verdict *and*
30
+ reason.
31
+
32
+ Why a fourth brain? If an independent reimplementation, built to the same
33
+ spec + vector reference, reaches the same verdict on every vector, that is
34
+ independent confirmation the standard is unambiguous and the reference is
35
+ correct. If it disagreed, we would have found something. It agrees on
36
+ **1153 passport + 17 delta + 9 directory** vectors.
37
+
38
+ ## Package name
39
+
40
+ `ainra` — checked against PyPI on 2026-07-30 (`GET /pypi/ainra/json` → HTTP 404,
41
+ i.e. the name is unregistered and available). It is **not** published to PyPI by
42
+ this work; the name is reserved by intent and used for local installs only.
43
+
44
+ ## Install
45
+
46
+ ```
47
+ pip install -e packages/sdk-py # editable, from a checkout
48
+ ```
49
+
50
+ Requires Python ≥ 3.10 and `cryptography` ≥ 44 (already present on most systems;
51
+ it ships Ed25519 and ML-DSA-65). SLH-DSA-SHA2-128s, SHA-256, and zlib need no
52
+ extra dependency (see below).
53
+
54
+ ## Quickstart
55
+
56
+ ```python
57
+ import json, base64
58
+ from ainra import Verifier
59
+
60
+ vec = json.load(open("vectors/v1/valid-0000.json"))
61
+
62
+ # 1) Build an offline verifier from the trust anchors.
63
+ verifier = Verifier(vec["anchors"])
64
+
65
+ # 2) Verify a bundle. The caller supplies `now`; there is no I/O.
66
+ verdict = verifier.verify(vec["presentation"], now=1500)
67
+ print("valid :", verdict.valid, "| reason:", verdict.reason)
68
+ print("event :", json.dumps(verdict.event()))
69
+
70
+ # 3) A revoked lineage fails closed with a named reason.
71
+ rvk = json.load(open("vectors/v1/revoked-0000.json"))
72
+ rv = Verifier(rvk["anchors"]).verify(rvk["presentation"], rvk["presentation"]["now"])
73
+ print("revoked ->", rv.valid, "| reason:", rv.reason)
74
+
75
+ # 4) The verifier owns the clock: forward-dating past `exp` cannot dodge expiry.
76
+ exp = json.loads(base64.urlsafe_b64decode(vec["presentation"]["claims"] + "=="))["exp"]
77
+ print("at exp ->", verifier.verify(vec["presentation"], exp).reason)
78
+ ```
79
+
80
+ Real output (run from the repo root):
81
+
82
+ ```
83
+ valid : True | reason: None
84
+ event : {"status": "valid", "reason": null, "name": "ainra:registrar-01:acme:invoicing@1.0.0", "number": "did:ainra:registrar-01:acme:invoicing", "tier": "L1", "freshness_age_s": 10}
85
+ revoked -> False | reason: revoked
86
+ at exp -> expired
87
+ ```
88
+
89
+ The verdict event is the M16 shape every AINRA surface emits
90
+ (`docs/PRESENTATION.md`): `status`, `reason`, `name`, `number` (the permanent
91
+ version-less AINRA Number), `tier`, `freshness_age_s`.
92
+
93
+ ## ASGI middleware — gate a route, fail closed
94
+
95
+ Framework-agnostic (Starlette, FastAPI, Quart, any ASGI app). Every gated request
96
+ must carry a valid passport or it is denied **403 + `x-ainra-reason`**; on allow,
97
+ the response carries the verdict event in `x-ainra-verdict`.
98
+
99
+ ```python
100
+ from ainra import Verifier, ainra_gate
101
+
102
+ verifier = Verifier.from_directory(directory, root_ed25519, root_slh) # None if not authentic
103
+ app.add_middleware(ainra_gate(verifier)) # or: AinraGate(app, verifier)
104
+ ```
105
+
106
+ The bundle is read from the `x-ainra-passport` header (base64url of canonical
107
+ JSON, or raw JSON for local testing), falling back to the JSON body field
108
+ `ainra_passport`. Verification never needs the body, so the header form is
109
+ streaming-safe.
110
+
111
+ ## What it verifies
112
+
113
+ The frozen nine-step verify, first-failure-wins, mapping to the 20 frozen reasons
114
+ (`docs/reasons.json`): AINRA name grammar; canonical JSON (sorted keys, no spaces,
115
+ rejecting floats / non-ASCII keys / integers beyond 2⁵³); the strict base64url
116
+ decode gateway (D-029 — every external decode is a canonical round-trip, fail
117
+ closed); hybrid **Ed25519 + ML-DSA-65** (both signatures or `alg_downgrade`/`sig_invalid`);
118
+ the exact validity window (`nbf` inclusive, `exp` exclusive — no skew, no grace);
119
+ scope-ceiling and delegation narrowing; revocation-status freshness classes
120
+ (F1 ≤ 30 s · F2 ≤ 5 min · F3 ≤ 24 h, fail closed); logged-before-valid RFC 6962
121
+ inclusion to a signed checkpoint (root **SLH-DSA-SHA2-128s** or a scope-limited,
122
+ in-window, unrevoked delegate); the signed status delta / fresh-head classes; and
123
+ the dual-root-signed directory. Fail closed everywhere; `.verify` never raises.
124
+
125
+ ## Cryptography — shared primitives, independent logic (state precisely, D-041)
126
+
127
+ The verification **logic** is independent; the cryptographic **primitives** are
128
+ shared, audited libraries — reimplementing a signature scheme would be less safe,
129
+ not more independent:
130
+
131
+ | Primitive | Sizes (confirmed) | Source |
132
+ |---|---|---|
133
+ | Ed25519 | 32 B key / 64 B sig | `cryptography` (pyca, wraps OpenSSL 3.5+) |
134
+ | ML-DSA-65 (FIPS 204) | 1952 B key / 3309 B sig | `cryptography` (pyca / OpenSSL 3.5+) |
135
+ | SLH-DSA-SHA2-128s (FIPS 205) | 32 B key / 7856 B sig | OpenSSL `libcrypto` via `ctypes` (EVP raw-public-key verify) — that primitive only |
136
+ | SHA-256 (RFC 6962 prefixes) | — | Python stdlib `hashlib` |
137
+
138
+ **The claim, precisely:** *shared cryptographic primitives (pyca `cryptography` /
139
+ OpenSSL 3.5+ for Ed25519 + ML-DSA-65; OpenSSL `libcrypto` via `ctypes` for
140
+ SLH-DSA-SHA2-128s; stdlib for SHA-256), with an independent verification logic —
141
+ the differential exercises the logic, not the primitives.* `cryptography` 49 does
142
+ not yet surface SLH-DSA, so it is reached from the same underlying OpenSSL
143
+ directly; if the OpenSSL SLH-DSA verify path is unavailable, that primitive fails
144
+ **closed** (returns `False`).
145
+
146
+ ## Differential — the fourth column
147
+
148
+ `make diff` (or `node tools/diff-harness/run.mjs`) runs the Python verifier over
149
+ the whole corpus and asserts it agrees with the Rust core's recorded verdict on
150
+ every vector, including the `alg-downgrade-*`, `noncanon-*`, `boundary-*`,
151
+ `renewal-*` classes, plus delta and directory:
152
+
153
+ ```
154
+ (A) verdict diff core↔sdk : 1153/1153 agree
155
+ (B) canon 3-way core↔sdk↔P0 : 10/10 byte-identical
156
+ (C) canon reject core↔sdk : 4/4 both refuse
157
+ (D) delta diff core↔sdk : 17/17 agree
158
+ (E) directory diff core↔sdk : 9/9 agree
159
+ (F) verdict diff core↔py : 1153/1153 agree
160
+ (F) delta diff core↔py : 17/17 agree
161
+ (F) directory diff core↔py : 9/9 agree
162
+
163
+ DIFF OK: all implementations agree (core ↔ sdk ↔ P0 ↔ py)
164
+ ```
165
+
166
+ ## Tests
167
+
168
+ ```
169
+ cd packages/sdk-py && PYTHONPATH=. python3 -m unittest discover -s tests
170
+ ```
171
+
172
+ Covers the whole-corpus agreement (all 20 reasons reachable), the ~5-line
173
+ `Verifier` surface (valid / revoked / verifier-owns-the-clock), the ASGI gate
174
+ (allow on VALID, deny 403 fail-closed on missing/revoked), and the strict
175
+ base64url / canonical-JSON gateways. Zero telemetry, no network.
176
+
177
+ ## License
178
+
179
+ Apache-2.0 OR MIT (dual). The conformance vectors are CC0 (`vectors/LICENSE`).
180
+
181
+ ## Build it the documented way
182
+
183
+ ```python
184
+ v = Verifier.from_directory(directory, roots["root_ed25519"], roots["root_slh"],
185
+ audience="https://api.example", freshness="F2")
186
+ ```
187
+
188
+ `from_directory` authenticates the directory against both ceremony roots and carries the registrar's **status
189
+ key**, its **status URI**, and any **graduated-distrust cutoff** into the verifier — so revocations are
190
+ authenticated (D-020) and a distrusted registrar is refused (D-044).
191
+
192
+ The raw `Verifier(anchors, …)` constructor exists for callers embedding anchors they already trust. It **cannot**
193
+ authenticate revocations, because anchors supplied that way carry no status key. Use `from_directory` in
194
+ production; the difference is recorded in [`docs/POLICY-PARITY.md`](../../docs/POLICY-PARITY.md).
195
+
196
+ ## Verify a running copy (ADR-019)
197
+
198
+ Same call; supply **your** audience when you build the verifier:
199
+
200
+ ```python
201
+ v = Verifier.from_directory(directory, roots["root_ed25519"], roots["root_slh"],
202
+ audience="https://api.example") # never taken from the bundle
203
+ v.verify(bundle, now) # the instance layer is checked automatically when present
204
+ ```
205
+
206
+ The default empty audience is fail-closed — a service that has not said who it is accepts no instance credential.
207
+
208
+ Minting lives on the operator's side and takes a signing callback, so no key material enters this package:
209
+ `mint_instance_credential`, `prove_instance_possession`.
ainra-0.4.0/README.md ADDED
@@ -0,0 +1,189 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->
2
+ # ainra — Python verifier (independent implementation #4)
3
+
4
+ A verify-only, offline, fail-closed, zero-telemetry Python implementation of the
5
+ AINRA agent-passport verifier. It is written **independently** from the AINRA
6
+ specification (`docs/AINRA_I_The_Standard.md`, `docs/AINRA_Master_Technical_Specification_v1.md`)
7
+ and the CC0 conformance vectors — **not** transliterated from the Rust core or
8
+ the TypeScript SDK. It joins the conformance differential as a **fourth column**:
9
+ core ↔ sdk ↔ P0 ↔ **py**, agreeing byte-for-byte on every vector's verdict *and*
10
+ reason.
11
+
12
+ Why a fourth brain? If an independent reimplementation, built to the same
13
+ spec + vector reference, reaches the same verdict on every vector, that is
14
+ independent confirmation the standard is unambiguous and the reference is
15
+ correct. If it disagreed, we would have found something. It agrees on
16
+ **1153 passport + 17 delta + 9 directory** vectors.
17
+
18
+ ## Package name
19
+
20
+ `ainra` — checked against PyPI on 2026-07-30 (`GET /pypi/ainra/json` → HTTP 404,
21
+ i.e. the name is unregistered and available). It is **not** published to PyPI by
22
+ this work; the name is reserved by intent and used for local installs only.
23
+
24
+ ## Install
25
+
26
+ ```
27
+ pip install -e packages/sdk-py # editable, from a checkout
28
+ ```
29
+
30
+ Requires Python ≥ 3.10 and `cryptography` ≥ 44 (already present on most systems;
31
+ it ships Ed25519 and ML-DSA-65). SLH-DSA-SHA2-128s, SHA-256, and zlib need no
32
+ extra dependency (see below).
33
+
34
+ ## Quickstart
35
+
36
+ ```python
37
+ import json, base64
38
+ from ainra import Verifier
39
+
40
+ vec = json.load(open("vectors/v1/valid-0000.json"))
41
+
42
+ # 1) Build an offline verifier from the trust anchors.
43
+ verifier = Verifier(vec["anchors"])
44
+
45
+ # 2) Verify a bundle. The caller supplies `now`; there is no I/O.
46
+ verdict = verifier.verify(vec["presentation"], now=1500)
47
+ print("valid :", verdict.valid, "| reason:", verdict.reason)
48
+ print("event :", json.dumps(verdict.event()))
49
+
50
+ # 3) A revoked lineage fails closed with a named reason.
51
+ rvk = json.load(open("vectors/v1/revoked-0000.json"))
52
+ rv = Verifier(rvk["anchors"]).verify(rvk["presentation"], rvk["presentation"]["now"])
53
+ print("revoked ->", rv.valid, "| reason:", rv.reason)
54
+
55
+ # 4) The verifier owns the clock: forward-dating past `exp` cannot dodge expiry.
56
+ exp = json.loads(base64.urlsafe_b64decode(vec["presentation"]["claims"] + "=="))["exp"]
57
+ print("at exp ->", verifier.verify(vec["presentation"], exp).reason)
58
+ ```
59
+
60
+ Real output (run from the repo root):
61
+
62
+ ```
63
+ valid : True | reason: None
64
+ event : {"status": "valid", "reason": null, "name": "ainra:registrar-01:acme:invoicing@1.0.0", "number": "did:ainra:registrar-01:acme:invoicing", "tier": "L1", "freshness_age_s": 10}
65
+ revoked -> False | reason: revoked
66
+ at exp -> expired
67
+ ```
68
+
69
+ The verdict event is the M16 shape every AINRA surface emits
70
+ (`docs/PRESENTATION.md`): `status`, `reason`, `name`, `number` (the permanent
71
+ version-less AINRA Number), `tier`, `freshness_age_s`.
72
+
73
+ ## ASGI middleware — gate a route, fail closed
74
+
75
+ Framework-agnostic (Starlette, FastAPI, Quart, any ASGI app). Every gated request
76
+ must carry a valid passport or it is denied **403 + `x-ainra-reason`**; on allow,
77
+ the response carries the verdict event in `x-ainra-verdict`.
78
+
79
+ ```python
80
+ from ainra import Verifier, ainra_gate
81
+
82
+ verifier = Verifier.from_directory(directory, root_ed25519, root_slh) # None if not authentic
83
+ app.add_middleware(ainra_gate(verifier)) # or: AinraGate(app, verifier)
84
+ ```
85
+
86
+ The bundle is read from the `x-ainra-passport` header (base64url of canonical
87
+ JSON, or raw JSON for local testing), falling back to the JSON body field
88
+ `ainra_passport`. Verification never needs the body, so the header form is
89
+ streaming-safe.
90
+
91
+ ## What it verifies
92
+
93
+ The frozen nine-step verify, first-failure-wins, mapping to the 20 frozen reasons
94
+ (`docs/reasons.json`): AINRA name grammar; canonical JSON (sorted keys, no spaces,
95
+ rejecting floats / non-ASCII keys / integers beyond 2⁵³); the strict base64url
96
+ decode gateway (D-029 — every external decode is a canonical round-trip, fail
97
+ closed); hybrid **Ed25519 + ML-DSA-65** (both signatures or `alg_downgrade`/`sig_invalid`);
98
+ the exact validity window (`nbf` inclusive, `exp` exclusive — no skew, no grace);
99
+ scope-ceiling and delegation narrowing; revocation-status freshness classes
100
+ (F1 ≤ 30 s · F2 ≤ 5 min · F3 ≤ 24 h, fail closed); logged-before-valid RFC 6962
101
+ inclusion to a signed checkpoint (root **SLH-DSA-SHA2-128s** or a scope-limited,
102
+ in-window, unrevoked delegate); the signed status delta / fresh-head classes; and
103
+ the dual-root-signed directory. Fail closed everywhere; `.verify` never raises.
104
+
105
+ ## Cryptography — shared primitives, independent logic (state precisely, D-041)
106
+
107
+ The verification **logic** is independent; the cryptographic **primitives** are
108
+ shared, audited libraries — reimplementing a signature scheme would be less safe,
109
+ not more independent:
110
+
111
+ | Primitive | Sizes (confirmed) | Source |
112
+ |---|---|---|
113
+ | Ed25519 | 32 B key / 64 B sig | `cryptography` (pyca, wraps OpenSSL 3.5+) |
114
+ | ML-DSA-65 (FIPS 204) | 1952 B key / 3309 B sig | `cryptography` (pyca / OpenSSL 3.5+) |
115
+ | SLH-DSA-SHA2-128s (FIPS 205) | 32 B key / 7856 B sig | OpenSSL `libcrypto` via `ctypes` (EVP raw-public-key verify) — that primitive only |
116
+ | SHA-256 (RFC 6962 prefixes) | — | Python stdlib `hashlib` |
117
+
118
+ **The claim, precisely:** *shared cryptographic primitives (pyca `cryptography` /
119
+ OpenSSL 3.5+ for Ed25519 + ML-DSA-65; OpenSSL `libcrypto` via `ctypes` for
120
+ SLH-DSA-SHA2-128s; stdlib for SHA-256), with an independent verification logic —
121
+ the differential exercises the logic, not the primitives.* `cryptography` 49 does
122
+ not yet surface SLH-DSA, so it is reached from the same underlying OpenSSL
123
+ directly; if the OpenSSL SLH-DSA verify path is unavailable, that primitive fails
124
+ **closed** (returns `False`).
125
+
126
+ ## Differential — the fourth column
127
+
128
+ `make diff` (or `node tools/diff-harness/run.mjs`) runs the Python verifier over
129
+ the whole corpus and asserts it agrees with the Rust core's recorded verdict on
130
+ every vector, including the `alg-downgrade-*`, `noncanon-*`, `boundary-*`,
131
+ `renewal-*` classes, plus delta and directory:
132
+
133
+ ```
134
+ (A) verdict diff core↔sdk : 1153/1153 agree
135
+ (B) canon 3-way core↔sdk↔P0 : 10/10 byte-identical
136
+ (C) canon reject core↔sdk : 4/4 both refuse
137
+ (D) delta diff core↔sdk : 17/17 agree
138
+ (E) directory diff core↔sdk : 9/9 agree
139
+ (F) verdict diff core↔py : 1153/1153 agree
140
+ (F) delta diff core↔py : 17/17 agree
141
+ (F) directory diff core↔py : 9/9 agree
142
+
143
+ DIFF OK: all implementations agree (core ↔ sdk ↔ P0 ↔ py)
144
+ ```
145
+
146
+ ## Tests
147
+
148
+ ```
149
+ cd packages/sdk-py && PYTHONPATH=. python3 -m unittest discover -s tests
150
+ ```
151
+
152
+ Covers the whole-corpus agreement (all 20 reasons reachable), the ~5-line
153
+ `Verifier` surface (valid / revoked / verifier-owns-the-clock), the ASGI gate
154
+ (allow on VALID, deny 403 fail-closed on missing/revoked), and the strict
155
+ base64url / canonical-JSON gateways. Zero telemetry, no network.
156
+
157
+ ## License
158
+
159
+ Apache-2.0 OR MIT (dual). The conformance vectors are CC0 (`vectors/LICENSE`).
160
+
161
+ ## Build it the documented way
162
+
163
+ ```python
164
+ v = Verifier.from_directory(directory, roots["root_ed25519"], roots["root_slh"],
165
+ audience="https://api.example", freshness="F2")
166
+ ```
167
+
168
+ `from_directory` authenticates the directory against both ceremony roots and carries the registrar's **status
169
+ key**, its **status URI**, and any **graduated-distrust cutoff** into the verifier — so revocations are
170
+ authenticated (D-020) and a distrusted registrar is refused (D-044).
171
+
172
+ The raw `Verifier(anchors, …)` constructor exists for callers embedding anchors they already trust. It **cannot**
173
+ authenticate revocations, because anchors supplied that way carry no status key. Use `from_directory` in
174
+ production; the difference is recorded in [`docs/POLICY-PARITY.md`](../../docs/POLICY-PARITY.md).
175
+
176
+ ## Verify a running copy (ADR-019)
177
+
178
+ Same call; supply **your** audience when you build the verifier:
179
+
180
+ ```python
181
+ v = Verifier.from_directory(directory, roots["root_ed25519"], roots["root_slh"],
182
+ audience="https://api.example") # never taken from the bundle
183
+ v.verify(bundle, now) # the instance layer is checked automatically when present
184
+ ```
185
+
186
+ The default empty audience is fail-closed — a service that has not said who it is accepts no instance credential.
187
+
188
+ Minting lives on the operator's side and takes a signing callback, so no key material enters this package:
189
+ `mint_instance_credential`, `prove_instance_possession`.
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """ainra — an independent Python implementation of the AINRA passport verifier.
3
+
4
+ Verify-only, offline, fail-closed, zero-telemetry. Written from the AINRA
5
+ specification and conformance vectors (not transliterated from the Rust core or
6
+ the TypeScript SDK); the differential harness proves it agrees with them
7
+ byte-for-byte on every vector's verdict and reason. See the package README and
8
+ decision D-041 for the precise independence claim and the shared cryptographic
9
+ primitives.
10
+
11
+ Quickstart::
12
+
13
+ from ainra import Verifier
14
+ v = Verifier(anchors) # anchors: {registrar_id: {...keys...}}
15
+ verdict = v.verify(bundle, now) # caller supplies `now`; no I/O
16
+ if verdict.valid:
17
+ ...
18
+ else:
19
+ print(verdict.reason) # one of the 20 frozen reasons
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from . import reasons
25
+ from .delta import verify_delta, verify_head
26
+ from .directory import verify_directory
27
+ from .instance import (
28
+ INSTANCE_CRED_DEFAULT_SECS,
29
+ instance_signing_bytes,
30
+ mint_instance_credential,
31
+ instance_cred_digest,
32
+ pop_signing_bytes,
33
+ prove_instance_possession,
34
+ )
35
+ from .middleware import AinraGate, ainra_gate
36
+ from .verdict import Verdict
37
+ from .verifier import Verifier
38
+ from .verify import verify
39
+
40
+ __all__ = [
41
+ # ADR-019 — the instance rung. Verification is automatic (step 10 of verify()); these are the PRODUCING side,
42
+ # and they live on opposite sides of the container boundary on purpose.
43
+ "mint_instance_credential",
44
+ "prove_instance_possession",
45
+ "instance_signing_bytes",
46
+ "instance_cred_digest",
47
+ "pop_signing_bytes",
48
+ "INSTANCE_CRED_DEFAULT_SECS",
49
+ "Verifier",
50
+ "Verdict",
51
+ "verify",
52
+ "verify_delta",
53
+ "verify_head",
54
+ "verify_directory",
55
+ "AinraGate",
56
+ "ainra_gate",
57
+ "reasons",
58
+ ]
59
+
60
+ __version__ = "0.3.0"
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """The strict canonical base64url gateway (decision D-029).
3
+
4
+ Every external base64url ingestion in the verifier routes through :func:`decode`.
5
+ Python's :func:`base64.urlsafe_b64decode` is *lenient* — it silently accepts
6
+ non-zero trailing bits, standard-alphabet ("+"/"/") swaps, whitespace, and
7
+ padding. That leniency is exactly the fail-open class D-029 closes. We require a
8
+ canonical round-trip (``encode(decode(s)) == s``) plus an explicit alphabet
9
+ check, mirroring the Rust core's ``base64ct`` ``Base64UrlUnpadded`` byte-for-byte:
10
+ a value is accepted iff it is unpadded base64url whose final character's unused
11
+ low bits are all zero. Anything else is refused (returns ``None``); the caller
12
+ maps that refusal to the field-appropriate frozen reason.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+
19
+ _ALPHABET = frozenset(
20
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
21
+ )
22
+
23
+
24
+ def decode(s: object) -> bytes | None:
25
+ """Strictly decode a canonical unpadded base64url string.
26
+
27
+ Returns the raw bytes, or ``None`` if ``s`` is not a canonical encoding.
28
+ Never raises — a verifier must fail closed, not crash.
29
+ """
30
+ if not isinstance(s, str):
31
+ return None
32
+ # Reject padding, whitespace, and any non-alphabet character up front.
33
+ for ch in s:
34
+ if ch not in _ALPHABET:
35
+ return None
36
+ try:
37
+ raw = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
38
+ except Exception:
39
+ return None
40
+ # Canonical round-trip: catches non-zero trailing bits (a lenient decoder
41
+ # would drop them). Identical acceptance set to base64ct Base64UrlUnpadded.
42
+ if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != s:
43
+ return None
44
+ return raw
45
+
46
+
47
+ def encode(raw: bytes) -> str:
48
+ """Canonical unpadded base64url encoding of ``raw``."""
49
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
50
+
51
+
52
+ def decode_fixed(s: object, length: int) -> bytes | None:
53
+ """Strictly decode and require exactly ``length`` bytes, else ``None``."""
54
+ raw = decode(s)
55
+ if raw is None or len(raw) != length:
56
+ return None
57
+ return raw
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """Deterministic canonical JSON (decisions D-003 and D-010).
3
+
4
+ The canonical form is the sorted-key, no-whitespace scheme the whole AINRA
5
+ corpus is signed and hashed over: object keys sorted, ``"key":value`` with no
6
+ spaces, scalars serialized exactly as a minimal JSON encoder would. Three input
7
+ classes are *rejected* (:class:`CanonError`) because they would diverge across a
8
+ byte-for-byte cross-language differential (D-010): floating-point numbers,
9
+ non-ASCII object keys (UTF-16 vs UTF-8 sort order disagree), and integers
10
+ outside ``[-(2**53 - 1), 2**53 - 1]`` (not exactly representable as a JS
11
+ ``Number``). No conformant AINRA credential uses any of them.
12
+
13
+ This is an independent implementation written from the specification, not a port
14
+ of the Rust ``canon`` or the TypeScript ``canonicalize``; the differential
15
+ harness is what proves the three agree.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ _MAX_SAFE_INT = (2**53) - 1
21
+
22
+
23
+ class CanonError(ValueError):
24
+ """Raised when a value cannot be canonicalized deterministically."""
25
+
26
+
27
+ def _string(s: str) -> str:
28
+ # Minimal JSON string escaping, matching a standard JSON encoder (the same
29
+ # set produced by JSON.stringify / serde_json): quote, backslash, the five
30
+ # short control escapes, and \u00xx for the remaining C0 controls. Forward
31
+ # slash is NOT escaped. Non-ASCII characters are emitted verbatim (UTF-8).
32
+ out = ['"']
33
+ for ch in s:
34
+ o = ord(ch)
35
+ if ch == '"':
36
+ out.append('\\"')
37
+ elif ch == "\\":
38
+ out.append("\\\\")
39
+ elif ch == "\b":
40
+ out.append("\\b")
41
+ elif ch == "\f":
42
+ out.append("\\f")
43
+ elif ch == "\n":
44
+ out.append("\\n")
45
+ elif ch == "\r":
46
+ out.append("\\r")
47
+ elif ch == "\t":
48
+ out.append("\\t")
49
+ elif o < 0x20:
50
+ out.append("\\u%04x" % o)
51
+ else:
52
+ out.append(ch)
53
+ out.append('"')
54
+ return "".join(out)
55
+
56
+
57
+ def _is_ascii(s: str) -> bool:
58
+ return all(ord(c) < 0x80 for c in s)
59
+
60
+
61
+ def canonicalize(value: object) -> str:
62
+ """Return the canonical JSON string for ``value`` (raises on divergent input)."""
63
+
64
+ def enc(v: object) -> str:
65
+ if v is None:
66
+ return "null"
67
+ if v is True:
68
+ return "true"
69
+ if v is False:
70
+ return "false"
71
+ if isinstance(v, float):
72
+ # A float in the input is a divergence hazard (D-010): reject.
73
+ raise CanonError("floats are not canonicalizable")
74
+ if isinstance(v, int):
75
+ if v > _MAX_SAFE_INT or v < -_MAX_SAFE_INT:
76
+ raise CanonError("integer outside the JS-safe range")
77
+ return str(v)
78
+ if isinstance(v, str):
79
+ return _string(v)
80
+ if isinstance(v, (list, tuple)):
81
+ return "[" + ",".join(enc(x) for x in v) + "]"
82
+ if isinstance(v, dict):
83
+ items = []
84
+ for k in v:
85
+ if not isinstance(k, str):
86
+ raise CanonError("object keys must be strings")
87
+ if not _is_ascii(k):
88
+ raise CanonError("non-ASCII object key")
89
+ for k in sorted(v.keys()):
90
+ items.append(_string(k) + ":" + enc(v[k]))
91
+ return "{" + ",".join(items) + "}"
92
+ raise CanonError(f"uncanonicalizable value of type {type(v).__name__}")
93
+
94
+ return enc(value)
95
+
96
+
97
+ def canon_bytes(value: object) -> bytes:
98
+ return canonicalize(value).encode("utf-8")