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/_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}"
|
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"}
|
pyevp/adapters/_fetch.py
ADDED
|
@@ -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}")
|
pyevp/adapters/_http.py
ADDED
|
@@ -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
|