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/core.py ADDED
@@ -0,0 +1,321 @@
1
+ """Sans-I/O verification core.
2
+
3
+ :func:`verification_steps` is a generator that yields :data:`Effect` requests
4
+ (DNS / HTTPS lookups) and receives their results via ``send``. Drivers in
5
+ :mod:`pyevp.verifier` run it synchronously or asynchronously; tests can drive it
6
+ by hand. Everything else in this module is a pure function.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import math
14
+ from collections.abc import Generator, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime
17
+ from typing import Any, Literal, TypeAlias
18
+
19
+ from pyevp import _jose, discovery
20
+ from pyevp.errors import DiscoveryError, ErrorCode, PolicyError, TokenError
21
+ from pyevp.profile import Profile
22
+ from pyevp.token import ParsedToken, compute_sd_hash, parse_token
23
+ from pyevp.types import JSONObject, VerifiedEmail
24
+
25
+ __all__ = [
26
+ "Effect",
27
+ "FetchJson",
28
+ "MarkUsed",
29
+ "ResolveTxt",
30
+ "Steps",
31
+ "check_email",
32
+ "precheck_evt",
33
+ "replay_key",
34
+ "verification_steps",
35
+ "verify_evt_signature",
36
+ "verify_kb",
37
+ ]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ResolveTxt:
42
+ """Request the TXT records of ``name``. Reply with ``list[str]`` (empty if none)."""
43
+
44
+ name: str
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class FetchJson:
49
+ """Request the JSON document at ``url``. Reply with the decoded JSON value.
50
+
51
+ ``refresh`` asks the driver to bypass its cache (e.g. after key rotation).
52
+ """
53
+
54
+ url: str
55
+ kind: Literal["metadata", "jwks"]
56
+ refresh: bool = False
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class MarkUsed:
61
+ """Record that a token has been accepted. Reply ``True`` if it was not seen before.
62
+
63
+ ``key`` only needs remembering until ``expires_at``: from that instant on, the
64
+ token fails the freshness checks anyway. Freshness is judged once, before any
65
+ I/O, so a driver must reject the token (``TOKEN_EXPIRED``) if its clock has
66
+ reached ``expires_at`` after marking: the record may already be gone, and a
67
+ replay would not find it.
68
+ """
69
+
70
+ key: str
71
+ expires_at: datetime
72
+
73
+
74
+ # TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
75
+ Effect: TypeAlias = ResolveTxt | FetchJson | MarkUsed
76
+ Steps: TypeAlias = Generator[Effect, Any, VerifiedEmail]
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class _EVTClaims:
81
+ email: str
82
+ claimed_issuer: str
83
+ issued_at: datetime
84
+ expires_at: datetime | None
85
+ cnf_jwk: JSONObject
86
+
87
+
88
+ def _numeric_date(claims: JSONObject, name: str, what: str) -> datetime | None:
89
+ value = claims.get(name)
90
+ if value is None:
91
+ return None
92
+ if isinstance(value, bool) or not isinstance(value, int | float):
93
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, f"{what} {name} is not a NumericDate")
94
+ try:
95
+ if not math.isfinite(value):
96
+ raise ValueError(value)
97
+ return datetime.fromtimestamp(value, UTC)
98
+ except (OverflowError, OSError, ValueError) as exc:
99
+ raise TokenError(
100
+ ErrorCode.MALFORMED_TOKEN, f"{what} {name} is not a representable NumericDate"
101
+ ) from exc
102
+
103
+
104
+ def _check_freshness(iat: datetime, now: datetime, profile: Profile, what: str) -> None:
105
+ if iat > now + profile.clock_skew:
106
+ raise TokenError(ErrorCode.TOKEN_NOT_YET_VALID, f"{what} iat is in the future")
107
+ # Expired from iat + max_token_age + clock_skew on, the instant replay records may be
108
+ # dropped (see MarkUsed.expires_at).
109
+ if now - iat >= profile.max_token_age + profile.clock_skew:
110
+ raise TokenError(ErrorCode.TOKEN_EXPIRED, f"{what} is too old")
111
+
112
+
113
+ def _check_header(
114
+ header_alg: str | None, typ: str | None, algs: frozenset[str], types: frozenset[str], what: str
115
+ ) -> str:
116
+ if header_alg is None or header_alg in _jose.FORBIDDEN_ALGORITHMS or header_alg not in algs:
117
+ raise TokenError(ErrorCode.UNSUPPORTED_ALG, f"{what} alg {header_alg!r} is not accepted")
118
+ if typ not in types:
119
+ raise TokenError(ErrorCode.BAD_TYPE, f"{what} typ {typ!r} is not accepted")
120
+ return header_alg
121
+
122
+
123
+ def precheck_evt(token: ParsedToken, *, now: datetime, profile: Profile) -> _EVTClaims:
124
+ """Check everything about the EVT that does not need the issuer's keys."""
125
+ evt = token.evt
126
+ _check_header(evt.alg, evt.typ, profile.evt_algorithms, profile.evt_types, "EVT")
127
+ kid = evt.header.get("kid")
128
+ if kid is not None and not isinstance(kid, str):
129
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT kid is not a string")
130
+ if profile.require_kid and not kid:
131
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT has no kid")
132
+
133
+ claims = evt.claims
134
+ email, iss = claims.get("email"), claims.get("iss")
135
+ if not isinstance(email, str) or "@" not in email:
136
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT email claim is missing or invalid")
137
+ if not isinstance(iss, str):
138
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT iss claim is missing")
139
+ if claims.get("email_verified") is not True:
140
+ raise PolicyError(ErrorCode.EMAIL_NOT_VERIFIED, "EVT does not assert email_verified")
141
+
142
+ iat = _numeric_date(claims, "iat", "EVT")
143
+ if iat is None:
144
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT iat claim is missing")
145
+ _check_freshness(iat, now, profile, "EVT")
146
+ exp = _numeric_date(claims, "exp", "EVT")
147
+ if exp is None and profile.require_exp:
148
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT exp claim is missing")
149
+ # Written so that an ``exp`` near datetime.max cannot overflow.
150
+ if exp is not None and now - profile.clock_skew > exp:
151
+ raise TokenError(ErrorCode.TOKEN_EXPIRED, "EVT has expired")
152
+
153
+ cnf = claims.get("cnf")
154
+ jwk = cnf.get("jwk") if isinstance(cnf, dict) else None
155
+ if not isinstance(jwk, dict) or not _jose.is_public_jwk(jwk):
156
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT cnf.jwk is missing or not a public key")
157
+
158
+ return _EVTClaims(email=email, claimed_issuer=iss, issued_at=iat, expires_at=exp, cnf_jwk=jwk)
159
+
160
+
161
+ def verify_kb(
162
+ token: ParsedToken,
163
+ *,
164
+ cnf_jwk: JSONObject,
165
+ audience: str,
166
+ nonce: str,
167
+ now: datetime,
168
+ profile: Profile,
169
+ ) -> datetime:
170
+ """Verify the key-binding JWT against the holder key bound in the EVT.
171
+
172
+ Returns the KB-JWT's ``iat``.
173
+ """
174
+ kb = token.kb
175
+ alg = _check_header(kb.alg, kb.typ, profile.kb_algorithms, profile.kb_types, "KB-JWT")
176
+ cnf_alg = cnf_jwk.get("alg")
177
+ if cnf_alg is None and profile.require_cnf_alg:
178
+ raise TokenError(ErrorCode.UNSUPPORTED_ALG, "cnf.jwk has no alg")
179
+ if cnf_alg is not None and (
180
+ cnf_alg != alg if profile.require_cnf_alg else not _jose.algorithms_compatible(cnf_alg, alg)
181
+ ):
182
+ raise TokenError(ErrorCode.UNSUPPORTED_ALG, "KB-JWT alg does not match cnf.jwk alg")
183
+ if not _jose.key_supports(alg, cnf_jwk):
184
+ raise TokenError(ErrorCode.UNSUPPORTED_ALG, f"cnf.jwk cannot verify KB-JWT alg {alg}")
185
+ if not _jose.verify_compact(kb.compact, cnf_jwk, alg):
186
+ raise TokenError(ErrorCode.KB_SIGNATURE_INVALID, "KB-JWT signature is invalid")
187
+
188
+ claims = kb.claims
189
+ if claims.get("aud") != audience:
190
+ raise TokenError(ErrorCode.AUDIENCE_MISMATCH, "KB-JWT aud does not match this origin")
191
+ presented = claims.get("nonce")
192
+ if not isinstance(presented, str) or not hmac.compare_digest(
193
+ presented.encode(), nonce.encode()
194
+ ):
195
+ raise TokenError(ErrorCode.NONCE_MISMATCH, "KB-JWT nonce does not match")
196
+ iat = _numeric_date(claims, "iat", "KB-JWT")
197
+ if iat is None:
198
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "KB-JWT iat claim is missing")
199
+ _check_freshness(iat, now, profile, "KB-JWT")
200
+ sd_hash = claims.get("sd_hash")
201
+ if not isinstance(sd_hash, str) or not hmac.compare_digest(
202
+ sd_hash.encode(), compute_sd_hash(token.sd_hash_input).encode()
203
+ ):
204
+ raise TokenError(ErrorCode.SD_HASH_MISMATCH, "KB-JWT sd_hash does not match the EVT")
205
+ return iat
206
+
207
+
208
+ def verify_evt_signature(token: ParsedToken, keys: Sequence[JSONObject], profile: Profile) -> None:
209
+ """Verify the EVT signature against an issuer JWK Set.
210
+
211
+ Raises ``KEY_NOT_FOUND`` when no key could even be tried, so the caller may
212
+ refresh the key set; ``EVT_SIGNATURE_INVALID`` otherwise.
213
+ """
214
+ alg = token.evt.alg
215
+ assert alg is not None # guaranteed by precheck_evt
216
+ kid = token.evt.header.get("kid") or None
217
+ candidates = [
218
+ k for k in keys if (kid is None or k.get("kid") == kid) and _jose.key_supports(alg, k)
219
+ ]
220
+ if not candidates:
221
+ raise DiscoveryError(ErrorCode.KEY_NOT_FOUND, f"no issuer key for kid={kid!r} alg={alg}")
222
+ if not any(_jose.verify_compact(token.evt.compact, k, alg) for k in candidates):
223
+ raise TokenError(ErrorCode.EVT_SIGNATURE_INVALID, "EVT signature is invalid")
224
+
225
+
226
+ def check_email(asserted: str, submitted: str | None, profile: Profile) -> None:
227
+ if submitted is not None and not profile.emails_match(asserted, submitted.strip()):
228
+ raise PolicyError(ErrorCode.EMAIL_MISMATCH, "token email does not match submitted email")
229
+
230
+
231
+ def _signing_alg_advertised(alg: str, advertised: tuple[str, ...] | None) -> bool:
232
+ if advertised is None:
233
+ return True
234
+ return any(_jose.algorithms_compatible(alg, a) for a in advertised)
235
+
236
+
237
+ def replay_key(token: str | ParsedToken) -> str:
238
+ """Stable identifier of a presentation for replay detection.
239
+
240
+ It is derived from the KB-JWT signing input (header and payload), not from the
241
+ whole token: signatures can be re-encoded without the holder key (ECDSA ``s``
242
+ → ``n - s``, non-canonical base64url), while the signing input cannot. The
243
+ payload binds the nonce, audience, ``iat`` and, through ``sd_hash``, the EVT.
244
+
245
+ A raw token is parsed first and raises :class:`~pyevp.TokenError` if malformed.
246
+ """
247
+ if isinstance(token, str):
248
+ token = parse_token(token, allow_disclosures=True)
249
+ signing_input = token.kb.compact.rpartition(".")[0]
250
+ return _jose.b64url_encode(hashlib.sha256(signing_input.encode("ascii")).digest())
251
+
252
+
253
+ def verification_steps(
254
+ token: str,
255
+ *,
256
+ audience: str,
257
+ nonce: str,
258
+ now: datetime,
259
+ profile: Profile,
260
+ email: str | None,
261
+ replay_protection: bool = False,
262
+ ) -> Steps:
263
+ """Full RP verification. Yields effects; returns :class:`VerifiedEmail`.
264
+
265
+ Order matters: everything that can be checked offline (including the
266
+ key-binding signature) is checked before any network effect is requested,
267
+ and the only hosts ever contacted are derived from DNS, never from the token.
268
+ With ``replay_protection`` the token is marked as used once everything else
269
+ has passed, so that garbage tokens cannot fill the replay store.
270
+ """
271
+ parsed = parse_token(token, allow_disclosures=profile.allow_disclosures)
272
+ evt = precheck_evt(parsed, now=now, profile=profile)
273
+ kb_issued_at = verify_kb(
274
+ parsed, cnf_jwk=evt.cnf_jwk, audience=audience, nonce=nonce, now=now, profile=profile
275
+ )
276
+ check_email(evt.email, email, profile)
277
+
278
+ try:
279
+ txt_name = discovery.txt_name_for(evt.email, profile)
280
+ except (ValueError, UnicodeError) as exc:
281
+ raise TokenError(ErrorCode.MALFORMED_TOKEN, "EVT email domain is invalid") from exc
282
+ records = yield ResolveTxt(txt_name)
283
+ issuer = discovery.parse_txt_records(records)
284
+ if discovery.canonical_issuer(evt.claimed_issuer, profile.issuer_format) != issuer:
285
+ raise DiscoveryError(
286
+ ErrorCode.ISSUER_MISMATCH,
287
+ f"EVT iss {evt.claimed_issuer!r} is not the issuer delegated by DNS ({issuer})",
288
+ )
289
+
290
+ metadata = discovery.validate_metadata(
291
+ (yield FetchJson(discovery.metadata_url(issuer, profile), "metadata")), issuer
292
+ )
293
+ alg = parsed.evt.alg
294
+ assert alg is not None
295
+ if not _signing_alg_advertised(alg, metadata.signing_alg_values_supported):
296
+ raise TokenError(ErrorCode.UNSUPPORTED_ALG, f"issuer does not advertise alg {alg}")
297
+
298
+ keys = discovery.validate_jwks((yield FetchJson(metadata.jwks_uri, "jwks")))
299
+ try:
300
+ verify_evt_signature(parsed, keys, profile)
301
+ except (DiscoveryError, TokenError) as exc:
302
+ if exc.code not in (ErrorCode.KEY_NOT_FOUND, ErrorCode.EVT_SIGNATURE_INVALID):
303
+ raise
304
+ # Possibly a key rotation; the driver rate-limits forced refreshes.
305
+ keys = discovery.validate_jwks((yield FetchJson(metadata.jwks_uri, "jwks", refresh=True)))
306
+ verify_evt_signature(parsed, keys, profile)
307
+
308
+ if replay_protection:
309
+ expires_at = kb_issued_at + profile.max_token_age + profile.clock_skew
310
+ if not (yield MarkUsed(replay_key(parsed), expires_at)):
311
+ raise TokenError(ErrorCode.TOKEN_REPLAYED, "token has already been used")
312
+
313
+ private = parsed.evt.claims.get("is_private_email")
314
+ return VerifiedEmail(
315
+ email=evt.email,
316
+ issuer=issuer,
317
+ issued_at=evt.issued_at,
318
+ expires_at=evt.expires_at,
319
+ is_private_email=private is True,
320
+ claims=parsed.evt.claims,
321
+ )
pyevp/diagnostics.py ADDED
@@ -0,0 +1,182 @@
1
+ """Issuer diagnostics: what a relying party sees when it discovers a domain's issuer.
2
+
3
+ Used by ``pyevp discover``, by the weekly drift check, and by issuer operators who
4
+ want to check their DNS record, metadata and keys against a profile.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Generator
10
+ from dataclasses import dataclass, field, replace
11
+ from typing import Any, TypeAlias
12
+
13
+ from pyevp import _jose, discovery
14
+ from pyevp.core import Effect, FetchJson, ResolveTxt, _signing_alg_advertised
15
+ from pyevp.errors import DiscoveryError, EVPError
16
+ from pyevp.ports import AsyncJsonFetcher, AsyncTxtResolver, JsonFetcher, TxtResolver
17
+ from pyevp.profile import DEFAULT_PROFILE, Profile
18
+ from pyevp.types import IssuerMetadata, JSONObject
19
+ from pyevp.verifier import _unreachable
20
+
21
+ __all__ = ["IssuerReport", "KeySummary", "adiscover", "discover", "discovery_steps"]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class KeySummary:
26
+ kty: str | None
27
+ crv: str | None
28
+ alg: str | None
29
+ kid: str | None
30
+ use: str | None
31
+
32
+ @classmethod
33
+ def of(cls, jwk: JSONObject) -> KeySummary:
34
+ def text(name: str) -> str | None:
35
+ value = jwk.get(name)
36
+ return value if isinstance(value, str) else None
37
+
38
+ return cls(text("kty"), text("crv"), text("alg"), text("kid"), text("use"))
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class IssuerReport:
43
+ domain: str
44
+ profile: str
45
+ dns_name: str
46
+ records: tuple[str, ...] = ()
47
+ issuer: str | None = None
48
+ metadata: IssuerMetadata | None = None
49
+ keys: tuple[KeySummary, ...] = ()
50
+ problems: tuple[str, ...] = field(default=())
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ return not self.problems
55
+
56
+
57
+ # TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
58
+ ReportSteps: TypeAlias = Generator[Effect, Any, IssuerReport]
59
+
60
+
61
+ def _normalize_domain(target: str) -> str:
62
+ if "@" in target:
63
+ return discovery.email_domain(target)
64
+ return discovery.email_domain(f"user@{target}")
65
+
66
+
67
+ def discovery_steps(target: str, profile: Profile = DEFAULT_PROFILE) -> ReportSteps:
68
+ """Sans-I/O issuer check for an email address or domain.
69
+
70
+ Content problems are collected in :attr:`IssuerReport.problems`; transport
71
+ failures surface as :class:`~pyevp.DiscoveryError` from the driver.
72
+ """
73
+ domain = _normalize_domain(target)
74
+ dns_name = f"{profile.dns_label}.{domain}"
75
+ report = IssuerReport(domain=domain, profile=profile.name, dns_name=dns_name)
76
+
77
+ def done(problem: str, **changes: Any) -> IssuerReport:
78
+ return replace(report, problems=(problem,), **changes)
79
+
80
+ records = tuple((yield ResolveTxt(dns_name)))
81
+ try:
82
+ issuer = discovery.parse_txt_records(records)
83
+ except DiscoveryError as exc:
84
+ return done(exc.args[0], records=records)
85
+ report = replace(report, records=records, issuer=issuer)
86
+
87
+ try:
88
+ metadata = discovery.validate_metadata(
89
+ (yield FetchJson(discovery.metadata_url(issuer, profile), "metadata")), issuer
90
+ )
91
+ except DiscoveryError as exc:
92
+ return done(f"metadata: {exc.args[0]}")
93
+ report = replace(report, metadata=metadata)
94
+
95
+ try:
96
+ jwks = discovery.validate_jwks((yield FetchJson(metadata.jwks_uri, "jwks")))
97
+ except DiscoveryError as exc:
98
+ return done(f"JWKS: {exc.args[0]}")
99
+
100
+ problems: list[str] = []
101
+ advertised = metadata.signing_alg_values_supported
102
+ # The profile's algorithms the verifier would accept from this issuer. An absent
103
+ # list does not restrict them, and "EdDSA" covers "Ed25519" and vice versa.
104
+ accepted = [a for a in sorted(profile.evt_algorithms) if _signing_alg_advertised(a, advertised)]
105
+ if advertised == ():
106
+ # Unlike an absent list, an empty one makes every token fail verification.
107
+ problems.append("issuer advertises an empty signing_alg_values_supported")
108
+ elif advertised is not None and not accepted:
109
+ problems.append(
110
+ f"issuer signs with {', '.join(advertised)}; profile {profile.name} accepts "
111
+ f"{', '.join(sorted(profile.evt_algorithms))}"
112
+ )
113
+ fitting = [k for k in jwks if any(_jose.key_supports(a, k) for a in accepted)]
114
+ usable = [k for k in fitting if _jose.import_public(k) is not None]
115
+ if accepted and not usable:
116
+ malformed = len(fitting) - len(usable)
117
+ hint = f" ({malformed} malformed)" if malformed else ""
118
+ problems.append(f"no key in {metadata.jwks_uri} can verify {', '.join(accepted)}{hint}")
119
+ if profile.require_kid and any(not k.get("kid") for k in usable):
120
+ problems.append(f"profile {profile.name} requires kid, but some keys have none")
121
+
122
+ return replace(report, keys=tuple(KeySummary.of(k) for k in jwks), problems=tuple(problems))
123
+
124
+
125
+ def discover(
126
+ target: str,
127
+ *,
128
+ resolver: TxtResolver,
129
+ fetcher: JsonFetcher,
130
+ profile: Profile = DEFAULT_PROFILE,
131
+ ) -> IssuerReport:
132
+ """Run :func:`discovery_steps` with synchronous ports (no caching)."""
133
+ steps = discovery_steps(target, profile)
134
+ try:
135
+ effect = next(steps)
136
+ while True:
137
+ if not isinstance(effect, ResolveTxt | FetchJson):
138
+ raise TypeError(f"unexpected effect {effect!r}")
139
+ try:
140
+ if isinstance(effect, ResolveTxt):
141
+ result: object = resolver.resolve_txt(effect.name)
142
+ else:
143
+ result = fetcher.fetch_json(effect.url)
144
+ except EVPError:
145
+ raise
146
+ except Exception as exc:
147
+ raise _unreachable(effect, exc) from exc
148
+ effect = steps.send(result)
149
+ except StopIteration as stop:
150
+ return stop.value
151
+ finally:
152
+ steps.close()
153
+
154
+
155
+ async def adiscover(
156
+ target: str,
157
+ *,
158
+ resolver: AsyncTxtResolver,
159
+ fetcher: AsyncJsonFetcher,
160
+ profile: Profile = DEFAULT_PROFILE,
161
+ ) -> IssuerReport:
162
+ """Async counterpart of :func:`discover`."""
163
+ steps = discovery_steps(target, profile)
164
+ try:
165
+ effect = next(steps)
166
+ while True:
167
+ if not isinstance(effect, ResolveTxt | FetchJson):
168
+ raise TypeError(f"unexpected effect {effect!r}")
169
+ try:
170
+ if isinstance(effect, ResolveTxt):
171
+ result: object = await resolver.resolve_txt(effect.name)
172
+ else:
173
+ result = await fetcher.fetch_json(effect.url)
174
+ except EVPError:
175
+ raise
176
+ except Exception as exc:
177
+ raise _unreachable(effect, exc) from exc
178
+ effect = steps.send(result)
179
+ except StopIteration as stop:
180
+ return stop.value
181
+ finally:
182
+ steps.close()
pyevp/discovery.py ADDED
@@ -0,0 +1,144 @@
1
+ """Issuer discovery: pure functions, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ from pyevp import _jose
11
+ from pyevp._email import email_domain
12
+ from pyevp.errors import DiscoveryError, ErrorCode
13
+ from pyevp.profile import IssuerFormat, Profile
14
+ from pyevp.types import IssuerMetadata, JSONObject
15
+
16
+ __all__ = [
17
+ "canonical_issuer",
18
+ "email_domain",
19
+ "is_public_hostname",
20
+ "metadata_url",
21
+ "parse_txt_records",
22
+ "txt_name_for",
23
+ "validate_jwks",
24
+ "validate_metadata",
25
+ ]
26
+
27
+ _FORBIDDEN_HOST_CHARS = frozenset("/:@?#\\ \t\r\n")
28
+ # Special-use names (RFC 6761, RFC 6762, RFC 8375) and the name ICANN reserved for
29
+ # private use; none of them can be a public issuer.
30
+ _PRIVATE_SUFFIXES = ("localhost", "local", "home.arpa", "internal")
31
+
32
+
33
+ def is_public_hostname(host: str) -> bool:
34
+ """Whether ``host`` may name a public issuer host.
35
+
36
+ Rejects IP literals, single-label names and special-use names such as
37
+ ``localhost``, so that a DNS record or metadata document chosen by an attacker
38
+ cannot point the verifier at the relying party's own network. The addresses a
39
+ public name resolves to are checked by the HTTP adapters before connecting.
40
+ """
41
+ name = host.removeprefix("[").removesuffix("]").rstrip(".").lower()
42
+ try:
43
+ ipaddress.ip_address(name)
44
+ except ValueError:
45
+ pass
46
+ else:
47
+ return False
48
+ if "." not in name:
49
+ return False
50
+ return not any(name == s or name.endswith("." + s) for s in _PRIVATE_SUFFIXES)
51
+
52
+
53
+ def txt_name_for(email: str, profile: Profile) -> str:
54
+ return f"{profile.dns_label}.{email_domain(email)}"
55
+
56
+
57
+ def canonical_issuer(value: str, accepted: IssuerFormat) -> str | None:
58
+ """Turn an issuer identifier into its ``https://host`` form.
59
+
60
+ Returns ``None`` when ``value`` is not acceptable under ``accepted``. The
61
+ host is not case-folded: the drafts require byte-for-byte comparison.
62
+ """
63
+ if value.startswith("https://"):
64
+ if accepted is IssuerFormat.HOST:
65
+ return None
66
+ host = value.removeprefix("https://")
67
+ else:
68
+ if accepted is IssuerFormat.ORIGIN:
69
+ return None
70
+ host = value
71
+ if not host or any(c in _FORBIDDEN_HOST_CHARS for c in host) or not host.isascii():
72
+ return None
73
+ if not is_public_hostname(host):
74
+ return None
75
+ return f"https://{host}"
76
+
77
+
78
+ def parse_txt_records(records: Sequence[str]) -> str:
79
+ """Extract the canonical issuer from the TXT records of the discovery name."""
80
+ values = [r.removeprefix("iss=").strip() for r in records if r.startswith("iss=")]
81
+ if len(values) != 1:
82
+ raise DiscoveryError(
83
+ ErrorCode.ISSUER_DISCOVERY_FAILED,
84
+ f"expected exactly one 'iss=' TXT record, found {len(values)}",
85
+ )
86
+ issuer = canonical_issuer(values[0], IssuerFormat.ANY)
87
+ if issuer is None:
88
+ raise DiscoveryError(ErrorCode.ISSUER_DISCOVERY_FAILED, "invalid 'iss=' TXT record")
89
+ return issuer
90
+
91
+
92
+ def metadata_url(issuer: str, profile: Profile) -> str:
93
+ return issuer + profile.metadata_path
94
+
95
+
96
+ def _require_https_url(value: Any, field: str) -> str:
97
+ if not isinstance(value, str):
98
+ raise DiscoveryError(ErrorCode.METADATA_INVALID, f"{field} is missing")
99
+ try:
100
+ parts = urlsplit(value)
101
+ ok = (
102
+ parts.scheme == "https"
103
+ and bool(parts.hostname)
104
+ and is_public_hostname(parts.hostname or "")
105
+ )
106
+ except ValueError: # e.g. "https://[": urlsplit itself rejects some inputs
107
+ ok = False
108
+ if not ok:
109
+ raise DiscoveryError(
110
+ ErrorCode.METADATA_INVALID, f"{field} is not an https URL on a public host"
111
+ )
112
+ return value
113
+
114
+
115
+ def validate_metadata(document: object, expected_issuer: str) -> IssuerMetadata:
116
+ if not isinstance(document, dict):
117
+ raise DiscoveryError(ErrorCode.METADATA_INVALID, "metadata is not a JSON object")
118
+ if document.get("issuer") != expected_issuer:
119
+ raise DiscoveryError(
120
+ ErrorCode.ISSUER_MISMATCH,
121
+ f"metadata issuer {document.get('issuer')!r} != {expected_issuer!r}",
122
+ )
123
+ algs = document.get("signing_alg_values_supported")
124
+ if algs is not None and not (isinstance(algs, list) and all(isinstance(a, str) for a in algs)):
125
+ raise DiscoveryError(ErrorCode.METADATA_INVALID, "bad signing_alg_values_supported")
126
+ return IssuerMetadata(
127
+ issuer=expected_issuer,
128
+ issuance_endpoint=_require_https_url(
129
+ document.get("issuance_endpoint"), "issuance_endpoint"
130
+ ),
131
+ jwks_uri=_require_https_url(document.get("jwks_uri"), "jwks_uri"),
132
+ signing_alg_values_supported=tuple(algs) if algs is not None else None,
133
+ raw=document,
134
+ )
135
+
136
+
137
+ def validate_jwks(document: object) -> tuple[JSONObject, ...]:
138
+ """Return the usable public keys of a JWK Set; unknown or private entries are dropped."""
139
+ if not isinstance(document, dict) or not isinstance(document.get("keys"), list):
140
+ raise DiscoveryError(ErrorCode.METADATA_INVALID, "JWKS is not a JWK Set")
141
+ keys = tuple(k for k in document["keys"] if isinstance(k, dict) and _jose.is_public_jwk(k))
142
+ if not keys:
143
+ raise DiscoveryError(ErrorCode.METADATA_INVALID, "JWKS contains no usable public keys")
144
+ return keys