ainra 0.4.0__py3-none-any.whl
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/__init__.py +60 -0
- ainra/_b64.py +57 -0
- ainra/_canon.py +98 -0
- ainra/_crypto.py +152 -0
- ainra/_merkle.py +76 -0
- ainra/_name.py +65 -0
- ainra/_vector_runner.py +54 -0
- ainra/delta.py +195 -0
- ainra/directory.py +96 -0
- ainra/instance.py +136 -0
- ainra/middleware.py +165 -0
- ainra/reasons.py +59 -0
- ainra/verdict.py +50 -0
- ainra/verifier.py +188 -0
- ainra/verify.py +626 -0
- ainra-0.4.0.dist-info/METADATA +209 -0
- ainra-0.4.0.dist-info/RECORD +19 -0
- ainra-0.4.0.dist-info/WHEEL +5 -0
- ainra-0.4.0.dist-info/top_level.txt +1 -0
ainra/__init__.py
ADDED
|
@@ -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"
|
ainra/_b64.py
ADDED
|
@@ -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
|
ainra/_canon.py
ADDED
|
@@ -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")
|
ainra/_crypto.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
"""Cryptographic primitives — shared audited libraries, fail-closed wrappers.
|
|
3
|
+
|
|
4
|
+
Independence note (see the package README and decision D-041): the verification
|
|
5
|
+
*logic* in this package is written independently from the specification and the
|
|
6
|
+
conformance vectors. The primitives themselves are deliberately NOT reimplemented
|
|
7
|
+
— reimplementing a signature scheme would be less safe, not more independent:
|
|
8
|
+
|
|
9
|
+
* **Ed25519** (32-byte key, 64-byte signature) and **ML-DSA-65 / FIPS 204**
|
|
10
|
+
(1952-byte key, 3309-byte signature) come from ``cryptography`` (pyca), which
|
|
11
|
+
wraps OpenSSL 3.5+.
|
|
12
|
+
* **SLH-DSA-SHA2-128s / FIPS 205** (32-byte key, 7856-byte signature) is not yet
|
|
13
|
+
surfaced by ``cryptography``, so it is bound directly from the same OpenSSL
|
|
14
|
+
``libcrypto`` via ``ctypes`` (EVP raw-public-key verify) — an established C
|
|
15
|
+
implementation used for that primitive only.
|
|
16
|
+
* **SHA-256** is the Python standard library ``hashlib``.
|
|
17
|
+
|
|
18
|
+
The differential (the fourth column of ``make diff``) exercises the *logic*, not
|
|
19
|
+
the primitives; agreement with the Rust core, the TS SDK, and the JS CLI is the
|
|
20
|
+
independent confirmation. Every wrapper returns ``bool`` and never raises: a
|
|
21
|
+
verifier fails closed, so any error, wrong size, or exception is ``False``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ctypes
|
|
27
|
+
import ctypes.util
|
|
28
|
+
import hashlib
|
|
29
|
+
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519, mldsa
|
|
31
|
+
|
|
32
|
+
# Exact FIPS / RFC 8032 sizes — asserted as a conformance gate, matching the
|
|
33
|
+
# Rust core's size asserts (a deviation means a broken dependency, not a code
|
|
34
|
+
# change).
|
|
35
|
+
ED25519_PUB_LEN = 32
|
|
36
|
+
ED25519_SIG_LEN = 64
|
|
37
|
+
MLDSA65_PUB_LEN = 1952
|
|
38
|
+
MLDSA65_SIG_LEN = 3309
|
|
39
|
+
SLH_DSA_SHA2_128S_PUB_LEN = 32
|
|
40
|
+
SLH_DSA_SHA2_128S_SIG_LEN = 7856
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sha256(data: bytes) -> bytes:
|
|
44
|
+
return hashlib.sha256(data).digest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ed25519_verify(pub: bytes, sig: bytes, msg: bytes) -> bool:
|
|
48
|
+
"""Verify an Ed25519 signature; fail closed on any anomaly."""
|
|
49
|
+
if len(pub) != ED25519_PUB_LEN or len(sig) != ED25519_SIG_LEN:
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
ed25519.Ed25519PublicKey.from_public_bytes(pub).verify(sig, msg)
|
|
53
|
+
return True
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def mldsa65_verify(pub: bytes, sig: bytes, msg: bytes) -> bool:
|
|
59
|
+
"""Verify an ML-DSA-65 (FIPS 204) signature; fail closed on any anomaly."""
|
|
60
|
+
if len(pub) != MLDSA65_PUB_LEN or len(sig) != MLDSA65_SIG_LEN:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
mldsa.MLDSA65PublicKey.from_public_bytes(pub).verify(sig, msg)
|
|
64
|
+
return True
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── SLH-DSA-SHA2-128s via OpenSSL libcrypto (ctypes) ────────────────────────
|
|
70
|
+
class _SlhBackend:
|
|
71
|
+
"""Lazy, process-wide binding to OpenSSL's SLH-DSA-SHA2-128s verify."""
|
|
72
|
+
|
|
73
|
+
_ALG = b"SLH-DSA-SHA2-128s"
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._lib = None
|
|
77
|
+
|
|
78
|
+
def _load(self):
|
|
79
|
+
if self._lib is not None:
|
|
80
|
+
return self._lib
|
|
81
|
+
name = ctypes.util.find_library("crypto") or "libcrypto.so.3"
|
|
82
|
+
lib = ctypes.CDLL(name)
|
|
83
|
+
|
|
84
|
+
lib.EVP_PKEY_new_raw_public_key_ex.restype = ctypes.c_void_p
|
|
85
|
+
lib.EVP_PKEY_new_raw_public_key_ex.argtypes = [
|
|
86
|
+
ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p,
|
|
87
|
+
ctypes.c_char_p, ctypes.c_size_t,
|
|
88
|
+
]
|
|
89
|
+
lib.EVP_PKEY_free.restype = None
|
|
90
|
+
lib.EVP_PKEY_free.argtypes = [ctypes.c_void_p]
|
|
91
|
+
lib.EVP_MD_CTX_new.restype = ctypes.c_void_p
|
|
92
|
+
lib.EVP_MD_CTX_new.argtypes = []
|
|
93
|
+
lib.EVP_MD_CTX_free.restype = None
|
|
94
|
+
lib.EVP_MD_CTX_free.argtypes = [ctypes.c_void_p]
|
|
95
|
+
lib.EVP_DigestVerifyInit_ex.restype = ctypes.c_int
|
|
96
|
+
lib.EVP_DigestVerifyInit_ex.argtypes = [
|
|
97
|
+
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p,
|
|
98
|
+
ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p,
|
|
99
|
+
]
|
|
100
|
+
lib.EVP_DigestVerify.restype = ctypes.c_int
|
|
101
|
+
lib.EVP_DigestVerify.argtypes = [
|
|
102
|
+
ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t,
|
|
103
|
+
ctypes.c_char_p, ctypes.c_size_t,
|
|
104
|
+
]
|
|
105
|
+
self._lib = lib
|
|
106
|
+
return lib
|
|
107
|
+
|
|
108
|
+
def verify(self, pub: bytes, sig: bytes, msg: bytes) -> bool:
|
|
109
|
+
if len(pub) != SLH_DSA_SHA2_128S_PUB_LEN or len(sig) != SLH_DSA_SHA2_128S_SIG_LEN:
|
|
110
|
+
return False
|
|
111
|
+
try:
|
|
112
|
+
lib = self._load()
|
|
113
|
+
except Exception:
|
|
114
|
+
return False
|
|
115
|
+
pkey = None
|
|
116
|
+
ctx = None
|
|
117
|
+
try:
|
|
118
|
+
pkey = lib.EVP_PKEY_new_raw_public_key_ex(
|
|
119
|
+
None, self._ALG, None, pub, len(pub)
|
|
120
|
+
)
|
|
121
|
+
if not pkey:
|
|
122
|
+
return False
|
|
123
|
+
ctx = lib.EVP_MD_CTX_new()
|
|
124
|
+
if not ctx:
|
|
125
|
+
return False
|
|
126
|
+
if lib.EVP_DigestVerifyInit_ex(ctx, None, None, None, None, pkey, None) != 1:
|
|
127
|
+
return False
|
|
128
|
+
return lib.EVP_DigestVerify(ctx, sig, len(sig), msg, len(msg)) == 1
|
|
129
|
+
except Exception:
|
|
130
|
+
return False
|
|
131
|
+
finally:
|
|
132
|
+
if ctx:
|
|
133
|
+
lib.EVP_MD_CTX_free(ctx)
|
|
134
|
+
if pkey:
|
|
135
|
+
lib.EVP_PKEY_free(pkey)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_slh = _SlhBackend()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def slh_dsa_sha2_128s_verify(pub: bytes, sig: bytes, msg: bytes) -> bool:
|
|
142
|
+
"""Verify an SLH-DSA-SHA2-128s (FIPS 205) signature; fail closed."""
|
|
143
|
+
return _slh.verify(pub, sig, msg)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def slh_available() -> bool:
|
|
147
|
+
"""True iff the OpenSSL SLH-DSA verify path is usable in this environment."""
|
|
148
|
+
try:
|
|
149
|
+
_slh._load()
|
|
150
|
+
return True
|
|
151
|
+
except Exception:
|
|
152
|
+
return False
|
ainra/_merkle.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
"""RFC 6962 Merkle hashing (decision D-008): SHA-256 with domain separation.
|
|
3
|
+
|
|
4
|
+
Leaf hash is ``SHA-256(0x00 || data)``; interior node is
|
|
5
|
+
``SHA-256(0x01 || left || right)``. Inclusion is verified by the standard
|
|
6
|
+
RFC 6962 §2.1.1 audit-path walk. Written independently from RFC 6962.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def leaf_hash(data: bytes) -> bytes:
|
|
15
|
+
return hashlib.sha256(b"\x00" + data).digest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _node_hash(left: bytes, right: bytes) -> bytes:
|
|
19
|
+
return hashlib.sha256(b"\x01" + left + right).digest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def root_from_audit_path(leaf: bytes, index: object, proof: list[bytes]) -> bytes | None:
|
|
23
|
+
"""Reconstruct the tree root from an audit path using only the leaf index.
|
|
24
|
+
|
|
25
|
+
At each level the proof sibling is combined left/right by the current index
|
|
26
|
+
bit (``index & 1``), then the index is shifted down — the method AINRA's
|
|
27
|
+
logs use to emit inclusion proofs. Returns ``None`` on a structurally invalid
|
|
28
|
+
input (fail closed). The caller compares the result to the credential's
|
|
29
|
+
committed ``log.root``.
|
|
30
|
+
"""
|
|
31
|
+
if not isinstance(index, int) or index < 0:
|
|
32
|
+
return None
|
|
33
|
+
h = leaf
|
|
34
|
+
i = index
|
|
35
|
+
for sibling in proof:
|
|
36
|
+
if len(sibling) != 32:
|
|
37
|
+
return None
|
|
38
|
+
if i & 1:
|
|
39
|
+
h = _node_hash(sibling, h)
|
|
40
|
+
else:
|
|
41
|
+
h = _node_hash(h, sibling)
|
|
42
|
+
i >>= 1
|
|
43
|
+
return h
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def root_from_inclusion(
|
|
47
|
+
leaf: bytes, index: int, tree_size: int, proof: list[bytes]
|
|
48
|
+
) -> bytes | None:
|
|
49
|
+
"""Reconstruct the tree root from an RFC 6962 inclusion proof.
|
|
50
|
+
|
|
51
|
+
Returns the computed root hash, or ``None`` if the (index, size, proof) are
|
|
52
|
+
structurally inconsistent. The caller compares the result to the signed
|
|
53
|
+
checkpoint root — a mismatch (or ``None``) is fail-closed ``not_logged``.
|
|
54
|
+
"""
|
|
55
|
+
if index < 0 or tree_size <= 0 or index >= tree_size:
|
|
56
|
+
return None
|
|
57
|
+
fn = index
|
|
58
|
+
sn = tree_size - 1
|
|
59
|
+
h = leaf
|
|
60
|
+
for sibling in proof:
|
|
61
|
+
if len(sibling) != 32:
|
|
62
|
+
return None
|
|
63
|
+
if fn == sn or (fn & 1) == 1:
|
|
64
|
+
h = _node_hash(sibling, h)
|
|
65
|
+
# Ascend past every left-child ancestor.
|
|
66
|
+
while fn != 0 and (fn & 1) == 0:
|
|
67
|
+
fn >>= 1
|
|
68
|
+
sn >>= 1
|
|
69
|
+
else:
|
|
70
|
+
h = _node_hash(h, sibling)
|
|
71
|
+
fn >>= 1
|
|
72
|
+
sn >>= 1
|
|
73
|
+
# A well-formed proof consumes exactly enough siblings to reach the root.
|
|
74
|
+
if sn != 0:
|
|
75
|
+
return None
|
|
76
|
+
return h
|
ainra/_name.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
"""AINRA name grammar (The Standard §2).
|
|
3
|
+
|
|
4
|
+
``ainra:{registrar}:{operator}:{lineage}@{version}`` where each label is
|
|
5
|
+
lowercase alphanumerics and hyphens, and ``version`` is a 1–3 field numeric
|
|
6
|
+
semver. The issuer is a version-less DID: ``did:ainra:{registrar}:{operator}:{lineage}``.
|
|
7
|
+
Written from the grammar; the ``name-malformed`` vectors are the ground truth.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
# A label is one or more lowercase alphanumerics / hyphens.
|
|
16
|
+
_LABEL = re.compile(r"^[a-z0-9-]+$")
|
|
17
|
+
# semver: 1 to 3 dot-separated numeric fields.
|
|
18
|
+
_VERSION = re.compile(r"^[0-9]+(\.[0-9]+){0,2}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _label_ok(s: str) -> bool:
|
|
22
|
+
return bool(_LABEL.match(s))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SubjectName:
|
|
27
|
+
registrar: str
|
|
28
|
+
operator: str
|
|
29
|
+
lineage: str
|
|
30
|
+
version: str
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def number(self) -> str:
|
|
34
|
+
"""The permanent version-less AINRA Number (a ``did:ainra`` DID)."""
|
|
35
|
+
return f"did:ainra:{self.registrar}:{self.operator}:{self.lineage}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_subject(name: object) -> SubjectName | None:
|
|
39
|
+
"""Parse ``ainra:reg:op:lin@ver``; ``None`` if it violates the grammar."""
|
|
40
|
+
if not isinstance(name, str) or "@" not in name:
|
|
41
|
+
return None
|
|
42
|
+
body, _, version = name.partition("@")
|
|
43
|
+
if not _VERSION.match(version):
|
|
44
|
+
return None
|
|
45
|
+
parts = body.split(":")
|
|
46
|
+
if len(parts) != 4 or parts[0] != "ainra":
|
|
47
|
+
return None
|
|
48
|
+
reg, op, lin = parts[1], parts[2], parts[3]
|
|
49
|
+
if not (_label_ok(reg) and _label_ok(op) and _label_ok(lin)):
|
|
50
|
+
return None
|
|
51
|
+
return SubjectName(reg, op, lin, version)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_issuer(iss: object) -> str | None:
|
|
55
|
+
"""Parse ``did:ainra:reg:op:lin`` and return the registrar id, else ``None``."""
|
|
56
|
+
if not isinstance(iss, str):
|
|
57
|
+
return None
|
|
58
|
+
parts = iss.split(":")
|
|
59
|
+
# ["did", "ainra", reg, op, lin]
|
|
60
|
+
if len(parts) != 5 or parts[0] != "did" or parts[1] != "ainra":
|
|
61
|
+
return None
|
|
62
|
+
reg, op, lin = parts[2], parts[3], parts[4]
|
|
63
|
+
if not (_label_ok(reg) and _label_ok(op) and _label_ok(lin)):
|
|
64
|
+
return None
|
|
65
|
+
return reg
|
ainra/_vector_runner.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
2
|
+
"""Differential runner: the fourth column of ``make diff``.
|
|
3
|
+
|
|
4
|
+
Reads a directory of conformance vectors and prints, one line per vector,
|
|
5
|
+
``<name>\\t<canonical-result-json>`` — the verdict+reason (passport), or the
|
|
6
|
+
accept+reason/registrars (delta / directory). ``tools/diff-harness/run.mjs``
|
|
7
|
+
spawns this once per corpus and asserts every line agrees with the vector's
|
|
8
|
+
recorded ``expect`` (which is the Rust core's verdict), so the Python verifier
|
|
9
|
+
joins the core / TS-SDK / JS-CLI differential as an independent fourth brain.
|
|
10
|
+
|
|
11
|
+
Usage: python -m ainra._vector_runner <passport|delta|directory> <dir>
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from .delta import verify_delta_vector
|
|
21
|
+
from .directory import verify_directory
|
|
22
|
+
from .verify import verify as verify_passport
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _stable(obj) -> str:
|
|
26
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(kind: str, directory: str) -> int:
|
|
30
|
+
files = sorted(f for f in os.listdir(directory) if f.endswith(".json") and f != "manifest.json")
|
|
31
|
+
out = []
|
|
32
|
+
for f in files:
|
|
33
|
+
with open(os.path.join(directory, f), encoding="utf-8") as fh:
|
|
34
|
+
v = json.load(fh)
|
|
35
|
+
if kind == "passport":
|
|
36
|
+
pres = v["presentation"]
|
|
37
|
+
result = verify_passport(v["anchors"], pres, pres.get("now")).as_result()
|
|
38
|
+
elif kind == "delta":
|
|
39
|
+
result = verify_delta_vector(v)
|
|
40
|
+
elif kind == "directory":
|
|
41
|
+
result = verify_directory(v)
|
|
42
|
+
else:
|
|
43
|
+
print(f"unknown kind: {kind}", file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
out.append(f"{v['name']}\t{_stable(result)}")
|
|
46
|
+
sys.stdout.write("\n".join(out) + ("\n" if out else ""))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
if len(sys.argv) != 3:
|
|
52
|
+
print(__doc__, file=sys.stderr)
|
|
53
|
+
sys.exit(2)
|
|
54
|
+
sys.exit(run(sys.argv[1], sys.argv[2]))
|