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 +209 -0
- ainra-0.4.0/README.md +189 -0
- ainra-0.4.0/ainra/__init__.py +60 -0
- ainra-0.4.0/ainra/_b64.py +57 -0
- ainra-0.4.0/ainra/_canon.py +98 -0
- ainra-0.4.0/ainra/_crypto.py +152 -0
- ainra-0.4.0/ainra/_merkle.py +76 -0
- ainra-0.4.0/ainra/_name.py +65 -0
- ainra-0.4.0/ainra/_vector_runner.py +54 -0
- ainra-0.4.0/ainra/delta.py +195 -0
- ainra-0.4.0/ainra/directory.py +96 -0
- ainra-0.4.0/ainra/instance.py +136 -0
- ainra-0.4.0/ainra/middleware.py +165 -0
- ainra-0.4.0/ainra/reasons.py +59 -0
- ainra-0.4.0/ainra/verdict.py +50 -0
- ainra-0.4.0/ainra/verifier.py +188 -0
- ainra-0.4.0/ainra/verify.py +626 -0
- ainra-0.4.0/ainra.egg-info/PKG-INFO +209 -0
- ainra-0.4.0/ainra.egg-info/SOURCES.txt +28 -0
- ainra-0.4.0/ainra.egg-info/dependency_links.txt +1 -0
- ainra-0.4.0/ainra.egg-info/requires.txt +4 -0
- ainra-0.4.0/ainra.egg-info/top_level.txt +1 -0
- ainra-0.4.0/pyproject.toml +35 -0
- ainra-0.4.0/setup.cfg +4 -0
- ainra-0.4.0/tests/test_canonical.py +66 -0
- ainra-0.4.0/tests/test_instance.py +94 -0
- ainra-0.4.0/tests/test_middleware.py +102 -0
- ainra-0.4.0/tests/test_reasons.py +77 -0
- ainra-0.4.0/tests/test_status_authentication.py +133 -0
- ainra-0.4.0/tests/test_verify_api.py +69 -0
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")
|