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.
Files changed (50) hide show
  1. pyevp/__init__.py +47 -0
  2. pyevp/__main__.py +28 -0
  3. pyevp/_email.py +65 -0
  4. pyevp/_httpsig.py +284 -0
  5. pyevp/_jose.py +145 -0
  6. pyevp/_sf.py +406 -0
  7. pyevp/adapters/__init__.py +4 -0
  8. pyevp/adapters/_doh.py +92 -0
  9. pyevp/adapters/_fetch.py +86 -0
  10. pyevp/adapters/_http.py +38 -0
  11. pyevp/adapters/dnspython.py +79 -0
  12. pyevp/adapters/doh.py +130 -0
  13. pyevp/adapters/httpx.py +147 -0
  14. pyevp/adapters/urllib.py +170 -0
  15. pyevp/cache.py +83 -0
  16. pyevp/cli/__init__.py +348 -0
  17. pyevp/contrib/__init__.py +4 -0
  18. pyevp/contrib/django/__init__.py +306 -0
  19. pyevp/contrib/django/apps.py +17 -0
  20. pyevp/contrib/django/issuer.py +314 -0
  21. pyevp/contrib/django/migrations/0001_initial.py +17 -0
  22. pyevp/contrib/django/migrations/__init__.py +0 -0
  23. pyevp/contrib/django/models.py +14 -0
  24. pyevp/contrib/django/templatetags/__init__.py +0 -0
  25. pyevp/contrib/django/templatetags/pyevp.py +32 -0
  26. pyevp/core.py +321 -0
  27. pyevp/diagnostics.py +182 -0
  28. pyevp/discovery.py +144 -0
  29. pyevp/errors.py +80 -0
  30. pyevp/issuer/__init__.py +39 -0
  31. pyevp/issuer/core.py +413 -0
  32. pyevp/issuer/errors.py +99 -0
  33. pyevp/issuer/fedcm.py +44 -0
  34. pyevp/issuer/keys.py +140 -0
  35. pyevp/issuer/profile.py +96 -0
  36. pyevp/nonce.py +25 -0
  37. pyevp/observability.py +89 -0
  38. pyevp/ports.py +54 -0
  39. pyevp/profile.py +153 -0
  40. pyevp/py.typed +0 -0
  41. pyevp/replay.py +69 -0
  42. pyevp/testing.py +343 -0
  43. pyevp/token.py +135 -0
  44. pyevp/types.py +37 -0
  45. pyevp/verifier.py +486 -0
  46. pyevp-0.1.0.dist-info/METADATA +171 -0
  47. pyevp-0.1.0.dist-info/RECORD +50 -0
  48. pyevp-0.1.0.dist-info/WHEEL +4 -0
  49. pyevp-0.1.0.dist-info/entry_points.txt +3 -0
  50. 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."""