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/_sf.py ADDED
@@ -0,0 +1,406 @@
1
+ """Structured Field Values for HTTP (RFC 9651): parsing and serialisation.
2
+
3
+ HTTP Message Signatures (RFC 9421) are built from Structured Fields, and the
4
+ signature base contains a re-serialisation of what was parsed, so both halves
5
+ must follow the RFC exactly. Every parse failure raises :class:`SFError`; per
6
+ RFC 9651 the whole field is then ignored, never partially used.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import re
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass, field
16
+ from decimal import ROUND_HALF_EVEN, Decimal
17
+ from typing import TypeAlias
18
+
19
+ __all__ = [
20
+ "Date",
21
+ "DisplayString",
22
+ "InnerList",
23
+ "Item",
24
+ "SFError",
25
+ "Token",
26
+ "parse_dictionary",
27
+ "parse_item",
28
+ "parse_list",
29
+ "serialize_dictionary",
30
+ "serialize_inner_list",
31
+ "serialize_item",
32
+ "serialize_list",
33
+ ]
34
+
35
+
36
+ class SFError(ValueError):
37
+ """The field value is not a valid Structured Field."""
38
+
39
+
40
+ class Token(str):
41
+ """An sf-token, as opposed to an sf-string."""
42
+
43
+ __slots__ = ()
44
+
45
+
46
+ class DisplayString(str):
47
+ """An sf-displaystring (Unicode text)."""
48
+
49
+ __slots__ = ()
50
+
51
+
52
+ class Date(int):
53
+ """An sf-date (seconds since the epoch)."""
54
+
55
+ __slots__ = ()
56
+
57
+
58
+ # TODO(py3.12): back to a ``type`` statement once 3.11 support is dropped.
59
+ BareItem: TypeAlias = int | Decimal | str | bytes | bool
60
+ Parameters: TypeAlias = dict[str, BareItem]
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Item:
65
+ value: BareItem
66
+ params: Parameters = field(default_factory=dict)
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class InnerList:
71
+ items: tuple[Item, ...]
72
+ params: Parameters = field(default_factory=dict)
73
+
74
+
75
+ Member: TypeAlias = Item | InnerList
76
+
77
+ _TOKEN_START = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz*")
78
+ _BASE64_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
79
+ _INTEGER_MAX = 999_999_999_999_999
80
+ _DECIMAL_INTEGER_MAX = 999_999_999_999
81
+ _TOKEN_RE = re.compile(r"[A-Za-z*][!#$%&'*+\-.^_`|~0-9A-Za-z:/]*")
82
+ _KEY_RE = re.compile(r"[a-z*][a-z0-9_\-.*]*")
83
+
84
+
85
+ class _Parser:
86
+ def __init__(self, lines: str | Iterable[str]) -> None:
87
+ text = lines if isinstance(lines, str) else ", ".join(lines)
88
+ if not text.isascii():
89
+ raise SFError("field value is not ASCII")
90
+ self.s = text.lstrip(" ")
91
+ self.i = 0
92
+
93
+ def peek(self) -> str:
94
+ return self.s[self.i] if self.i < len(self.s) else ""
95
+
96
+ def take(self) -> str:
97
+ c = self.peek()
98
+ self.i += 1
99
+ return c
100
+
101
+ def skip_sp(self) -> None:
102
+ while self.peek() == " ":
103
+ self.i += 1
104
+
105
+ def skip_ows(self) -> None:
106
+ while self.peek() in (" ", "\t") and self.peek():
107
+ self.i += 1
108
+
109
+ def done(self) -> bool:
110
+ return self.i >= len(self.s)
111
+
112
+ def finish(self) -> None:
113
+ self.skip_sp()
114
+ if not self.done():
115
+ raise SFError(f"unexpected {self.peek()!r} at {self.i}")
116
+
117
+ def comma_or_end(self) -> bool:
118
+ """After a member: ``True`` to continue, ``False`` at the end of the field."""
119
+ self.skip_ows()
120
+ if self.done():
121
+ return False
122
+ if self.take() != ",":
123
+ raise SFError(f"expected ',' at {self.i - 1}")
124
+ self.skip_ows()
125
+ if self.done():
126
+ raise SFError("trailing comma")
127
+ return True
128
+
129
+ # --- RFC 9651 section 4.2 ---
130
+
131
+ def list_(self) -> list[Member]:
132
+ members: list[Member] = []
133
+ while not self.done():
134
+ members.append(self.member())
135
+ if not self.comma_or_end():
136
+ break
137
+ return members
138
+
139
+ def dictionary(self) -> dict[str, Member]:
140
+ members: dict[str, Member] = {}
141
+ while not self.done():
142
+ key = self.key()
143
+ if self.peek() == "=":
144
+ self.i += 1
145
+ member = self.member()
146
+ else:
147
+ member = Item(True, self.parameters())
148
+ # A repeated key keeps its first position and takes its last value.
149
+ members[key] = member
150
+ if not self.comma_or_end():
151
+ break
152
+ return members
153
+
154
+ def member(self) -> Member:
155
+ return self.inner_list() if self.peek() == "(" else self.item()
156
+
157
+ def inner_list(self) -> InnerList:
158
+ self.i += 1 # "("
159
+ items: list[Item] = []
160
+ while not self.done():
161
+ self.skip_sp()
162
+ if self.peek() == ")":
163
+ self.i += 1
164
+ return InnerList(tuple(items), self.parameters())
165
+ items.append(self.item())
166
+ if self.peek() not in (" ", ")"):
167
+ raise SFError(f"expected ' ' or ')' at {self.i}")
168
+ raise SFError("unterminated inner list")
169
+
170
+ def item(self) -> Item:
171
+ return Item(self.bare_item(), self.parameters())
172
+
173
+ def bare_item(self) -> BareItem:
174
+ c = self.peek()
175
+ if c == "-" or c.isdigit():
176
+ return self.number()
177
+ if c == '"':
178
+ return self.string()
179
+ if c in _TOKEN_START and c:
180
+ return self.token()
181
+ if c == ":":
182
+ return self.byte_sequence()
183
+ if c == "?":
184
+ return self.boolean()
185
+ if c == "@":
186
+ self.i += 1
187
+ value = self.number()
188
+ if not isinstance(value, int):
189
+ raise SFError("date is not an integer")
190
+ return Date(value)
191
+ if c == "%":
192
+ return self.display_string()
193
+ raise SFError(f"unexpected {c!r} at {self.i}" if c else "missing item")
194
+
195
+ def parameters(self) -> Parameters:
196
+ params: Parameters = {}
197
+ while self.peek() == ";":
198
+ self.i += 1
199
+ self.skip_sp()
200
+ key = self.key()
201
+ value: BareItem = True
202
+ if self.peek() == "=":
203
+ self.i += 1
204
+ value = self.bare_item()
205
+ params[key] = value
206
+ return params
207
+
208
+ def key(self) -> str:
209
+ if (m := _KEY_RE.match(self.s, self.i)) is None:
210
+ raise SFError(f"invalid key at {self.i}")
211
+ self.i = m.end()
212
+ return m.group()
213
+
214
+ def number(self) -> int | Decimal:
215
+ start = self.i
216
+ if self.peek() == "-":
217
+ self.i += 1
218
+ if not self.peek().isdigit() or not self.peek():
219
+ raise SFError(f"expected a digit at {self.i}")
220
+ while self.peek() and (self.peek().isdigit() or self.peek() == "."):
221
+ self.i += 1
222
+ text = self.s[start : self.i]
223
+ digits = text.lstrip("-")
224
+ if "." not in digits:
225
+ if len(digits) > 15:
226
+ raise SFError("integer too long")
227
+ return int(text)
228
+ whole, _, frac = digits.partition(".")
229
+ if "." in frac or len(whole) > 12 or not 1 <= len(frac) <= 3:
230
+ raise SFError("invalid decimal")
231
+ return Decimal(text)
232
+
233
+ def string(self) -> str:
234
+ self.i += 1 # '"'
235
+ out: list[str] = []
236
+ while not self.done():
237
+ c = self.take()
238
+ if c == "\\":
239
+ if (n := self.take()) not in ('"', "\\"):
240
+ raise SFError("invalid escape in string")
241
+ out.append(n)
242
+ elif c == '"':
243
+ return "".join(out)
244
+ elif not " " <= c <= "~":
245
+ raise SFError("invalid character in string")
246
+ else:
247
+ out.append(c)
248
+ raise SFError("unterminated string")
249
+
250
+ def token(self) -> Token:
251
+ m = _TOKEN_RE.match(self.s, self.i)
252
+ assert m is not None
253
+ self.i = m.end()
254
+ return Token(m.group())
255
+
256
+ def byte_sequence(self) -> bytes:
257
+ end = self.s.find(":", self.i + 1)
258
+ if end < 0:
259
+ raise SFError("unterminated byte sequence")
260
+ b64 = self.s[self.i + 1 : end]
261
+ self.i = end + 1
262
+ if not set(b64) <= _BASE64_CHARS:
263
+ raise SFError("invalid base64 in byte sequence")
264
+ try:
265
+ # Senders must pad; RFC 9651 lets parsers accept unpadded input.
266
+ return base64.b64decode(b64 + "=" * (-len(b64) % 4), validate=True)
267
+ except binascii.Error as exc:
268
+ raise SFError("invalid base64 in byte sequence") from exc
269
+
270
+ def boolean(self) -> bool:
271
+ self.i += 1 # "?"
272
+ c = self.take()
273
+ if c not in ("0", "1"):
274
+ raise SFError("invalid boolean")
275
+ return c == "1"
276
+
277
+ def display_string(self) -> DisplayString:
278
+ self.i += 1 # "%"
279
+ if self.take() != '"':
280
+ raise SFError("expected '\"' after '%'")
281
+ out = bytearray()
282
+ while not self.done():
283
+ c = self.take()
284
+ if c == "%":
285
+ hex2 = self.s[self.i : self.i + 2]
286
+ if len(hex2) != 2 or not set(hex2) <= set("0123456789abcdef"):
287
+ raise SFError("invalid percent-encoding in display string")
288
+ out.append(int(hex2, 16))
289
+ self.i += 2
290
+ elif c == '"':
291
+ try:
292
+ return DisplayString(out.decode("utf-8"))
293
+ except UnicodeDecodeError as exc:
294
+ raise SFError("display string is not UTF-8") from exc
295
+ elif not " " <= c <= "~":
296
+ raise SFError("invalid character in display string")
297
+ else:
298
+ out.append(ord(c))
299
+ raise SFError("unterminated display string")
300
+
301
+
302
+ def parse_list(lines: str | Iterable[str]) -> list[Member]:
303
+ p = _Parser(lines)
304
+ members = p.list_()
305
+ p.finish()
306
+ return members
307
+
308
+
309
+ def parse_dictionary(lines: str | Iterable[str]) -> dict[str, Member]:
310
+ p = _Parser(lines)
311
+ members = p.dictionary()
312
+ p.finish()
313
+ return members
314
+
315
+
316
+ def parse_item(lines: str | Iterable[str]) -> Item:
317
+ p = _Parser(lines)
318
+ item = p.item()
319
+ p.finish()
320
+ return item
321
+
322
+
323
+ # --- RFC 9651 section 4.1 ---
324
+
325
+
326
+ def serialize_list(members: Iterable[Member]) -> str:
327
+ return ", ".join(_serialize_member(m) for m in members)
328
+
329
+
330
+ def serialize_dictionary(members: dict[str, Member]) -> str:
331
+ out: list[str] = []
332
+ for key, member in members.items():
333
+ if isinstance(member, Item) and member.value is True:
334
+ out.append(_serialize_key(key) + _serialize_params(member.params))
335
+ else:
336
+ out.append(f"{_serialize_key(key)}={_serialize_member(member)}")
337
+ return ", ".join(out)
338
+
339
+
340
+ def serialize_inner_list(inner: InnerList) -> str:
341
+ items = " ".join(serialize_item(i) for i in inner.items)
342
+ return f"({items}){_serialize_params(inner.params)}"
343
+
344
+
345
+ def serialize_item(item: Item) -> str:
346
+ return _serialize_bare(item.value) + _serialize_params(item.params)
347
+
348
+
349
+ def _serialize_member(member: Member) -> str:
350
+ return serialize_inner_list(member) if isinstance(member, InnerList) else serialize_item(member)
351
+
352
+
353
+ def _serialize_params(params: Parameters) -> str:
354
+ return "".join(
355
+ ";" + _serialize_key(key) + ("" if value is True else "=" + _serialize_bare(value))
356
+ for key, value in params.items()
357
+ )
358
+
359
+
360
+ def _serialize_key(key: str) -> str:
361
+ if _KEY_RE.fullmatch(key) is None:
362
+ raise SFError(f"invalid key {key!r}")
363
+ return key
364
+
365
+
366
+ def _serialize_bare(value: BareItem) -> str:
367
+ if isinstance(value, bool):
368
+ return "?1" if value else "?0"
369
+ if isinstance(value, Date):
370
+ return "@" + _serialize_integer(value)
371
+ if isinstance(value, int):
372
+ return _serialize_integer(value)
373
+ if isinstance(value, Decimal):
374
+ return _serialize_decimal(value)
375
+ if isinstance(value, Token):
376
+ if _TOKEN_RE.fullmatch(value) is None:
377
+ raise SFError(f"invalid token {value!r}")
378
+ return str(value)
379
+ if isinstance(value, DisplayString):
380
+ encoded = "".join(
381
+ f"%{b:02x}" if b in (0x25, 0x22) or not 0x20 <= b <= 0x7E else chr(b)
382
+ for b in value.encode("utf-8")
383
+ )
384
+ return f'%"{encoded}"'
385
+ if isinstance(value, str):
386
+ if not all(" " <= c <= "~" for c in value):
387
+ raise SFError("string contains characters outside printable ASCII")
388
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
389
+ if isinstance(value, bytes):
390
+ return ":" + base64.b64encode(value).decode("ascii") + ":"
391
+ raise SFError(f"cannot serialise {type(value).__name__}")
392
+
393
+
394
+ def _serialize_integer(value: int) -> str:
395
+ if not -_INTEGER_MAX <= value <= _INTEGER_MAX:
396
+ raise SFError("integer out of range")
397
+ return str(int(value))
398
+
399
+
400
+ def _serialize_decimal(value: Decimal) -> str:
401
+ rounded = value.quantize(Decimal("0.001"), rounding=ROUND_HALF_EVEN)
402
+ whole, _, frac = f"{abs(rounded):f}".partition(".")
403
+ if len(whole) > 12 or int(whole) > _DECIMAL_INTEGER_MAX:
404
+ raise SFError("decimal out of range")
405
+ frac = frac.rstrip("0") or "0"
406
+ return ("-" if rounded < 0 else "") + f"{whole}.{frac}"
@@ -0,0 +1,4 @@
1
+ """Optional port implementations backed by third-party libraries.
2
+
3
+ Import the submodules explicitly; they require the matching extras.
4
+ """
pyevp/adapters/_doh.py ADDED
@@ -0,0 +1,92 @@
1
+ """DoH JSON API parsing shared by the resolvers; imports nothing outside the standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = [
8
+ "CLOUDFLARE",
9
+ "GOOGLE",
10
+ "HEADERS",
11
+ "DnssecError",
12
+ "DohError",
13
+ "params",
14
+ "parse_txt_data",
15
+ "records",
16
+ ]
17
+
18
+ GOOGLE = "https://dns.google/resolve"
19
+ CLOUDFLARE = "https://cloudflare-dns.com/dns-query"
20
+
21
+ _TXT = 16
22
+ _NOERROR, _NXDOMAIN = 0, 3
23
+ HEADERS = {"Accept": "application/dns-json", "Accept-Encoding": "identity"}
24
+
25
+
26
+ class DohError(Exception):
27
+ """The DoH provider failed or answered with an error (e.g. SERVFAIL)."""
28
+
29
+
30
+ class DnssecError(DohError):
31
+ """The answer was not marked authenticated (AD) by the DoH provider."""
32
+
33
+
34
+ def parse_txt_data(data: str) -> str:
35
+ """Decode a JSON-API TXT ``data`` value into one string.
36
+
37
+ Cloudflare returns presentation format: each character-string quoted, with
38
+ ``\\"``, ``\\\\`` and ``\\DDD`` escapes, separated by spaces. Google returns
39
+ the strings already joined and unquoted.
40
+ """
41
+ if not data.startswith('"'):
42
+ return data
43
+ out = bytearray()
44
+ i, n = 0, len(data)
45
+ while i < n:
46
+ if data[i].isspace():
47
+ i += 1
48
+ continue
49
+ if data[i] != '"':
50
+ raise DohError(f"malformed TXT data: {data!r}")
51
+ i += 1
52
+ while True:
53
+ if i >= n:
54
+ raise DohError(f"unterminated TXT string: {data!r}")
55
+ c = data[i]
56
+ if c == '"':
57
+ i += 1
58
+ break
59
+ if c == "\\":
60
+ if data[i + 1 : i + 4].isdigit() and len(data[i + 1 : i + 4]) == 3:
61
+ out.append(int(data[i + 1 : i + 4]) & 0xFF)
62
+ i += 4
63
+ continue
64
+ if i + 1 >= n:
65
+ raise DohError(f"dangling escape in TXT data: {data!r}")
66
+ c = data[i + 1]
67
+ i += 1
68
+ out += c.encode()
69
+ i += 1
70
+ return out.decode("utf-8", "replace")
71
+
72
+
73
+ def records(document: object, name: str, require_dnssec: bool) -> list[str]:
74
+ if not isinstance(document, dict):
75
+ raise DohError(f"DoH answer for {name} is not a JSON object")
76
+ status = document.get("Status")
77
+ if status == _NXDOMAIN:
78
+ return []
79
+ if status != _NOERROR:
80
+ raise DohError(f"DoH lookup of {name} failed with RCODE {status}")
81
+ if require_dnssec and document.get("AD") is not True:
82
+ raise DnssecError(f"TXT answer for {name} is not DNSSEC-authenticated")
83
+ answers: Any = document.get("Answer") or []
84
+ return [
85
+ parse_txt_data(a["data"])
86
+ for a in answers
87
+ if isinstance(a, dict) and a.get("type") == _TXT and isinstance(a.get("data"), str)
88
+ ]
89
+
90
+
91
+ def params(name: str) -> dict[str, str]:
92
+ return {"name": name, "type": "TXT", "do": "1"}
@@ -0,0 +1,86 @@
1
+ """Pieces shared by the JSON fetchers; imports nothing outside the standard library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import socket
8
+ from collections.abc import Awaitable, Callable, Iterable
9
+ from typing import TypeAlias
10
+ from urllib.parse import urlsplit
11
+
12
+ __all__ = [
13
+ "HEADERS",
14
+ "MAX_DOCUMENT_BYTES",
15
+ "AsyncResolveHost",
16
+ "FetchError",
17
+ "ResolveHost",
18
+ "check_global_addresses",
19
+ "decode",
20
+ "host_of",
21
+ "require_global",
22
+ "system_resolve_host",
23
+ ]
24
+
25
+ MAX_DOCUMENT_BYTES = 256 * 1024
26
+ # Bodies are read undecoded so the size cap applies to what is held in memory;
27
+ # a decompression bomb would otherwise be expanded before the cap is checked.
28
+ HEADERS = {"Accept": "application/json", "Accept-Encoding": "identity"}
29
+
30
+
31
+ class FetchError(Exception):
32
+ pass
33
+
34
+
35
+ def decode(body: bytes, url: str) -> object:
36
+ try:
37
+ return json.loads(body)
38
+ except (ValueError, RecursionError) as exc:
39
+ raise FetchError(f"GET {url} did not return JSON") from exc
40
+
41
+
42
+ # TODO(py3.12): back to ``type`` statements once 3.11 support is dropped.
43
+ ResolveHost: TypeAlias = Callable[[str], Iterable[str]]
44
+ """Return the IP addresses ``host`` resolves to, as strings."""
45
+ AsyncResolveHost: TypeAlias = Callable[[str], Awaitable[Iterable[str]]]
46
+
47
+
48
+ def host_of(url: str) -> str:
49
+ host = urlsplit(url).hostname
50
+ if not host:
51
+ raise FetchError(f"GET {url}: no host")
52
+ return host
53
+
54
+
55
+ def system_resolve_host(host: str) -> list[str]:
56
+ return [str(info[4][0]) for info in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)]
57
+
58
+
59
+ def require_global(url: str, resolve: ResolveHost) -> None:
60
+ """Resolve the host of ``url`` and :func:`check_global_addresses`."""
61
+ host = host_of(url)
62
+ try:
63
+ addresses = list(resolve(host))
64
+ except OSError as exc:
65
+ raise FetchError(f"GET {url}: cannot resolve {host}: {exc}") from exc
66
+ check_global_addresses(url, host, addresses)
67
+
68
+
69
+ def check_global_addresses(url: str, host: str, addresses: Iterable[str]) -> None:
70
+ """Refuse to fetch ``url`` unless every address of its host is globally routable.
71
+
72
+ The host comes from DNS records and metadata that anyone can publish, so without
73
+ this check a token could make the verifier fetch from the relying party's own
74
+ network (SSRF). The HTTP library resolves the name again when it connects; a
75
+ DNS server that answers differently the second time is not caught here.
76
+ """
77
+ resolved = list(addresses)
78
+ if not resolved:
79
+ raise FetchError(f"GET {url}: {host} has no addresses")
80
+ for address in resolved:
81
+ try:
82
+ ip = ipaddress.ip_address(address.partition("%")[0])
83
+ except ValueError as exc:
84
+ raise FetchError(f"GET {url}: {host} resolved to {address!r}") from exc
85
+ if not ip.is_global:
86
+ raise FetchError(f"GET {url}: {host} resolves to non-global address {ip}")
@@ -0,0 +1,38 @@
1
+ """Pick an HTTP client library: httpx2 when installed, httpx otherwise.
2
+
3
+ httpx2 (pydantic's maintained fork) has the same API as httpx but distinct
4
+ types, so clients and transports must not be mixed between the two. The
5
+ adapters only rely on the shared API and accept a client from either library.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from types import ModuleType
12
+ from typing import TYPE_CHECKING, TypeAlias
13
+
14
+ __all__ = ["AsyncClient", "Client", "Response", "http"]
15
+
16
+
17
+ def _load() -> ModuleType:
18
+ for name in ("httpx2", "httpx"):
19
+ try:
20
+ return importlib.import_module(name)
21
+ except ImportError:
22
+ continue
23
+ raise ImportError(
24
+ "PyEVP's HTTP adapters need httpx2 or httpx: pip install 'pyevp[httpx2]' or 'pyevp[httpx]'"
25
+ )
26
+
27
+
28
+ http = _load()
29
+ """The selected module (``httpx2`` or ``httpx``)."""
30
+
31
+ if TYPE_CHECKING:
32
+ import httpx
33
+ import httpx2
34
+
35
+ # TODO(py3.12): back to ``type`` statements once 3.11 support is dropped.
36
+ Client: TypeAlias = httpx.Client | httpx2.Client
37
+ AsyncClient: TypeAlias = httpx.AsyncClient | httpx2.AsyncClient
38
+ Response: TypeAlias = httpx.Response | httpx2.Response