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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""TXT resolvers backed by dnspython (``pip install pyevp[dns]``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dns.asyncresolver
|
|
6
|
+
import dns.flags
|
|
7
|
+
import dns.resolver
|
|
8
|
+
from dns.resolver import Answer
|
|
9
|
+
|
|
10
|
+
__all__ = ["AsyncDnsPythonResolver", "DnsPythonResolver", "DnssecError"]
|
|
11
|
+
|
|
12
|
+
_EMPTY = (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DnssecError(Exception):
|
|
16
|
+
"""The answer was not marked authenticated (AD) by the resolver."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _configure(resolver: dns.resolver.BaseResolver, require_dnssec: bool) -> None:
|
|
20
|
+
if require_dnssec:
|
|
21
|
+
resolver.use_edns(0, dns.flags.DO, 1232)
|
|
22
|
+
# ``None`` means dnspython's default query flags, which set RD; keep it.
|
|
23
|
+
base = dns.flags.RD if resolver.flags is None else resolver.flags
|
|
24
|
+
resolver.flags = base | dns.flags.AD
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _records(answer: Answer, name: str, require_dnssec: bool) -> list[str]:
|
|
28
|
+
if require_dnssec and not (answer.response.flags & dns.flags.AD):
|
|
29
|
+
raise DnssecError(f"TXT answer for {name} is not DNSSEC-authenticated")
|
|
30
|
+
return [b"".join(rdata.strings).decode("utf-8", "replace") for rdata in answer]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DnsPythonResolver:
|
|
34
|
+
"""Synchronous resolver.
|
|
35
|
+
|
|
36
|
+
With ``require_dnssec=True`` the answer must carry the AD flag, which is only
|
|
37
|
+
meaningful when the configured upstream resolver validates DNSSEC and the
|
|
38
|
+
path to it is trusted (e.g. a local validating resolver).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
resolver: dns.resolver.Resolver | None = None,
|
|
44
|
+
*,
|
|
45
|
+
require_dnssec: bool = False,
|
|
46
|
+
lifetime: float = 5.0,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._resolver = resolver or dns.resolver.Resolver()
|
|
49
|
+
self._resolver.lifetime = lifetime
|
|
50
|
+
self._require_dnssec = require_dnssec
|
|
51
|
+
_configure(self._resolver, require_dnssec)
|
|
52
|
+
|
|
53
|
+
def resolve_txt(self, name: str) -> list[str]:
|
|
54
|
+
try:
|
|
55
|
+
answer = self._resolver.resolve(name, "TXT")
|
|
56
|
+
except _EMPTY:
|
|
57
|
+
return []
|
|
58
|
+
return _records(answer, name, self._require_dnssec)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AsyncDnsPythonResolver:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
resolver: dns.asyncresolver.Resolver | None = None,
|
|
65
|
+
*,
|
|
66
|
+
require_dnssec: bool = False,
|
|
67
|
+
lifetime: float = 5.0,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._resolver = resolver or dns.asyncresolver.Resolver()
|
|
70
|
+
self._resolver.lifetime = lifetime
|
|
71
|
+
self._require_dnssec = require_dnssec
|
|
72
|
+
_configure(self._resolver, require_dnssec)
|
|
73
|
+
|
|
74
|
+
async def resolve_txt(self, name: str) -> list[str]:
|
|
75
|
+
try:
|
|
76
|
+
answer = await self._resolver.resolve(name, "TXT")
|
|
77
|
+
except _EMPTY:
|
|
78
|
+
return []
|
|
79
|
+
return _records(answer, name, self._require_dnssec)
|
pyevp/adapters/doh.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""TXT resolvers over DNS-over-HTTPS JSON APIs (``pip install pyevp[httpx2]`` or ``pyevp[httpx]``).
|
|
2
|
+
|
|
3
|
+
Useful where plain DNS is unavailable or untrusted (serverless platforms,
|
|
4
|
+
locked-down networks). Only an HTTP client is needed; dnspython is not.
|
|
5
|
+
|
|
6
|
+
``require_dnssec`` checks the resolver's ``AD`` flag, which only means that the
|
|
7
|
+
DoH provider validated the answer: you are trusting that provider over TLS.
|
|
8
|
+
Unsigned zones (gmail.com, for example) never pass this check.
|
|
9
|
+
|
|
10
|
+
If you already use ``dnspython[doh]``, an RFC 8484 resolver is also possible::
|
|
11
|
+
|
|
12
|
+
resolver = dns.resolver.Resolver(configure=False)
|
|
13
|
+
resolver.nameservers = ["https://cloudflare-dns.com/dns-query"]
|
|
14
|
+
DnsPythonResolver(resolver)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import TYPE_CHECKING, Self
|
|
21
|
+
|
|
22
|
+
from pyevp.adapters._doh import (
|
|
23
|
+
CLOUDFLARE,
|
|
24
|
+
GOOGLE,
|
|
25
|
+
DnssecError,
|
|
26
|
+
DohError,
|
|
27
|
+
)
|
|
28
|
+
from pyevp.adapters._doh import HEADERS as _HEADERS
|
|
29
|
+
from pyevp.adapters._doh import params as _params
|
|
30
|
+
from pyevp.adapters._doh import records as _records
|
|
31
|
+
from pyevp.adapters._http import http
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pyevp.adapters._http import AsyncClient, Client, Response
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CLOUDFLARE",
|
|
38
|
+
"GOOGLE",
|
|
39
|
+
"AsyncDohResolver",
|
|
40
|
+
"DnssecError",
|
|
41
|
+
"DohError",
|
|
42
|
+
"DohResolver",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check(response: Response, name: str) -> object:
|
|
47
|
+
if response.status_code != 200:
|
|
48
|
+
raise DohError(f"DoH lookup of {name} returned HTTP {response.status_code}")
|
|
49
|
+
try:
|
|
50
|
+
return response.json()
|
|
51
|
+
except (ValueError, RecursionError) as exc:
|
|
52
|
+
raise DohError(f"DoH lookup of {name} did not return JSON") from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DohResolver:
|
|
56
|
+
"""Synchronous DoH TXT resolver (Google by default; pass ``endpoint=CLOUDFLARE`` etc.)."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
endpoint: str = GOOGLE,
|
|
61
|
+
*,
|
|
62
|
+
client: Client | None = None,
|
|
63
|
+
require_dnssec: bool = False,
|
|
64
|
+
timeout: float = 5.0,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.endpoint = endpoint
|
|
67
|
+
self._owns_client = client is None
|
|
68
|
+
self._client: Client = client or http.Client(timeout=timeout, follow_redirects=False)
|
|
69
|
+
self._require_dnssec = require_dnssec
|
|
70
|
+
|
|
71
|
+
def resolve_txt(self, name: str) -> list[str]:
|
|
72
|
+
response = self._client.get(
|
|
73
|
+
self.endpoint, params=_params(name), headers=_HEADERS, follow_redirects=False
|
|
74
|
+
)
|
|
75
|
+
return _records(_check(response, name), name, self._require_dnssec)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Close the client this resolver created; a client passed in stays open."""
|
|
79
|
+
if self._owns_client:
|
|
80
|
+
self._client.close()
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> Self:
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(
|
|
86
|
+
self,
|
|
87
|
+
exc_type: type[BaseException] | None,
|
|
88
|
+
exc: BaseException | None,
|
|
89
|
+
tb: TracebackType | None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AsyncDohResolver:
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
endpoint: str = GOOGLE,
|
|
98
|
+
*,
|
|
99
|
+
client: AsyncClient | None = None,
|
|
100
|
+
require_dnssec: bool = False,
|
|
101
|
+
timeout: float = 5.0,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.endpoint = endpoint
|
|
104
|
+
self._owns_client = client is None
|
|
105
|
+
self._client: AsyncClient = client or http.AsyncClient(
|
|
106
|
+
timeout=timeout, follow_redirects=False
|
|
107
|
+
)
|
|
108
|
+
self._require_dnssec = require_dnssec
|
|
109
|
+
|
|
110
|
+
async def resolve_txt(self, name: str) -> list[str]:
|
|
111
|
+
response = await self._client.get(
|
|
112
|
+
self.endpoint, params=_params(name), headers=_HEADERS, follow_redirects=False
|
|
113
|
+
)
|
|
114
|
+
return _records(_check(response, name), name, self._require_dnssec)
|
|
115
|
+
|
|
116
|
+
async def aclose(self) -> None:
|
|
117
|
+
"""Close the client this resolver created; a client passed in stays open."""
|
|
118
|
+
if self._owns_client:
|
|
119
|
+
await self._client.aclose()
|
|
120
|
+
|
|
121
|
+
async def __aenter__(self) -> Self:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __aexit__(
|
|
125
|
+
self,
|
|
126
|
+
exc_type: type[BaseException] | None,
|
|
127
|
+
exc: BaseException | None,
|
|
128
|
+
tb: TracebackType | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
await self.aclose()
|
pyevp/adapters/httpx.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""JSON fetchers backed by httpx2 or httpx (``pip install pyevp[httpx2]`` / ``pyevp[httpx]``).
|
|
2
|
+
|
|
3
|
+
httpx2 is used for default clients when it is installed; a client from either
|
|
4
|
+
library can be passed in explicitly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import socket
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import TYPE_CHECKING, Self
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
|
|
15
|
+
from pyevp.adapters import _fetch
|
|
16
|
+
from pyevp.adapters._fetch import HEADERS as _HEADERS
|
|
17
|
+
from pyevp.adapters._fetch import (
|
|
18
|
+
MAX_DOCUMENT_BYTES,
|
|
19
|
+
AsyncResolveHost,
|
|
20
|
+
FetchError,
|
|
21
|
+
ResolveHost,
|
|
22
|
+
)
|
|
23
|
+
from pyevp.adapters._fetch import decode as _decode
|
|
24
|
+
from pyevp.adapters._http import http
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pyevp.adapters._http import AsyncClient, Client, Response
|
|
28
|
+
|
|
29
|
+
__all__ = ["AsyncHttpxFetcher", "FetchError", "HttpxFetcher"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check(response: Response) -> None:
|
|
33
|
+
if response.status_code != 200:
|
|
34
|
+
raise FetchError(f"GET {response.request.url} returned HTTP {response.status_code}")
|
|
35
|
+
if response.headers.get("Content-Encoding", "identity").strip().lower() != "identity":
|
|
36
|
+
raise FetchError(f"GET {response.request.url} returned a compressed response")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _anyio_resolve_host(host: str) -> list[str]:
|
|
40
|
+
infos = await anyio.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
|
41
|
+
return [str(info[4][0]) for info in infos]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HttpxFetcher:
|
|
45
|
+
"""Synchronous fetcher.
|
|
46
|
+
|
|
47
|
+
Redirects are not followed, compressed responses are refused and bodies are size-capped.
|
|
48
|
+
Before each request the host is resolved and refused unless every address is globally
|
|
49
|
+
routable; pass ``require_global_addresses=False`` to reach an issuer on a private network
|
|
50
|
+
(local development) or when an egress proxy enforces that policy and resolves names
|
|
51
|
+
itself. ``resolve_host`` replaces the system resolver used for that check.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
client: Client | None = None,
|
|
57
|
+
*,
|
|
58
|
+
timeout: float = 5.0,
|
|
59
|
+
require_global_addresses: bool = True,
|
|
60
|
+
resolve_host: ResolveHost = _fetch.system_resolve_host,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._owns_client = client is None
|
|
63
|
+
self._client: Client = client or http.Client(timeout=timeout, follow_redirects=False)
|
|
64
|
+
self._require_global = require_global_addresses
|
|
65
|
+
self._resolve_host = resolve_host
|
|
66
|
+
|
|
67
|
+
def fetch_json(self, url: str) -> object:
|
|
68
|
+
if self._require_global:
|
|
69
|
+
_fetch.require_global(url, self._resolve_host)
|
|
70
|
+
with self._client.stream("GET", url, headers=_HEADERS, follow_redirects=False) as response:
|
|
71
|
+
_check(response)
|
|
72
|
+
body = bytearray()
|
|
73
|
+
for chunk in response.iter_raw():
|
|
74
|
+
body += chunk
|
|
75
|
+
if len(body) > MAX_DOCUMENT_BYTES:
|
|
76
|
+
raise FetchError(f"GET {url} response is too large")
|
|
77
|
+
return _decode(bytes(body), url)
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
"""Close the client this fetcher created; a client passed in stays open."""
|
|
81
|
+
if self._owns_client:
|
|
82
|
+
self._client.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> Self:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(
|
|
88
|
+
self,
|
|
89
|
+
exc_type: type[BaseException] | None,
|
|
90
|
+
exc: BaseException | None,
|
|
91
|
+
tb: TracebackType | None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AsyncHttpxFetcher:
|
|
97
|
+
"""Asynchronous counterpart of :class:`HttpxFetcher` (asyncio or trio)."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
client: AsyncClient | None = None,
|
|
102
|
+
*,
|
|
103
|
+
timeout: float = 5.0,
|
|
104
|
+
require_global_addresses: bool = True,
|
|
105
|
+
resolve_host: AsyncResolveHost = _anyio_resolve_host,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._owns_client = client is None
|
|
108
|
+
self._client: AsyncClient = client or http.AsyncClient(
|
|
109
|
+
timeout=timeout, follow_redirects=False
|
|
110
|
+
)
|
|
111
|
+
self._require_global = require_global_addresses
|
|
112
|
+
self._resolve_host = resolve_host
|
|
113
|
+
|
|
114
|
+
async def fetch_json(self, url: str) -> object:
|
|
115
|
+
if self._require_global:
|
|
116
|
+
host = _fetch.host_of(url)
|
|
117
|
+
try:
|
|
118
|
+
addresses = list(await self._resolve_host(host))
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
raise FetchError(f"GET {url}: cannot resolve {host}: {exc}") from exc
|
|
121
|
+
_fetch.check_global_addresses(url, host, addresses)
|
|
122
|
+
async with self._client.stream(
|
|
123
|
+
"GET", url, headers=_HEADERS, follow_redirects=False
|
|
124
|
+
) as response:
|
|
125
|
+
_check(response)
|
|
126
|
+
body = bytearray()
|
|
127
|
+
async for chunk in response.aiter_raw():
|
|
128
|
+
body += chunk
|
|
129
|
+
if len(body) > MAX_DOCUMENT_BYTES:
|
|
130
|
+
raise FetchError(f"GET {url} response is too large")
|
|
131
|
+
return _decode(bytes(body), url)
|
|
132
|
+
|
|
133
|
+
async def aclose(self) -> None:
|
|
134
|
+
"""Close the client this fetcher created; a client passed in stays open."""
|
|
135
|
+
if self._owns_client:
|
|
136
|
+
await self._client.aclose()
|
|
137
|
+
|
|
138
|
+
async def __aenter__(self) -> Self:
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
async def __aexit__(
|
|
142
|
+
self,
|
|
143
|
+
exc_type: type[BaseException] | None,
|
|
144
|
+
exc: BaseException | None,
|
|
145
|
+
tb: TracebackType | None,
|
|
146
|
+
) -> None:
|
|
147
|
+
await self.aclose()
|
pyevp/adapters/urllib.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""A JSON fetcher and a DoH TXT resolver using only the standard library.
|
|
2
|
+
|
|
3
|
+
Nothing beyond PyEVP's core dependencies is needed, which suits applications
|
|
4
|
+
that already pick their own HTTP stack and do not want httpx or dnspython::
|
|
5
|
+
|
|
6
|
+
from pyevp import Verifier
|
|
7
|
+
from pyevp.adapters.urllib import UrllibDohResolver, UrllibFetcher
|
|
8
|
+
|
|
9
|
+
verifier = Verifier(audience=..., resolver=UrllibDohResolver(), fetcher=UrllibFetcher())
|
|
10
|
+
|
|
11
|
+
Both are synchronous; async applications should use the httpx adapters.
|
|
12
|
+
Like those, they never follow redirects, refuse compressed responses and
|
|
13
|
+
cap body sizes. Proxies from the environment (``HTTPS_PROXY``) are honoured
|
|
14
|
+
as usual for urllib; pass ``handlers`` to configure proxies or TLS differently::
|
|
15
|
+
|
|
16
|
+
UrllibFetcher(handlers=[urllib.request.HTTPSHandler(context=my_ssl_context)])
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import http.client
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from collections.abc import Mapping, Sequence
|
|
25
|
+
from email.message import Message
|
|
26
|
+
from typing import IO
|
|
27
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
28
|
+
|
|
29
|
+
from pyevp.adapters import _fetch
|
|
30
|
+
from pyevp.adapters._doh import CLOUDFLARE, GOOGLE, DnssecError, DohError, params, records
|
|
31
|
+
from pyevp.adapters._doh import HEADERS as _DOH_HEADERS
|
|
32
|
+
from pyevp.adapters._fetch import FetchError
|
|
33
|
+
|
|
34
|
+
# The DoH names are re-exported because pyevp.adapters.doh needs httpx.
|
|
35
|
+
__all__ = [
|
|
36
|
+
"CLOUDFLARE",
|
|
37
|
+
"GOOGLE",
|
|
38
|
+
"DnssecError",
|
|
39
|
+
"DohError",
|
|
40
|
+
"FetchError",
|
|
41
|
+
"UrllibDohResolver",
|
|
42
|
+
"UrllibFetcher",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
47
|
+
"""Turn every redirect into an ``HTTPError`` instead of following it."""
|
|
48
|
+
|
|
49
|
+
def redirect_request(self, *args: object, **kwargs: object) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _opener(handlers: Sequence[urllib.request.BaseHandler]) -> urllib.request.OpenerDirector:
|
|
54
|
+
# Our handler replaces urllib's default redirect handler; drop any the caller passed.
|
|
55
|
+
kept = [h for h in handlers if not isinstance(h, urllib.request.HTTPRedirectHandler)]
|
|
56
|
+
return urllib.request.build_opener(*kept, _NoRedirects())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get(
|
|
60
|
+
opener: urllib.request.OpenerDirector,
|
|
61
|
+
url: str,
|
|
62
|
+
headers: Mapping[str, str],
|
|
63
|
+
timeout: float,
|
|
64
|
+
error: type[Exception],
|
|
65
|
+
) -> bytes:
|
|
66
|
+
"""GET ``url`` and return the raw body of a 200, uncompressed response."""
|
|
67
|
+
request = urllib.request.Request(url, headers=dict(headers), method="GET")
|
|
68
|
+
try:
|
|
69
|
+
with opener.open(request, timeout=timeout) as response:
|
|
70
|
+
return _read(response, response.status, response.headers, url, error)
|
|
71
|
+
except urllib.error.HTTPError as exc:
|
|
72
|
+
exc.close()
|
|
73
|
+
raise error(f"GET {url} returned HTTP {exc.code}") from None
|
|
74
|
+
# HTTPException covers IncompleteRead from truncated chunked bodies.
|
|
75
|
+
except (urllib.error.URLError, OSError, http.client.HTTPException) as exc:
|
|
76
|
+
raise error(f"GET {url} failed: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read(
|
|
80
|
+
response: IO[bytes], status: int, headers: Message, url: str, error: type[Exception]
|
|
81
|
+
) -> bytes:
|
|
82
|
+
if status != 200:
|
|
83
|
+
raise error(f"GET {url} returned HTTP {status}")
|
|
84
|
+
if headers.get("Content-Encoding", "identity").strip().lower() != "identity":
|
|
85
|
+
raise error(f"GET {url} returned a compressed response")
|
|
86
|
+
declared = _content_length(headers)
|
|
87
|
+
if declared is not None and declared > _fetch.MAX_DOCUMENT_BYTES:
|
|
88
|
+
raise error(f"GET {url} response is too large")
|
|
89
|
+
body = response.read(_fetch.MAX_DOCUMENT_BYTES + 1)
|
|
90
|
+
if len(body) > _fetch.MAX_DOCUMENT_BYTES:
|
|
91
|
+
raise error(f"GET {url} response is too large")
|
|
92
|
+
# read(n) returns short when the server closes early; httpx would raise here.
|
|
93
|
+
if declared is not None and len(body) != declared:
|
|
94
|
+
raise error(f"GET {url} response is incomplete")
|
|
95
|
+
return body
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _content_length(headers: Message) -> int | None:
|
|
99
|
+
value = headers.get("Content-Length")
|
|
100
|
+
if value is None or headers.get("Transfer-Encoding"):
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
length = int(value)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
return length if length >= 0 else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _with_query(endpoint: str, extra: Mapping[str, str]) -> str:
|
|
110
|
+
"""Append ``extra`` to whatever query ``endpoint`` already has, as httpx's ``params`` do."""
|
|
111
|
+
parts = urlsplit(endpoint)
|
|
112
|
+
query = "&".join(q for q in (parts.query, urlencode(extra)) if q)
|
|
113
|
+
return urlunsplit(parts._replace(query=query))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class UrllibFetcher:
|
|
117
|
+
"""Synchronous :class:`~pyevp.JsonFetcher` built on :mod:`urllib.request`.
|
|
118
|
+
|
|
119
|
+
Like :class:`~pyevp.adapters.httpx.HttpxFetcher`, it refuses hosts that do not resolve
|
|
120
|
+
exclusively to globally routable addresses unless ``require_global_addresses=False``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
timeout: float = 5.0,
|
|
127
|
+
handlers: Sequence[urllib.request.BaseHandler] = (),
|
|
128
|
+
require_global_addresses: bool = True,
|
|
129
|
+
resolve_host: _fetch.ResolveHost = _fetch.system_resolve_host,
|
|
130
|
+
) -> None:
|
|
131
|
+
self._timeout = timeout
|
|
132
|
+
self._opener = _opener(handlers)
|
|
133
|
+
self._require_global = require_global_addresses
|
|
134
|
+
self._resolve_host = resolve_host
|
|
135
|
+
|
|
136
|
+
def fetch_json(self, url: str) -> object:
|
|
137
|
+
if self._require_global:
|
|
138
|
+
_fetch.require_global(url, self._resolve_host)
|
|
139
|
+
body = _get(self._opener, url, _fetch.HEADERS, self._timeout, FetchError)
|
|
140
|
+
return _fetch.decode(body, url)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class UrllibDohResolver:
|
|
144
|
+
"""Synchronous DoH TXT resolver (Google by default; pass ``CLOUDFLARE`` etc.).
|
|
145
|
+
|
|
146
|
+
``require_dnssec=True`` checks the provider's ``AD`` flag, so it means trusting that
|
|
147
|
+
provider over TLS; unsigned zones (gmail.com, for example) never pass it.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
endpoint: str = GOOGLE,
|
|
153
|
+
*,
|
|
154
|
+
require_dnssec: bool = False,
|
|
155
|
+
timeout: float = 5.0,
|
|
156
|
+
handlers: Sequence[urllib.request.BaseHandler] = (),
|
|
157
|
+
) -> None:
|
|
158
|
+
self.endpoint = endpoint
|
|
159
|
+
self._require_dnssec = require_dnssec
|
|
160
|
+
self._timeout = timeout
|
|
161
|
+
self._opener = _opener(handlers)
|
|
162
|
+
|
|
163
|
+
def resolve_txt(self, name: str) -> list[str]:
|
|
164
|
+
url = _with_query(self.endpoint, params(name))
|
|
165
|
+
body = _get(self._opener, url, _DOH_HEADERS, self._timeout, DohError)
|
|
166
|
+
try:
|
|
167
|
+
document = _fetch.decode(body, url)
|
|
168
|
+
except FetchError as exc:
|
|
169
|
+
raise DohError(f"DoH lookup of {name} did not return JSON") from exc
|
|
170
|
+
return records(document, name, self._require_dnssec)
|
pyevp/cache.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Caching of issuer metadata and key sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from pyevp.ports import Clock, system_clock
|
|
11
|
+
|
|
12
|
+
__all__ = ["AsyncCache", "Cache", "CacheEntry", "InMemoryCache", "NullCache"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class CacheEntry:
|
|
17
|
+
value: object
|
|
18
|
+
stored_at: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Cache(Protocol):
|
|
22
|
+
"""Key/value store for decoded JSON documents.
|
|
23
|
+
|
|
24
|
+
Implementations may be backed by Django's cache, Redis, … ; they are called
|
|
25
|
+
from both sync and async drivers so must not block for long.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def get(self, key: str) -> CacheEntry | None: ...
|
|
29
|
+
|
|
30
|
+
def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncCache(Protocol):
|
|
34
|
+
""":class:`Cache` with coroutine methods, for stores that must not block the event loop.
|
|
35
|
+
|
|
36
|
+
:class:`~pyevp.AsyncVerifier` accepts either kind.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
async def get(self, key: str) -> CacheEntry | None: ...
|
|
40
|
+
|
|
41
|
+
async def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NullCache:
|
|
45
|
+
"""A cache that stores nothing: every verification fetches metadata and keys."""
|
|
46
|
+
|
|
47
|
+
def get(self, key: str) -> CacheEntry | None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InMemoryCache:
|
|
55
|
+
"""Process-local TTL cache (not shared between workers).
|
|
56
|
+
|
|
57
|
+
Safe to share between threads, e.g. one ``Verifier`` used by a threaded server.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, *, clock: Clock = system_clock, max_entries: int = 256) -> None:
|
|
61
|
+
self._clock = clock
|
|
62
|
+
self._max_entries = max_entries
|
|
63
|
+
self._data: dict[str, tuple[datetime, CacheEntry]] = {}
|
|
64
|
+
self._lock = threading.Lock()
|
|
65
|
+
|
|
66
|
+
def get(self, key: str) -> CacheEntry | None:
|
|
67
|
+
now = self._clock() # outside the lock: the clock is user code
|
|
68
|
+
with self._lock:
|
|
69
|
+
item = self._data.get(key)
|
|
70
|
+
if item is None:
|
|
71
|
+
return None
|
|
72
|
+
expires_at, entry = item
|
|
73
|
+
if now >= expires_at:
|
|
74
|
+
del self._data[key]
|
|
75
|
+
return None
|
|
76
|
+
return entry
|
|
77
|
+
|
|
78
|
+
def set(self, key: str, entry: CacheEntry, ttl: timedelta) -> None:
|
|
79
|
+
expires_at = self._clock() + ttl
|
|
80
|
+
with self._lock:
|
|
81
|
+
if len(self._data) >= self._max_entries and key not in self._data:
|
|
82
|
+
del self._data[min(self._data, key=lambda k: self._data[k][0])]
|
|
83
|
+
self._data[key] = (expires_at, entry)
|