aics-cli 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.
- aics_cli/__init__.py +8 -0
- aics_cli/__main__.py +3 -0
- aics_cli/bundle_build.py +216 -0
- aics_cli/card.py +933 -0
- aics_cli/checkpoints.py +498 -0
- aics_cli/cli.py +298 -0
- aics_cli/commands.py +1656 -0
- aics_cli/consistency.py +280 -0
- aics_cli/dnstxt.py +319 -0
- aics_cli/encoding.py +125 -0
- aics_cli/errors.py +57 -0
- aics_cli/evidence.py +88 -0
- aics_cli/home.py +112 -0
- aics_cli/keystore.py +189 -0
- aics_cli/message.py +875 -0
- aics_cli/net.py +117 -0
- aics_cli/policy.py +618 -0
- aics_cli/published_keys.py +34 -0
- aics_cli/tiles_http.py +59 -0
- aics_cli/trust.py +243 -0
- aics_cli-0.1.0.dist-info/METADATA +420 -0
- aics_cli-0.1.0.dist-info/RECORD +25 -0
- aics_cli-0.1.0.dist-info/WHEEL +4 -0
- aics_cli-0.1.0.dist-info/entry_points.txt +3 -0
- aics_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
aics_cli/consistency.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Proving that one signed checkpoint is a prefix of another
|
|
2
|
+
(docs/spec/AICS-1.md §14.2, §14.6; RFC 9162 §2.1.4).
|
|
3
|
+
|
|
4
|
+
A proof may come from a proof file, the log's consistency endpoint
|
|
5
|
+
(``GET /v2/tlog/consistency?from=<m>&to=<n>`` -> ``{from,to,hashes}``), or
|
|
6
|
+
tiles. Any source will do because the proof is checked against both
|
|
7
|
+
log-signed roots; a source that serves bad data is reported and the next one
|
|
8
|
+
is tried.
|
|
9
|
+
|
|
10
|
+
Tiles are read through ``aics_tlog.VerifiedTileReader``, which authenticates
|
|
11
|
+
every tile up to the signed root of a *reference* checkpoint R published by
|
|
12
|
+
that source (normally the newer of the two notes). Each note X is then
|
|
13
|
+
tested as a prefix of R: the consistency proof and the root at X's size come
|
|
14
|
+
from authenticated tiles, so if X's signed root differs from that
|
|
15
|
+
authenticated prefix root, X and R are two log-signed, mutually inconsistent
|
|
16
|
+
heads - constructive split-view evidence (§14.6 step 4) that anyone can
|
|
17
|
+
re-check from the proof.
|
|
18
|
+
|
|
19
|
+
Outcomes (:func:`relate`):
|
|
20
|
+
|
|
21
|
+
* ``consistent`` - a valid proof (or equal size and root, or the empty
|
|
22
|
+
tree with the empty root);
|
|
23
|
+
* ``split-proven`` - equal sizes with different roots, an authenticated
|
|
24
|
+
prefix root that differs from a signed root, or a size-0 checkpoint whose
|
|
25
|
+
root is not SHA-256("") (``bad-empty-root``, §14.2 step 1);
|
|
26
|
+
* ``unproven`` - no valid proof was obtained. A timeout, a missing tile or a
|
|
27
|
+
bad proof does not show that no valid proof exists (§14.6 step 3): this is
|
|
28
|
+
an availability alert, not evidence of a split.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from aics_tlog import EMPTY_HASH, VerifiedTileReader, verify_consistency
|
|
37
|
+
from aics_tlog.tiles import TileVerificationError, consistency_proof_from_tiles, tree_root_from_tiles
|
|
38
|
+
|
|
39
|
+
from .checkpoints import Cp
|
|
40
|
+
from .encoding import b64d_strict, b64e
|
|
41
|
+
from .errors import FetchError
|
|
42
|
+
from .net import Fetcher
|
|
43
|
+
from .tiles_http import TileSourceError
|
|
44
|
+
|
|
45
|
+
PROOF_MAX_BYTES = 256 * 1024
|
|
46
|
+
MAX_PROOF_HASHES = 128
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# sources
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TileSource:
|
|
56
|
+
label: str
|
|
57
|
+
reader: Any # an aics_tlog TileReader (raw, unauthenticated)
|
|
58
|
+
ref: Cp | None = None # checkpoint the source publishes tiles for (default: the newer note)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class EndpointSource:
|
|
63
|
+
label: str
|
|
64
|
+
template: str # URL with {old} and {new}
|
|
65
|
+
fetcher: Fetcher
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class GivenProof:
|
|
70
|
+
label: str
|
|
71
|
+
hashes: list[bytes]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
Source = TileSource | EndpointSource | GivenProof
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Attempt:
|
|
79
|
+
source: str
|
|
80
|
+
status: str # consistent | split | invalid | incomplete
|
|
81
|
+
detail: str = ""
|
|
82
|
+
old: Cp | None = None
|
|
83
|
+
new: Cp | None = None
|
|
84
|
+
proof: list[bytes] | None = None
|
|
85
|
+
prefix_root: bytes | None = None # authenticated root of `new` at old's size
|
|
86
|
+
|
|
87
|
+
def report(self) -> dict[str, Any]:
|
|
88
|
+
d: dict[str, Any] = {"source": self.source, "status": self.status}
|
|
89
|
+
if self.detail:
|
|
90
|
+
d["detail"] = self.detail
|
|
91
|
+
if self.old is not None and self.new is not None:
|
|
92
|
+
d["old_size"] = self.old.size
|
|
93
|
+
d["new_size"] = self.new.size
|
|
94
|
+
if self.proof is not None:
|
|
95
|
+
d["proof"] = [b64e(h) for h in self.proof]
|
|
96
|
+
if self.prefix_root is not None:
|
|
97
|
+
d["authenticated_prefix_root"] = b64e(self.prefix_root)
|
|
98
|
+
return d
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Relation:
|
|
103
|
+
status: str # consistent | split-proven | unproven
|
|
104
|
+
a: Cp
|
|
105
|
+
b: Cp
|
|
106
|
+
reason: str | None = None
|
|
107
|
+
attempts: list[Attempt] = field(default_factory=list)
|
|
108
|
+
winning: Attempt | None = None
|
|
109
|
+
split_pairs: list[tuple[Cp, Cp, Attempt | None]] = field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
def report(self) -> dict[str, Any]:
|
|
112
|
+
d: dict[str, Any] = {
|
|
113
|
+
"status": self.status,
|
|
114
|
+
"a": self.a.brief(),
|
|
115
|
+
"b": self.b.brief(),
|
|
116
|
+
"attempts": [x.report() for x in self.attempts],
|
|
117
|
+
}
|
|
118
|
+
if self.reason:
|
|
119
|
+
d["reason"] = self.reason
|
|
120
|
+
if self.winning is not None:
|
|
121
|
+
d["proof_source"] = self.winning.source
|
|
122
|
+
return d
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# endpoint
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_proof_body(body: bytes, old: int, new: int) -> list[bytes]:
|
|
131
|
+
"""``{"from": m, "to": n, "hashes": [B64(hash), ...]}`` (AICS-1 §12.1),
|
|
132
|
+
with canonical padded base64 and exactly 32-byte hashes."""
|
|
133
|
+
from .card import loads_strict
|
|
134
|
+
|
|
135
|
+
obj = loads_strict(body)
|
|
136
|
+
if not isinstance(obj, dict) or set(obj) != {"from", "to", "hashes"}:
|
|
137
|
+
raise ValueError("proof must be exactly {from, to, hashes}")
|
|
138
|
+
if obj["from"] != old or obj["to"] != new or isinstance(obj["from"], bool) or isinstance(obj["to"], bool):
|
|
139
|
+
raise ValueError(f"proof is for {obj['from']!r}->{obj['to']!r}, not {old}->{new}")
|
|
140
|
+
hashes = obj["hashes"]
|
|
141
|
+
if not isinstance(hashes, list) or len(hashes) > MAX_PROOF_HASHES:
|
|
142
|
+
raise ValueError("hashes must be a list of at most 128 entries")
|
|
143
|
+
out = []
|
|
144
|
+
for h in hashes:
|
|
145
|
+
raw = b64d_strict(h) if isinstance(h, str) else b""
|
|
146
|
+
if len(raw) != 32:
|
|
147
|
+
raise ValueError("each proof hash is canonical base64 of 32 bytes")
|
|
148
|
+
out.append(raw)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def proof_url(template: str, old: int, new: int) -> str:
|
|
153
|
+
return template.replace("{old}", str(old)).replace("{new}", str(new))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def fetch_proof(src: EndpointSource, old: int, new: int) -> list[bytes]:
|
|
157
|
+
url = proof_url(src.template, old, new)
|
|
158
|
+
resp = src.fetcher.get(url, max_bytes=PROOF_MAX_BYTES, accept="application/json")
|
|
159
|
+
if resp.status != 200:
|
|
160
|
+
raise FetchError(f"{url}: HTTP {resp.status}")
|
|
161
|
+
return parse_proof_body(resp.body, old, new)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# the checks
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def prefix_check(x: Cp, ref: Cp, reader: Any, label: str) -> Attempt:
|
|
170
|
+
"""Is ``x`` a prefix of ``ref``? ``reader`` serves the tiles of ``ref``."""
|
|
171
|
+
if x.size > ref.size:
|
|
172
|
+
return Attempt(label, "incomplete", f"reference is smaller ({ref.size} < {x.size})", x, ref)
|
|
173
|
+
if x.size == ref.size:
|
|
174
|
+
if x.root == ref.root:
|
|
175
|
+
return Attempt(label, "consistent", "same size and root", x, ref)
|
|
176
|
+
return Attempt(label, "split", "same size, different roots", x, ref)
|
|
177
|
+
if x.size == 0:
|
|
178
|
+
if x.root == EMPTY_HASH:
|
|
179
|
+
return Attempt(label, "consistent", "empty tree", x, ref)
|
|
180
|
+
return Attempt(label, "split", "a size-0 checkpoint whose root is not SHA-256('')", x, ref)
|
|
181
|
+
try:
|
|
182
|
+
verified = VerifiedTileReader(reader, ref.size, ref.root)
|
|
183
|
+
proof = consistency_proof_from_tiles(verified, x.size, ref.size)
|
|
184
|
+
prefix = tree_root_from_tiles(verified, x.size)
|
|
185
|
+
except TileSourceError as exc:
|
|
186
|
+
return Attempt(label, "incomplete", f"tile source failed: {exc}", x, ref)
|
|
187
|
+
except TileVerificationError as exc:
|
|
188
|
+
return Attempt(label, "invalid", f"tiles do not authenticate against the signed root: {exc}", x, ref)
|
|
189
|
+
except FileNotFoundError as exc:
|
|
190
|
+
return Attempt(label, "incomplete", f"tiles missing: {exc}", x, ref)
|
|
191
|
+
except (ValueError, OSError) as exc:
|
|
192
|
+
return Attempt(label, "invalid", f"unusable tile data: {exc}", x, ref)
|
|
193
|
+
if verify_consistency(x.size, ref.size, x.root, ref.root, proof):
|
|
194
|
+
return Attempt(label, "consistent", "valid consistency proof from authenticated tiles", x, ref, proof)
|
|
195
|
+
if prefix != x.root and verify_consistency(x.size, ref.size, prefix, ref.root, proof):
|
|
196
|
+
return Attempt(
|
|
197
|
+
label, "split",
|
|
198
|
+
"the root at this size, authenticated by the newer signed root, differs from the signed root",
|
|
199
|
+
x, ref, proof, prefix,
|
|
200
|
+
)
|
|
201
|
+
return Attempt(label, "invalid", "authenticated tiles yield no valid proof (inconsistent source data)",
|
|
202
|
+
x, ref, proof)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def relate(a: Cp, b: Cp, sources: list[Source]) -> Relation:
|
|
206
|
+
"""Is the smaller of ``a`` and ``b`` a prefix of the larger?"""
|
|
207
|
+
if a.size > b.size:
|
|
208
|
+
a, b = b, a
|
|
209
|
+
rel = Relation(status="unproven", a=a, b=b)
|
|
210
|
+
# AICS-1 §14.2 step 1: for size 0 require the standard empty root and an
|
|
211
|
+
# empty proof. No proof source is consulted (an empty "proof" verifies
|
|
212
|
+
# any old root at size 0).
|
|
213
|
+
bad_empty = [x for x in (a, b) if x.size == 0 and x.root != EMPTY_HASH]
|
|
214
|
+
if bad_empty:
|
|
215
|
+
rel.status, rel.reason = "split-proven", "bad-empty-root"
|
|
216
|
+
for x in bad_empty:
|
|
217
|
+
rel.split_pairs.append((x, b if x is a else a, None))
|
|
218
|
+
return rel
|
|
219
|
+
if a.size == 0:
|
|
220
|
+
rel.status, rel.reason = "consistent", "the empty tree is a prefix of every tree"
|
|
221
|
+
return rel
|
|
222
|
+
if a.size == b.size:
|
|
223
|
+
if a.root == b.root:
|
|
224
|
+
rel.status, rel.reason = "consistent", "same size and root"
|
|
225
|
+
else:
|
|
226
|
+
rel.status, rel.reason = "split-proven", "equal-size-different-root"
|
|
227
|
+
rel.split_pairs.append((a, b, None))
|
|
228
|
+
return rel
|
|
229
|
+
for src in sources:
|
|
230
|
+
if isinstance(src, (GivenProof, EndpointSource)):
|
|
231
|
+
if isinstance(src, GivenProof):
|
|
232
|
+
hashes = src.hashes
|
|
233
|
+
else:
|
|
234
|
+
try:
|
|
235
|
+
hashes = fetch_proof(src, a.size, b.size)
|
|
236
|
+
except FetchError as exc:
|
|
237
|
+
rel.attempts.append(Attempt(src.label, "incomplete", exc.message, a, b))
|
|
238
|
+
continue
|
|
239
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
240
|
+
rel.attempts.append(Attempt(src.label, "invalid", f"unusable proof: {exc}", a, b))
|
|
241
|
+
continue
|
|
242
|
+
ok = verify_consistency(a.size, b.size, a.root, b.root, hashes)
|
|
243
|
+
att = Attempt(src.label, "consistent" if ok else "invalid",
|
|
244
|
+
"valid consistency proof" if ok else "proof does not verify", a, b, hashes)
|
|
245
|
+
rel.attempts.append(att)
|
|
246
|
+
if ok:
|
|
247
|
+
rel.status, rel.winning = "consistent", att
|
|
248
|
+
return rel
|
|
249
|
+
continue
|
|
250
|
+
ref = src.ref if src.ref is not None else b
|
|
251
|
+
if ref.size < b.size:
|
|
252
|
+
rel.attempts.append(Attempt(src.label, "incomplete", f"source is behind ({ref.size} < {b.size})", b, ref))
|
|
253
|
+
continue
|
|
254
|
+
rb = prefix_check(b, ref, src.reader, src.label)
|
|
255
|
+
if rb.status in ("invalid", "incomplete"):
|
|
256
|
+
rel.attempts.append(rb)
|
|
257
|
+
continue
|
|
258
|
+
ra = prefix_check(a, ref, src.reader, src.label)
|
|
259
|
+
if ra.status in ("invalid", "incomplete"):
|
|
260
|
+
rel.attempts.append(ra)
|
|
261
|
+
if rb.status == "split":
|
|
262
|
+
rel.attempts.append(rb)
|
|
263
|
+
rel.status, rel.reason = "split-proven", "not-a-prefix"
|
|
264
|
+
rel.split_pairs.append((b, ref, rb))
|
|
265
|
+
return rel
|
|
266
|
+
continue
|
|
267
|
+
rel.attempts += [x for x in (rb, ra) if x.old is not x.new]
|
|
268
|
+
if ra.status == "consistent" and rb.status == "consistent":
|
|
269
|
+
rel.status, rel.winning = "consistent", ra
|
|
270
|
+
return rel
|
|
271
|
+
rel.status = "split-proven"
|
|
272
|
+
equal = [x for x in (ra, rb) if x.status == "split" and x.old is not None and x.new is not None
|
|
273
|
+
and x.old.size == x.new.size]
|
|
274
|
+
rel.reason = "equal-size-different-root" if equal else "not-a-prefix"
|
|
275
|
+
for x, chk in ((a, ra), (b, rb)):
|
|
276
|
+
if chk.status == "split":
|
|
277
|
+
rel.split_pairs.append((x, ref, chk))
|
|
278
|
+
return rel
|
|
279
|
+
rel.reason = "invalid-proof-data" if any(x.status == "invalid" for x in rel.attempts) else "no-proof-available"
|
|
280
|
+
return rel
|
aics_cli/dnstxt.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""DNS TXT lookup: dnspython when installed (``pip install 'aics-cli[dns]'``),
|
|
2
|
+
otherwise a small stdlib resolver (UDP with EDNS0, TCP on truncation).
|
|
3
|
+
|
|
4
|
+
Neither path validates DNSSEC itself. The query sets the AD bit and the
|
|
5
|
+
result reports whether the resolver claimed the answer was authenticated
|
|
6
|
+
(``ad``); that claim is only as good as the path to the resolver.
|
|
7
|
+
|
|
8
|
+
The stdlib resolver accepts a response only if it answers the question that
|
|
9
|
+
was asked: the same ID, exactly one question with the queried name
|
|
10
|
+
(compared case-insensitively), type TXT and class IN, and it takes TXT
|
|
11
|
+
records only from the queried name or a CNAME target reached from it in the
|
|
12
|
+
answer section.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import secrets
|
|
18
|
+
import socket
|
|
19
|
+
import struct
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from .errors import DnsError
|
|
23
|
+
|
|
24
|
+
TYPE_CNAME = 5
|
|
25
|
+
TYPE_TXT = 16
|
|
26
|
+
TYPE_OPT = 41
|
|
27
|
+
CLASS_IN = 1
|
|
28
|
+
FLAG_QR = 0x8000
|
|
29
|
+
FLAG_TC = 0x0200
|
|
30
|
+
FLAG_RD = 0x0100
|
|
31
|
+
FLAG_AD = 0x0020
|
|
32
|
+
RCODE_NXDOMAIN = 3
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TxtResult:
|
|
37
|
+
name: str
|
|
38
|
+
records: list[str] = field(default_factory=list) # each record's strings joined
|
|
39
|
+
ad: bool = False # resolver said "authenticated data" (DNSSEC); not checked by us
|
|
40
|
+
resolver: str = "" # "dnspython" or "stdlib:<ip>:<port>"
|
|
41
|
+
nxdomain: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# entry point
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lookup_txt(name: str, *, nameserver: str | None = None, timeout: float = 5.0,
|
|
50
|
+
use_dnspython: bool | None = None) -> TxtResult:
|
|
51
|
+
"""TXT records at ``name``. ``nameserver`` is ``ip`` or ``ip:port``
|
|
52
|
+
(``[v6]:port``). No record or NXDOMAIN returns an empty list; other
|
|
53
|
+
failures raise :class:`DnsError`."""
|
|
54
|
+
name = name.rstrip(".")
|
|
55
|
+
if use_dnspython is None:
|
|
56
|
+
try:
|
|
57
|
+
import dns.resolver
|
|
58
|
+
|
|
59
|
+
use_dnspython = True
|
|
60
|
+
except ImportError:
|
|
61
|
+
use_dnspython = False
|
|
62
|
+
if use_dnspython:
|
|
63
|
+
return _lookup_dnspython(name, nameserver, timeout)
|
|
64
|
+
return lookup_txt_stdlib(name, nameserver=nameserver, timeout=timeout)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _split_ns(nameserver: str) -> tuple[str, int]:
|
|
68
|
+
ns = nameserver.strip()
|
|
69
|
+
if ns.startswith("["):
|
|
70
|
+
host, _, rest = ns[1:].partition("]")
|
|
71
|
+
port = int(rest[1:]) if rest.startswith(":") else 53
|
|
72
|
+
return host, port
|
|
73
|
+
if ns.count(":") == 1:
|
|
74
|
+
host, port = ns.split(":")
|
|
75
|
+
return host, int(port)
|
|
76
|
+
return ns, 53
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _lookup_dnspython(name: str, nameserver: str | None, timeout: float) -> TxtResult:
|
|
80
|
+
import dns.exception
|
|
81
|
+
import dns.flags
|
|
82
|
+
import dns.resolver
|
|
83
|
+
|
|
84
|
+
resolver = dns.resolver.Resolver(configure=nameserver is None)
|
|
85
|
+
if nameserver is not None:
|
|
86
|
+
host, port = _split_ns(nameserver)
|
|
87
|
+
resolver.nameservers = [host]
|
|
88
|
+
resolver.port = port
|
|
89
|
+
resolver.lifetime = timeout
|
|
90
|
+
resolver.flags = dns.flags.RD | dns.flags.AD
|
|
91
|
+
try:
|
|
92
|
+
answer = resolver.resolve(name, "TXT", raise_on_no_answer=False)
|
|
93
|
+
except dns.resolver.NXDOMAIN:
|
|
94
|
+
return TxtResult(name=name, resolver="dnspython", nxdomain=True)
|
|
95
|
+
except dns.exception.DNSException as exc:
|
|
96
|
+
raise DnsError(f"TXT lookup for {name} failed: {exc}") from None
|
|
97
|
+
records: list[str] = []
|
|
98
|
+
for rr in answer:
|
|
99
|
+
try:
|
|
100
|
+
records.append(b"".join(rr.strings).decode("utf-8"))
|
|
101
|
+
except UnicodeDecodeError:
|
|
102
|
+
continue
|
|
103
|
+
ad = bool(answer.response.flags & dns.flags.AD) if answer.response is not None else False
|
|
104
|
+
return TxtResult(name=name, records=records, ad=ad, resolver="dnspython")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# stdlib resolver
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def system_nameservers(path: str = "/etc/resolv.conf") -> list[str]:
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
try:
|
|
115
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
116
|
+
for line in fh:
|
|
117
|
+
parts = line.split()
|
|
118
|
+
if len(parts) >= 2 and parts[0] == "nameserver":
|
|
119
|
+
out.append(parts[1])
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def encode_name(name: str) -> bytes:
|
|
126
|
+
out = bytearray()
|
|
127
|
+
for label in name.rstrip(".").split("."):
|
|
128
|
+
raw = label.encode("idna") if any(ord(c) > 127 for c in label) else label.encode("ascii")
|
|
129
|
+
if not 0 < len(raw) < 64:
|
|
130
|
+
raise DnsError(f"bad DNS label in {name!r}")
|
|
131
|
+
out.append(len(raw))
|
|
132
|
+
out += raw
|
|
133
|
+
out.append(0)
|
|
134
|
+
if len(out) > 255:
|
|
135
|
+
raise DnsError(f"DNS name too long: {name!r}")
|
|
136
|
+
return bytes(out)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_query(name: str, qid: int, *, qtype: int = TYPE_TXT, udp_size: int = 1232) -> bytes:
|
|
140
|
+
header = struct.pack(">HHHHHH", qid, FLAG_RD | FLAG_AD, 1, 0, 0, 1)
|
|
141
|
+
question = encode_name(name) + struct.pack(">HH", qtype, CLASS_IN)
|
|
142
|
+
opt = b"\x00" + struct.pack(">HHIH", TYPE_OPT, udp_size, 0, 0) # EDNS0, no DO bit
|
|
143
|
+
return header + question + opt
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_name(msg: bytes, off: int) -> tuple[str, int]:
|
|
147
|
+
labels: list[str] = []
|
|
148
|
+
jumps = 0
|
|
149
|
+
end: int | None = None
|
|
150
|
+
while True:
|
|
151
|
+
if off >= len(msg):
|
|
152
|
+
raise DnsError("truncated DNS name")
|
|
153
|
+
length = msg[off]
|
|
154
|
+
if length & 0xC0 == 0xC0:
|
|
155
|
+
if off + 1 >= len(msg):
|
|
156
|
+
raise DnsError("truncated DNS pointer")
|
|
157
|
+
if end is None:
|
|
158
|
+
end = off + 2
|
|
159
|
+
off = ((length & 0x3F) << 8) | msg[off + 1]
|
|
160
|
+
jumps += 1
|
|
161
|
+
if jumps > 32:
|
|
162
|
+
raise DnsError("DNS name compression loop")
|
|
163
|
+
continue
|
|
164
|
+
if length & 0xC0:
|
|
165
|
+
raise DnsError("unsupported DNS label type")
|
|
166
|
+
off += 1
|
|
167
|
+
if length == 0:
|
|
168
|
+
break
|
|
169
|
+
labels.append(msg[off:off + length].decode("ascii", errors="replace"))
|
|
170
|
+
off += length
|
|
171
|
+
return ".".join(labels), (end if end is not None else off)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _norm(name: str) -> str:
|
|
175
|
+
"""A name as it appears on the wire (IDNA labels), lowercased, no final dot."""
|
|
176
|
+
labels = []
|
|
177
|
+
for label in name.rstrip(".").split("."):
|
|
178
|
+
if any(ord(c) > 127 for c in label):
|
|
179
|
+
try:
|
|
180
|
+
label = label.encode("idna").decode("ascii")
|
|
181
|
+
except UnicodeError:
|
|
182
|
+
pass
|
|
183
|
+
labels.append(label.lower())
|
|
184
|
+
return ".".join(labels)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_response(msg: bytes, qid: int, qname: str | None = None,
|
|
188
|
+
qtype: int = TYPE_TXT) -> tuple[int, int, list[bytes]]:
|
|
189
|
+
"""Returns (flags, rcode, [TXT rdata strings joined per record]).
|
|
190
|
+
|
|
191
|
+
The response must carry exactly one question; with ``qname`` it must be
|
|
192
|
+
that name (case-insensitive), ``qtype`` and class IN, and only TXT
|
|
193
|
+
records owned by ``qname`` or by a CNAME target in the answer chain
|
|
194
|
+
from it are returned."""
|
|
195
|
+
if len(msg) < 12:
|
|
196
|
+
raise DnsError("short DNS response")
|
|
197
|
+
rid, flags, qd, an, _ns, _ar = struct.unpack(">HHHHHH", msg[:12])
|
|
198
|
+
if rid != qid:
|
|
199
|
+
raise DnsError("DNS response ID mismatch")
|
|
200
|
+
if not flags & FLAG_QR:
|
|
201
|
+
raise DnsError("DNS message is not a response")
|
|
202
|
+
rcode = flags & 0x000F
|
|
203
|
+
if qd != 1:
|
|
204
|
+
raise DnsError(f"DNS response carries {qd} questions, not the one that was asked")
|
|
205
|
+
name, off = _read_name(msg, 12)
|
|
206
|
+
if off + 4 > len(msg):
|
|
207
|
+
raise DnsError("truncated DNS question")
|
|
208
|
+
rqtype, rqclass = struct.unpack(">HH", msg[off:off + 4])
|
|
209
|
+
off += 4
|
|
210
|
+
if qname is not None and (_norm(name) != _norm(qname) or rqtype != qtype or rqclass != CLASS_IN):
|
|
211
|
+
raise DnsError(f"DNS response answers another question ({name!r} type {rqtype} class {rqclass})")
|
|
212
|
+
answers: list[tuple[str, int, int, int, bytes]] = [] # owner, type, class, rdata offset, rdata
|
|
213
|
+
for _ in range(an):
|
|
214
|
+
owner, off = _read_name(msg, off)
|
|
215
|
+
if off + 10 > len(msg):
|
|
216
|
+
raise DnsError("truncated DNS answer")
|
|
217
|
+
rtype, rclass, _ttl, rdlen = struct.unpack(">HHIH", msg[off:off + 10])
|
|
218
|
+
off += 10
|
|
219
|
+
rdata = msg[off:off + rdlen]
|
|
220
|
+
if len(rdata) != rdlen:
|
|
221
|
+
raise DnsError("truncated DNS rdata")
|
|
222
|
+
answers.append((_norm(owner), rtype, rclass, off, rdata))
|
|
223
|
+
off += rdlen
|
|
224
|
+
allowed = {_norm(qname if qname is not None else name)}
|
|
225
|
+
changed = True
|
|
226
|
+
while changed: # follow CNAMEs from the queried name, in any order
|
|
227
|
+
changed = False
|
|
228
|
+
for owner, rtype, rclass, roff, _rdata in answers:
|
|
229
|
+
if rtype == TYPE_CNAME and rclass == CLASS_IN and owner in allowed:
|
|
230
|
+
target = _norm(_read_name(msg, roff)[0])
|
|
231
|
+
if target not in allowed:
|
|
232
|
+
allowed.add(target)
|
|
233
|
+
changed = True
|
|
234
|
+
records: list[bytes] = []
|
|
235
|
+
for owner, rtype, rclass, _roff, rdata in answers:
|
|
236
|
+
if rtype != TYPE_TXT or rclass != CLASS_IN or owner not in allowed:
|
|
237
|
+
continue # a CNAME on the way, or a record about some other name
|
|
238
|
+
parts: list[bytes] = []
|
|
239
|
+
i = 0
|
|
240
|
+
while i < len(rdata):
|
|
241
|
+
n = rdata[i]
|
|
242
|
+
i += 1
|
|
243
|
+
if i + n > len(rdata):
|
|
244
|
+
raise DnsError("malformed TXT character-string")
|
|
245
|
+
parts.append(rdata[i:i + n])
|
|
246
|
+
i += n
|
|
247
|
+
records.append(b"".join(parts))
|
|
248
|
+
return flags, rcode, records
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _query_udp(query: bytes, addr: tuple, family: int, qid: int, timeout: float) -> bytes:
|
|
252
|
+
with socket.socket(family, socket.SOCK_DGRAM) as s:
|
|
253
|
+
s.settimeout(timeout)
|
|
254
|
+
s.connect(addr) # only accept datagrams from the server we asked
|
|
255
|
+
s.send(query)
|
|
256
|
+
while True:
|
|
257
|
+
data = s.recv(65535)
|
|
258
|
+
if len(data) >= 2 and struct.unpack(">H", data[:2])[0] == qid:
|
|
259
|
+
return data
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _query_tcp(query: bytes, addr: tuple, family: int, timeout: float) -> bytes:
|
|
263
|
+
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
264
|
+
s.settimeout(timeout)
|
|
265
|
+
s.connect(addr)
|
|
266
|
+
s.sendall(struct.pack(">H", len(query)) + query)
|
|
267
|
+
head = _recv_exact(s, 2)
|
|
268
|
+
(n,) = struct.unpack(">H", head)
|
|
269
|
+
return _recv_exact(s, n)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _recv_exact(s: socket.socket, n: int) -> bytes:
|
|
273
|
+
buf = bytearray()
|
|
274
|
+
while len(buf) < n:
|
|
275
|
+
chunk = s.recv(n - len(buf))
|
|
276
|
+
if not chunk:
|
|
277
|
+
raise DnsError("DNS TCP connection closed early")
|
|
278
|
+
buf += chunk
|
|
279
|
+
return bytes(buf)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def lookup_txt_stdlib(name: str, *, nameserver: str | None = None, timeout: float = 5.0) -> TxtResult:
|
|
283
|
+
servers = [nameserver] if nameserver else system_nameservers()
|
|
284
|
+
if not servers:
|
|
285
|
+
raise DnsError("no nameserver configured (no /etc/resolv.conf); pass --nameserver")
|
|
286
|
+
last: Exception | None = None
|
|
287
|
+
for ns in servers:
|
|
288
|
+
host, port = _split_ns(ns)
|
|
289
|
+
try:
|
|
290
|
+
infos = socket.getaddrinfo(host, port, type=socket.SOCK_DGRAM)
|
|
291
|
+
except socket.gaierror as exc:
|
|
292
|
+
last = exc
|
|
293
|
+
continue
|
|
294
|
+
family, _, _, _, addr = infos[0]
|
|
295
|
+
qid = secrets.randbelow(65536)
|
|
296
|
+
query = build_query(name, qid)
|
|
297
|
+
try:
|
|
298
|
+
data = _query_udp(query, addr, family, qid, timeout)
|
|
299
|
+
flags, rcode, records = parse_response(data, qid, name)
|
|
300
|
+
if flags & FLAG_TC:
|
|
301
|
+
data = _query_tcp(query, addr, family, timeout)
|
|
302
|
+
flags, rcode, records = parse_response(data, qid, name)
|
|
303
|
+
except (OSError, DnsError) as exc:
|
|
304
|
+
last = exc
|
|
305
|
+
continue
|
|
306
|
+
label = f"stdlib:{host}:{port}"
|
|
307
|
+
if rcode == RCODE_NXDOMAIN:
|
|
308
|
+
return TxtResult(name=name, resolver=label, nxdomain=True, ad=bool(flags & FLAG_AD))
|
|
309
|
+
if rcode != 0:
|
|
310
|
+
last = DnsError(f"DNS server {host} answered rcode {rcode}")
|
|
311
|
+
continue
|
|
312
|
+
texts: list[str] = []
|
|
313
|
+
for r in records:
|
|
314
|
+
try:
|
|
315
|
+
texts.append(r.decode("utf-8"))
|
|
316
|
+
except UnicodeDecodeError:
|
|
317
|
+
continue
|
|
318
|
+
return TxtResult(name=name, records=texts, ad=bool(flags & FLAG_AD), resolver=label)
|
|
319
|
+
raise DnsError(f"TXT lookup for {name} failed: {last}")
|