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/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
|
+
"""
|
pyevp/issuer/__init__.py
ADDED
|
@@ -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]}
|