iqa-org 0.1.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.
- iqa/__init__.py +62 -0
- iqa/attest.py +351 -0
- iqa/iqa_uri.py +170 -0
- iqa/selftest.py +324 -0
- iqa/vectors/iqa-conformance-v1.2.6.json +260 -0
- iqa_org-0.1.0.dist-info/METADATA +218 -0
- iqa_org-0.1.0.dist-info/RECORD +10 -0
- iqa_org-0.1.0.dist-info/WHEEL +5 -0
- iqa_org-0.1.0.dist-info/licenses/LICENSE +201 -0
- iqa_org-0.1.0.dist-info/top_level.txt +1 -0
iqa/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""iqa — reference implementation of the IQA attestation layer (RFC-009 §10/§11).
|
|
2
|
+
|
|
3
|
+
What is in the box
|
|
4
|
+
------------------
|
|
5
|
+
* :mod:`iqa.iqa_uri` — the ``iqa://`` URI codec: validate, canonicalise,
|
|
6
|
+
classify (hash vs name subject), enforce the two **closed sets**
|
|
7
|
+
(``organ`` / ``action``), report §11.3 action safety classes, and derive
|
|
8
|
+
``IQA_ROUTE`` (SPEC/IQA-URI-ATTEST-v1.2.6 §3).
|
|
9
|
+
* :mod:`iqa.attest` — the sovereign **attestation envelope** (Ed25519,
|
|
10
|
+
self-certifying, domain prefix ``iqa-attest-v1``). Optional dependency.
|
|
11
|
+
* :mod:`iqa.vectors` — the published conformance vectors, shipped inside the
|
|
12
|
+
wheel so the self-test works from an installed package with no repository
|
|
13
|
+
checkout.
|
|
14
|
+
|
|
15
|
+
Scope
|
|
16
|
+
-----
|
|
17
|
+
This is the **codec**, not the organism. Staking, vitality monitoring and the
|
|
18
|
+
post-quantum Lattice Guard are RFC-009 narrative with no object here — see the
|
|
19
|
+
package README's scope table and SPEC §8.
|
|
20
|
+
|
|
21
|
+
Verify it
|
|
22
|
+
---------
|
|
23
|
+
$ python -m iqa.selftest
|
|
24
|
+
[PASS] all N checks passed
|
|
25
|
+
|
|
26
|
+
Offline. No account. No network. ``[PASS]`` or it is not.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from . import iqa_uri
|
|
30
|
+
from .iqa_uri import (
|
|
31
|
+
ACTIONS,
|
|
32
|
+
HASH_LENGTHS,
|
|
33
|
+
ORGANS,
|
|
34
|
+
SAFE_ACTIONS,
|
|
35
|
+
SCHEME,
|
|
36
|
+
STANDINGS,
|
|
37
|
+
UNSAFE_ACTIONS,
|
|
38
|
+
IqaUriError,
|
|
39
|
+
derive_route,
|
|
40
|
+
derive_route_hex,
|
|
41
|
+
is_valid_action,
|
|
42
|
+
parse,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"__version__",
|
|
49
|
+
"iqa_uri",
|
|
50
|
+
"SCHEME",
|
|
51
|
+
"ORGANS",
|
|
52
|
+
"ACTIONS",
|
|
53
|
+
"STANDINGS",
|
|
54
|
+
"HASH_LENGTHS",
|
|
55
|
+
"SAFE_ACTIONS",
|
|
56
|
+
"UNSAFE_ACTIONS",
|
|
57
|
+
"IqaUriError",
|
|
58
|
+
"parse",
|
|
59
|
+
"derive_route",
|
|
60
|
+
"derive_route_hex",
|
|
61
|
+
"is_valid_action",
|
|
62
|
+
]
|
iqa/attest.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""
|
|
2
|
+
attest — the sovereign **attestation envelope** (Ed25519, self-certifying)
|
|
3
|
+
|
|
4
|
+
Why this module exists
|
|
5
|
+
----------------------
|
|
6
|
+
An ``iqa://`` URI is a *claim about standing* (RFC-009 §10.4: "parsing is not
|
|
7
|
+
attestation"). To act on the claim, a verifier needs evidence. The evidence
|
|
8
|
+
travels as an **envelope**: a signed object that cites the subject URI and
|
|
9
|
+
carries the standing an Organ rendered.
|
|
10
|
+
|
|
11
|
+
Layout: identical to the RTTP sovereign seal
|
|
12
|
+
(``SPEC/RTTP-SEAL-ENVELOPE-v1.2.6.md`` §3) with two changes fixed by
|
|
13
|
+
``SPEC/IQA-URI-ATTEST-v1.2.6.md`` §5:
|
|
14
|
+
|
|
15
|
+
1. **Domain separation.** The signing-input prefix is ``b"iqa-attest-v1\\n"``
|
|
16
|
+
— a signature made for one scheme can never be replayed as the other.
|
|
17
|
+
2. **Payload semantics.** ``{"subject_uri", "organ", "standing"}``, with
|
|
18
|
+
``standing`` a closed set (``ghost`` / ``probation`` / ``radiant`` /
|
|
19
|
+
``genesis``, RFC-009-C §3).
|
|
20
|
+
|
|
21
|
+
Identity: ``AID = SHA-256(public key)`` — self-certifying, recomputed by the
|
|
22
|
+
verifier from the in-band key. No registry, no issuer, no directory. Lose the
|
|
23
|
+
key, lose the identity; there is no operator who can restore it.
|
|
24
|
+
|
|
25
|
+
``ts`` and ``nonce`` are **inside** the signature: rewriting them must require
|
|
26
|
+
breaking Ed25519, not just editing JSON. Freshness window: 120 seconds for any
|
|
27
|
+
envelope arriving over a network; archival validation only may disable it.
|
|
28
|
+
|
|
29
|
+
Installing
|
|
30
|
+
----------
|
|
31
|
+
pip install iqa[ed25519]
|
|
32
|
+
|
|
33
|
+
Without the extra, importing this module succeeds but every call raises
|
|
34
|
+
``AttestError`` with that instruction — it never degrades to an unsigned path.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
from . import iqa_uri
|
|
45
|
+
from .iqa_uri import IqaUriError
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from cryptography.exceptions import InvalidSignature
|
|
49
|
+
from cryptography.hazmat.primitives import serialization
|
|
50
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
51
|
+
Ed25519PrivateKey,
|
|
52
|
+
Ed25519PublicKey,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
HAVE_ED25519 = True
|
|
56
|
+
_IMPORT_ERROR = None
|
|
57
|
+
except ImportError as _exc: # pragma: no cover - depends on the environment
|
|
58
|
+
HAVE_ED25519 = False
|
|
59
|
+
_IMPORT_ERROR = _exc
|
|
60
|
+
|
|
61
|
+
ALG = "ed25519"
|
|
62
|
+
ENVELOPE_VERSION = 1
|
|
63
|
+
|
|
64
|
+
#: Maximum accepted |now - ts|, in seconds.
|
|
65
|
+
MAX_CLOCK_SKEW = 120
|
|
66
|
+
|
|
67
|
+
#: Nonce length in bytes (hex-encoded on the wire).
|
|
68
|
+
NONCE_BYTES = 8
|
|
69
|
+
|
|
70
|
+
AID_BYTES = 32
|
|
71
|
+
PUBLIC_KEY_BYTES = 32
|
|
72
|
+
SIGNATURE_BYTES = 64
|
|
73
|
+
SEED_BYTES = 32
|
|
74
|
+
|
|
75
|
+
#: SPEC/IQA-URI-ATTEST-v1.2.6 §5.2 — domain separation from the RTTP seal.
|
|
76
|
+
CANONICAL_PREFIX = b"iqa-attest-v1\n"
|
|
77
|
+
|
|
78
|
+
#: Closed set of standings an Organ may render (RFC-009-C §3).
|
|
79
|
+
STANDINGS = iqa_uri.STANDINGS
|
|
80
|
+
|
|
81
|
+
INSTALL_HINT = "pip install iqa-org[ed25519]"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AttestError(Exception):
|
|
85
|
+
"""The envelope is unavailable, unparsable or malformed."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _require_backend() -> None:
|
|
89
|
+
if not HAVE_ED25519:
|
|
90
|
+
raise AttestError(
|
|
91
|
+
f"Ed25519 backend unavailable - install it with '{INSTALL_HINT}' "
|
|
92
|
+
f"(import error: {_IMPORT_ERROR})")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Identity
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def aid_from_public_key(public_key: bytes) -> bytes:
|
|
100
|
+
"""AID = SHA-256(public_key), 32 bytes — self-certifying by construction."""
|
|
101
|
+
if not isinstance(public_key, (bytes, bytearray)):
|
|
102
|
+
raise AttestError("public_key must be bytes")
|
|
103
|
+
if len(public_key) != PUBLIC_KEY_BYTES:
|
|
104
|
+
raise AttestError(f"public_key must be {PUBLIC_KEY_BYTES} bytes")
|
|
105
|
+
return hashlib.sha256(bytes(public_key)).digest()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def generate_keypair(seed: bytes = None) -> dict:
|
|
109
|
+
"""Generate an Ed25519 key pair and its AID.
|
|
110
|
+
|
|
111
|
+
:param seed: 32-byte seed. **For reproducible test vectors only**; omit in
|
|
112
|
+
production and let the OS entropy source decide.
|
|
113
|
+
"""
|
|
114
|
+
_require_backend()
|
|
115
|
+
if seed is None:
|
|
116
|
+
private_key = Ed25519PrivateKey.generate()
|
|
117
|
+
else:
|
|
118
|
+
if not isinstance(seed, (bytes, bytearray)) or len(seed) != SEED_BYTES:
|
|
119
|
+
raise AttestError(f"seed must be {SEED_BYTES} bytes")
|
|
120
|
+
private_key = Ed25519PrivateKey.from_private_bytes(bytes(seed))
|
|
121
|
+
|
|
122
|
+
raw_seed = private_key.private_bytes(
|
|
123
|
+
encoding=serialization.Encoding.Raw,
|
|
124
|
+
format=serialization.PrivateFormat.Raw,
|
|
125
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
126
|
+
)
|
|
127
|
+
public_key = private_key.public_key().public_bytes(
|
|
128
|
+
encoding=serialization.Encoding.Raw,
|
|
129
|
+
format=serialization.PublicFormat.Raw,
|
|
130
|
+
)
|
|
131
|
+
aid_bytes = aid_from_public_key(public_key)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"private_key": private_key, # signing use — never share
|
|
135
|
+
"seed": raw_seed, # 32B seed — never share
|
|
136
|
+
"public_key": public_key, # 32B
|
|
137
|
+
"aid": aid_bytes, # 32B = sha256(public_key)
|
|
138
|
+
"seed_hex": raw_seed.hex(),
|
|
139
|
+
"public_hex": public_key.hex(),
|
|
140
|
+
"aid_hex": aid_bytes.hex(),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def public_bundle(keypair: dict) -> dict:
|
|
145
|
+
"""The safely publishable part (identity + public key), no private material."""
|
|
146
|
+
return {"alg": ALG, "aid": keypair["aid_hex"], "pub": keypair["public_hex"]}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# Canonical signing input
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _hex_lower(value, what: str, nbytes: int) -> str:
|
|
154
|
+
if not isinstance(value, str):
|
|
155
|
+
raise AttestError(f"{what} must be a hex string")
|
|
156
|
+
if value != value.lower():
|
|
157
|
+
raise AttestError(f"{what} must be lowercase hex (RFC-009 §10.3 discipline)")
|
|
158
|
+
if len(value) != nbytes * 2:
|
|
159
|
+
raise AttestError(f"{what} must be {nbytes} bytes ({nbytes * 2} hex chars)")
|
|
160
|
+
try:
|
|
161
|
+
bytes.fromhex(value)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise AttestError(f"{what} is not valid hex: {exc}") from exc
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def json_canonical(obj) -> bytes:
|
|
168
|
+
"""Deterministic JSON encoding (UTF-8, sorted keys, no insignificant whitespace)."""
|
|
169
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
|
|
170
|
+
ensure_ascii=False).encode("utf-8")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def signing_input(alg: str, aid_hex: str, pub_hex: str, ts: int, nonce: str,
|
|
174
|
+
payload: dict) -> bytes:
|
|
175
|
+
"""The signed byte sequence. Every envelope field except ``sig`` enters it.
|
|
176
|
+
|
|
177
|
+
``ts`` / ``nonce`` MUST be inside the signature — beside it, rewriting the
|
|
178
|
+
replay window would not touch Ed25519 at all.
|
|
179
|
+
"""
|
|
180
|
+
return CANONICAL_PREFIX + json_canonical({
|
|
181
|
+
"alg": alg,
|
|
182
|
+
"aid": aid_hex,
|
|
183
|
+
"pub": pub_hex,
|
|
184
|
+
"ts": ts,
|
|
185
|
+
"nonce": nonce,
|
|
186
|
+
"payload": payload,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Attestation claims
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def build_claim(subject_uri: str, organ: str, standing: str) -> dict:
|
|
195
|
+
"""Build and validate an attestation payload (closed sets enforced).
|
|
196
|
+
|
|
197
|
+
The subject URI must parse; ``organ`` and ``standing`` must be members of
|
|
198
|
+
their closed sets. A claim that violates the grammar is not built — it is
|
|
199
|
+
rejected, so an envelope can never carry a subject URI the codec itself
|
|
200
|
+
would reject.
|
|
201
|
+
"""
|
|
202
|
+
parsed = iqa_uri.parse(subject_uri) # raises IqaUriError on any violation
|
|
203
|
+
if organ not in iqa_uri.ORGANS:
|
|
204
|
+
raise AttestError(f"organ {organ!r} is outside the closed set {list(iqa_uri.ORGANS)}")
|
|
205
|
+
if standing not in STANDINGS:
|
|
206
|
+
raise AttestError(f"standing {standing!r} is outside the closed set {list(STANDINGS)}")
|
|
207
|
+
if organ != parsed["organ"]:
|
|
208
|
+
raise AttestError("organ does not match the subject URI's organ")
|
|
209
|
+
return {
|
|
210
|
+
"subject_uri": parsed["canonical_uri"],
|
|
211
|
+
"organ": organ,
|
|
212
|
+
"standing": standing,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Seal / verify
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def seal(payload: dict, keypair: dict, ts: int = None, nonce: str = None) -> dict:
|
|
221
|
+
"""Seal a payload into a self-certifying envelope with your own key.
|
|
222
|
+
|
|
223
|
+
:param ts: unix seconds. Omitted = now. **Pass it only to produce
|
|
224
|
+
reproducible vectors** — in production it pins the replay window.
|
|
225
|
+
:param nonce: ``NONCE_BYTES`` bytes as lowercase hex. Omitted = OS entropy.
|
|
226
|
+
"""
|
|
227
|
+
_require_backend()
|
|
228
|
+
if not isinstance(payload, dict):
|
|
229
|
+
raise AttestError("payload must be a dict")
|
|
230
|
+
if not isinstance(keypair, dict) or "private_key" not in keypair:
|
|
231
|
+
raise AttestError("keypair must come from generate_keypair()")
|
|
232
|
+
|
|
233
|
+
if ts is None:
|
|
234
|
+
ts = int(time.time())
|
|
235
|
+
if not isinstance(ts, int) or isinstance(ts, bool):
|
|
236
|
+
raise AttestError("ts must be an integer (unix seconds)")
|
|
237
|
+
|
|
238
|
+
if nonce is None:
|
|
239
|
+
nonce = os.urandom(NONCE_BYTES).hex()
|
|
240
|
+
_hex_lower(nonce, "nonce", NONCE_BYTES)
|
|
241
|
+
|
|
242
|
+
aid_hex = keypair["aid_hex"]
|
|
243
|
+
pub_hex = keypair["public_hex"]
|
|
244
|
+
signature = keypair["private_key"].sign(
|
|
245
|
+
signing_input(ALG, aid_hex, pub_hex, ts, nonce, payload))
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"v": ENVELOPE_VERSION,
|
|
249
|
+
"alg": ALG,
|
|
250
|
+
"aid": aid_hex,
|
|
251
|
+
"pub": pub_hex,
|
|
252
|
+
"ts": ts,
|
|
253
|
+
"nonce": nonce,
|
|
254
|
+
"sig": signature.hex(),
|
|
255
|
+
"payload": payload,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def verify_envelope(envelope: dict, now: int = None,
|
|
260
|
+
max_skew: int = MAX_CLOCK_SKEW,
|
|
261
|
+
check_freshness: bool = True) -> tuple:
|
|
262
|
+
"""Verify an envelope. Returns ``(ok: bool, reason: str, aid_hex | None)``.
|
|
263
|
+
|
|
264
|
+
Any failed step returns False — **a check that cannot be performed is a
|
|
265
|
+
failure, never a pass.**
|
|
266
|
+
|
|
267
|
+
:param check_freshness: default True. False is **only** for archived
|
|
268
|
+
envelopes (audit records, test vectors). On live network input it must
|
|
269
|
+
stay True — turn it off and replay attacks become trivial.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
_require_backend()
|
|
273
|
+
|
|
274
|
+
if not isinstance(envelope, dict):
|
|
275
|
+
return False, "envelope must be a dict", None
|
|
276
|
+
if envelope.get("v") != ENVELOPE_VERSION:
|
|
277
|
+
return False, f"unsupported envelope version: {envelope.get('v')!r}", None
|
|
278
|
+
if envelope.get("alg") != ALG:
|
|
279
|
+
return False, f"unsupported algorithm: {envelope.get('alg')!r}", None
|
|
280
|
+
|
|
281
|
+
aid_hex = _hex_lower(envelope.get("aid"), "aid", AID_BYTES)
|
|
282
|
+
pub_hex = _hex_lower(envelope.get("pub"), "pub", PUBLIC_KEY_BYTES)
|
|
283
|
+
sig_hex = _hex_lower(envelope.get("sig"), "sig", SIGNATURE_BYTES)
|
|
284
|
+
nonce = _hex_lower(envelope.get("nonce"), "nonce", NONCE_BYTES)
|
|
285
|
+
|
|
286
|
+
ts = envelope.get("ts")
|
|
287
|
+
if not isinstance(ts, int) or isinstance(ts, bool):
|
|
288
|
+
return False, "ts must be an integer (unix seconds)", None
|
|
289
|
+
|
|
290
|
+
payload = envelope.get("payload")
|
|
291
|
+
if not isinstance(payload, dict):
|
|
292
|
+
return False, "payload must be an object", None
|
|
293
|
+
|
|
294
|
+
if check_freshness:
|
|
295
|
+
reference = int(time.time()) if now is None else now
|
|
296
|
+
drift = abs(reference - ts)
|
|
297
|
+
if drift > max_skew:
|
|
298
|
+
return False, (f"timestamp skew too large ({drift}s > {max_skew}s)"
|
|
299
|
+
" - replay?"), None
|
|
300
|
+
|
|
301
|
+
public_key = bytes.fromhex(pub_hex)
|
|
302
|
+
|
|
303
|
+
# --- self-certification: identity is derived, not claimed ---
|
|
304
|
+
if aid_from_public_key(public_key).hex() != aid_hex:
|
|
305
|
+
return False, "AID does not match public key (self-certification failed)", None
|
|
306
|
+
|
|
307
|
+
Ed25519PublicKey.from_public_bytes(public_key).verify(
|
|
308
|
+
bytes.fromhex(sig_hex),
|
|
309
|
+
signing_input(ALG, aid_hex, pub_hex, ts, nonce, payload),
|
|
310
|
+
)
|
|
311
|
+
return True, "sealed", aid_hex
|
|
312
|
+
|
|
313
|
+
except InvalidSignature:
|
|
314
|
+
return False, "bad seal (signature does not verify)", None
|
|
315
|
+
except AttestError as exc:
|
|
316
|
+
return False, str(exc), None
|
|
317
|
+
except (ValueError, TypeError, KeyError) as exc:
|
|
318
|
+
return False, f"malformed envelope: {exc}", None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def verify_attestation(envelope: dict, expected_subject_uri: str = None,
|
|
322
|
+
now: int = None, check_freshness: bool = True) -> tuple:
|
|
323
|
+
"""Verify an envelope **and** its claim payload (closed sets, URI grammar).
|
|
324
|
+
|
|
325
|
+
The signature proves who said it; the claim checks make sure what they said
|
|
326
|
+
is at least well-formed. Neither proves the standing is true — trusting an
|
|
327
|
+
attestation is a different act from verifying one.
|
|
328
|
+
"""
|
|
329
|
+
ok, reason, _aid = verify_envelope(envelope, now=now,
|
|
330
|
+
check_freshness=check_freshness)
|
|
331
|
+
if not ok:
|
|
332
|
+
return False, reason
|
|
333
|
+
payload = envelope["payload"]
|
|
334
|
+
try:
|
|
335
|
+
claim = build_claim(payload.get("subject_uri"), payload.get("organ"),
|
|
336
|
+
payload.get("standing"))
|
|
337
|
+
except (AttestError, IqaUriError) as exc:
|
|
338
|
+
return False, f"claim payload malformed: {exc}", None
|
|
339
|
+
if expected_subject_uri is not None:
|
|
340
|
+
if claim["subject_uri"] != iqa_uri.parse(expected_subject_uri)["canonical_uri"]:
|
|
341
|
+
return False, "subject URI mismatch (cited a different subject?)", None
|
|
342
|
+
return True, "sealed", None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def unseal(envelope: dict, now: int = None, check_freshness: bool = True) -> dict:
|
|
346
|
+
"""Return the payload if the envelope verifies; raise AttestError otherwise."""
|
|
347
|
+
ok, reason, _aid = verify_envelope(envelope, now=now,
|
|
348
|
+
check_freshness=check_freshness)
|
|
349
|
+
if not ok:
|
|
350
|
+
raise AttestError(reason)
|
|
351
|
+
return envelope["payload"]
|
iqa/iqa_uri.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""iqa:// URI codec — validate, canonicalise, derive (RFC-009 §10/§11).
|
|
2
|
+
|
|
3
|
+
Authority boundaries
|
|
4
|
+
Grammar ......... RFC-009 §10.2 ABNF (sole authority; this module implements it)
|
|
5
|
+
Closed sets ..... §10.1 ("Closed set" stated for both organ and action)
|
|
6
|
+
Case discipline . §10.3 (lowercase US-ASCII only) + §12 #8 (fail closed)
|
|
7
|
+
Deref safety .... §11.3 (action safety classes)
|
|
8
|
+
Route derivation SPEC/IQA-URI-ATTEST-v1.2.6.md §3 (IQA_ROUTE)
|
|
9
|
+
|
|
10
|
+
Design rules inherited from the RTTP codec discipline:
|
|
11
|
+
* Malformed input is REJECTED, never normalised.
|
|
12
|
+
* A check that cannot be performed is a failure, never a pass.
|
|
13
|
+
* Zero dependencies: standard library only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
|
|
20
|
+
SCHEME = "iqa"
|
|
21
|
+
|
|
22
|
+
#: RFC-009 §10.1 — organ is a CLOSED set (RFC-009-A/-B/-C).
|
|
23
|
+
ORGANS = ("forge", "tss", "gateway")
|
|
24
|
+
|
|
25
|
+
#: RFC-009 §10.1 — action is a CLOSED set (differs from `rttp`, whose verb set
|
|
26
|
+
#: is open; here anything outside the four verbs is rejected).
|
|
27
|
+
ACTIONS = ("verify", "audit", "attest", "revoke")
|
|
28
|
+
|
|
29
|
+
#: RFC-009 §11.3 — safety classes. Omitted action = standing read (SAFE);
|
|
30
|
+
#: `verify` compares, writes nothing (SAFE). The other three are state
|
|
31
|
+
#: transitions or potentially harmful measurements (NOT SAFE).
|
|
32
|
+
SAFE_ACTIONS = frozenset({None, "verify"})
|
|
33
|
+
UNSAFE_ACTIONS = frozenset({"audit", "attest", "revoke"})
|
|
34
|
+
|
|
35
|
+
#: Standing values an Organ may render (RFC-009-C §3) — closed set, lowercase.
|
|
36
|
+
STANDINGS = ("ghost", "probation", "radiant", "genesis")
|
|
37
|
+
|
|
38
|
+
#: RFC-009 §10.2 — hash-subject lengths: routing short form / 128-bit / 256-bit.
|
|
39
|
+
HASH_LENGTHS = (8, 32, 64)
|
|
40
|
+
|
|
41
|
+
_LOWHEX = frozenset("0123456789abcdef")
|
|
42
|
+
_NAME_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-")
|
|
43
|
+
#: §10.3: a string containing any of these is NOT a valid iqa URI. Enforced
|
|
44
|
+
#: implicitly by the allowed-charset scan; listed here for documentation.
|
|
45
|
+
_FORBIDDEN_CHARS = "@:?#[]"
|
|
46
|
+
|
|
47
|
+
_ALLOWED = _LOWHEX | _NAME_CHARS | frozenset("./")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class IqaUriError(ValueError):
|
|
51
|
+
"""The only error type raised by this module."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fail(uri: str, reason: str) -> "IqaUriError":
|
|
55
|
+
return IqaUriError(f"{uri!r}: {reason}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_valid_action(value: str) -> bool:
|
|
59
|
+
"""§10.2 action test — True only for the closed four-verb set."""
|
|
60
|
+
return value in ACTIONS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def derive_route(authority: str) -> bytes:
|
|
64
|
+
"""IQA_ROUTE = SHA-256(ASCII(canonical_authority))[0:4].
|
|
65
|
+
|
|
66
|
+
Pure computation, DNS-free (RFC-009 §12 #9). Mirrors the RTTP ROUTE_SHARD
|
|
67
|
+
rule (SHA-256(authority)[0:16]) so the two schemes of the same addressing
|
|
68
|
+
family derive the same way. NOTE: this is the *address* fingerprint — it is
|
|
69
|
+
NOT the subject hash, which is a routing hint for the *AID*. The two numbers
|
|
70
|
+
are about different things and MUST NOT be conflated.
|
|
71
|
+
"""
|
|
72
|
+
if not authority:
|
|
73
|
+
raise IqaUriError("derive_route: authority is empty")
|
|
74
|
+
return hashlib.sha256(authority.encode("ascii")).digest()[:4]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def derive_route_hex(authority: str) -> str:
|
|
78
|
+
"""The same four bytes, as 8 lowercase hex characters."""
|
|
79
|
+
return derive_route(authority).hex()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse(uri: str) -> dict:
|
|
83
|
+
"""Validate, canonicalise and derive. Raises IqaUriError on any violation.
|
|
84
|
+
|
|
85
|
+
Returned fields are snake_case ON PURPOSE so the Python and JavaScript
|
|
86
|
+
implementations compare field for field against the published vectors.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(uri, str):
|
|
89
|
+
raise _fail(uri, "input must be a string")
|
|
90
|
+
|
|
91
|
+
# Scheme — exact lowercase prefix. IQA://, iqas://, https:// ... all fail here.
|
|
92
|
+
prefix = SCHEME + "://"
|
|
93
|
+
if not uri.startswith(prefix):
|
|
94
|
+
raise _fail(uri, f"scheme must be exactly {prefix!r} - no other scheme, "
|
|
95
|
+
"no case variant, no fallback (RFC-009 §10.3, §12 #8)")
|
|
96
|
+
rest = uri[len(prefix):]
|
|
97
|
+
|
|
98
|
+
# Charset gate: lowercase US-ASCII only (§10.3). This single scan rejects
|
|
99
|
+
# uppercase anywhere (scheme, subject, organ, action), userinfo '@', port
|
|
100
|
+
# ':', query '?', fragment '#', whitespace and every other foreign byte.
|
|
101
|
+
for ch in rest:
|
|
102
|
+
if ch not in _ALLOWED:
|
|
103
|
+
if ch in _FORBIDDEN_CHARS:
|
|
104
|
+
raise _fail(uri, f"{ch!r} is not defined by the scheme (RFC-009 §10.3)")
|
|
105
|
+
if ch.isspace():
|
|
106
|
+
raise _fail(uri, "whitespace is not allowed (RFC-009 §10.3 lowercase US-ASCII only)")
|
|
107
|
+
raise _fail(uri, f"character {ch!r} is outside the allowed set "
|
|
108
|
+
"(lowercase US-ASCII, digits, '-', '.', '/')")
|
|
109
|
+
|
|
110
|
+
# Path: at most one '/', and the action behind it must be non-empty.
|
|
111
|
+
if "/" in rest:
|
|
112
|
+
authority, _, action = rest.partition("/")
|
|
113
|
+
if action == "":
|
|
114
|
+
raise _fail(uri, "trailing '/' with empty action - action is a "
|
|
115
|
+
"closed-set verb, not omissible by empty string (§10.2)")
|
|
116
|
+
if "/" in action:
|
|
117
|
+
raise _fail(uri, "second path segment - path is a single '/<action>' (§10.2)")
|
|
118
|
+
else:
|
|
119
|
+
authority, action = rest, None
|
|
120
|
+
|
|
121
|
+
# Authority: exactly three segments.
|
|
122
|
+
segments = authority.split(".")
|
|
123
|
+
if len(segments) != 3:
|
|
124
|
+
raise _fail(uri, f"authority has {len(segments)} segment(s), "
|
|
125
|
+
"must be <subject>.<organ>.<root> (RFC-009 §10.2)")
|
|
126
|
+
subject, organ, root = segments
|
|
127
|
+
if subject == "" or root == "":
|
|
128
|
+
raise _fail(uri, "authority segments must be non-empty (RFC-009 §10.2)")
|
|
129
|
+
|
|
130
|
+
# Subject: hash form (8/32/64 lowercase hex) or name form (RFC-009 §10.2).
|
|
131
|
+
# Ordered choice per the ABNF: all-lowerhex input is tried as hash-subject
|
|
132
|
+
# first; a lowercase-hex string of another length is therefore a NAME
|
|
133
|
+
# subject, exactly as the grammar says. Documented, not "fixed".
|
|
134
|
+
if all(c in _LOWHEX for c in subject):
|
|
135
|
+
if len(subject) in HASH_LENGTHS:
|
|
136
|
+
subject_is_hash = True
|
|
137
|
+
subject_hex = subject
|
|
138
|
+
else:
|
|
139
|
+
subject_is_hash = False
|
|
140
|
+
subject_hex = None
|
|
141
|
+
else:
|
|
142
|
+
subject_is_hash = False
|
|
143
|
+
subject_hex = None
|
|
144
|
+
|
|
145
|
+
# Organ — CLOSED set, case-sensitive (§10.1). 'Forge', 'forgery', '' all fail.
|
|
146
|
+
if organ not in ORGANS:
|
|
147
|
+
raise _fail(uri, f"organ {organ!r} is outside the closed set "
|
|
148
|
+
f"{list(ORGANS)} (RFC-009 §10.1/§10.3)")
|
|
149
|
+
|
|
150
|
+
# Action — CLOSED set (§10.1). This is the deliberate difference from rttp.
|
|
151
|
+
if action is not None and action not in ACTIONS:
|
|
152
|
+
raise _fail(uri, f"action {action!r} is outside the closed set "
|
|
153
|
+
f"{list(ACTIONS)} (RFC-009 §10.1)")
|
|
154
|
+
|
|
155
|
+
canonical_uri = SCHEME + "://" + authority + ("/" + action if action else "")
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"scheme": SCHEME,
|
|
159
|
+
"subject": subject,
|
|
160
|
+
"subject_is_hash": subject_is_hash,
|
|
161
|
+
"subject_hex": subject_hex,
|
|
162
|
+
"organ": organ,
|
|
163
|
+
"root": root,
|
|
164
|
+
"authority": authority,
|
|
165
|
+
"action": action,
|
|
166
|
+
"action_omitted": action is None,
|
|
167
|
+
"action_safe": action in SAFE_ACTIONS, # §11.3
|
|
168
|
+
"canonical_uri": canonical_uri,
|
|
169
|
+
"route_hex": derive_route_hex(authority), # SPEC §3
|
|
170
|
+
}
|