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/errors.py ADDED
@@ -0,0 +1,80 @@
1
+ """Exception hierarchy.
2
+
3
+ Every rejected token raises an :class:`EVPError` with an :class:`ErrorCode`, so that
4
+ web frameworks can map it to a form error or an HTTP status without parsing
5
+ messages. Failures of the application's own cache or replay store are not
6
+ ``EVPError``: they propagate unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import StrEnum
12
+
13
+ __all__ = [
14
+ "DiscoveryError",
15
+ "EVPError",
16
+ "ErrorCode",
17
+ "PolicyError",
18
+ "TokenError",
19
+ ]
20
+
21
+
22
+ class ErrorCode(StrEnum):
23
+ # token structure
24
+ MALFORMED_TOKEN = "malformed_token"
25
+ UNSUPPORTED_ALG = "unsupported_alg"
26
+ BAD_TYPE = "bad_type"
27
+ # key binding / freshness
28
+ AUDIENCE_MISMATCH = "audience_mismatch"
29
+ NONCE_MISMATCH = "nonce_mismatch"
30
+ TOKEN_EXPIRED = "token_expired"
31
+ TOKEN_NOT_YET_VALID = "token_not_yet_valid"
32
+ TOKEN_REPLAYED = "token_replayed"
33
+ SD_HASH_MISMATCH = "sd_hash_mismatch"
34
+ KB_SIGNATURE_INVALID = "kb_signature_invalid"
35
+ # issuer
36
+ ISSUER_DISCOVERY_FAILED = "issuer_discovery_failed"
37
+ ISSUER_UNREACHABLE = "issuer_unreachable"
38
+ ISSUER_MISMATCH = "issuer_mismatch"
39
+ METADATA_INVALID = "metadata_invalid"
40
+ KEY_NOT_FOUND = "key_not_found"
41
+ EVT_SIGNATURE_INVALID = "evt_signature_invalid"
42
+ # policy
43
+ EMAIL_NOT_VERIFIED = "email_not_verified"
44
+ EMAIL_MISMATCH = "email_mismatch"
45
+
46
+
47
+ class EVPError(Exception):
48
+ """Base class for verification failures: the token must not be trusted.
49
+
50
+ :class:`TokenError` and :class:`PolicyError` concern what was submitted;
51
+ :class:`DiscoveryError` concerns the issuer.
52
+ """
53
+
54
+ code: ErrorCode
55
+
56
+ def __init__(self, code: ErrorCode, message: str) -> None:
57
+ super().__init__(message)
58
+ self.code = code
59
+
60
+ def __str__(self) -> str:
61
+ return f"[{self.code}] {self.args[0]}"
62
+
63
+
64
+ class TokenError(EVPError):
65
+ """The presented token is malformed, stale, mis-bound or badly signed."""
66
+
67
+
68
+ class DiscoveryError(EVPError):
69
+ """The issuer could not be discovered or its metadata / keys are unusable.
70
+
71
+ ``ISSUER_UNREACHABLE`` indicates a transport failure that may be transient.
72
+ """
73
+
74
+
75
+ class PolicyError(EVPError):
76
+ """The token does not satisfy the relying party's policy (``email_mismatch``, ...).
77
+
78
+ Checked offline, before the issuer's signature, so it says nothing about whether
79
+ the token is authentic.
80
+ """
@@ -0,0 +1,39 @@
1
+ """Issuer side of the Email Verification Protocol.
2
+
3
+ Framework-neutral building blocks for running an issuer for your own email
4
+ domains: validate the browser's signed issuance request, mint EVTs, and produce
5
+ the metadata, JWKS and DNS records to publish. See :class:`Issuer`.
6
+ """
7
+
8
+ from pyevp.issuer.core import (
9
+ IssuanceEvent,
10
+ IssuanceObserver,
11
+ IssuanceRequest,
12
+ Issuer,
13
+ is_valid_email,
14
+ )
15
+ from pyevp.issuer.errors import IssuanceError, IssuanceErrorCode, IssuanceResponse
16
+ from pyevp.issuer.fedcm import FEDCM_FETCH_DEST, accounts_document, web_identity_document
17
+ from pyevp.issuer.keys import SIGNING_ALGORITHMS, Signer, SigningKey, public_jwk
18
+ from pyevp.issuer.profile import DEFAULT_ISSUANCE_PROFILE, ISSUANCE_PROFILES, IssuanceProfile
19
+
20
+ __all__ = [
21
+ "DEFAULT_ISSUANCE_PROFILE",
22
+ "FEDCM_FETCH_DEST",
23
+ "ISSUANCE_PROFILES",
24
+ "SIGNING_ALGORITHMS",
25
+ "IssuanceError",
26
+ "IssuanceErrorCode",
27
+ "IssuanceEvent",
28
+ "IssuanceObserver",
29
+ "IssuanceProfile",
30
+ "IssuanceRequest",
31
+ "IssuanceResponse",
32
+ "Issuer",
33
+ "Signer",
34
+ "SigningKey",
35
+ "accounts_document",
36
+ "is_valid_email",
37
+ "public_jwk",
38
+ "web_identity_document",
39
+ ]
pyevp/issuer/core.py ADDED
@@ -0,0 +1,413 @@
1
+ """The issuer: validate a browser's issuance request, mint an EVT, publish metadata.
2
+
3
+ Authenticating the user is the application's job and happens between
4
+ :meth:`Issuer.parse_request` and :meth:`Issuer.issue`::
5
+
6
+ try:
7
+ request = issuer.parse_request(method=..., headers=..., body=...)
8
+ if not session_user_controls(request.email): # your code, from cookies
9
+ raise IssuanceError.authentication_required()
10
+ response = issuer.success_response(issuer.issue(request))
11
+ except IssuanceError as exc:
12
+ response = exc.to_response()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import inspect
19
+ import json
20
+ import logging
21
+ import re
22
+ from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timedelta
25
+ from typing import Any, TypeAlias, cast
26
+ from urllib.parse import urlsplit
27
+
28
+ import idna
29
+
30
+ from pyevp import _httpsig, _jose, discovery
31
+ from pyevp._httpsig import Headers
32
+ from pyevp.issuer.errors import IssuanceError, IssuanceErrorCode, IssuanceResponse
33
+ from pyevp.issuer.keys import SIGNING_ALGORITHMS, Signer, public_jwk
34
+ from pyevp.issuer.profile import DEFAULT_ISSUANCE_PROFILE, IssuanceProfile
35
+ from pyevp.ports import Clock, system_clock
36
+ from pyevp.profile import IssuerFormat
37
+ from pyevp.replay import AsyncReplayGuard, ReplayGuard
38
+
39
+ __all__ = [
40
+ "IssuanceEvent",
41
+ "IssuanceObserver",
42
+ "IssuanceRequest",
43
+ "Issuer",
44
+ "is_valid_email",
45
+ ]
46
+
47
+ _logger = logging.getLogger("pyevp")
48
+
49
+ # WHATWG HTML "valid email address".
50
+ _VALID_EMAIL = re.compile(
51
+ r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
52
+ r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
53
+ )
54
+ _MAX_BODY = 16 * 1024
55
+
56
+
57
+ def is_valid_email(value: str) -> bool:
58
+ """Whether ``value`` is a WHATWG HTML valid email address (ASCII only)."""
59
+ return value.isascii() and _VALID_EMAIL.fullmatch(value) is not None
60
+
61
+
62
+ def _email_domain(name: object) -> str:
63
+ """``name`` as a lowercase A-label; ``ValueError`` unless it can be an email domain."""
64
+ if not isinstance(name, str) or "@" in name or name.endswith(".."):
65
+ raise ValueError(f"not a valid email domain: {name!r}")
66
+ domain = discovery.email_domain(f"x@{name}")
67
+ try:
68
+ # Rejects malformed A-labels (xn--…) and names over DNS's length limits.
69
+ idna.encode(domain)
70
+ except idna.IDNAError as exc:
71
+ raise ValueError(f"not a valid email domain: {name!r}") from exc
72
+ if not is_valid_email(f"x@{domain}"):
73
+ raise ValueError(f"not a valid email domain: {name!r}")
74
+ return domain
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class IssuanceRequest:
79
+ """A request whose signature, freshness and body have been validated.
80
+
81
+ The user has *not* been authenticated yet.
82
+ """
83
+
84
+ email: str
85
+ """Exactly as the browser sent it; also what the EVT will assert."""
86
+ holder_jwk: Mapping[str, str]
87
+ """The browser's public key, bound into the EVT as ``cnf.jwk``."""
88
+ alg: str
89
+ created: datetime
90
+ signature: bytes = field(repr=False)
91
+ signature_base: bytes = field(repr=False)
92
+ """What ``signature`` covers; it identifies the request for the replay guard."""
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class IssuanceEvent:
97
+ ok: bool
98
+ stage: str
99
+ """``"request"`` (validation) or ``"issue"`` (an EVT was signed)."""
100
+ code: IssuanceErrorCode | None
101
+ email_domain: str | None
102
+
103
+
104
+ # TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
105
+ IssuanceObserver: TypeAlias = Callable[[IssuanceEvent], None]
106
+ """Receives one event per validated request and per issued EVT; must not block or raise."""
107
+
108
+
109
+ def _require_https_url(value: str, what: str) -> str:
110
+ url = urlsplit(value)
111
+ if url.scheme != "https" or not url.hostname or url.username or url.password:
112
+ raise ValueError(f"{what} must be an https URL, got {value!r}")
113
+ if url.fragment or url.query:
114
+ raise ValueError(f"{what} must not have a query or fragment")
115
+ return value
116
+
117
+
118
+ def _media_type(lines: Iterable[str]) -> str | None:
119
+ values = list(lines)
120
+ if len(values) != 1:
121
+ return None
122
+ return values[0].split(";", 1)[0].strip(" \t").lower()
123
+
124
+
125
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
126
+ out: dict[str, Any] = {}
127
+ for key, value in pairs:
128
+ if key in out:
129
+ raise ValueError(f"duplicate member {key!r}")
130
+ out[key] = value
131
+ return out
132
+
133
+
134
+ def _parse_body(body: bytes) -> dict[str, Any]:
135
+ if len(body) > _MAX_BODY:
136
+ raise ValueError("body too large")
137
+ try:
138
+ value = json.loads(body.decode("utf-8"), object_pairs_hook=_unique_object)
139
+ # Lone surrogates from JSON escapes, which nothing downstream expects.
140
+ json.dumps(value, ensure_ascii=False).encode()
141
+ except (ValueError, UnicodeError, RecursionError) as exc:
142
+ raise ValueError(f"body is not a JSON object: {exc}") from None
143
+ if not isinstance(value, dict):
144
+ raise ValueError("body is not a JSON object")
145
+ return value
146
+
147
+
148
+ class Issuer:
149
+ """Issues EVTs for the email domains this issuer is authoritative for.
150
+
151
+ :param issuer: the issuer identifier, ``https://`` + host (what DNS ``iss=`` names).
152
+ :param issuance_endpoint: the URL browsers POST to. The request signature is checked
153
+ against this URL, not the ``Host`` header, so it works behind a proxy.
154
+ :param jwks_uri: where :meth:`jwks_document` is served.
155
+ :param signer: the active signing key.
156
+ :param published_keys: further public JWKs to publish: the next key before a rotation,
157
+ and retired keys until every EVT they signed has expired at relying parties.
158
+ :param email_domains: domains EVTs may be issued for, as U-labels or A-labels. Requests
159
+ for any other domain are refused with ``authentication_required``. Pass a callable
160
+ returning the current domains when they change at runtime, for example when they live
161
+ in a database. It is called whenever the domains are needed, may return none, and
162
+ names that are not valid domains are skipped with a warning; in a collection they
163
+ raise ``ValueError``.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ *,
169
+ issuer: str,
170
+ issuance_endpoint: str,
171
+ jwks_uri: str,
172
+ signer: Signer,
173
+ email_domains: Collection[str] | Callable[[], Iterable[str]],
174
+ published_keys: Sequence[Mapping[str, Any]] = (),
175
+ signing_alg_values_supported: Sequence[str] = ("Ed25519", "ES256"),
176
+ profile: IssuanceProfile = DEFAULT_ISSUANCE_PROFILE,
177
+ replay_guard: ReplayGuard | AsyncReplayGuard | None = None,
178
+ observer: IssuanceObserver | None = None,
179
+ clock: Clock = system_clock,
180
+ ) -> None:
181
+ if discovery.canonical_issuer(issuer, IssuerFormat.ORIGIN) != issuer:
182
+ raise ValueError(f"issuer must be https:// + a public host, got {issuer!r}")
183
+ self.issuer = issuer
184
+ self.host = issuer.removeprefix("https://")
185
+ self.issuance_endpoint = _require_https_url(issuance_endpoint, "issuance_endpoint")
186
+ self.jwks_uri = _require_https_url(jwks_uri, "jwks_uri")
187
+ if not set(signing_alg_values_supported) <= SIGNING_ALGORITHMS:
188
+ raise ValueError("signing_alg_values_supported must be Ed25519 and/or ES256")
189
+ self.signing_alg_values_supported = tuple(signing_alg_values_supported)
190
+ if signer.alg not in self.signing_alg_values_supported:
191
+ raise ValueError(f"the signer's {signer.alg} is not in signing_alg_values_supported")
192
+ self.signer = signer
193
+ self._jwks = self._build_jwks(signer, published_keys)
194
+ self._domain_source: Callable[[], Iterable[str]] | None = None
195
+ self._domains: frozenset[str] = frozenset()
196
+ if callable(email_domains):
197
+ self._domain_source = cast("Callable[[], Iterable[str]]", email_domains)
198
+ else:
199
+ self._domains = frozenset(_email_domain(d) for d in email_domains)
200
+ if not self._domains:
201
+ raise ValueError("email_domains must not be empty")
202
+ self.profile = profile
203
+ self.replay_guard = replay_guard
204
+ self.observer = observer
205
+ self.clock = clock
206
+
207
+ @staticmethod
208
+ def _build_jwks(signer: Signer, published: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
209
+ keys = [dict(signer.public_jwk)]
210
+ for key in published:
211
+ kid, alg = key.get("kid"), key.get("alg")
212
+ if not isinstance(kid, str) or not isinstance(alg, str):
213
+ raise ValueError("published keys need kid and alg")
214
+ keys.append(public_jwk(key, kid=kid, alg=alg))
215
+ kids = [k["kid"] for k in keys]
216
+ if len(set(kids)) != len(kids):
217
+ raise ValueError(f"duplicate kid in published keys: {kids}")
218
+ return {"keys": keys}
219
+
220
+ @property
221
+ def email_domains(self) -> frozenset[str]:
222
+ """The domains EVTs may be issued for now, as lowercase A-labels."""
223
+ if self._domain_source is None:
224
+ return self._domains
225
+ domains = set()
226
+ for name in self._domain_source():
227
+ try:
228
+ domains.add(_email_domain(name))
229
+ except ValueError:
230
+ _logger.warning("EVP issuer: skipping invalid email domain %r", name)
231
+ return frozenset(domains)
232
+
233
+ # --- documents ---
234
+
235
+ def metadata_document(self) -> dict[str, Any]:
236
+ """Serve at ``{issuer}/.well-known/email-verification``."""
237
+ return {
238
+ "issuer": self.issuer,
239
+ "issuance_endpoint": self.issuance_endpoint,
240
+ "jwks_uri": self.jwks_uri,
241
+ "signing_alg_values_supported": list(self.signing_alg_values_supported),
242
+ "private_email_supported": False,
243
+ }
244
+
245
+ def jwks_document(self) -> dict[str, Any]:
246
+ """Serve at ``jwks_uri``."""
247
+ return {"keys": [dict(k) for k in self._jwks["keys"]]}
248
+
249
+ def dns_txt_records(self) -> dict[str, str]:
250
+ """The TXT record to publish for each email domain."""
251
+ return {f"_email-verification.{d}": f"iss={self.host}" for d in sorted(self.email_domains)}
252
+
253
+ # --- issuance ---
254
+
255
+ def parse_request(self, *, method: str, headers: Headers, body: bytes) -> IssuanceRequest:
256
+ """Validate a request with a synchronous (or no) replay guard."""
257
+ guard = self.replay_guard
258
+ if guard is not None and inspect.iscoroutinefunction(guard.mark_used):
259
+ raise TypeError("use aparse_request with an asynchronous replay guard")
260
+ try:
261
+ request = self._validate(method, headers, body)
262
+ if guard is not None:
263
+ key, expires_at = self._replay_key(request)
264
+ fresh = cast(ReplayGuard, guard).mark_used(key, expires_at)
265
+ self._check_replay(fresh, expires_at)
266
+ except IssuanceError as exc:
267
+ self._rejected(exc)
268
+ raise
269
+ self._accepted(request)
270
+ return request
271
+
272
+ async def aparse_request(
273
+ self, *, method: str, headers: Headers, body: bytes
274
+ ) -> IssuanceRequest:
275
+ """Validate a request with a synchronous or asynchronous replay guard."""
276
+ try:
277
+ request = self._validate(method, headers, body)
278
+ if self.replay_guard is not None:
279
+ key, expires_at = self._replay_key(request)
280
+ marked = self.replay_guard.mark_used(key, expires_at)
281
+ fresh = await marked if inspect.isawaitable(marked) else marked
282
+ self._check_replay(fresh, expires_at)
283
+ except IssuanceError as exc:
284
+ self._rejected(exc)
285
+ raise
286
+ self._accepted(request)
287
+ return request
288
+
289
+ def issue(self, request: IssuanceRequest) -> str:
290
+ """Sign an EVT for ``request``. Call only after authenticating the user."""
291
+ header = {
292
+ "alg": self._header_alg(self.signer.alg),
293
+ "kid": self.signer.kid,
294
+ "typ": self.profile.evt_type,
295
+ }
296
+ claims = {
297
+ "iss": self.issuer,
298
+ "iat": int(self.clock().timestamp()),
299
+ "cnf": {"jwk": dict(request.holder_jwk)},
300
+ "email": request.email,
301
+ "email_verified": True,
302
+ }
303
+ signing_input = ".".join(
304
+ _jose.b64url_encode(json.dumps(part, separators=(",", ":")).encode())
305
+ for part in (header, claims)
306
+ )
307
+ signature = self.signer.sign(signing_input.encode("ascii"))
308
+ self._notify(IssuanceEvent(True, "issue", None, discovery.email_domain(request.email)))
309
+ return f"{signing_input}.{_jose.b64url_encode(signature)}~"
310
+
311
+ @staticmethod
312
+ def success_response(evt: str) -> IssuanceResponse:
313
+ return IssuanceResponse.json(200, {"issuance_token": evt})
314
+
315
+ # --- internals ---
316
+
317
+ def _header_alg(self, alg: str) -> str:
318
+ return "EdDSA" if alg == "Ed25519" and self.profile.polymorphic_eddsa_header else alg
319
+
320
+ def _rejected(self, exc: IssuanceError) -> None:
321
+ self._notify(IssuanceEvent(False, "request", exc.code, None))
322
+
323
+ def _accepted(self, request: IssuanceRequest) -> None:
324
+ self._notify(IssuanceEvent(True, "request", None, discovery.email_domain(request.email)))
325
+
326
+ def _notify(self, event: IssuanceEvent) -> None:
327
+ if self.observer is None:
328
+ return
329
+ try:
330
+ self.observer(event)
331
+ except Exception:
332
+ _logger.exception("EVP issuance observer failed")
333
+
334
+ def _validate(self, method: str, headers: Headers, body: bytes) -> IssuanceRequest:
335
+ if method != "POST":
336
+ raise IssuanceError(IssuanceErrorCode.INVALID_REQUEST, f"method {method} not allowed")
337
+ # Read once: an iterator would be empty when verify_request parses it again.
338
+ headers = _httpsig.header_pairs(headers)
339
+ lines = _httpsig._field_lines(headers)
340
+ if _media_type(lines.get("content-type", ())) != "application/json":
341
+ raise IssuanceError(
342
+ IssuanceErrorCode.UNSUPPORTED_MEDIA_TYPE, "Content-Type is not application/json"
343
+ )
344
+ if self.profile.require_sec_fetch_dest and lines.get("sec-fetch-dest") != [
345
+ "email-verification"
346
+ ]:
347
+ raise IssuanceError(
348
+ IssuanceErrorCode.INVALID_REQUEST, "missing or invalid Sec-Fetch-Dest"
349
+ )
350
+ accepted = self.profile.request_algorithms & frozenset(self.signing_alg_values_supported)
351
+ try:
352
+ signed = _httpsig.verify_request(
353
+ method=method,
354
+ endpoint=self.issuance_endpoint,
355
+ headers=headers,
356
+ body=body,
357
+ now=self.clock(),
358
+ max_age=self.profile.max_request_age,
359
+ algorithms=accepted,
360
+ require_key_alg=self.profile.require_request_key_alg,
361
+ )
362
+ except _httpsig.SignatureError as exc:
363
+ if exc.code == "invalid_request":
364
+ raise IssuanceError(IssuanceErrorCode.INVALID_REQUEST, str(exc)) from None
365
+ raise IssuanceError(
366
+ IssuanceErrorCode.INVALID_SIGNATURE, str(exc), signature_error=exc.code
367
+ ) from None
368
+
369
+ try:
370
+ document = _parse_body(body)
371
+ except ValueError as exc:
372
+ raise IssuanceError(IssuanceErrorCode.INVALID_REQUEST, str(exc)) from None
373
+ email = document.get("email")
374
+ if not isinstance(email, str) or not is_valid_email(email):
375
+ raise IssuanceError(IssuanceErrorCode.INVALID_REQUEST, "email is missing or invalid")
376
+ private = document.get("private_email", False)
377
+ directed = document.get("directed_email")
378
+ if not isinstance(private, bool) or not isinstance(directed, str | None):
379
+ raise IssuanceError(
380
+ IssuanceErrorCode.INVALID_REQUEST, "private_email or directed_email is malformed"
381
+ )
382
+ if private or directed is not None:
383
+ raise IssuanceError(
384
+ IssuanceErrorCode.PRIVATE_EMAIL_NOT_SUPPORTED, "private email requested"
385
+ )
386
+ if discovery.email_domain(email) not in self.email_domains:
387
+ # Same answer as for an unknown account, so domains cannot be probed either.
388
+ raise IssuanceError.authentication_required(f"not authoritative for {email!r}")
389
+ return IssuanceRequest(
390
+ email, signed.public_jwk, signed.alg, signed.created, signed.signature, signed.base
391
+ )
392
+
393
+ def _replay_key(self, request: IssuanceRequest) -> tuple[str, datetime]:
394
+ # Keyed on what was signed, not on the signature: anyone can re-encode an ECDSA
395
+ # signature ((r, s) -> (r, n - s)) into another valid one for the same request.
396
+ key = "issuance:" + hashlib.sha256(request.signature_base).hexdigest()
397
+ return key, request.created + self.profile.max_request_age + timedelta(seconds=1)
398
+
399
+ def _check_replay(self, fresh: bool, expires_at: datetime) -> None:
400
+ if not fresh:
401
+ raise IssuanceError(
402
+ IssuanceErrorCode.INVALID_SIGNATURE,
403
+ "request was already used",
404
+ signature_error="invalid_signature",
405
+ )
406
+ # Freshness was judged before the guard ran. If the request expired since, the
407
+ # record just written may already be gone, and a concurrent copy found nothing.
408
+ if self.clock() >= expires_at:
409
+ raise IssuanceError(
410
+ IssuanceErrorCode.INVALID_SIGNATURE,
411
+ "request expired during validation",
412
+ signature_error="invalid_signature",
413
+ )
pyevp/issuer/errors.py ADDED
@@ -0,0 +1,99 @@
1
+ """Issuance errors and the HTTP responses they map to (draft-hardt-02, "Error Responses")."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from enum import StrEnum
9
+
10
+ __all__ = ["IssuanceError", "IssuanceErrorCode", "IssuanceResponse"]
11
+
12
+
13
+ class IssuanceErrorCode(StrEnum):
14
+ INVALID_REQUEST = "invalid_request"
15
+ INVALID_SIGNATURE = "invalid_signature"
16
+ AUTHENTICATION_REQUIRED = "authentication_required"
17
+ PRIVATE_EMAIL_NOT_SUPPORTED = "private_email_not_supported"
18
+ INVALID_DIRECTED_EMAIL = "invalid_directed_email"
19
+ UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
20
+ SERVER_ERROR = "server_error"
21
+
22
+
23
+ _STATUS = {
24
+ IssuanceErrorCode.AUTHENTICATION_REQUIRED: 401,
25
+ IssuanceErrorCode.UNSUPPORTED_MEDIA_TYPE: 415,
26
+ IssuanceErrorCode.SERVER_ERROR: 500,
27
+ }
28
+
29
+ # Descriptions are fixed per code: the message passed to IssuanceError is for logs only and
30
+ # never reaches the browser, so a response cannot reveal which check failed.
31
+ _DESCRIPTIONS = {
32
+ IssuanceErrorCode.INVALID_REQUEST: "Invalid or malformed request",
33
+ IssuanceErrorCode.INVALID_SIGNATURE: "HTTP Message Signature verification failed",
34
+ IssuanceErrorCode.AUTHENTICATION_REQUIRED: (
35
+ "User must be authenticated and control requested email"
36
+ ),
37
+ IssuanceErrorCode.PRIVATE_EMAIL_NOT_SUPPORTED: (
38
+ "Issuer does not support private email addresses"
39
+ ),
40
+ IssuanceErrorCode.INVALID_DIRECTED_EMAIL: "Private email invalid or not linked to this email",
41
+ IssuanceErrorCode.UNSUPPORTED_MEDIA_TYPE: "Content-Type must be application/json",
42
+ IssuanceErrorCode.SERVER_ERROR: "Temporary server error, please try again later",
43
+ }
44
+
45
+ _JSON_HEADERS = {"Content-Type": "application/json", "Cache-Control": "no-store"}
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class IssuanceResponse:
50
+ """A framework-neutral HTTP response."""
51
+
52
+ status: int
53
+ headers: dict[str, str] = field(default_factory=dict)
54
+ body: bytes = b""
55
+
56
+ @classmethod
57
+ def json(
58
+ cls, status: int, document: object, headers: Mapping[str, str] | None = None
59
+ ) -> IssuanceResponse:
60
+ body = json.dumps(document, separators=(",", ":")).encode()
61
+ return cls(status, {**_JSON_HEADERS, **(headers or {})}, body)
62
+
63
+
64
+ class IssuanceError(Exception):
65
+ """A request the issuer must refuse. Render it with :meth:`to_response`.
66
+
67
+ ``message`` is for logs; the response body only ever carries the fixed
68
+ description of ``code``.
69
+ """
70
+
71
+ def __init__(
72
+ self, code: IssuanceErrorCode, message: str, *, signature_error: str | None = None
73
+ ) -> None:
74
+ super().__init__(message)
75
+ self.code = code
76
+ self.signature_error = signature_error
77
+ """``Signature-Error`` code, for ``invalid_signature``."""
78
+
79
+ def __str__(self) -> str:
80
+ return f"[{self.code}] {self.args[0]}"
81
+
82
+ @property
83
+ def status(self) -> int:
84
+ return _STATUS.get(self.code, 400)
85
+
86
+ @classmethod
87
+ def authentication_required(cls, message: str = "not authenticated") -> IssuanceError:
88
+ """The one error for "no session", "unknown address" and "not this user's address".
89
+
90
+ Raise it for every such case so that responses cannot be used to probe accounts.
91
+ """
92
+ return cls(IssuanceErrorCode.AUTHENTICATION_REQUIRED, message)
93
+
94
+ def to_response(self) -> IssuanceResponse:
95
+ headers = {}
96
+ if self.signature_error is not None:
97
+ headers["Signature-Error"] = f"error={self.signature_error}"
98
+ document = {"error": str(self.code), "error_description": _DESCRIPTIONS[self.code]}
99
+ return IssuanceResponse.json(self.status, document, headers)
pyevp/issuer/fedcm.py ADDED
@@ -0,0 +1,44 @@
1
+ """FedCM documents that Chrome requires from an issuer, beyond the EVP draft.
2
+
3
+ Before Chrome sends an issuance request it checks, through FedCM, that the user is
4
+ signed in to the issuer with the address they typed (Chrome 154; see
5
+ ``content/browser/webid/delegation/email_verification_request.cc``):
6
+
7
+ 1. It fetches ``https://<registrable domain>/.well-known/web-identity`` — for an
8
+ issuer on ``accounts.example.com`` that is ``https://example.com/...`` — and
9
+ reads ``accounts_endpoint`` and ``login_url`` from it. The document must not
10
+ contain ``provider_urls``, or Chrome ignores the other two members.
11
+ 2. ``accounts_endpoint`` must be on the issuer's origin. Chrome requests it with
12
+ the issuer's cookies and ``Sec-Fetch-Dest: webidentity``; the session cookie
13
+ therefore needs ``SameSite=None; Secure``.
14
+ 3. One of the returned accounts must have the typed address as ``email``
15
+ (compared case-insensitively).
16
+
17
+ Chrome also skips issuers it knows the user is signed out of (FedCM Login Status
18
+ API): send ``Set-Login: logged-in`` on a normal page response after login, or call
19
+ ``navigator.login.setStatus("logged-in")``, and ``logged-out`` on logout.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Iterable
25
+ from typing import Any
26
+
27
+ __all__ = ["FEDCM_FETCH_DEST", "accounts_document", "web_identity_document"]
28
+
29
+ FEDCM_FETCH_DEST = "webidentity"
30
+ """``Sec-Fetch-Dest`` of Chrome's accounts request; refuse other requests."""
31
+
32
+
33
+ def web_identity_document(*, accounts_endpoint: str, login_url: str) -> dict[str, Any]:
34
+ """Serve at ``https://<registrable domain>/.well-known/web-identity``."""
35
+ return {"accounts_endpoint": accounts_endpoint, "login_url": login_url}
36
+
37
+
38
+ def accounts_document(emails: Iterable[str]) -> dict[str, Any]:
39
+ """The accounts endpoint's response for a signed-in user's addresses.
40
+
41
+ Pass every address the session's user may get EVTs for. Return this only for
42
+ requests with the session cookie; without a session, answer 401.
43
+ """
44
+ return {"accounts": [{"id": email, "email": email, "name": email} for email in emails]}