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 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
+ }