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/issuer/keys.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Issuer signing keys.
|
|
2
|
+
|
|
3
|
+
:class:`Signer` is the boundary for keys that never leave a KMS or HSM: implement
|
|
4
|
+
``sign`` with the service's raw-signature call. :class:`SigningKey` is the
|
|
5
|
+
in-process implementation backed by a private JWK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from joserfc.errors import JoseError
|
|
14
|
+
from joserfc.jwk import ECKey, OKPKey
|
|
15
|
+
|
|
16
|
+
from pyevp import _jose
|
|
17
|
+
|
|
18
|
+
__all__ = ["SIGNING_ALGORITHMS", "Signer", "SigningKey", "public_jwk"]
|
|
19
|
+
|
|
20
|
+
SIGNING_ALGORITHMS = frozenset({"Ed25519", "ES256"})
|
|
21
|
+
"""Fully specified algorithms an issuer may sign EVTs with."""
|
|
22
|
+
|
|
23
|
+
_CURVES = {"Ed25519": ("OKP", "Ed25519"), "ES256": ("EC", "P-256")}
|
|
24
|
+
_PRIVATE_MEMBERS = ("d", "p", "q", "dp", "dq", "qi", "k")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class Signer(Protocol):
|
|
29
|
+
alg: str
|
|
30
|
+
"""``Ed25519`` or ``ES256`` (fully specified, so never the polymorphic ``EdDSA``)."""
|
|
31
|
+
kid: str
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def public_jwk(self) -> Mapping[str, Any]:
|
|
35
|
+
"""The verification key as published in the JWKS (with ``kid`` and ``alg``)."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def sign(self, signing_input: bytes) -> bytes:
|
|
39
|
+
"""Sign in JWS encoding (raw ``r || s`` for ES256)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def public_jwk(key: Mapping[str, Any], *, kid: str, alg: str) -> dict[str, Any]:
|
|
44
|
+
"""Normalise a public key for publication: no private members, ``kid``, ``alg``, use."""
|
|
45
|
+
if alg not in SIGNING_ALGORITHMS:
|
|
46
|
+
raise ValueError(f"unsupported signing algorithm {alg!r}; use one of Ed25519, ES256")
|
|
47
|
+
if not kid:
|
|
48
|
+
raise ValueError("issuer keys need a non-empty kid")
|
|
49
|
+
jwk = {k: v for k, v in key.items() if k not in _PRIVATE_MEMBERS}
|
|
50
|
+
jwk.update(kid=kid, alg=alg, use="sig", key_ops=["verify"])
|
|
51
|
+
if (jwk.get("kty"), jwk.get("crv")) != _CURVES[alg]:
|
|
52
|
+
raise ValueError(f"key type does not match {alg}")
|
|
53
|
+
if _jose.import_public(jwk) is None:
|
|
54
|
+
raise ValueError("invalid public key material")
|
|
55
|
+
return jwk
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SigningKey:
|
|
59
|
+
"""A private key held in process memory."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, key: OKPKey | ECKey, *, kid: str, alg: str) -> None:
|
|
62
|
+
self._key = key
|
|
63
|
+
self.kid = kid
|
|
64
|
+
self.alg = alg
|
|
65
|
+
self._public = public_jwk(_derived_public(key), kid=kid, alg=alg)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def generate(cls, alg: str = "Ed25519", *, kid: str) -> SigningKey:
|
|
69
|
+
if alg == "Ed25519":
|
|
70
|
+
return cls(OKPKey.generate_key("Ed25519", private=True), kid=kid, alg=alg)
|
|
71
|
+
if alg == "ES256":
|
|
72
|
+
return cls(ECKey.generate_key("P-256", private=True), kid=kid, alg=alg)
|
|
73
|
+
raise ValueError(f"unsupported signing algorithm {alg!r}; use one of Ed25519, ES256")
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_jwk(cls, jwk: Mapping[str, Any]) -> SigningKey:
|
|
77
|
+
"""Load a private JWK; it must carry ``kid``, and ``alg`` unless the curve implies it."""
|
|
78
|
+
kid = jwk.get("kid")
|
|
79
|
+
if not isinstance(kid, str) or not kid:
|
|
80
|
+
raise ValueError("issuer keys need a non-empty kid")
|
|
81
|
+
alg = jwk.get("alg") or {v: k for k, v in _CURVES.items()}.get(
|
|
82
|
+
(jwk.get("kty"), jwk.get("crv"))
|
|
83
|
+
)
|
|
84
|
+
if alg not in SIGNING_ALGORITHMS:
|
|
85
|
+
raise ValueError(f"unsupported signing algorithm {alg!r}; use one of Ed25519, ES256")
|
|
86
|
+
if "d" not in jwk:
|
|
87
|
+
raise ValueError("not a private key")
|
|
88
|
+
material = {k: v for k, v in jwk.items() if k not in ("alg", "key_ops", "use", "kid")}
|
|
89
|
+
cls_ = OKPKey if alg == "Ed25519" else ECKey
|
|
90
|
+
try:
|
|
91
|
+
key = cls_.import_key(material)
|
|
92
|
+
except (JoseError, ValueError, TypeError) as exc:
|
|
93
|
+
raise ValueError(f"invalid private key: {exc}") from None
|
|
94
|
+
# joserfc signs with ``d`` but reports the ``x`` it was given; a mismatch would
|
|
95
|
+
# publish a key that verifies none of our signatures.
|
|
96
|
+
derived = _derived_public(key)
|
|
97
|
+
if any(material.get(m) != derived[m] for m in ("x", "y") if m in derived):
|
|
98
|
+
raise ValueError("invalid private key: public key does not match the private key")
|
|
99
|
+
return cls(key, kid=kid, alg=alg)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_pem(cls, pem: str | bytes, *, kid: str) -> SigningKey:
|
|
103
|
+
"""Load an Ed25519 or P-256 private key in PEM, as key stores and IdPs keep them."""
|
|
104
|
+
for key_cls in (OKPKey, ECKey):
|
|
105
|
+
try:
|
|
106
|
+
key = key_cls.import_key(pem)
|
|
107
|
+
except (JoseError, ValueError, TypeError):
|
|
108
|
+
continue
|
|
109
|
+
if not key.is_private:
|
|
110
|
+
break
|
|
111
|
+
jwk = key.as_dict(private=True)
|
|
112
|
+
if (jwk["kty"], jwk["crv"]) not in _CURVES.values():
|
|
113
|
+
raise ValueError(f"unsupported curve {jwk['crv']}; use Ed25519 or P-256")
|
|
114
|
+
return cls.from_jwk({**jwk, "kid": kid})
|
|
115
|
+
raise ValueError("not an Ed25519 or P-256 private key in PEM")
|
|
116
|
+
|
|
117
|
+
def private_jwk(self) -> dict[str, Any]:
|
|
118
|
+
"""The private key as a JWK, for writing to a secret store."""
|
|
119
|
+
return {**self._key.as_dict(private=True), "kid": self.kid, "alg": self.alg}
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def public_jwk(self) -> Mapping[str, Any]:
|
|
123
|
+
return self._public
|
|
124
|
+
|
|
125
|
+
def sign(self, signing_input: bytes) -> bytes:
|
|
126
|
+
return _jose.sign_raw(signing_input, self._key, self.alg)
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return f"SigningKey(kid={self.kid!r}, alg={self.alg!r})"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _derived_public(key: OKPKey | ECKey) -> dict[str, Any]:
|
|
133
|
+
"""The public JWK computed from the private key, ignoring any stored public members."""
|
|
134
|
+
if isinstance(key, OKPKey):
|
|
135
|
+
assert key.private_key is not None
|
|
136
|
+
public: OKPKey | ECKey = OKPKey.import_key(key.private_key.public_key())
|
|
137
|
+
else:
|
|
138
|
+
assert key.private_key is not None
|
|
139
|
+
public = ECKey.import_key(key.private_key.public_key())
|
|
140
|
+
return dict(public.as_dict(private=False))
|
pyevp/issuer/profile.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Issuance profiles: what an issuer accepts from browsers and how it writes EVTs.
|
|
2
|
+
|
|
3
|
+
Like :mod:`pyevp.profile` on the verifying side, every point where browsers and the
|
|
4
|
+
drafts disagree is a field here, and following a change usually means adding a
|
|
5
|
+
preset rather than changing an existing one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from types import MappingProxyType
|
|
15
|
+
from typing import Self, TypedDict, Unpack
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_ISSUANCE_PROFILE",
|
|
19
|
+
"ISSUANCE_PROFILES",
|
|
20
|
+
"IssuanceProfile",
|
|
21
|
+
"IssuanceProfileChanges",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
26
|
+
class IssuanceProfile:
|
|
27
|
+
name: str
|
|
28
|
+
request_algorithms: frozenset[str]
|
|
29
|
+
"""Algorithms accepted for the browser's request signature (also limited by metadata)."""
|
|
30
|
+
require_request_key_alg: bool
|
|
31
|
+
"""Require ``alg`` in the ``hwk`` Signature-Key; otherwise it is implied by the curve."""
|
|
32
|
+
max_request_age: timedelta
|
|
33
|
+
"""How far the signature's ``created`` may lie from now, either way."""
|
|
34
|
+
require_sec_fetch_dest: bool
|
|
35
|
+
evt_type: str = "evt+jwt"
|
|
36
|
+
polymorphic_eddsa_header: bool = False
|
|
37
|
+
"""Write ``"alg": "EdDSA"`` in EVT headers signed with Ed25519 (as Gmail did in 2026)."""
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def chrome_153(cls) -> Self:
|
|
41
|
+
"""What Chrome 153+ sends and accepts (verified end to end with 154.0.8037.92).
|
|
42
|
+
|
|
43
|
+
Chrome signs requests with ``hwk`` keys that omit ``alg``, and it rejects EVTs
|
|
44
|
+
whose header says ``"alg": "Ed25519"``: it only accepts ``EdDSA``, ``ES256``
|
|
45
|
+
and ``RS256``. Ed25519-signed EVTs therefore carry ``EdDSA``, as Gmail's do.
|
|
46
|
+
"""
|
|
47
|
+
return cls(
|
|
48
|
+
name="chrome-153",
|
|
49
|
+
request_algorithms=frozenset({"Ed25519", "ES256"}),
|
|
50
|
+
require_request_key_alg=False,
|
|
51
|
+
max_request_age=timedelta(seconds=300),
|
|
52
|
+
require_sec_fetch_dest=True,
|
|
53
|
+
polymorphic_eddsa_header=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def draft_hardt_02(cls) -> Self:
|
|
58
|
+
"""Strict reading of draft-hardt-email-verification-02 and signature-key-09."""
|
|
59
|
+
return cls(
|
|
60
|
+
name="draft-hardt-02",
|
|
61
|
+
request_algorithms=frozenset({"Ed25519", "ES256"}),
|
|
62
|
+
require_request_key_alg=True,
|
|
63
|
+
max_request_age=timedelta(seconds=300),
|
|
64
|
+
require_sec_fetch_dest=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def named(name: str) -> IssuanceProfile:
|
|
69
|
+
try:
|
|
70
|
+
return ISSUANCE_PROFILES[name]
|
|
71
|
+
except KeyError:
|
|
72
|
+
known = ", ".join(ISSUANCE_PROFILES)
|
|
73
|
+
raise ValueError(f"unknown issuance profile {name!r}; known: {known}") from None
|
|
74
|
+
|
|
75
|
+
def replace(self, **changes: Unpack[IssuanceProfileChanges]) -> Self:
|
|
76
|
+
"""Return a copy with some fields changed (``dataclasses.replace``)."""
|
|
77
|
+
return dataclasses.replace(self, **changes)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class IssuanceProfileChanges(TypedDict, total=False):
|
|
81
|
+
"""The fields of :class:`IssuanceProfile`, as keyword arguments of its ``replace``."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
request_algorithms: frozenset[str]
|
|
85
|
+
require_request_key_alg: bool
|
|
86
|
+
max_request_age: timedelta
|
|
87
|
+
require_sec_fetch_dest: bool
|
|
88
|
+
evt_type: str
|
|
89
|
+
polymorphic_eddsa_header: bool
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
ISSUANCE_PROFILES: Mapping[str, IssuanceProfile] = MappingProxyType(
|
|
93
|
+
{p.name: p for p in (IssuanceProfile.chrome_153(), IssuanceProfile.draft_hardt_02())}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
DEFAULT_ISSUANCE_PROFILE = ISSUANCE_PROFILES["chrome-153"]
|
pyevp/nonce.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Nonce helpers.
|
|
2
|
+
|
|
3
|
+
The RP puts a fresh nonce on the form (``<input ... nonce="...">``), stores it
|
|
4
|
+
in the user's session, and passes it to ``verify``. Storing and consuming the
|
|
5
|
+
nonce exactly once is the application's job; see the examples.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hmac
|
|
11
|
+
import secrets
|
|
12
|
+
|
|
13
|
+
__all__ = ["generate_nonce", "nonces_equal"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_nonce(nbytes: int = 32) -> str:
|
|
17
|
+
"""Return a URL-safe nonce with ``nbytes`` of entropy (at least 16)."""
|
|
18
|
+
if nbytes < 16:
|
|
19
|
+
raise ValueError("a nonce needs at least 128 bits of entropy")
|
|
20
|
+
return secrets.token_urlsafe(nbytes)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def nonces_equal(a: str, b: str) -> bool:
|
|
24
|
+
"""Compare two nonces in constant time."""
|
|
25
|
+
return hmac.compare_digest(a.encode(), b.encode())
|
pyevp/observability.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Hooks for logging and metrics.
|
|
2
|
+
|
|
3
|
+
Pass ``observer=`` to a verifier to receive one :class:`VerificationEvent` per
|
|
4
|
+
``verify`` call. Observers must be fast and must not raise; exceptions are
|
|
5
|
+
logged and swallowed so that monitoring can never break authentication.
|
|
6
|
+
|
|
7
|
+
A Prometheus counter, for example::
|
|
8
|
+
|
|
9
|
+
VERIFICATIONS = Counter("evp_verifications_total", "EVP verifications", ["result", "issuer"])
|
|
10
|
+
|
|
11
|
+
def observe(event: VerificationEvent) -> None:
|
|
12
|
+
VERIFICATIONS.labels(event.code or "ok", event.issuer or "").inc()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import timedelta
|
|
22
|
+
from typing import TypeAlias
|
|
23
|
+
|
|
24
|
+
from pyevp import discovery
|
|
25
|
+
from pyevp.errors import ErrorCode
|
|
26
|
+
from pyevp.token import parse_token
|
|
27
|
+
|
|
28
|
+
__all__ = ["LoggingObserver", "Observer", "VerificationEvent", "claimed_email_domain"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class VerificationEvent:
|
|
33
|
+
ok: bool
|
|
34
|
+
code: ErrorCode | None
|
|
35
|
+
"""Set when verification failed with an :class:`~pyevp.EVPError`."""
|
|
36
|
+
issuer: str | None
|
|
37
|
+
"""Canonical issuer; only known after a successful verification."""
|
|
38
|
+
email_domain: str | None
|
|
39
|
+
"""Domain of the ``email`` claim, read from the token *before* verification."""
|
|
40
|
+
profile: str
|
|
41
|
+
duration: timedelta
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
|
|
45
|
+
Observer: TypeAlias = Callable[[VerificationEvent], None]
|
|
46
|
+
"""Receives one :class:`VerificationEvent` per verification; must not block or raise."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# What ``email_domain`` returns for a name DNS could hold. Anything else (spaces, line
|
|
50
|
+
# breaks, ...) comes from an unverified token and must not reach logs or metric labels.
|
|
51
|
+
_DNS_NAME = re.compile(r"[a-z0-9_-]+(?:\.[a-z0-9_-]+)*")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def claimed_email_domain(token: str) -> str | None:
|
|
55
|
+
"""Best-effort domain of the (unverified) ``email`` claim, for grouping metrics.
|
|
56
|
+
|
|
57
|
+
``None`` unless the claim holds an address whose domain is a plausible DNS name.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
email = parse_token(token, allow_disclosures=True).evt.claims.get("email")
|
|
61
|
+
domain = discovery.email_domain(email) if isinstance(email, str) else None
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
return domain if domain is not None and _DNS_NAME.fullmatch(domain) else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LoggingObserver:
|
|
68
|
+
"""Log one line per verification to the ``pyevp`` logger (or ``logger``)."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, logger: logging.Logger | None = None, *, level: int = logging.INFO) -> None:
|
|
71
|
+
self._logger = logger or logging.getLogger("pyevp")
|
|
72
|
+
self._level = level
|
|
73
|
+
|
|
74
|
+
def __call__(self, event: VerificationEvent) -> None:
|
|
75
|
+
self._logger.log(
|
|
76
|
+
self._level,
|
|
77
|
+
"EVP verification %s code=%s issuer=%s domain=%s profile=%s duration_ms=%.1f",
|
|
78
|
+
"succeeded" if event.ok else "failed",
|
|
79
|
+
event.code,
|
|
80
|
+
_escaped(event.issuer),
|
|
81
|
+
_escaped(event.email_domain),
|
|
82
|
+
_escaped(event.profile),
|
|
83
|
+
event.duration.total_seconds() * 1000,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _escaped(value: str | None) -> str | None:
|
|
88
|
+
"""``value``, or its ``repr`` if it holds a line break or another unprintable character."""
|
|
89
|
+
return value if value is None or value.isprintable() else repr(value)
|
pyevp/ports.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""I/O boundaries. Implement these to plug in your own DNS / HTTP / clock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Protocol, TypeAlias, runtime_checkable
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AsyncJsonFetcher",
|
|
11
|
+
"AsyncTxtResolver",
|
|
12
|
+
"Clock",
|
|
13
|
+
"JsonFetcher",
|
|
14
|
+
"TxtResolver",
|
|
15
|
+
"system_clock",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
|
|
19
|
+
Clock: TypeAlias = Callable[[], datetime]
|
|
20
|
+
"""Returns the current time as an aware ``datetime``."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def system_clock() -> datetime:
|
|
24
|
+
return datetime.now(UTC)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class TxtResolver(Protocol):
|
|
29
|
+
def resolve_txt(self, name: str) -> list[str]:
|
|
30
|
+
"""Return each TXT record as one string (character-strings concatenated).
|
|
31
|
+
|
|
32
|
+
Return an empty list for NXDOMAIN / NODATA; raise for other failures.
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class AsyncTxtResolver(Protocol):
|
|
39
|
+
async def resolve_txt(self, name: str) -> list[str]: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class JsonFetcher(Protocol):
|
|
44
|
+
def fetch_json(self, url: str) -> object:
|
|
45
|
+
"""GET ``url`` (no redirects to other origins) and return the decoded JSON.
|
|
46
|
+
|
|
47
|
+
Raise on transport errors, non-200 responses or invalid JSON.
|
|
48
|
+
"""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class AsyncJsonFetcher(Protocol):
|
|
54
|
+
async def fetch_json(self, url: str) -> object: ...
|
pyevp/profile.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Verification profiles.
|
|
2
|
+
|
|
3
|
+
The protocol is still moving (algorithm names, ``iss`` format, ``kid`` rules, …)
|
|
4
|
+
and deployed issuers lag behind the drafts. Every such knob lives here so that
|
|
5
|
+
following a spec change usually means adding a new preset rather than changing an
|
|
6
|
+
existing one or the verification code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
from typing import Self, TypedDict, Unpack
|
|
18
|
+
|
|
19
|
+
from pyevp._email import EmailComparison, emails_match
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_PROFILE",
|
|
23
|
+
"PROFILES",
|
|
24
|
+
"EmailComparison",
|
|
25
|
+
"IssuerFormat",
|
|
26
|
+
"Profile",
|
|
27
|
+
"ProfileChanges",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IssuerFormat(StrEnum):
|
|
32
|
+
"""Accepted spellings of an issuer identifier (token ``iss`` / DNS ``iss=``)."""
|
|
33
|
+
|
|
34
|
+
HOST = "host"
|
|
35
|
+
"""Bare host, e.g. ``accounts.google.com`` (early drafts)."""
|
|
36
|
+
ORIGIN = "origin"
|
|
37
|
+
"""HTTPS origin, e.g. ``https://accounts.google.com`` (draft-hardt -02)."""
|
|
38
|
+
ANY = "any"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
42
|
+
class Profile:
|
|
43
|
+
name: str
|
|
44
|
+
evt_types: frozenset[str]
|
|
45
|
+
kb_types: frozenset[str]
|
|
46
|
+
evt_algorithms: frozenset[str]
|
|
47
|
+
kb_algorithms: frozenset[str]
|
|
48
|
+
require_kid: bool
|
|
49
|
+
"""When false, an absent or empty ``kid`` tries every compatible issuer key."""
|
|
50
|
+
require_cnf_alg: bool
|
|
51
|
+
"""Require ``cnf.jwk.alg`` and that it equals the KB-JWT ``alg``."""
|
|
52
|
+
issuer_format: IssuerFormat
|
|
53
|
+
email_comparison: EmailComparison
|
|
54
|
+
max_token_age: timedelta
|
|
55
|
+
clock_skew: timedelta
|
|
56
|
+
require_exp: bool
|
|
57
|
+
allow_disclosures: bool
|
|
58
|
+
"""Accept SD-JWT disclosures between the EVT and the KB-JWT (``MALFORMED_TOKEN`` otherwise).
|
|
59
|
+
|
|
60
|
+
They are covered by ``sd_hash`` but never decoded, so they add no claims.
|
|
61
|
+
"""
|
|
62
|
+
dns_label: str = "_email-verification"
|
|
63
|
+
metadata_path: str = "/.well-known/email-verification"
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def compat_2026_10(cls) -> Self:
|
|
67
|
+
"""Accept both the -02 draft and what Chrome + Gmail ship as of 2026-10.
|
|
68
|
+
|
|
69
|
+
Gmail signs with ``EdDSA`` and publishes keys without ``kid``.
|
|
70
|
+
"""
|
|
71
|
+
return cls(
|
|
72
|
+
name="compat-2026-10",
|
|
73
|
+
evt_types=frozenset({"evt+jwt"}),
|
|
74
|
+
kb_types=frozenset({"kb+jwt"}),
|
|
75
|
+
evt_algorithms=frozenset({"Ed25519", "EdDSA", "ES256"}),
|
|
76
|
+
kb_algorithms=frozenset({"Ed25519", "EdDSA", "ES256"}),
|
|
77
|
+
require_kid=False,
|
|
78
|
+
require_cnf_alg=False,
|
|
79
|
+
issuer_format=IssuerFormat.ANY,
|
|
80
|
+
email_comparison=EmailComparison.CASE_INSENSITIVE,
|
|
81
|
+
max_token_age=timedelta(minutes=5),
|
|
82
|
+
clock_skew=timedelta(minutes=1),
|
|
83
|
+
require_exp=False,
|
|
84
|
+
allow_disclosures=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def draft_hardt_02(cls) -> Self:
|
|
89
|
+
"""Strict reading of draft-hardt-email-verification editor's copy (-02)."""
|
|
90
|
+
return cls(
|
|
91
|
+
name="draft-hardt-02",
|
|
92
|
+
evt_types=frozenset({"evt+jwt"}),
|
|
93
|
+
kb_types=frozenset({"kb+jwt"}),
|
|
94
|
+
evt_algorithms=frozenset({"Ed25519", "ES256"}),
|
|
95
|
+
kb_algorithms=frozenset({"Ed25519", "ES256"}),
|
|
96
|
+
require_kid=True,
|
|
97
|
+
require_cnf_alg=True,
|
|
98
|
+
issuer_format=IssuerFormat.ORIGIN,
|
|
99
|
+
email_comparison=EmailComparison.EXACT,
|
|
100
|
+
max_token_age=timedelta(minutes=5),
|
|
101
|
+
clock_skew=timedelta(minutes=1),
|
|
102
|
+
require_exp=False,
|
|
103
|
+
allow_disclosures=False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def named(name: str) -> Profile:
|
|
108
|
+
"""Look up a preset by name (e.g. ``"draft-hardt-02"``)."""
|
|
109
|
+
try:
|
|
110
|
+
return PROFILES[name]
|
|
111
|
+
except KeyError:
|
|
112
|
+
raise ValueError(f"unknown profile {name!r}; known: {', '.join(PROFILES)}") from None
|
|
113
|
+
|
|
114
|
+
def emails_match(self, asserted: str, submitted: str) -> bool:
|
|
115
|
+
"""Compare two addresses the way verification does (see :class:`EmailComparison`).
|
|
116
|
+
|
|
117
|
+
Use it when looking up the account for a verified address, so that lookup and
|
|
118
|
+
verification agree on which addresses are the same.
|
|
119
|
+
"""
|
|
120
|
+
return emails_match(asserted, submitted, self.email_comparison)
|
|
121
|
+
|
|
122
|
+
def replace(self, **changes: Unpack[ProfileChanges]) -> Self:
|
|
123
|
+
"""Return a copy with some fields changed (``dataclasses.replace``)."""
|
|
124
|
+
return dataclasses.replace(self, **changes)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ProfileChanges(TypedDict, total=False):
|
|
128
|
+
"""The fields of :class:`Profile`, as keyword arguments of :meth:`Profile.replace`."""
|
|
129
|
+
|
|
130
|
+
name: str
|
|
131
|
+
evt_types: frozenset[str]
|
|
132
|
+
kb_types: frozenset[str]
|
|
133
|
+
evt_algorithms: frozenset[str]
|
|
134
|
+
kb_algorithms: frozenset[str]
|
|
135
|
+
require_kid: bool
|
|
136
|
+
require_cnf_alg: bool
|
|
137
|
+
issuer_format: IssuerFormat
|
|
138
|
+
email_comparison: EmailComparison
|
|
139
|
+
max_token_age: timedelta
|
|
140
|
+
clock_skew: timedelta
|
|
141
|
+
require_exp: bool
|
|
142
|
+
allow_disclosures: bool
|
|
143
|
+
dns_label: str
|
|
144
|
+
metadata_path: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
PROFILES: Mapping[str, Profile] = MappingProxyType(
|
|
148
|
+
{p.name: p for p in (Profile.compat_2026_10(), Profile.draft_hardt_02())}
|
|
149
|
+
)
|
|
150
|
+
"""All presets by name."""
|
|
151
|
+
|
|
152
|
+
DEFAULT_PROFILE = PROFILES["compat-2026-10"]
|
|
153
|
+
"""The profile verifiers use unless told otherwise."""
|
pyevp/py.typed
ADDED
|
File without changes
|
pyevp/replay.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Replay protection.
|
|
2
|
+
|
|
3
|
+
The single-use nonce is the primary defence against replay, but it only works
|
|
4
|
+
when the nonce lives server-side. With client-side sessions (e.g. Starlette's
|
|
5
|
+
signed-cookie ``SessionMiddleware``) an attacker who captured a token can resend
|
|
6
|
+
it together with the old session cookie. A replay guard closes that gap by
|
|
7
|
+
remembering every accepted token until it would expire anyway.
|
|
8
|
+
|
|
9
|
+
Implementations must make :meth:`ReplayGuard.mark_used` an atomic
|
|
10
|
+
"add if absent", shared by every worker that verifies tokens, for example:
|
|
11
|
+
|
|
12
|
+
- Redis: ``SET evp:<key> 1 NX PXAT <expires_at in ms>``, with
|
|
13
|
+
``maxmemory-policy noeviction``
|
|
14
|
+
- A database table with the key as primary key, as
|
|
15
|
+
:class:`pyevp.contrib.django.EVPReplayGuard` does
|
|
16
|
+
|
|
17
|
+
The store must keep every record until ``expires_at``: caches that evict
|
|
18
|
+
entries under memory pressure (Memcached, Django's cache backends) do not.
|
|
19
|
+
|
|
20
|
+
``expires_at`` can already be past when verification took long; guards need not
|
|
21
|
+
handle that specially, because the verifier rejects the token afterwards.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from pyevp.ports import Clock, system_clock
|
|
31
|
+
|
|
32
|
+
__all__ = ["AsyncReplayGuard", "InMemoryReplayGuard", "ReplayGuard"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class ReplayGuard(Protocol):
|
|
37
|
+
def mark_used(self, key: str, expires_at: datetime) -> bool:
|
|
38
|
+
"""Remember ``key`` until ``expires_at``.
|
|
39
|
+
|
|
40
|
+
Return ``True`` if the key was not already present, ``False`` otherwise.
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class AsyncReplayGuard(Protocol):
|
|
47
|
+
async def mark_used(self, key: str, expires_at: datetime) -> bool: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InMemoryReplayGuard:
|
|
51
|
+
"""Process-local guard.
|
|
52
|
+
|
|
53
|
+
Only correct when a single process verifies tokens; with several workers a
|
|
54
|
+
token can be replayed against another worker. Use a shared store there.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, clock: Clock = system_clock) -> None:
|
|
58
|
+
self._clock = clock
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
self._seen: dict[str, datetime] = {}
|
|
61
|
+
|
|
62
|
+
def mark_used(self, key: str, expires_at: datetime) -> bool:
|
|
63
|
+
now = self._clock()
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._seen = {k: exp for k, exp in self._seen.items() if exp > now}
|
|
66
|
+
if key in self._seen:
|
|
67
|
+
return False
|
|
68
|
+
self._seen[key] = expires_at
|
|
69
|
+
return True
|