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.
@@ -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}")