PyEVP 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.
- pyevp/__init__.py +47 -0
- pyevp/__main__.py +28 -0
- pyevp/_email.py +65 -0
- pyevp/_httpsig.py +284 -0
- pyevp/_jose.py +145 -0
- pyevp/_sf.py +406 -0
- pyevp/adapters/__init__.py +4 -0
- pyevp/adapters/_doh.py +92 -0
- pyevp/adapters/_fetch.py +86 -0
- pyevp/adapters/_http.py +38 -0
- pyevp/adapters/dnspython.py +79 -0
- pyevp/adapters/doh.py +130 -0
- pyevp/adapters/httpx.py +147 -0
- pyevp/adapters/urllib.py +170 -0
- pyevp/cache.py +83 -0
- pyevp/cli/__init__.py +348 -0
- pyevp/contrib/__init__.py +4 -0
- pyevp/contrib/django/__init__.py +306 -0
- pyevp/contrib/django/apps.py +17 -0
- pyevp/contrib/django/issuer.py +314 -0
- pyevp/contrib/django/migrations/0001_initial.py +17 -0
- pyevp/contrib/django/migrations/__init__.py +0 -0
- pyevp/contrib/django/models.py +14 -0
- pyevp/contrib/django/templatetags/__init__.py +0 -0
- pyevp/contrib/django/templatetags/pyevp.py +32 -0
- pyevp/core.py +321 -0
- pyevp/diagnostics.py +182 -0
- pyevp/discovery.py +144 -0
- pyevp/errors.py +80 -0
- pyevp/issuer/__init__.py +39 -0
- pyevp/issuer/core.py +413 -0
- pyevp/issuer/errors.py +99 -0
- pyevp/issuer/fedcm.py +44 -0
- pyevp/issuer/keys.py +140 -0
- pyevp/issuer/profile.py +96 -0
- pyevp/nonce.py +25 -0
- pyevp/observability.py +89 -0
- pyevp/ports.py +54 -0
- pyevp/profile.py +153 -0
- pyevp/py.typed +0 -0
- pyevp/replay.py +69 -0
- pyevp/testing.py +343 -0
- pyevp/token.py +135 -0
- pyevp/types.py +37 -0
- pyevp/verifier.py +486 -0
- pyevp-0.1.0.dist-info/METADATA +171 -0
- pyevp-0.1.0.dist-info/RECORD +50 -0
- pyevp-0.1.0.dist-info/WHEEL +4 -0
- pyevp-0.1.0.dist-info/entry_points.txt +3 -0
- pyevp-0.1.0.dist-info/licenses/LICENSE +21 -0
pyevp/testing.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Test doubles: a fake issuer, a fake browser and in-memory ports.
|
|
2
|
+
|
|
3
|
+
These let applications test their EVP integration end-to-end without network
|
|
4
|
+
access::
|
|
5
|
+
|
|
6
|
+
issuer = FakeIssuer()
|
|
7
|
+
browser = FakeBrowser()
|
|
8
|
+
verifier = make_verifier(issuer, audience="https://rp.example")
|
|
9
|
+
token = browser.present(issuer.issue("alice@example.com", browser.public_jwk),
|
|
10
|
+
audience="https://rp.example", nonce=nonce)
|
|
11
|
+
verifier.verify(token, nonce=nonce, email="alice@example.com")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import UTC, datetime, timedelta
|
|
20
|
+
from typing import Any, Literal, TypeAlias
|
|
21
|
+
|
|
22
|
+
from joserfc.jwk import ECKey, OKPKey
|
|
23
|
+
|
|
24
|
+
from pyevp import _httpsig, discovery
|
|
25
|
+
from pyevp.cache import AsyncCache, Cache
|
|
26
|
+
from pyevp.observability import Observer
|
|
27
|
+
from pyevp.profile import DEFAULT_PROFILE, Profile
|
|
28
|
+
from pyevp.replay import AsyncReplayGuard, ReplayGuard
|
|
29
|
+
from pyevp.token import build_kb, sign_jwt
|
|
30
|
+
from pyevp.types import JSONObject
|
|
31
|
+
from pyevp.verifier import AsyncVerifier, Verifier
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AsyncInMemoryDns",
|
|
35
|
+
"AsyncInMemoryHttp",
|
|
36
|
+
"FakeBrowser",
|
|
37
|
+
"FakeIssuer",
|
|
38
|
+
"FixedClock",
|
|
39
|
+
"InMemoryDns",
|
|
40
|
+
"InMemoryHttp",
|
|
41
|
+
"make_async_verifier",
|
|
42
|
+
"make_verifier",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
|
|
46
|
+
SigningAlg: TypeAlias = Literal["Ed25519", "EdDSA", "ES256"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _generate_key(alg: str) -> OKPKey | ECKey:
|
|
50
|
+
if alg in ("Ed25519", "EdDSA"):
|
|
51
|
+
return OKPKey.generate_key("Ed25519", private=True)
|
|
52
|
+
if alg == "ES256":
|
|
53
|
+
return ECKey.generate_key("P-256", private=True)
|
|
54
|
+
raise ValueError(f"unsupported test algorithm {alg!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FixedClock:
|
|
58
|
+
"""A manually advanced clock, usable wherever a ``Clock`` is expected."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, now: datetime | None = None) -> None:
|
|
61
|
+
self.now = now or datetime(2026, 10, 1, 12, 0, tzinfo=UTC)
|
|
62
|
+
|
|
63
|
+
def __call__(self) -> datetime:
|
|
64
|
+
return self.now
|
|
65
|
+
|
|
66
|
+
def advance(self, delta: timedelta) -> None:
|
|
67
|
+
self.now += delta
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FakeIssuer:
|
|
71
|
+
"""Mints EVTs. Deviations from the spec can be configured to mimic real issuers.
|
|
72
|
+
|
|
73
|
+
:param host: issuer host; the issuer identifier is ``https://{host}``.
|
|
74
|
+
:param kid: key id; ``""`` or ``None`` reproduces Gmail's key-id-less JWKS.
|
|
75
|
+
:param iss_format: how ``iss`` is written in tokens.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
host: str = "issuer.example",
|
|
81
|
+
*,
|
|
82
|
+
alg: SigningAlg = "Ed25519",
|
|
83
|
+
kid: str | None = "test-key-1",
|
|
84
|
+
typ: str = "evt+jwt",
|
|
85
|
+
iss_format: Literal["origin", "host"] = "origin",
|
|
86
|
+
email_domains: tuple[str, ...] = ("example.com",),
|
|
87
|
+
clock: FixedClock | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.host = host
|
|
90
|
+
self.alg = alg
|
|
91
|
+
self.kid = kid
|
|
92
|
+
self.typ = typ
|
|
93
|
+
self.iss_format = iss_format
|
|
94
|
+
self.email_domains = email_domains
|
|
95
|
+
self.clock = clock or FixedClock()
|
|
96
|
+
self.key = _generate_key(alg)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def gmail_like(cls, **kwargs: Any) -> FakeIssuer:
|
|
100
|
+
"""Mimic Gmail as deployed in 2026-10: ``EdDSA``, keys without ``kid``."""
|
|
101
|
+
kwargs.setdefault("host", "accounts.google.example")
|
|
102
|
+
kwargs.setdefault("email_domains", ("gmail.example",))
|
|
103
|
+
return cls(alg="EdDSA", kid="", **kwargs)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def issuer(self) -> str:
|
|
107
|
+
return f"https://{self.host}"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def metadata_url(self) -> str:
|
|
111
|
+
return discovery.metadata_url(self.issuer, DEFAULT_PROFILE)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def jwks_uri(self) -> str:
|
|
115
|
+
return f"{self.issuer}/jwks.json"
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def metadata(self) -> dict[str, Any]:
|
|
119
|
+
return {
|
|
120
|
+
"issuer": self.issuer,
|
|
121
|
+
"issuance_endpoint": f"{self.issuer}/email-verification/issue",
|
|
122
|
+
"jwks_uri": self.jwks_uri,
|
|
123
|
+
"signing_alg_values_supported": [self.alg],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def jwks(self) -> dict[str, Any]:
|
|
128
|
+
jwk: dict[str, Any] = {**self.key.as_dict(private=False), "use": "sig", "alg": self.alg}
|
|
129
|
+
if self.kid:
|
|
130
|
+
jwk["kid"] = self.kid
|
|
131
|
+
return {"keys": [jwk]}
|
|
132
|
+
|
|
133
|
+
def dns_records(self) -> dict[str, list[str]]:
|
|
134
|
+
return {
|
|
135
|
+
f"{DEFAULT_PROFILE.dns_label}.{domain}": [f"iss={self.host}"]
|
|
136
|
+
for domain in self.email_domains
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def http_documents(self) -> dict[str, object]:
|
|
140
|
+
return {self.metadata_url: self.metadata, self.jwks_uri: self.jwks}
|
|
141
|
+
|
|
142
|
+
def rotate_key(self) -> None:
|
|
143
|
+
self.key = _generate_key(self.alg)
|
|
144
|
+
|
|
145
|
+
def issue(
|
|
146
|
+
self,
|
|
147
|
+
email: str,
|
|
148
|
+
holder_jwk: JSONObject,
|
|
149
|
+
*,
|
|
150
|
+
issued_at: datetime | None = None,
|
|
151
|
+
claims: Mapping[str, Any] | None = None,
|
|
152
|
+
header: Mapping[str, Any] | None = None,
|
|
153
|
+
) -> str:
|
|
154
|
+
"""Return a signed EVT. ``claims`` / ``header`` override or add fields
|
|
155
|
+
(a value of ``None`` removes the field)."""
|
|
156
|
+
iat = issued_at or self.clock()
|
|
157
|
+
body: dict[str, Any] = {
|
|
158
|
+
"iss": self.issuer if self.iss_format == "origin" else self.host,
|
|
159
|
+
"iat": int(iat.timestamp()),
|
|
160
|
+
"cnf": {"jwk": dict(holder_jwk)},
|
|
161
|
+
"email": email,
|
|
162
|
+
"email_verified": True,
|
|
163
|
+
}
|
|
164
|
+
hdr: dict[str, Any] = {"alg": self.alg, "typ": self.typ}
|
|
165
|
+
if self.kid is not None:
|
|
166
|
+
hdr["kid"] = self.kid
|
|
167
|
+
for target, overrides in ((body, claims), (hdr, header)):
|
|
168
|
+
for k, v in (overrides or {}).items():
|
|
169
|
+
if v is None:
|
|
170
|
+
target.pop(k, None)
|
|
171
|
+
else:
|
|
172
|
+
target[k] = v
|
|
173
|
+
return sign_jwt(hdr, body, self.key)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class FakeBrowser:
|
|
177
|
+
"""Holds the key-binding key and produces presentation tokens."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, *, alg: SigningAlg = "Ed25519", clock: FixedClock | None = None) -> None:
|
|
180
|
+
self.alg = alg
|
|
181
|
+
self.clock = clock or FixedClock()
|
|
182
|
+
self.key = _generate_key(alg)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def public_jwk(self) -> dict[str, Any]:
|
|
186
|
+
return {**self.key.as_dict(private=False), "alg": self.alg}
|
|
187
|
+
|
|
188
|
+
def issuance_request(
|
|
189
|
+
self,
|
|
190
|
+
email: str,
|
|
191
|
+
*,
|
|
192
|
+
endpoint: str,
|
|
193
|
+
include_alg: bool = False,
|
|
194
|
+
extra: Mapping[str, Any] | None = None,
|
|
195
|
+
created: datetime | None = None,
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
"""A signed issuance request, as keyword arguments for ``Issuer.parse_request``.
|
|
198
|
+
|
|
199
|
+
Like Chrome 153, the ``hwk`` key omits ``alg`` unless ``include_alg`` is set.
|
|
200
|
+
``extra`` adds members to the JSON body.
|
|
201
|
+
"""
|
|
202
|
+
body = json.dumps({"email": email, **(extra or {})}).encode()
|
|
203
|
+
alg = "Ed25519" if self.alg == "EdDSA" else self.alg
|
|
204
|
+
headers = _httpsig.sign_request(
|
|
205
|
+
method="POST",
|
|
206
|
+
endpoint=endpoint,
|
|
207
|
+
body=body,
|
|
208
|
+
private_key=self.key,
|
|
209
|
+
public_jwk=self.key.as_dict(private=False),
|
|
210
|
+
alg=alg,
|
|
211
|
+
created=created or self.clock(),
|
|
212
|
+
include_alg=include_alg,
|
|
213
|
+
)
|
|
214
|
+
return {"method": "POST", "headers": headers, "body": body}
|
|
215
|
+
|
|
216
|
+
def present(
|
|
217
|
+
self,
|
|
218
|
+
evt: str,
|
|
219
|
+
*,
|
|
220
|
+
audience: str,
|
|
221
|
+
nonce: str,
|
|
222
|
+
issued_at: datetime | None = None,
|
|
223
|
+
typ: str = "kb+jwt",
|
|
224
|
+
) -> str:
|
|
225
|
+
return build_kb(
|
|
226
|
+
evt,
|
|
227
|
+
private_key=self.key,
|
|
228
|
+
alg=self.alg,
|
|
229
|
+
audience=audience,
|
|
230
|
+
nonce=nonce,
|
|
231
|
+
issued_at=issued_at or self.clock(),
|
|
232
|
+
typ=typ,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class InMemoryDns:
|
|
238
|
+
records: dict[str, list[str]] = field(default_factory=dict)
|
|
239
|
+
queries: list[str] = field(default_factory=list)
|
|
240
|
+
|
|
241
|
+
def resolve_txt(self, name: str) -> list[str]:
|
|
242
|
+
self.queries.append(name)
|
|
243
|
+
return list(self.records.get(name, []))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class AsyncInMemoryDns:
|
|
248
|
+
records: dict[str, list[str]] = field(default_factory=dict)
|
|
249
|
+
queries: list[str] = field(default_factory=list)
|
|
250
|
+
|
|
251
|
+
async def resolve_txt(self, name: str) -> list[str]:
|
|
252
|
+
self.queries.append(name)
|
|
253
|
+
return list(self.records.get(name, []))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _NotFoundError(Exception):
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class InMemoryHttp:
|
|
262
|
+
"""Serves documents by URL; a callable value is invoked on each request."""
|
|
263
|
+
|
|
264
|
+
documents: dict[str, object] = field(default_factory=dict)
|
|
265
|
+
requests: list[str] = field(default_factory=list)
|
|
266
|
+
|
|
267
|
+
def fetch_json(self, url: str) -> object:
|
|
268
|
+
self.requests.append(url)
|
|
269
|
+
if url not in self.documents:
|
|
270
|
+
raise _NotFoundError(f"404 {url}")
|
|
271
|
+
doc = self.documents[url]
|
|
272
|
+
return doc() if callable(doc) else doc
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class AsyncInMemoryHttp:
|
|
277
|
+
documents: dict[str, object] = field(default_factory=dict)
|
|
278
|
+
requests: list[str] = field(default_factory=list)
|
|
279
|
+
|
|
280
|
+
async def fetch_json(self, url: str) -> object:
|
|
281
|
+
self.requests.append(url)
|
|
282
|
+
if url not in self.documents:
|
|
283
|
+
raise _NotFoundError(f"404 {url}")
|
|
284
|
+
doc = self.documents[url]
|
|
285
|
+
return doc() if callable(doc) else doc
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _live(issuers: tuple[FakeIssuer, ...]) -> tuple[dict[str, list[str]], dict[str, object]]:
|
|
289
|
+
records: dict[str, list[str]] = {}
|
|
290
|
+
documents: dict[str, object] = {}
|
|
291
|
+
for issuer in issuers:
|
|
292
|
+
records |= issuer.dns_records()
|
|
293
|
+
# Callables so that key rotation on the fake issuer is visible.
|
|
294
|
+
documents[issuer.metadata_url] = lambda i=issuer: i.metadata
|
|
295
|
+
documents[issuer.jwks_uri] = lambda i=issuer: i.jwks
|
|
296
|
+
return records, documents
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def make_verifier(
|
|
300
|
+
*issuers: FakeIssuer,
|
|
301
|
+
audience: str,
|
|
302
|
+
profile: Profile = DEFAULT_PROFILE,
|
|
303
|
+
clock: FixedClock | None = None,
|
|
304
|
+
cache: Cache | None = None,
|
|
305
|
+
replay_guard: ReplayGuard | None = None,
|
|
306
|
+
observer: Observer | None = None,
|
|
307
|
+
) -> Verifier:
|
|
308
|
+
"""A :class:`Verifier` wired to in-memory DNS/HTTP serving ``issuers``."""
|
|
309
|
+
records, documents = _live(issuers)
|
|
310
|
+
clock = clock or (issuers[0].clock if issuers else FixedClock())
|
|
311
|
+
return Verifier(
|
|
312
|
+
audience=audience,
|
|
313
|
+
resolver=InMemoryDns(records),
|
|
314
|
+
fetcher=InMemoryHttp(documents),
|
|
315
|
+
profile=profile,
|
|
316
|
+
clock=clock,
|
|
317
|
+
cache=cache,
|
|
318
|
+
replay_guard=replay_guard,
|
|
319
|
+
observer=observer,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def make_async_verifier(
|
|
324
|
+
*issuers: FakeIssuer,
|
|
325
|
+
audience: str,
|
|
326
|
+
profile: Profile = DEFAULT_PROFILE,
|
|
327
|
+
clock: FixedClock | None = None,
|
|
328
|
+
cache: Cache | AsyncCache | None = None,
|
|
329
|
+
replay_guard: ReplayGuard | AsyncReplayGuard | None = None,
|
|
330
|
+
observer: Observer | None = None,
|
|
331
|
+
) -> AsyncVerifier:
|
|
332
|
+
records, documents = _live(issuers)
|
|
333
|
+
clock = clock or (issuers[0].clock if issuers else FixedClock())
|
|
334
|
+
return AsyncVerifier(
|
|
335
|
+
audience=audience,
|
|
336
|
+
resolver=AsyncInMemoryDns(records),
|
|
337
|
+
fetcher=AsyncInMemoryHttp(documents),
|
|
338
|
+
profile=profile,
|
|
339
|
+
clock=clock,
|
|
340
|
+
cache=cache,
|
|
341
|
+
replay_guard=replay_guard,
|
|
342
|
+
observer=observer,
|
|
343
|
+
)
|
pyevp/token.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Presentation token codec: ``<EVT>~[<disclosure>~...]<KB-JWT>``.
|
|
2
|
+
|
|
3
|
+
Parsing only splits and decodes; it performs no signature checks. The build
|
|
4
|
+
helpers are used by :mod:`pyevp.testing` and are kept here so a future issuer
|
|
5
|
+
implementation can reuse them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pyevp import _jose
|
|
16
|
+
from pyevp.errors import ErrorCode, TokenError
|
|
17
|
+
from pyevp.types import JSONObject
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CompactJWT",
|
|
21
|
+
"ParsedToken",
|
|
22
|
+
"build_kb",
|
|
23
|
+
"compute_sd_hash",
|
|
24
|
+
"parse_token",
|
|
25
|
+
"sign_jwt",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Generous upper bound to avoid spending effort on garbage input.
|
|
29
|
+
MAX_TOKEN_LENGTH = 16 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class CompactJWT:
|
|
34
|
+
"""A compact JWS whose header and payload were decoded but not verified."""
|
|
35
|
+
|
|
36
|
+
compact: str
|
|
37
|
+
header: JSONObject
|
|
38
|
+
claims: JSONObject
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def decode(cls, compact: str, *, what: str) -> CompactJWT:
|
|
42
|
+
parts = compact.split(".")
|
|
43
|
+
if len(parts) != 3 or not all(parts):
|
|
44
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, f"{what} is not a compact JWS")
|
|
45
|
+
try:
|
|
46
|
+
header = _jose.decode_json_segment(parts[0])
|
|
47
|
+
claims = _jose.decode_json_segment(parts[1])
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, f"{what}: {exc}") from exc
|
|
50
|
+
# The claims above are read from a base64url payload; an unencoded one (RFC 7797)
|
|
51
|
+
# would be signed as different bytes. RFC 7797 section 7 forbids it in JWTs.
|
|
52
|
+
if header.get("b64", True) is not True:
|
|
53
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, f"{what} has an unencoded payload")
|
|
54
|
+
return cls(compact=compact, header=header, claims=claims)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def alg(self) -> str | None:
|
|
58
|
+
value = self.header.get("alg")
|
|
59
|
+
return value if isinstance(value, str) else None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def typ(self) -> str | None:
|
|
63
|
+
value = self.header.get("typ")
|
|
64
|
+
return value if isinstance(value, str) else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class ParsedToken:
|
|
69
|
+
raw: str
|
|
70
|
+
evt: CompactJWT
|
|
71
|
+
disclosures: tuple[str, ...]
|
|
72
|
+
kb: CompactJWT
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def sd_hash_input(self) -> str:
|
|
76
|
+
"""Everything before the KB-JWT, including the trailing ``~``."""
|
|
77
|
+
return self.raw[: len(self.raw) - len(self.kb.compact)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_token(token: str, *, allow_disclosures: bool = False) -> ParsedToken:
|
|
81
|
+
token = token.strip()
|
|
82
|
+
if not token or len(token) > MAX_TOKEN_LENGTH or not token.isascii():
|
|
83
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, "token is empty, too long or not ASCII")
|
|
84
|
+
parts = token.split("~")
|
|
85
|
+
if len(parts) < 2:
|
|
86
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, "token has no key-binding JWT")
|
|
87
|
+
evt_part, *disclosures, kb_part = parts
|
|
88
|
+
if disclosures and not allow_disclosures:
|
|
89
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, "token contains unexpected disclosures")
|
|
90
|
+
if any(not d for d in disclosures):
|
|
91
|
+
raise TokenError(ErrorCode.MALFORMED_TOKEN, "token contains an empty disclosure")
|
|
92
|
+
return ParsedToken(
|
|
93
|
+
raw=token,
|
|
94
|
+
evt=CompactJWT.decode(evt_part, what="EVT"),
|
|
95
|
+
disclosures=tuple(disclosures),
|
|
96
|
+
kb=CompactJWT.decode(kb_part, what="KB-JWT"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compute_sd_hash(sd_hash_input: str) -> str:
|
|
101
|
+
return _jose.b64url_encode(hashlib.sha256(sd_hash_input.encode("ascii")).digest())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sign_jwt(header: JSONObject, claims: JSONObject, private_key: Any) -> str:
|
|
105
|
+
"""Sign ``claims`` as a compact JWS. ``private_key`` is a joserfc key object."""
|
|
106
|
+
return _jose.sign_compact(header, claims, private_key)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_kb(
|
|
110
|
+
evt: str,
|
|
111
|
+
*,
|
|
112
|
+
private_key: Any,
|
|
113
|
+
alg: str,
|
|
114
|
+
audience: str,
|
|
115
|
+
nonce: str,
|
|
116
|
+
issued_at: datetime,
|
|
117
|
+
typ: str = "kb+jwt",
|
|
118
|
+
disclosures: tuple[str, ...] = (),
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Append a key-binding JWT to an EVT, producing the presentation token.
|
|
121
|
+
|
|
122
|
+
``evt`` may carry the SD-JWT ``~`` that issuers append (``issuance_token``) or not.
|
|
123
|
+
"""
|
|
124
|
+
prefix = "~".join((evt.removesuffix("~"), *disclosures)) + "~"
|
|
125
|
+
kb = sign_jwt(
|
|
126
|
+
{"alg": alg, "typ": typ},
|
|
127
|
+
{
|
|
128
|
+
"aud": audience,
|
|
129
|
+
"nonce": nonce,
|
|
130
|
+
"iat": int(issued_at.timestamp()),
|
|
131
|
+
"sd_hash": compute_sd_hash(prefix),
|
|
132
|
+
},
|
|
133
|
+
private_key,
|
|
134
|
+
)
|
|
135
|
+
return prefix + kb
|
pyevp/types.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Value types shared across the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, TypeAlias
|
|
9
|
+
|
|
10
|
+
__all__ = ["IssuerMetadata", "JSONObject", "VerifiedEmail"]
|
|
11
|
+
|
|
12
|
+
# TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
|
|
13
|
+
JSONObject: TypeAlias = Mapping[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class IssuerMetadata:
|
|
18
|
+
issuer: str
|
|
19
|
+
issuance_endpoint: str
|
|
20
|
+
jwks_uri: str
|
|
21
|
+
signing_alg_values_supported: tuple[str, ...] | None
|
|
22
|
+
raw: JSONObject
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class VerifiedEmail:
|
|
27
|
+
"""The result of a successful verification."""
|
|
28
|
+
|
|
29
|
+
email: str
|
|
30
|
+
"""The address as asserted by the issuer (not normalised)."""
|
|
31
|
+
issuer: str
|
|
32
|
+
"""Canonical issuer origin, e.g. ``https://accounts.google.com``."""
|
|
33
|
+
issued_at: datetime
|
|
34
|
+
expires_at: datetime | None
|
|
35
|
+
is_private_email: bool
|
|
36
|
+
claims: JSONObject
|
|
37
|
+
"""All issuer-signed claims, for forward compatibility."""
|