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