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/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))
@@ -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