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/cli/__init__.py ADDED
@@ -0,0 +1,348 @@
1
+ """Command-line tools for relying parties and issuer operators.
2
+
3
+ - ``pyevp discover``: check a domain's issuer as a relying party would see it.
4
+ - ``pyevp inspect``: decode a presentation token offline (signatures not checked).
5
+ - ``pyevp verify``: run the full verification of a token.
6
+ - ``pyevp issuer keygen`` / ``pyevp issuer documents``: set up an issuer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ from collections.abc import Callable
15
+ from dataclasses import asdict
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Annotated, Any
19
+
20
+ import typer
21
+ from joserfc import jwk
22
+ from rich.console import Console
23
+ from rich.table import Table
24
+
25
+ from pyevp.diagnostics import IssuerReport, discover
26
+ from pyevp.errors import EVPError
27
+ from pyevp.issuer import SIGNING_ALGORITHMS, Issuer, SigningKey
28
+ from pyevp.ports import JsonFetcher, TxtResolver
29
+ from pyevp.profile import DEFAULT_PROFILE, PROFILES, Profile
30
+ from pyevp.token import ParsedToken, compute_sd_hash, parse_token
31
+ from pyevp.verifier import Verifier
32
+
33
+ __all__ = ["app", "make_app"]
34
+
35
+ FAILED = 1
36
+ MISSING_EXTRA = 3
37
+
38
+ ResolverFactory = Callable[[str | None], TxtResolver]
39
+ """Builds a resolver; the argument is a DoH endpoint, or ``None`` for plain DNS."""
40
+ FetcherFactory = Callable[[], JsonFetcher]
41
+
42
+
43
+ def _default_resolver(doh_endpoint: str | None) -> TxtResolver:
44
+ if doh_endpoint is not None:
45
+ from pyevp.adapters.doh import DohResolver # noqa: PLC0415
46
+
47
+ return DohResolver(doh_endpoint)
48
+ from pyevp.adapters.dnspython import DnsPythonResolver # noqa: PLC0415
49
+
50
+ return DnsPythonResolver()
51
+
52
+
53
+ def _default_fetcher() -> JsonFetcher:
54
+ from pyevp.adapters.httpx import HttpxFetcher # noqa: PLC0415
55
+
56
+ return HttpxFetcher()
57
+
58
+
59
+ def _profile(name: str) -> Profile:
60
+ try:
61
+ return Profile.named(name)
62
+ except ValueError as exc:
63
+ raise typer.BadParameter(str(exc)) from None
64
+
65
+
66
+ def _json(data: Any) -> None:
67
+ typer.echo(json.dumps(data, indent=2, default=str, ensure_ascii=False))
68
+
69
+
70
+ def _age(timestamp: Any, now: datetime) -> str:
71
+ if isinstance(timestamp, bool) or not isinstance(timestamp, int | float):
72
+ return "-"
73
+ try:
74
+ seconds = int((now - datetime.fromtimestamp(timestamp, UTC)).total_seconds())
75
+ except (OverflowError, OSError, ValueError):
76
+ return "-"
77
+ return f"{seconds}s ago" if seconds >= 0 else f"in {-seconds}s"
78
+
79
+
80
+ ProfileOption = Annotated[
81
+ str, typer.Option("--profile", "-p", help=f"Profile preset: {', '.join(PROFILES)}.")
82
+ ]
83
+ DohOption = Annotated[
84
+ bool, typer.Option("--doh", help="Resolve DNS over HTTPS instead of the system resolver.")
85
+ ]
86
+ DohEndpointOption = Annotated[str, typer.Option("--doh-endpoint", help="DoH JSON API endpoint.")]
87
+ JsonOption = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
88
+
89
+
90
+ def make_app(
91
+ *,
92
+ resolver_factory: ResolverFactory = _default_resolver,
93
+ fetcher_factory: FetcherFactory = _default_fetcher,
94
+ console: Console | None = None,
95
+ ) -> typer.Typer:
96
+ """Build the CLI; tests inject in-memory ports through the factories."""
97
+ app = typer.Typer(
98
+ help="Email Verification Protocol tools.",
99
+ no_args_is_help=True,
100
+ add_completion=False,
101
+ pretty_exceptions_enable=False,
102
+ )
103
+ out = console or Console()
104
+
105
+ def ports(doh: bool, doh_endpoint: str) -> tuple[TxtResolver, JsonFetcher]:
106
+ try:
107
+ return resolver_factory(doh_endpoint if doh else None), fetcher_factory()
108
+ except ImportError as exc:
109
+ typer.echo(f'Missing dependency {exc.name!r}: pip install "pyevp[cli]"', err=True)
110
+ raise typer.Exit(MISSING_EXTRA) from None
111
+
112
+ @app.command("discover")
113
+ def discover_cmd(
114
+ target: Annotated[str, typer.Argument(help="Email address or domain.")],
115
+ profile: ProfileOption = DEFAULT_PROFILE.name,
116
+ doh: DohOption = False,
117
+ doh_endpoint: DohEndpointOption = "https://dns.google/resolve",
118
+ as_json: JsonOption = False,
119
+ ) -> None:
120
+ """Check a domain's issuer: DNS record, metadata and keys."""
121
+ resolver, fetcher = ports(doh, doh_endpoint)
122
+ try:
123
+ report = discover(target, resolver=resolver, fetcher=fetcher, profile=_profile(profile))
124
+ except EVPError as exc:
125
+ _fail(out, as_json, exc)
126
+ finally:
127
+ _close(resolver, fetcher)
128
+ if as_json:
129
+ _json(asdict(report) | {"ok": report.ok})
130
+ else:
131
+ _print_report(out, report)
132
+ if not report.ok:
133
+ raise typer.Exit(FAILED)
134
+
135
+ @app.command("inspect")
136
+ def inspect_cmd(
137
+ token: Annotated[str, typer.Argument(help="Presentation token, or - for stdin.")] = "-",
138
+ as_json: JsonOption = False,
139
+ ) -> None:
140
+ """Decode a token offline. Signatures are NOT verified."""
141
+ raw = sys.stdin.read() if token == "-" else token
142
+ try:
143
+ parsed = parse_token(raw, allow_disclosures=True)
144
+ except EVPError as exc:
145
+ _fail(out, as_json, exc)
146
+ data = _inspection(parsed, datetime.now(UTC))
147
+ if as_json:
148
+ _json(data)
149
+ else:
150
+ _print_inspection(out, data)
151
+
152
+ @app.command("verify")
153
+ def verify_cmd(
154
+ token: Annotated[str, typer.Argument(help="Presentation token, or - for stdin.")],
155
+ audience: Annotated[str, typer.Option(help="Your origin, e.g. https://example.com.")],
156
+ nonce: Annotated[str, typer.Option(help="The nonce that was put on the form.")],
157
+ email: Annotated[str | None, typer.Option(help="Submitted address to compare.")] = None,
158
+ profile: ProfileOption = DEFAULT_PROFILE.name,
159
+ doh: DohOption = False,
160
+ doh_endpoint: DohEndpointOption = "https://dns.google/resolve",
161
+ as_json: JsonOption = False,
162
+ ) -> None:
163
+ """Fully verify a token, as a relying party would."""
164
+ raw = sys.stdin.read() if token == "-" else token
165
+ resolver, fetcher = ports(doh, doh_endpoint)
166
+ try:
167
+ verifier = Verifier(
168
+ audience=audience, resolver=resolver, fetcher=fetcher, profile=_profile(profile)
169
+ )
170
+ result = verifier.verify(raw.strip(), nonce=nonce, email=email)
171
+ except ValueError as exc:
172
+ raise typer.BadParameter(str(exc)) from None
173
+ except EVPError as exc:
174
+ _fail(out, as_json, exc)
175
+ finally:
176
+ _close(resolver, fetcher)
177
+ if as_json:
178
+ _json({"ok": True} | asdict(result))
179
+ else:
180
+ out.print(f"[green]verified[/green] {result.email} (issuer {result.issuer})")
181
+
182
+ app.add_typer(_issuer_app(), name="issuer")
183
+ return app
184
+
185
+
186
+ def _issuer_app() -> typer.Typer:
187
+ app = typer.Typer(help="Set up an issuer for your own email domains.", no_args_is_help=True)
188
+
189
+ @app.command("keygen")
190
+ def keygen_cmd(
191
+ kid: Annotated[str, typer.Option(help="Key id, e.g. the date: 2026-10.")],
192
+ out_path: Annotated[
193
+ Path, typer.Option("--out", help="Private JWK file to create (mode 0600).")
194
+ ],
195
+ alg: Annotated[str, typer.Option(help="Ed25519 or ES256.")] = "Ed25519",
196
+ ) -> None:
197
+ """Generate a signing key. Prints the public JWK; the private one goes to --out."""
198
+ if alg not in SIGNING_ALGORITHMS:
199
+ choices = ", ".join(sorted(SIGNING_ALGORITHMS))
200
+ raise typer.BadParameter(f"--alg must be one of {choices}")
201
+ try:
202
+ key = SigningKey.generate(alg, kid=kid)
203
+ except ValueError as exc:
204
+ raise typer.BadParameter(str(exc)) from None
205
+ try:
206
+ fd = os.open(out_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
207
+ except FileExistsError:
208
+ raise typer.BadParameter(f"{out_path} exists; refusing to overwrite a key") from None
209
+ with os.fdopen(fd, "w") as file:
210
+ json.dump(key.private_jwk(), file, indent=2)
211
+ file.write("\n")
212
+ _json(dict(key.public_jwk))
213
+
214
+ @app.command("documents")
215
+ def documents_cmd(
216
+ issuer: Annotated[str, typer.Option(help="Issuer identifier, https://host.")],
217
+ issuance_endpoint: Annotated[str, typer.Option(help="URL browsers POST requests to.")],
218
+ jwks_uri: Annotated[str, typer.Option(help="URL the JWKS is served at.")],
219
+ key: Annotated[Path, typer.Option(help="Private JWK file of the active key.")],
220
+ domain: Annotated[list[str], typer.Option(help="Email domain served (repeatable).")],
221
+ publish: Annotated[
222
+ list[Path] | None,
223
+ typer.Option(help="Extra JWK file to publish: next or retired key (repeatable)."),
224
+ ] = None,
225
+ ) -> None:
226
+ """Print the metadata document, JWKS and DNS records to publish, as JSON."""
227
+ try:
228
+ signer = SigningKey.from_jwk(_read_json(key))
229
+ extra = [_public_part(_read_json(p)) for p in publish or ()]
230
+ built = Issuer(
231
+ issuer=issuer,
232
+ issuance_endpoint=issuance_endpoint,
233
+ jwks_uri=jwks_uri,
234
+ signer=signer,
235
+ email_domains=domain,
236
+ published_keys=extra,
237
+ )
238
+ except (OSError, ValueError) as exc:
239
+ raise typer.BadParameter(str(exc)) from None
240
+ _json(
241
+ {
242
+ "metadata_url": f"{built.issuer}/.well-known/email-verification",
243
+ "metadata": built.metadata_document(),
244
+ "jwks_uri": built.jwks_uri,
245
+ "jwks": built.jwks_document(),
246
+ "dns_txt": built.dns_txt_records(),
247
+ }
248
+ )
249
+
250
+ return app
251
+
252
+
253
+ def _read_json(path: Path) -> dict[str, Any]:
254
+ value = json.loads(path.read_text())
255
+ if not isinstance(value, dict):
256
+ raise ValueError(f"{path} does not hold a JSON object")
257
+ return value
258
+
259
+
260
+ def _public_part(jwk_: dict[str, Any]) -> dict[str, Any]:
261
+ if "d" in jwk_:
262
+ return dict(SigningKey.from_jwk(jwk_).public_jwk)
263
+ return jwk_
264
+
265
+
266
+ def _close(*resources: object) -> None:
267
+ for resource in resources:
268
+ close = getattr(resource, "close", None)
269
+ if callable(close):
270
+ close()
271
+
272
+
273
+ def _fail(out: Console, as_json: bool, exc: EVPError) -> Any:
274
+ if as_json:
275
+ _json({"ok": False, "code": exc.code, "message": exc.args[0]})
276
+ else:
277
+ out.print(f"[red]{exc.code}[/red] {exc.args[0]}")
278
+ raise typer.Exit(FAILED)
279
+
280
+
281
+ def _inspection(parsed: ParsedToken, now: datetime) -> dict[str, Any]:
282
+ cnf = parsed.evt.claims.get("cnf")
283
+ holder = cnf.get("jwk") if isinstance(cnf, dict) else None
284
+ thumbprint = None
285
+ if isinstance(holder, dict):
286
+ try:
287
+ thumbprint = jwk.thumbprint(holder)
288
+ except Exception:
289
+ thumbprint = None
290
+ return {
291
+ "signatures_verified": False,
292
+ "evt": {"header": dict(parsed.evt.header), "claims": dict(parsed.evt.claims)},
293
+ "disclosures": list(parsed.disclosures),
294
+ "kb": {"header": dict(parsed.kb.header), "claims": dict(parsed.kb.claims)},
295
+ "checks": {
296
+ "evt_issued": _age(parsed.evt.claims.get("iat"), now),
297
+ "kb_issued": _age(parsed.kb.claims.get("iat"), now),
298
+ "sd_hash_matches": parsed.kb.claims.get("sd_hash")
299
+ == compute_sd_hash(parsed.sd_hash_input),
300
+ "holder_key_thumbprint": thumbprint,
301
+ },
302
+ }
303
+
304
+
305
+ def _table(title: str, columns: int) -> Table:
306
+ table = Table(title=title, show_header=False, title_justify="left")
307
+ for i in range(columns):
308
+ table.add_column(style="bold" if i < columns - 1 else None, overflow="fold")
309
+ return table
310
+
311
+
312
+ def _print_report(out: Console, report: IssuerReport) -> None:
313
+ table = _table(f"Issuer for {report.domain}", 2)
314
+ table.add_row("profile", report.profile)
315
+ table.add_row("DNS name", report.dns_name)
316
+ table.add_row("TXT records", "\n".join(report.records) or "[dim](none)[/dim]")
317
+ table.add_row("issuer", report.issuer or "-")
318
+ if report.metadata is not None:
319
+ table.add_row("jwks_uri", report.metadata.jwks_uri)
320
+ algs = report.metadata.signing_alg_values_supported
321
+ table.add_row(
322
+ "algorithms", "(not advertised)" if algs is None else ", ".join(algs) or "(none)"
323
+ )
324
+ for key in report.keys:
325
+ table.add_row("key", f"{key.kty}/{key.crv} alg={key.alg} kid={key.kid!r}")
326
+ out.print(table)
327
+ for problem in report.problems:
328
+ out.print(f"[red]✗[/red] {problem}")
329
+ if report.ok:
330
+ out.print("[green]✓ usable with this profile[/green]")
331
+
332
+
333
+ def _print_inspection(out: Console, data: dict[str, Any]) -> None:
334
+ out.print("[yellow]Signatures are NOT verified; use `pyevp verify` for that.[/yellow]")
335
+ for part in ("evt", "kb"):
336
+ table = _table(part.upper(), 3)
337
+ for section in ("header", "claims"):
338
+ for name, value in data[part][section].items():
339
+ table.add_row(section, name, json.dumps(value, ensure_ascii=False))
340
+ out.print(table)
341
+ checks = data["checks"]
342
+ table = _table("checks", 2)
343
+ for name, value in checks.items():
344
+ table.add_row(name, str(value))
345
+ out.print(table)
346
+
347
+
348
+ app = make_app()
@@ -0,0 +1,4 @@
1
+ """Integrations with third-party frameworks.
2
+
3
+ Import the submodules explicitly; they require the matching extras.
4
+ """
@@ -0,0 +1,306 @@
1
+ """Django integration (``pip install pyevp[django]``).
2
+
3
+ Add ``"pyevp.contrib.django"`` to ``INSTALLED_APPS`` and run ``migrate``.
4
+
5
+ - ``{% load pyevp %}`` and ``{% evp_token_input %}`` render the hidden token input, and
6
+ :func:`verify_request` / :func:`averify_request` verify what the form submitted.
7
+ - :class:`EVPCache` / :class:`AsyncEVPCache` share issuer metadata and key
8
+ sets between workers through Django's cache framework.
9
+ - :class:`EVPReplayGuard` / :class:`AsyncEVPReplayGuard` remember accepted
10
+ tokens in a database table.
11
+
12
+ ::
13
+
14
+ from pyevp import EVPError, Verifier
15
+ from pyevp.contrib.django import EVPCache, EVPReplayGuard, verify_request
16
+
17
+ verifier = Verifier.default(
18
+ audience=settings.EVP_ORIGIN, cache=EVPCache(), replay_guard=EVPReplayGuard()
19
+ )
20
+
21
+ # In the view that handles the form:
22
+ try:
23
+ verified = verify_request(request, verifier, email=form.cleaned_data["email"])
24
+ except EVPError:
25
+ verified = None # fall back to a confirmation email
26
+
27
+ Caches and databases are looked up on every call, so these can be created at
28
+ import time, before Django's settings are configured.
29
+
30
+ For running an issuer, see :mod:`pyevp.contrib.django.issuer`.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ from datetime import UTC, datetime, timedelta
37
+ from typing import TYPE_CHECKING
38
+
39
+ from asgiref.sync import sync_to_async
40
+ from django.conf import settings
41
+ from django.core.cache import caches
42
+ from django.db import IntegrityError, transaction
43
+ from django.http import HttpRequest
44
+
45
+ from pyevp.cache import CacheEntry
46
+ from pyevp.nonce import generate_nonce
47
+ from pyevp.ports import Clock, system_clock
48
+ from pyevp.types import VerifiedEmail
49
+ from pyevp.verifier import AsyncVerifier, Verifier
50
+
51
+ if TYPE_CHECKING:
52
+ from django.contrib.sessions.backends.base import SessionBase
53
+
54
+ __all__ = [
55
+ "AsyncEVPCache",
56
+ "AsyncEVPReplayGuard",
57
+ "EVPCache",
58
+ "EVPReplayGuard",
59
+ "aget_nonce",
60
+ "averify_request",
61
+ "get_nonce",
62
+ "verify_request",
63
+ ]
64
+
65
+ _SESSION_KEY = "evp_nonce"
66
+ # The nonce handed out during the current request, so that every form on a page gets it.
67
+ _REQUEST_KEY = "_pyevp_nonce"
68
+
69
+
70
+ def get_nonce(request: HttpRequest) -> str:
71
+ """Return the nonce for the forms on this page, keeping it in the session.
72
+
73
+ The session holds one nonce, so all forms rendered in a request share it: the
74
+ first call creates it and later calls return the same value. Opening the
75
+ page in another tab replaces it, and the older tab's forms then fall back to
76
+ your usual flow. ``{% evp_token_input %}`` calls this for you.
77
+
78
+ It replaces the nonce in the session, so in a view that handles a submission,
79
+ call :func:`verify_request` before rendering a form again.
80
+
81
+ Every call writes the session, so render the token input only on pages that
82
+ have a form: anywhere else it gives each visitor a session for nothing.
83
+ """
84
+ nonce = request.__dict__.get(_REQUEST_KEY)
85
+ if nonce is None:
86
+ nonce = generate_nonce()
87
+ _session(request)[_SESSION_KEY] = nonce
88
+ request.__dict__[_REQUEST_KEY] = nonce
89
+ return nonce
90
+
91
+
92
+ async def aget_nonce(request: HttpRequest) -> str:
93
+ """:func:`get_nonce` for async views.
94
+
95
+ Django refuses session access from async code, including from a template
96
+ tag. Call this before rendering; ``{% evp_token_input %}`` then reuses the
97
+ nonce without touching the session.
98
+ """
99
+ return await sync_to_async(get_nonce, thread_sensitive=True)(request)
100
+
101
+
102
+ def verify_request(
103
+ request: HttpRequest,
104
+ verifier: Verifier,
105
+ *,
106
+ email: str | None,
107
+ field: str = "evt",
108
+ audience: str | None = None,
109
+ ) -> VerifiedEmail | None:
110
+ """Verify the token that a form submitted, if it carried one.
111
+
112
+ Returns ``None`` when the ``field`` is empty: the browser does not support
113
+ EVP, or the email provider does not issue tokens. The nonce then stays in
114
+ the session, so a form that is not rendered again (one sent with ``fetch()``)
115
+ can still be verified on the next submission.
116
+
117
+ Otherwise the nonce is consumed and the token checked with
118
+ :meth:`pyevp.Verifier.verify`, which raises :class:`~pyevp.EVPError` on
119
+ failure. If the session has no nonce, for example because it expired, that
120
+ is ``nonce_mismatch``.
121
+
122
+ :param email: the address the user submitted, as for :meth:`~pyevp.Verifier.verify`.
123
+ :param field: the name of the hidden input (``{% evp_token_input field=... %}``).
124
+ """
125
+ taken = _take_nonce(request, field)
126
+ if taken is None:
127
+ return None
128
+ token, nonce = taken
129
+ return verifier.verify(token, nonce=nonce, email=email, audience=audience)
130
+
131
+
132
+ async def averify_request(
133
+ request: HttpRequest,
134
+ verifier: AsyncVerifier,
135
+ *,
136
+ email: str | None,
137
+ field: str = "evt",
138
+ audience: str | None = None,
139
+ ) -> VerifiedEmail | None:
140
+ """:func:`verify_request` for async views and :class:`~pyevp.AsyncVerifier`."""
141
+ taken = await sync_to_async(_take_nonce, thread_sensitive=True)(request, field)
142
+ if taken is None:
143
+ return None
144
+ token, nonce = taken
145
+ return await verifier.verify(token, nonce=nonce, email=email, audience=audience)
146
+
147
+
148
+ def _take_nonce(request: HttpRequest, field: str) -> tuple[str, str] | None:
149
+ token = request.POST.get(field, "")
150
+ if not token:
151
+ return None
152
+ # The nonce is consumed: a form rendered later in this request needs a new one.
153
+ request.__dict__.pop(_REQUEST_KEY, None)
154
+ # Without a nonce in the session, a throwaway one makes the verifier fail with
155
+ # nonce_mismatch, so observers see it like any other rejection.
156
+ nonce = _session(request).pop(_SESSION_KEY, None) or generate_nonce()
157
+ return token, nonce
158
+
159
+
160
+ def _session(request: HttpRequest) -> SessionBase:
161
+ # Added by SessionMiddleware, which Django's types do not model.
162
+ return request.session # ty: ignore[unresolved-attribute]
163
+
164
+
165
+ def _digest(key: str) -> str:
166
+ # Fixed length, so long URLs stay within Memcached's 250-byte key limit.
167
+ return hashlib.sha256(key.encode()).hexdigest()
168
+
169
+
170
+ # Part of every cache key. Bump it when the stored value changes shape, so that workers
171
+ # running different versions during a deploy ignore each other's entries.
172
+ _CACHE_FORMAT = "1:"
173
+
174
+
175
+ def _dump(entry: CacheEntry) -> tuple[object, datetime]:
176
+ # Built-in types only: a pickled CacheEntry would break if the class ever moved.
177
+ return (entry.value, entry.stored_at)
178
+
179
+
180
+ def _load(stored: object) -> CacheEntry | None:
181
+ if isinstance(stored, tuple) and len(stored) == 2 and isinstance(stored[1], datetime):
182
+ return CacheEntry(stored[0], stored[1])
183
+ return None
184
+
185
+
186
+ class _CacheBase:
187
+ def __init__(self, alias: str = "default", *, prefix: str = "evp:") -> None:
188
+ self.alias = alias
189
+ self.prefix = prefix
190
+
191
+ def _key(self, key: str) -> str:
192
+ return self.prefix + _CACHE_FORMAT + _digest(key)
193
+
194
+
195
+ class EVPCache(_CacheBase):
196
+ """A :class:`~pyevp.Cache` backed by one of Django's ``CACHES``.
197
+
198
+ Use a backend shared between workers (Redis, Memcached, database) for the
199
+ cache to help; ``locmem`` is per process like :class:`~pyevp.InMemoryCache`.
200
+ An evicted entry is simply fetched again. With :class:`~pyevp.AsyncVerifier`,
201
+ use :class:`AsyncEVPCache`, which does not block the event loop.
202
+ """
203
+
204
+ def get(self, key: str) -> CacheEntry | None:
205
+ return _load(caches[self.alias].get(self._key(key)))
206
+
207
+ def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None:
208
+ caches[self.alias].set(self._key(key), _dump(entry), timeout=ttl.total_seconds())
209
+
210
+
211
+ class AsyncEVPCache(_CacheBase):
212
+ """An :class:`~pyevp.AsyncCache` using Django's async cache API (``aget`` / ``aset``).
213
+
214
+ Takes the same arguments as :class:`EVPCache` and shares its entries.
215
+ """
216
+
217
+ async def get(self, key: str) -> CacheEntry | None:
218
+ return _load(await caches[self.alias].aget(self._key(key)))
219
+
220
+ async def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None:
221
+ await caches[self.alias].aset(self._key(key), _dump(entry), timeout=ttl.total_seconds())
222
+
223
+
224
+ class _GuardBase:
225
+ def __init__(self, using: str = "default", *, clock: Clock = system_clock) -> None:
226
+ self.using = using
227
+ self._clock = clock
228
+
229
+ def _mark(self, key: str, expires_at: datetime) -> bool:
230
+ from pyevp.contrib.django.models import UsedToken # noqa: PLC0415
231
+
232
+ connection = transaction.get_connection(self.using)
233
+ # Outside an atomic block but with autocommit off, Django would treat the caller's
234
+ # manual transaction as the outer block: nothing commits and a rollback forgets the
235
+ # token. (Inside an atomic block autocommit is off too; durable=True handles that.)
236
+ if not connection.in_atomic_block and not connection.get_autocommit():
237
+ raise RuntimeError(_MANUAL.format(using=self.using))
238
+ rows = UsedToken.objects.using(self.using)
239
+ try:
240
+ # Durable: the record is committed here, not with (or rolled back with) the
241
+ # caller's transaction. Nested in one, Django raises instead.
242
+ with transaction.atomic(using=self.using, durable=True):
243
+ rows.filter(expires_at__lte=_db_time(self._clock())).delete()
244
+ rows.create(key=_digest(key), expires_at=_db_time(expires_at))
245
+ except IntegrityError:
246
+ return False
247
+ except RuntimeError as exc:
248
+ if connection.in_atomic_block:
249
+ raise RuntimeError(_NESTED.format(using=self.using)) from exc
250
+ raise
251
+ return True
252
+
253
+
254
+ _NESTED = (
255
+ "EVPReplayGuard must commit its record on its own, but database {using!r} is inside "
256
+ "an atomic block (ATOMIC_REQUESTS or transaction.atomic()); a rollback there would "
257
+ "forget the token. Give the guard its own alias for the same database with "
258
+ "ATOMIC_REQUESTS off, e.g. EVPReplayGuard(using='evp'), or verify outside the "
259
+ "transaction."
260
+ )
261
+
262
+
263
+ _MANUAL = (
264
+ "EVPReplayGuard must commit its record on its own, but autocommit is off on "
265
+ "database {using!r} (AUTOCOMMIT=False or transaction.set_autocommit(False)); a "
266
+ "rollback there would forget the token. Give the guard its own alias for the same "
267
+ "database with AUTOCOMMIT on, e.g. EVPReplayGuard(using='evp')."
268
+ )
269
+
270
+
271
+ class EVPReplayGuard(_GuardBase):
272
+ """A :class:`~pyevp.ReplayGuard` storing accepted tokens in the database.
273
+
274
+ Each token is a row keyed by its digest until the token expires; a second
275
+ insert of the same key fails on the primary key, so concurrent workers
276
+ cannot both accept a token. Unlike a cache, the table never evicts rows
277
+ early, and database errors propagate, so verification fails closed.
278
+ Expired rows are deleted as new tokens are recorded.
279
+
280
+ Each record is committed immediately in its own transaction, so a later
281
+ rollback of the request cannot undo it. Called inside a transaction on
282
+ the same database (``ATOMIC_REQUESTS``, ``transaction.atomic()``, or with
283
+ autocommit off) it raises ``RuntimeError`` instead; point ``using`` at a
284
+ second alias for the same database with ``ATOMIC_REQUESTS`` off and
285
+ autocommit on. Django's ``TestCase`` transactions are exempt.
286
+
287
+ Requires ``"pyevp.contrib.django"`` in ``INSTALLED_APPS`` and ``migrate``.
288
+ """
289
+
290
+ def mark_used(self, key: str, expires_at: datetime) -> bool:
291
+ return self._mark(key, expires_at)
292
+
293
+
294
+ class AsyncEVPReplayGuard(_GuardBase):
295
+ """:class:`EVPReplayGuard` for :class:`~pyevp.AsyncVerifier`.
296
+
297
+ The database work runs in Django's thread for synchronous code.
298
+ """
299
+
300
+ async def mark_used(self, key: str, expires_at: datetime) -> bool:
301
+ return await sync_to_async(self._mark, thread_sensitive=True)(key, expires_at)
302
+
303
+
304
+ def _db_time(value: datetime) -> datetime:
305
+ # Without USE_TZ, Django stores naive datetimes (and SQLite / MySQL reject aware ones).
306
+ return value if settings.USE_TZ else value.astimezone(UTC).replace(tzinfo=None)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+ from django.core import checks
5
+
6
+
7
+ class PyevpConfig(AppConfig):
8
+ name = "pyevp.contrib.django"
9
+ label = "pyevp"
10
+ verbose_name = "Email Verification Protocol"
11
+
12
+ def ready(self) -> None:
13
+ from pyevp.contrib.django.issuer import check_session_cookie # noqa: PLC0415
14
+
15
+ # Like Django's own cookie checks, only for `check --deploy`: development servers
16
+ # usually run without HTTPS.
17
+ checks.register(check_session_cookie, checks.Tags.security, deploy=True)