obsigil 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.
obsigil/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """obsigil — a mandate-token format and shared-secret JWT alternative.
2
+
3
+ A token split into a public **manifest** and an encrypted **mandate**, each an
4
+ authenticated, deterministically-sealed ciphertext (AES-SIV / AES-GCM-SIV) in
5
+ compact text. Each half's fields are a canonical CBOR map (RFC 8949 §4.2);
6
+ reserved fields take negative integer keys, application data non-negative
7
+ integer or text keys. Verification is symmetric — the same mandate key both
8
+ mints and verifies — so obsigil fits shared-secret (HS256-style) JWT/JWE use
9
+ cases, not public-key verification.
10
+
11
+ This package is the pure-Python reference implementation, built on
12
+ ``cryptography`` for the AEAD primitives and ``cbor2`` for CBOR decoding (the
13
+ canonical encoder is obsigil's own).
14
+
15
+ import obsigil
16
+
17
+ key = obsigil.generate_key()
18
+ tok = obsigil.Obsigil.mint(
19
+ clauses={"role": "admin"},
20
+ mandate_key=key,
21
+ exp=4_000_000_000,
22
+ aud=["api"],
23
+ manifest={"iss": "auth.example"},
24
+ )
25
+ obsigil.Obsigil(tok.token()).claims() # advisory, front end
26
+ obsigil.Obsigil(tok.token(), keys=key, audience="api",
27
+ now=1).clauses() # authoritative, backend
28
+
29
+ The free functions ``mint`` / ``clauses`` / ``claims`` / ``mandate`` /
30
+ ``manifest`` wrap the same core.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from ._constants import MANIFEST_KEY
36
+ from .core import Obsigil, clauses_unchecked, mandate_plaintext
37
+ from .errors import ObsigilError, Reason
38
+ from .keys import generate_key
39
+ from .manifest import authorization_header, claims, mandate, manifest, manifest_plaintext
40
+ from .mint import mint
41
+ from .uuid7 import generate_uuid7, is_uuid7, is_uuid7_bytes, uuid7_time
42
+ from .verify import clauses
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "MANIFEST_KEY",
48
+ "Obsigil",
49
+ "ObsigilError",
50
+ "Reason",
51
+ "authorization_header",
52
+ "claims",
53
+ "clauses",
54
+ "clauses_unchecked",
55
+ "generate_key",
56
+ "generate_uuid7",
57
+ "is_uuid7",
58
+ "is_uuid7_bytes",
59
+ "mandate",
60
+ "mandate_plaintext",
61
+ "manifest",
62
+ "manifest_plaintext",
63
+ "mint",
64
+ "uuid7_time",
65
+ ]
obsigil/_constants.py ADDED
@@ -0,0 +1,51 @@
1
+ """Pinned constants and the reserved-key namespace (the published manifest
2
+ key of Construction §5.2, the output layout of Algorithm registry §6.2, the
3
+ Limits and robustness rules of the Security Considerations §16.10, the
4
+ reserved namespace of Reserved fields §8.1)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ # The public 64-byte manifest key (the published manifest key, Construction §5.2). Public by design: it opens
9
+ # AND forges manifests. Every conformant implementation MUST use this value.
10
+ MANIFEST_KEY: bytes = bytes.fromhex(
11
+ "381284633d02ea5f35df8596b5cc4218310060468e8b465455a415174ea6e966"
12
+ "a9f48eec4ba446ddfc8b78587895356f45a75a1ab7419454dd9f7aa8a95dbdd5"
13
+ )
14
+
15
+ # Lowest legal decoded length of a sealed half (the output layout of
16
+ # Sealing parameters and output layout, §6.2): the AEAD's
17
+ # 16-byte floor plus at least one byte of CBOR plaintext (the empty map
18
+ # 0xa0).
19
+ MIN_HALF_BYTES: int = 17
20
+
21
+ # Reserved field keys: the negative-integer namespace (the reserved
22
+ # namespace of Reserved fields, §8.1). The
23
+ # sign of a map key is its namespace — negative is obsigil's, non-negative
24
+ # integers and text strings are the application's.
25
+ TID_KEY: int = -1
26
+ EXP_KEY: int = -2
27
+ AUD_KEY: int = -3
28
+ SUB_KEY: int = -4
29
+ ISS_KEY: int = -5
30
+
31
+ # Reserved wire key -> conventional name, and the inverse. An implementation
32
+ # MAY surface reserved fields under their names (Reserved fields §8.1; the
33
+ # reserved-field access of API conformance §12.4).
34
+ RESERVED_KEYS: dict = {
35
+ TID_KEY: "tid",
36
+ EXP_KEY: "exp",
37
+ AUD_KEY: "aud",
38
+ SUB_KEY: "sub",
39
+ ISS_KEY: "iss",
40
+ }
41
+ RESERVED_NAMES: dict = {name: key for key, name in RESERVED_KEYS.items()}
42
+
43
+ # Hard ceiling on clock-skew leeway, seconds (Limits and robustness, §16.10): a configured
44
+ # leeway is clamped to this so an over-large value cannot silently extend a
45
+ # token past its exp.
46
+ MAX_LEEWAY: int = 60
47
+
48
+ # Default cap on a half's decoded byte length (Limits and robustness, §16.10): admits any
49
+ # realistic mandate while refusing an attacker-supplied oversize half before
50
+ # any trial decryption.
51
+ DEFAULT_MAX_DECODED_LEN: int = 64 * 1024
obsigil/aead.py ADDED
@@ -0,0 +1,52 @@
1
+ """Deterministic AEAD seal/open (Algorithm registry, spec §6), on pyca/cryptography.
2
+
3
+ No random nonce, no associated data (Sealing parameters and output layout, §6.2):
4
+
5
+ * Code 0 (AES-SIV, RFC 5297): the full 64-byte master is the AES-256-SIV
6
+ key; sealed with a zero-element associated-data vector. Layout
7
+ ``synthetic-IV(16) || ciphertext``.
8
+ * Code 1 (AES-GCM-SIV, RFC 8452): the 32-byte key is
9
+ ``HKDF-Expand(master, "gcmsiv", 32)`` over HMAC-SHA-256 (Expand only);
10
+ sealed with a fixed all-zero 12-byte nonce. Layout
11
+ ``ciphertext || tag(16)``.
12
+
13
+ All cryptography-library usage is isolated to this module.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from cryptography.exceptions import InvalidTag
19
+ from cryptography.hazmat.primitives import hashes
20
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV, AESSIV
21
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
22
+
23
+ _GCMSIV_NONCE = b"\x00" * 12
24
+
25
+
26
+ def _gcmsiv_key(master: bytes) -> bytes:
27
+ # Expand-only: master IS the PRK; info = "gcmsiv", L = 32 (Key material, §6.1).
28
+ return HKDFExpand(algorithm=hashes.SHA256(), length=32, info=b"gcmsiv").derive(master)
29
+
30
+
31
+ def seal(plaintext: bytes, master: bytes, alg: str) -> bytes:
32
+ """Seal a half's plaintext under a 64-byte master with the AEAD named by
33
+ ``alg``. Output layout per the Sealing parameters and output layout section (§6.2)."""
34
+ if alg == "0":
35
+ # Empty AD list => zero-element S2V vector, not one empty component.
36
+ return AESSIV(master).encrypt(plaintext, [])
37
+ if alg == "1":
38
+ return AESGCMSIV(_gcmsiv_key(master)).encrypt(_GCMSIV_NONCE, plaintext, None)
39
+ raise ValueError(f"obsigil: unsupported algorithm code {alg!r}")
40
+
41
+
42
+ def open_(sealed: bytes, master: bytes, alg: str) -> bytes | None:
43
+ """Open a sealed half. Returns the plaintext, or ``None`` on
44
+ authentication failure or an algorithm code this build cannot open."""
45
+ try:
46
+ if alg == "0":
47
+ return AESSIV(master).decrypt(sealed, [])
48
+ if alg == "1":
49
+ return AESGCMSIV(_gcmsiv_key(master)).decrypt(_GCMSIV_NONCE, sealed, None)
50
+ return None
51
+ except (InvalidTag, ValueError):
52
+ return None
obsigil/core.py ADDED
@@ -0,0 +1,249 @@
1
+ """The :class:`Obsigil` token view (API conformance, §12): one type, three roles — a
2
+ keyless front-end view, a verifying backend view, and a minting issuer — over
3
+ an obsigil token. The free functions ``mint`` / ``clauses`` / ``claims`` /
4
+ ``mandate`` / ``manifest`` remain as thin wrappers around the same core.
5
+
6
+ Each half is reachable at three fidelities (the three fidelities of API conformance, §12.2): the wire string
7
+ (:meth:`Obsigil.mandate` / :meth:`Obsigil.manifest`), the decrypted plaintext
8
+ (``*_plaintext``), and the parsed fields (:meth:`Obsigil.clauses` /
9
+ :meth:`Obsigil.claims`). The mandate ladder relaxes one layer at a time and
10
+ never relaxes authentication (authentication vs policy layers, §16.3).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Callable, Optional, Union
16
+
17
+ from ._constants import DEFAULT_MAX_DECODED_LEN
18
+ from .errors import ObsigilError, Reason
19
+ from .manifest import authorization_header as _authorization_header
20
+ from .manifest import claims as _claims
21
+ from .manifest import mandate as _mandate
22
+ from .manifest import manifest as _manifest
23
+ from .manifest import manifest_plaintext as _manifest_plaintext
24
+ from .mint import mint as _mint
25
+ from .serial import deserialize
26
+ from .uuid7 import uuid7_time
27
+ from .verify import _authenticate, _surface_unenforced
28
+ from .verify import clauses as _clauses_verify
29
+
30
+ _UNSET = object()
31
+ Keys = Union[bytes, "list[bytes]", "tuple[bytes, ...]"]
32
+
33
+
34
+ class Obsigil:
35
+ """A view over an obsigil token (API conformance, §12).
36
+
37
+ * ``Obsigil(token)`` — keyless front-end view: read the advisory manifest
38
+ (:meth:`claims`) and forward the mandate (:meth:`mandate`).
39
+ * ``Obsigil(token, keys=…, audience=…, …)`` — verifying backend view:
40
+ :meth:`clauses` authenticates and enforces policy, raising on failure.
41
+ * ``Obsigil.mint(…)`` — issuer: build and seal a fresh token.
42
+
43
+ >>> import obsigil
44
+ >>> key = bytes(range(1, 65))
45
+ >>> tok = obsigil.Obsigil.mint(clauses={"role": "admin"}, mandate_key=key,
46
+ ... exp=4_000_000_000, aud=["api"])
47
+ >>> obsigil.Obsigil(tok.token(), keys=key, audience="api", now=1).clause("role")
48
+ 'admin'
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ token: str,
54
+ *,
55
+ keys: Optional[Keys] = None,
56
+ audience: Optional[str] = None,
57
+ leeway: int = 0,
58
+ now: Optional[int] = None,
59
+ max_decoded_len: int = DEFAULT_MAX_DECODED_LEN,
60
+ on_reject: Optional[Callable[[Reason], None]] = None,
61
+ ) -> None:
62
+ self._token = token
63
+ self._keys = keys
64
+ self._audience = audience
65
+ self._leeway = leeway
66
+ self._now = now
67
+ self._max = max_decoded_len
68
+ self._on_reject = on_reject
69
+ self._clauses_cache: object = _UNSET
70
+
71
+ @classmethod
72
+ def mint(
73
+ cls,
74
+ *,
75
+ clauses: dict,
76
+ mandate_key: bytes,
77
+ exp: int,
78
+ tid=None,
79
+ aud: Optional[list] = None,
80
+ sub: Optional[str] = None,
81
+ iss: Optional[str] = None,
82
+ alg: str = "0",
83
+ encoding: str = "b64",
84
+ manifest: Optional[dict] = None,
85
+ ) -> "Obsigil":
86
+ """Mint a token and return it as an :class:`Obsigil` view (Construction, §5).
87
+ ``tid`` is generated unless supplied (the tid clause, Reserved fields §8.2)."""
88
+ return cls(
89
+ _mint(
90
+ clauses=clauses,
91
+ mandate_key=mandate_key,
92
+ exp=exp,
93
+ tid=tid,
94
+ aud=aud,
95
+ sub=sub,
96
+ iss=iss,
97
+ alg=alg,
98
+ encoding=encoding,
99
+ manifest=manifest,
100
+ )
101
+ )
102
+
103
+ # --- wire fidelity (the three fidelities of API conformance, §12.2) ---
104
+
105
+ def token(self) -> str:
106
+ """The whole token string."""
107
+ return self._token
108
+
109
+ def mandate(self) -> Optional[str]:
110
+ """The mandate half as a standalone ``.0mandate`` token — the value
111
+ forwarded to the backend (Audiences, §9). ``None`` if no mandate half."""
112
+ return _mandate(self._token)
113
+
114
+ def manifest(self) -> Optional[str]:
115
+ """The manifest half as a standalone ``manifest0.`` token. ``None`` if
116
+ no manifest half."""
117
+ return _manifest(self._token)
118
+
119
+ # --- plaintext fidelity (authenticate only; backend-internal, authentication vs policy layers §16.3) ---
120
+
121
+ def mandate_plaintext(self) -> bytes:
122
+ """The decrypted mandate plaintext (canonical CBOR octets), with no
123
+ validation (the decoded reads of API conformance, §12.3). Authenticates first; raises on auth failure.
124
+ Backend-internal — keep non-bearer-facing (authentication vs policy layers, §16.3)."""
125
+ return self._decrypt()
126
+
127
+ def manifest_plaintext(self) -> Optional[bytes]:
128
+ """The decrypted manifest plaintext (canonical CBOR octets), keyless
129
+ and advisory (the three fidelities of API conformance, §12.2). ``None`` if absent or auth fails."""
130
+ return _manifest_plaintext(self._token, max_decoded_len=self._max)
131
+
132
+ # --- parsed fidelity (the three fidelities of API conformance, §12.2) ---
133
+
134
+ def claims(self) -> Optional[dict]:
135
+ """The advisory manifest claims, or ``None`` (keyless; the manifest is non-authoritative, §16.7).
136
+ Never raises."""
137
+ return _claims(self._token, max_decoded_len=self._max)
138
+
139
+ def clauses(self) -> dict:
140
+ """The verified mandate clauses (the decoded reads of API conformance, §12.3): authenticate and enforce
141
+ policy, raising :class:`ObsigilError` on any failure. The result is
142
+ cached. Requires a verifying view built with ``keys=``."""
143
+ if self._clauses_cache is _UNSET:
144
+ if self._keys is None:
145
+ raise ValueError("obsigil: no mandate key configured; build the view with keys=")
146
+ self._clauses_cache = _clauses_verify(
147
+ self._token,
148
+ keys=self._keys,
149
+ audience=self._audience,
150
+ leeway=self._leeway,
151
+ now=self._now,
152
+ max_decoded_len=self._max,
153
+ on_reject=self._on_reject,
154
+ )
155
+ return self._clauses_cache # type: ignore[return-value]
156
+
157
+ def clauses_unchecked(self) -> dict:
158
+ """The mandate clauses with **no policy** applied (the decoded reads of API conformance, §12.3):
159
+ authenticate and decode the canonical CBOR map, but skip the value
160
+ checks (``exp`` / ``aud`` / ``tid`` well-formedness / reserved types).
161
+ A non-canonical or duplicate-key encoding still fails. Backend-internal
162
+ — keep non-bearer-facing (authentication vs policy layers, §16.3)."""
163
+ fields = deserialize(self._decrypt())
164
+ if fields is None:
165
+ raise ObsigilError()
166
+ return _surface_unenforced(fields)
167
+
168
+ # --- reserved-clause accessors (the reserved-field access of API conformance, §12.4) ---
169
+
170
+ def exp(self) -> int:
171
+ """Authoritative expiry (the exp clause, Reserved fields §8.3)."""
172
+ return self.clauses()["exp"]
173
+
174
+ def tid(self) -> str:
175
+ """The unique token id, UUIDv7 text form (the tid clause, Reserved fields §8.2)."""
176
+ return self.clauses()["tid"]
177
+
178
+ def issued_at(self) -> int:
179
+ """Issue time (NumericDate seconds), derived from ``tid`` (the tid clause, Reserved fields §8.2)."""
180
+ return uuid7_time(self.clauses()["tid"])
181
+
182
+ def sub(self) -> Optional[str]:
183
+ """Subject authorized, if present (the sub clause, Reserved fields §8.5)."""
184
+ return self.clauses().get("sub")
185
+
186
+ def iss(self) -> Optional[str]:
187
+ """Issuer, if present (the iss clause, Reserved fields §8.6)."""
188
+ return self.clauses().get("iss")
189
+
190
+ def aud(self) -> Optional[list]:
191
+ """Intended verifiers, if present (the aud clause, Reserved fields §8.4)."""
192
+ return self.clauses().get("aud")
193
+
194
+ def clause(self, key):
195
+ """A single clause by reserved name (``"tid"``, ``"exp"``, …) or by
196
+ application key (a non-negative integer or text string)."""
197
+ return self.clauses().get(key)
198
+
199
+ def authorization_header(self, scheme: str = "Bearer") -> Optional[str]:
200
+ """The ``Authorization`` value carrying the mandate (Audiences, §9)."""
201
+ return _authorization_header(self._token, scheme)
202
+
203
+ # --- internal ---
204
+
205
+ def _decrypt(self) -> bytes:
206
+ if self._keys is None:
207
+ raise ValueError("obsigil: no mandate key configured; build the view with keys=")
208
+ ok, payload = _authenticate(self._token, self._keys, self._max)
209
+ if not ok:
210
+ if self._on_reject is not None:
211
+ self._on_reject(payload) # type: ignore[arg-type]
212
+ raise ObsigilError()
213
+ return payload # type: ignore[return-value]
214
+
215
+
216
+ # --- free-function wrappers over the backend-internal fidelities (the decoded
217
+ # reads of API conformance §12.3, authentication vs policy layers §16.3). Thin
218
+ # shims over the Obsigil view, mirroring the keyless free
219
+ # functions in manifest.py. ---
220
+
221
+
222
+ def clauses_unchecked(
223
+ token: str,
224
+ *,
225
+ keys: Keys,
226
+ max_decoded_len: int = DEFAULT_MAX_DECODED_LEN,
227
+ on_reject: Optional[Callable[[Reason], None]] = None,
228
+ ) -> dict:
229
+ """The mandate clauses with **no policy** applied (the decoded reads of API conformance, §12.3): authenticate
230
+ and decode the canonical CBOR map, but skip the value checks (``exp`` /
231
+ ``aud`` / ``tid`` well-formedness / reserved types). A non-canonical or
232
+ duplicate-key encoding still fails. Backend-internal — keep
233
+ non-bearer-facing (authentication vs policy layers, §16.3)."""
234
+ return Obsigil(token, keys=keys, max_decoded_len=max_decoded_len,
235
+ on_reject=on_reject).clauses_unchecked()
236
+
237
+
238
+ def mandate_plaintext(
239
+ token: str,
240
+ *,
241
+ keys: Keys,
242
+ max_decoded_len: int = DEFAULT_MAX_DECODED_LEN,
243
+ on_reject: Optional[Callable[[Reason], None]] = None,
244
+ ) -> bytes:
245
+ """The decrypted mandate plaintext — the canonical CBOR octets — with no
246
+ validation (the decoded reads of API conformance, §12.3). Authenticates first; raises on auth failure.
247
+ Backend-internal — keep non-bearer-facing (authentication vs policy layers, §16.3)."""
248
+ return Obsigil(token, keys=keys, max_decoded_len=max_decoded_len,
249
+ on_reject=on_reject).mandate_plaintext()
obsigil/encoding.py ADDED
@@ -0,0 +1,90 @@
1
+ """Strict, canonical text codecs (Token structure, spec §4).
2
+
3
+ Decoders return ``None`` on any non-canonical input — padding, whitespace,
4
+ out-of-alphabet characters, non-zero trailing b64 bits, or a bad length —
5
+ so callers can fold the failure into a uniform rejection (the uniform-failure rule of the Security Considerations, spec §16.6). We
6
+ hand-roll these because the stdlib decoders do not enforce that strictness.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ _B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
12
+ _B64URL_REV = {ord(c): i for i, c in enumerate(_B64URL)}
13
+
14
+
15
+ def decode_b64url(s: str) -> bytes | None:
16
+ """Decode URL-safe base64 with no padding (Token structure, spec §4)."""
17
+ n = len(s)
18
+ if n % 4 == 1:
19
+ return None
20
+ out = bytearray()
21
+ full = n // 4
22
+ i = 0
23
+ for _ in range(full):
24
+ a = _B64URL_REV.get(ord(s[i]))
25
+ b = _B64URL_REV.get(ord(s[i + 1]))
26
+ c = _B64URL_REV.get(ord(s[i + 2]))
27
+ d = _B64URL_REV.get(ord(s[i + 3]))
28
+ if a is None or b is None or c is None or d is None:
29
+ return None
30
+ out.append((a << 2) | (b >> 4))
31
+ out.append(((b & 0x0F) << 4) | (c >> 2))
32
+ out.append(((c & 0x03) << 6) | d)
33
+ i += 4
34
+ rem = n - full * 4
35
+ if rem == 2:
36
+ a = _B64URL_REV.get(ord(s[i]))
37
+ b = _B64URL_REV.get(ord(s[i + 1]))
38
+ if a is None or b is None or (b & 0x0F) != 0:
39
+ return None
40
+ out.append((a << 2) | (b >> 4))
41
+ elif rem == 3:
42
+ a = _B64URL_REV.get(ord(s[i]))
43
+ b = _B64URL_REV.get(ord(s[i + 1]))
44
+ c = _B64URL_REV.get(ord(s[i + 2]))
45
+ if a is None or b is None or c is None or (c & 0x03) != 0:
46
+ return None
47
+ out.append((a << 2) | (b >> 4))
48
+ out.append(((b & 0x0F) << 4) | (c >> 2))
49
+ return bytes(out)
50
+
51
+
52
+ def encode_b64url(data: bytes) -> str:
53
+ """Encode bytes as URL-safe base64 with no padding (Token structure, spec §4)."""
54
+ out = []
55
+ n = len(data)
56
+ i = 0
57
+ while i + 3 <= n:
58
+ x = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]
59
+ out.append(_B64URL[(x >> 18) & 63])
60
+ out.append(_B64URL[(x >> 12) & 63])
61
+ out.append(_B64URL[(x >> 6) & 63])
62
+ out.append(_B64URL[x & 63])
63
+ i += 3
64
+ rem = n - i
65
+ if rem == 1:
66
+ x = data[i] << 16
67
+ out.append(_B64URL[(x >> 18) & 63])
68
+ out.append(_B64URL[(x >> 12) & 63])
69
+ elif rem == 2:
70
+ x = (data[i] << 16) | (data[i + 1] << 8)
71
+ out.append(_B64URL[(x >> 18) & 63])
72
+ out.append(_B64URL[(x >> 12) & 63])
73
+ out.append(_B64URL[(x >> 6) & 63])
74
+ return "".join(out)
75
+
76
+
77
+ def decode_hex(s: str) -> bytes | None:
78
+ """Decode lowercase hex of even length (Token structure, spec §4). Uppercase is rejected."""
79
+ if len(s) % 2 != 0:
80
+ return None
81
+ for ch in s:
82
+ o = ord(ch)
83
+ if not (0x30 <= o <= 0x39 or 0x61 <= o <= 0x66):
84
+ return None
85
+ return bytes.fromhex(s)
86
+
87
+
88
+ def encode_hex(data: bytes) -> str:
89
+ """Encode bytes as lowercase hex (Token structure, spec §4)."""
90
+ return data.hex()
obsigil/errors.py ADDED
@@ -0,0 +1,30 @@
1
+ """Error types. Verification failures are uniform and opaque to the bearer
2
+ (the uniform-failure rule of the Security Considerations, spec §16.6)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import enum
7
+
8
+
9
+ class Reason(str, enum.Enum):
10
+ """The internal cause of a rejection — for server-side logging via
11
+ ``clauses(..., on_reject=...)`` ONLY. A verifier MUST NOT signal *why* a
12
+ token was rejected to the bearer (the uniform-failure rule of the Security Considerations, spec §16.6)."""
13
+
14
+ MALFORMED = "malformed"
15
+ UNSUPPORTED = "unsupported"
16
+ AUTH_FAILED = "auth-failed"
17
+ EMPTY_MANDATE = "empty-mandate"
18
+ BAD_TID = "bad-tid"
19
+ MISSING_CLAUSE = "missing-clause"
20
+ EXPIRED = "expired"
21
+ AUDIENCE_MISMATCH = "audience-mismatch"
22
+
23
+
24
+ class ObsigilError(Exception):
25
+ """The single, opaque failure :func:`obsigil.clauses` raises on any
26
+ rejection. Its message is uniform across every cause (the uniform-failure rule of the Security Considerations, spec §16.6); the
27
+ granular :class:`Reason` is delivered to ``on_reject``, never here."""
28
+
29
+ def __init__(self) -> None:
30
+ super().__init__("obsigil: token rejected")
obsigil/keys.py ADDED
@@ -0,0 +1,28 @@
1
+ """Mandate key handling (the mandate-key rules of Construction, spec §5.1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ._constants import MANIFEST_KEY
8
+
9
+
10
+ def generate_key() -> bytes:
11
+ """Generate a fresh 64-byte mandate key from the platform CSPRNG (Construction, §5.1)."""
12
+ return os.urandom(64)
13
+
14
+
15
+ def _assert_mandate_key(key: bytes) -> None:
16
+ """Reject a key that cannot be a mandate key: wrong length, the public
17
+ manifest key (accepting which would let anyone mint mandates, Construction §5.1), or
18
+ all-zero (a misconfiguration, never a CSPRNG output). Raises a
19
+ (non-uniform) configuration error; this is a deployment bug, not a token
20
+ rejection."""
21
+ if not isinstance(key, (bytes, bytearray)):
22
+ raise ValueError("obsigil: mandate key must be bytes")
23
+ if len(key) != 64:
24
+ raise ValueError("obsigil: mandate key must be 64 bytes")
25
+ if key == MANIFEST_KEY:
26
+ raise ValueError("obsigil: mandate key must not be the public manifest key")
27
+ if not any(key):
28
+ raise ValueError("obsigil: mandate key must not be all-zero")