Coverage for node / src / stigmem_node / federation / dnssec / validator.py: 87%

282 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""In-process DNSSEC chain-to-root validator (Rev 6 I2/I3). 

2 

3This is the security core of Federation Phase 3. Given a host and an injectable 

4``Resolver``, it walks the DNS hierarchy root -> ... -> zone, validating each 

5delegation *cryptographically*: 

6 

7 1. Start from the embedded IANA root trust anchor (``anchor.ROOT_TRUST_ANCHORS``). 

8 2. At each zone cut, fetch the child's DNSKEY RRset, validate it against the 

9 parent's DS RRset (the DS digest must match a self-signed DNSKEY), then 

10 trust that DNSKEY for the next step. 

11 3. At the leaf zone, validate the binding TXT RRset 

12 (``_stigmem-fed._key.<host>``) against the zone DNSKEY. 

13 

14**The AD bit is never read.** Trust is re-derived from signatures every time 

15(Rev 6 I2). A response that merely *claims* authentication (AD=1) but carries no 

16validating RRSIGs is ``BOGUS``. 

17 

18Outcomes (this module, extended in 3a.5/3a.6): 

19 * ``SECURE`` — the binding TXT validated to the root; ``Validation.record`` 

20 is the parsed ``BindingRecord``. 

21 * ``BOGUS`` — a signature failed, was forged, was expired/not-yet-valid, the 

22 DS did not match, or a required record was missing while the chain is 

23 signed. 

24 

25dnspython is imported function-locally (Rev 6 I11). 

26""" 

27 

28from __future__ import annotations 

29 

30import enum 

31from dataclasses import dataclass 

32from typing import TYPE_CHECKING 

33 

34from .record import BindingRecord, parse_binding_record 

35 

36if TYPE_CHECKING: # import for type-checkers only; never at runtime (I11). 

37 import dns.message 

38 import dns.name 

39 import dns.rdata 

40 import dns.rdataset 

41 import dns.rdatatype 

42 import dns.rrset 

43 from dns.rdtypes.ANY.NSEC3 import NSEC3 

44 

45 from .resolver import Resolver 

46 

47_BINDING_PREFIX = "_stigmem-fed._key." 

48 

49 

50class Validation(enum.Enum): 

51 """Outcome of a binding validation walk. 

52 

53 ``SECURE``/``BOGUS`` land in 3a.4; ``ABSENT_AUTHENTICATED``/``UNVALIDATABLE``/ 

54 ``INSECURE`` land in 3a.5 (authenticated denial-of-existence). 

55 """ 

56 

57 SECURE = "secure" 

58 BOGUS = "bogus" 

59 ABSENT_AUTHENTICATED = "absent_authenticated" 

60 UNVALIDATABLE = "unvalidatable" 

61 INSECURE = "insecure" 

62 

63 

64@dataclass(frozen=True) 

65class ValidationResult: 

66 """The validator's verdict plus the parsed record on success. 

67 

68 ``rrsig_inception`` is the epoch-seconds inception of the *newest* RRSIG 

69 covering the binding TXT (the most-recent re-sign — the correct freshness 

70 reference). It is populated ONLY on the ``SECURE`` path and stays ``None`` on 

71 every other outcome. It is measured against the SAME clock the RRSIG validity 

72 is checked against (``_validation_now``), so the ladder's RRSIG-age clamp 

73 (Rev 6 I4) can derive a real age from it. Surfacing it changes no trust 

74 decision here — it is an additional, validated input the ladder consumes. 

75 

76 ``ttl`` is the DNS TTL of the binding TXT RRset (seconds), surfaced ONLY on 

77 the ``SECURE`` path and ``None`` on every other outcome. The relay-path 

78 re-check (Rev 6 I5 / §7, build-phase 3c) drives its cadence from this value 

79 — ``clamp(ttl, floor, cap)`` — so a binding is the origin's own freshness 

80 signal. Like ``rrsig_inception`` it is threaded out of the validated answer 

81 and changes no trust decision here. 

82 """ 

83 

84 status: Validation 

85 record: BindingRecord | None = None 

86 detail: str = "" 

87 rrsig_inception: float | None = None 

88 ttl: int | None = None 

89 

90 

91class _ChainError(Exception): 

92 """Internal: a chain step failed in a way that maps to BOGUS.""" 

93 

94 

95def validate_binding(host: str, *, resolver: Resolver) -> ValidationResult: 

96 """Validate the DNSSEC binding for ``host`` to the root. 

97 

98 ``host`` is the canonical A-label host from ``host_from_entity_uri`` (I3). 

99 ``resolver`` is any object satisfying the ``Resolver`` protocol. 

100 """ 

101 import dns.dnssec 

102 import dns.name 

103 import dns.rdatatype 

104 import dns.rrset 

105 

106 from . import anchor 

107 

108 try: 

109 zone_name = dns.name.from_text(host) 

110 except Exception: # noqa: BLE001 — any malformed host is fail-closed. 

111 return ValidationResult(Validation.BOGUS, detail="malformed host") 

112 

113 now = _validation_now() 

114 

115 try: 

116 # 1. Walk the delegation chain from the root down to the leaf zone, 

117 # establishing a validated DNSKEY RRset for the zone that should hold 

118 # the binding TXT. 

119 signing_zone, zone_keys = _validate_chain_to_zone( 

120 zone_name, resolver=resolver, root_ds=anchor.root_ds_rdataset(), now=now 

121 ) 

122 except _InsecureDelegation as exc: 

123 # Authenticated absence of a parent DS -> the subtree is unsigned. 3a.5 

124 # decides whether the caller may fall through; for 3a a bare INSECURE is 

125 # surfaced (the ladder treats it as operator-confirm, never accept). 

126 return ValidationResult(Validation.INSECURE, detail=str(exc)) 

127 except _ChainError as exc: 

128 return ValidationResult(Validation.BOGUS, detail=str(exc)) 

129 

130 # 2. Fetch + validate the binding TXT against the validated zone DNSKEY. 

131 binding_qname = dns.name.from_text(_BINDING_PREFIX + host) 

132 try: 

133 txt_message = resolver.query(binding_qname.to_text(), "TXT") 

134 except Exception as exc: # noqa: BLE001 — transport failure is fail-closed. 

135 return ValidationResult(Validation.BOGUS, detail=f"TXT query failed: {exc}") 

136 

137 txt_rrset = _find_rrset(txt_message, binding_qname, dns.rdatatype.TXT) 

138 if txt_rrset is None: 

139 # No TXT answer. Whether this is an authenticated absence (-> fall 

140 # through) or an unvalidatable absence (-> reject) is decided by the 

141 # denial-of-existence logic added in 3a.5. 

142 return _classify_absence( 

143 txt_message, 

144 binding_qname=binding_qname, 

145 zone_name=signing_zone, 

146 zone_keys=zone_keys, 

147 now=now, 

148 ) 

149 

150 txt_rrsig = _find_rrsig(txt_message, binding_qname, dns.rdatatype.TXT) 

151 if txt_rrsig is None: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 return ValidationResult(Validation.BOGUS, detail="binding TXT has no RRSIG") 

153 

154 try: 

155 dns.dnssec.validate(txt_rrset, txt_rrsig, {signing_zone: zone_keys}, now=now) 

156 except Exception as exc: # noqa: BLE001 — forged/expired/wrong-key -> BOGUS. 

157 return ValidationResult(Validation.BOGUS, detail=f"binding TXT RRSIG invalid: {exc}") 

158 

159 # 3a.6: reject a binding answer synthesized from a wildcard (an exact-match 

160 # RRSIG is required for the binding record). Defined in the wildcard module; 

161 # a no-op until 3a.6 lands. 

162 wildcard_detail = _reject_if_wildcard_synthesized(txt_rrset, txt_rrsig, binding_qname) 

163 if wildcard_detail is not None: 

164 return ValidationResult(Validation.BOGUS, detail=wildcard_detail) 

165 

166 # 4. Parse the (now cryptographically-validated) record text. 

167 record = _parse_txt_rrset(txt_rrset) 

168 if record is None: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true

169 return ValidationResult(Validation.BOGUS, detail="binding TXT failed grammar") 

170 

171 # Surface the freshness reference for the ladder's RRSIG-age clamp (I4): the 

172 # NEWEST inception among the covering RRSIGs (the most-recent re-sign). 

173 # 

174 # The combined `dns.dnssec.validate` above passes if ANY ONE served RRSIG 

175 # validates; it does NOT individually window-check the others. Taking 

176 # `max(inception)` over the whole served set would therefore let a 

177 # zone-serving / on-path attacker (Rev 6 threat model) append a NON-validating 

178 # RRSIG with a near-now inception alongside a real stale-but-valid signature, 

179 # and have that fresh-looking value selected — defeating the I4 

180 # aged-on-previously-fresh clamp (same class as the 3a DS-keyset break). So 

181 # derive the inception from ONLY the signature(s) that INDIVIDUALLY validate: 

182 # re-run the proven chain check per-RRSIG, passing `now` so an expired/forged 

183 # injected sig is excluded by its own window/key check. 

184 validating_inceptions: list[int] = [] 

185 for sig in txt_rrsig: 

186 single = dns.rrset.from_rdata(txt_rrsig.name, txt_rrsig.ttl, sig) 

187 try: 

188 dns.dnssec.validate(txt_rrset, single, {signing_zone: zone_keys}, now=now) 

189 except Exception: # noqa: BLE001,S112 — this RRSIG is expired/forged/wrong-key: exclude it. 

190 continue # nosec B112 — excluding a non-validating signature is the intent. 

191 validating_inceptions.append(int(sig.inception)) 

192 if not validating_inceptions: 192 ↛ 195line 192 didn't jump to line 195 because the condition on line 192 was never true

193 # The combined check passed but no single RRSIG validates in isolation: 

194 # treat as bogus rather than trust an unattributable inception. 

195 return ValidationResult( 

196 Validation.BOGUS, detail="binding TXT: no individually-validating RRSIG" 

197 ) 

198 rrsig_inception = max(validating_inceptions) 

199 

200 # Surface the binding TXT RRset's DNS TTL (Rev 6 §7 / I5): the relay-path 

201 # re-check clamps its cadence to this. It is the validated answer's own TTL, 

202 # so it rides only on the SECURE path (None everywhere else by default). 

203 return ValidationResult( 

204 Validation.SECURE, 

205 record=record, 

206 rrsig_inception=rrsig_inception, 

207 ttl=int(txt_rrset.ttl), 

208 ) 

209 

210 

211# --------------------------------------------------------------------------- # 

212# Chain walk 

213# --------------------------------------------------------------------------- # 

214 

215 

216class _InsecureDelegation(Exception): 

217 """Internal: an authenticated absence of a parent DS (unsigned subtree).""" 

218 

219 

220def _validate_chain_to_zone( 

221 zone_name: dns.name.Name, *, resolver: Resolver, root_ds: dns.rdataset.Rdataset, now: float 

222) -> tuple[dns.name.Name, dns.rdataset.Rdataset]: 

223 """Return ``(signing_zone, validated_dnskey_rdataset)`` for ``zone_name``. 

224 

225 ``signing_zone`` is the deepest zone cut at or above ``zone_name`` (the zone 

226 that actually signs the binding TXT); ``validated_dnskey_rdataset`` is that 

227 zone's chain-validated DNSKEY rdataset. 

228 

229 Walks root -> ... -> zone. The invariant at each step is "we hold the 

230 parent zone's validated DNSKEY rdataset and the validated DS RRset for the 

231 child". For each child cut: 

232 

233 1. Validate the child's DNSKEY RRset: it must be self-signed *and* a SEP 

234 (KSK) key in it must produce the DS digest the parent published 

235 (RFC 4035 §5.2 / RFC 4509). This establishes the child's keys. 

236 2. Fetch + validate the *grandchild's* DS RRset from the child zone 

237 (signed by the child's now-validated DNSKEY). That DS feeds the next 

238 iteration. 

239 

240 The root is the base case: ``root_ds`` is the IANA anchor and the root 

241 DNSKEY is validated against it like any other cut. Raises ``_ChainError`` 

242 (-> BOGUS) on any signature/match failure; ``_InsecureDelegation`` 

243 (-> INSECURE) on an authenticated absence of a child DS (3a.5). 

244 """ 

245 import dns.name 

246 

247 # Descend the name one label at a time from the root toward the host. Not 

248 # every label is a zone cut: a cut exists only where the parent publishes a 

249 # DS (a signed delegation). The walk is therefore DS-driven: 

250 # * Start at the root with the IANA-anchored DS; validate the root DNSKEY. 

251 # * For each descendant name, query its DS. A *present + validated* DS is a 

252 # signed delegation -> validate that child's DNSKEY and adopt its keys as 

253 # the current zone keys. An *absent* DS means the descendant is not a cut 

254 # (its records live in the current zone) -> carry the current keys. 

255 # 

256 # . -> example. -> acme.example. -> memory.acme.example. 

257 labels = list(zone_name.labels) 

258 names: list[dns.name.Name] = [ 

259 dns.name.Name(labels[i:]) for i in range(len(labels) - 1, -1, -1) 

260 ] 

261 

262 # Base case: validate the root DNSKEY against the embedded anchor DS. 

263 root_name = names[0] 

264 current_keys = _validate_dnskey_against_ds( 

265 root_name, ds_rrset=root_ds, resolver=resolver, now=now 

266 ) 

267 current_zone = root_name 

268 

269 for descendant in names[1:]: 

270 ds_rrset = _fetch_validated_ds( 

271 descendant, parent_name=current_zone, parent_keys=current_keys, 

272 resolver=resolver, now=now, 

273 ) 

274 if ds_rrset is None: 

275 # No signed delegation at this label: the descendant's records stay 

276 # in the current zone. Keep the current keys and continue. 

277 continue 

278 # Signed delegation: validate the child's DNSKEY against its DS and 

279 # descend into the child zone. 

280 current_keys = _validate_dnskey_against_ds( 

281 descendant, ds_rrset=ds_rrset, resolver=resolver, now=now 

282 ) 

283 current_zone = descendant 

284 

285 if current_keys is None: # pragma: no cover — root always establishes keys. 

286 raise _ChainError("no zone keys established") 

287 return current_zone, current_keys 

288 

289 

290def _validate_dnskey_against_ds( 

291 zone: dns.name.Name, *, ds_rrset: dns.rdataset.Rdataset, resolver: Resolver, now: float 

292) -> dns.rdataset.Rdataset: 

293 """Validate ``zone``'s DNSKEY RRset against ``ds_rrset``; return its rdataset. 

294 

295 ``ds_rrset`` is the DS the parent published for ``zone`` (the IANA anchor at 

296 the root). RFC 4035 §5.2: a SEP key in the RRset must reproduce a parent DS 

297 digest, AND the DNSKEY RRset's RRSIG MUST validate using ONLY that 

298 DS-authenticated key as the keyset — not the whole served RRset. Validating 

299 against the whole RRset would let an attacker who knows the zone's public KSK 

300 serve {real KSK, attacker KSK, ...} self-signed by the attacker key and have 

301 it accepted (the two checks must bind to the SAME key, not merely co-occur). 

302 """ 

303 import dns.dnssec 

304 import dns.rdataset 

305 import dns.rdatatype 

306 

307 try: 

308 dnskey_message = resolver.query(zone.to_text(), "DNSKEY") 

309 except Exception as exc: # noqa: BLE001 

310 raise _ChainError(f"DNSKEY query for {zone} failed: {exc}") from exc 

311 

312 dnskey_rrset = _find_rrset(dnskey_message, zone, dns.rdatatype.DNSKEY) 

313 dnskey_rrsig = _find_rrsig(dnskey_message, zone, dns.rdatatype.DNSKEY) 

314 if dnskey_rrset is None or dnskey_rrsig is None: 

315 raise _ChainError(f"{zone} missing DNSKEY/RRSIG") 

316 

317 ds_matched_keys = _ds_matched_keys(zone, ds_rrset, dnskey_rrset) 

318 if not ds_matched_keys: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true

319 raise _ChainError(f"{zone} DNSKEY does not match parent DS") 

320 

321 # Build the validation keyset from ONLY the DS-authenticated key(s). The 

322 # DNSKEY RRset's self-signature is then verified against the keys the parent 

323 # actually pinned, so a non-DS key signing the RRset raises ValidationFailure 

324 # -> BOGUS (RFC 4035 §5.2). NOT a key_tag filter: key tags collide and are 

325 # attacker-spoofable; the keyset itself is restricted. 

326 trusted_keyset = dns.rdataset.Rdataset(dnskey_rrset.rdclass, dns.rdatatype.DNSKEY) 

327 trusted_keyset.ttl = dnskey_rrset.ttl 

328 for key in ds_matched_keys: 

329 trusted_keyset.add(key) 

330 

331 try: 

332 dns.dnssec.validate(dnskey_rrset, dnskey_rrsig, {zone: trusted_keyset}, now=now) 

333 except Exception as exc: # noqa: BLE001 

334 raise _ChainError(f"{zone} DNSKEY RRSIG invalid: {exc}") from exc 

335 

336 # The full RRset (every key in it) becomes the trusted keyset for the *next* 

337 # step only after its self-signature has been authenticated by the DS-pinned 

338 # key above — i.e. the parent has vouched (via DS+RRSIG chain) for the whole 

339 # set, so the zone's ZSKs are now usable for the records it signs. 

340 return dnskey_rrset.to_rdataset() 

341 

342 

343def _ds_matched_keys( 

344 zone: dns.name.Name, ds_rrset: dns.rdataset.Rdataset, dnskey_rrset: dns.rrset.RRset 

345) -> list[dns.rdata.Rdata]: 

346 """Return the SEP key(s) in ``dnskey_rrset`` that reproduce a DS in ``ds_rrset``. 

347 

348 Empty list when no key matches. The returned keys are the only ones the 

349 parent has authenticated; the DNSKEY RRset's RRSIG must validate against 

350 exactly these (RFC 4035 §5.2). 

351 """ 

352 import dns.dnssec 

353 

354 ds_records = list(ds_rrset) 

355 if not ds_records: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 return [] 

357 matched: list[dns.rdata.Rdata] = [] 

358 for key in dnskey_rrset: 

359 # Only SEP/KSK keys (flags bit 0 set) are eligible as a DS target. 

360 if not (key.flags & 0x0001): 

361 continue 

362 for ds in ds_records: 

363 try: 

364 candidate = dns.dnssec.make_ds(zone, key, ds.digest_type) 

365 except Exception: # noqa: BLE001,S112 

366 continue # nosec B112 — unknown DS digest type: skip this DS, try the next. 

367 if candidate == ds: 

368 matched.append(key) 

369 break 

370 return matched 

371 

372 

373def _fetch_validated_ds( 

374 child: dns.name.Name, 

375 *, 

376 parent_name: dns.name.Name, 

377 parent_keys: dns.rdataset.Rdataset, 

378 resolver: Resolver, 

379 now: float, 

380) -> dns.rdataset.Rdataset | None: 

381 """Fetch + validate the DS RRset for ``child`` from the parent zone. 

382 

383 Returns the validated DS rdataset when ``child`` is a *signed delegation*, 

384 or ``None`` when no DS is present at ``child`` (``child`` is not a zone cut; 

385 its records live in the parent zone — a normal, expected case in real DNS). 

386 

387 The DS for ``child`` is published at ``child``'s owner name but signed by 

388 the *parent* zone's keys (``parent_keys`` at ``parent_name``). 

389 

390 Authenticated-denial discipline (Rev 6 I2): a DS *absence* here is treated as 

391 "not a cut, continue in the current zone." That is safe for the chain walk — 

392 if the binding TXT is not actually present in the parent zone, the absence 

393 of the TXT is what gets classified (``_classify_absence``), and 3a.5 requires 

394 a validated NSEC3 proof of that TXT absence before the caller may fall 

395 through. An attacker stripping a DS can only force the name to be sought in a 

396 zone the attacker does not control, which cannot yield a forged binding. 

397 """ 

398 import dns.dnssec 

399 import dns.rdatatype 

400 

401 try: 

402 ds_message = resolver.query(child.to_text(), "DS") 

403 except Exception as exc: # noqa: BLE001 

404 raise _ChainError(f"DS query for {child} failed: {exc}") from exc 

405 

406 ds_rrset = _find_rrset(ds_message, child, dns.rdatatype.DS) 

407 if ds_rrset is None: 

408 # No DS at the child. Distinguish two authenticated cases (Rev 6 I2): 

409 # * insecure delegation — a validated NSEC3 that MATCHES the child and 

410 # whose type bitmap shows NS-without-DS proves a signed parent 

411 # delegating to an unsigned child. Surface INSECURE so the caller 

412 # routes to operator-confirm (never silent-accept). 

413 # * not a cut — no such proof; the child's records live in the parent 

414 # zone. Carry the current keys (return None). 

415 if _nsec3_proves_insecure_delegation( 

416 ds_message, child=child, parent_name=parent_name, parent_keys=parent_keys, now=now 

417 ): 

418 raise _InsecureDelegation(f"authenticated unsigned delegation at {child}") 

419 return None 

420 

421 ds_rrsig = _find_rrsig(ds_message, child, dns.rdatatype.DS) 

422 if ds_rrsig is None: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 raise _ChainError(f"{child} DS has no RRSIG") 

424 try: 

425 dns.dnssec.validate(ds_rrset, ds_rrsig, {parent_name: parent_keys}, now=now) 

426 except Exception as exc: # noqa: BLE001 

427 raise _ChainError(f"{child} DS RRSIG invalid: {exc}") from exc 

428 return ds_rrset.to_rdataset() 

429 

430 

431# --------------------------------------------------------------------------- # 

432# Authenticated denial-of-existence (Rev 6 I2) — 3a.5 

433# --------------------------------------------------------------------------- # 

434 

435 

436def _classify_absence( 

437 message: dns.message.Message, 

438 *, 

439 binding_qname: dns.name.Name, 

440 zone_name: dns.name.Name, 

441 zone_keys: dns.rdataset.Rdataset, 

442 now: float, 

443) -> ValidationResult: 

444 """Classify a missing binding TXT via authenticated denial-of-existence. 

445 

446 Rev 6 I2: to treat the binding TXT as "absent" and let the caller fall 

447 through, the receiver MUST hold a cryptographically-validated NSEC3 

448 denial-of-existence proof for the qname against the zone DNSKEY. Outcomes: 

449 

450 * ``ABSENT_AUTHENTICATED`` — a validated NSEC3 closest-encloser proof 

451 covers the qname. The caller MAY fall through to operator-confirm. 

452 * ``UNVALIDATABLE`` — no validated proof of absence (the answer is just 

453 empty, or carries unsigned/forged NSEC3). The caller MUST reject and 

454 never fall through. 

455 

456 NSEC3 is REQUIRED (Rev 6 I2): a bare NSEC record, or an NSEC3 with the 

457 opt-out flag set, is rejected as ``UNVALIDATABLE`` (no insecure-delegation 

458 fall-through for the binding name). 

459 """ 

460 import dns.rdatatype 

461 

462 # A bare NSEC (not NSEC3) authority section does not satisfy the NSEC3 

463 # requirement -> unvalidatable. 

464 if _has_rrset_of_type(message, dns.rdatatype.NSEC): 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 return ValidationResult( 

466 Validation.UNVALIDATABLE, detail="bare NSEC denial; NSEC3 required (I2)" 

467 ) 

468 

469 nsec3s = _collect_validated_nsec3(message, zone_name=zone_name, zone_keys=zone_keys, now=now) 

470 if nsec3s is None: 

471 return ValidationResult( 

472 Validation.UNVALIDATABLE, detail="NSEC3 denial RRSIG invalid" 

473 ) 

474 if not nsec3s: 

475 return ValidationResult( 

476 Validation.UNVALIDATABLE, detail="binding TXT absent with no NSEC3 proof" 

477 ) 

478 

479 # Reject opt-out NSEC3 (flags bit 0): opt-out weakens the proof to an 

480 # unsigned-delegation assertion, which Rev 6 I2 forbids for the binding. 

481 for _owner, rdata in nsec3s: 

482 if rdata.flags & 0x01: 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true

483 return ValidationResult( 

484 Validation.UNVALIDATABLE, detail="NSEC3 opt-out set; rejected (I2)" 

485 ) 

486 

487 if _nsec3_proves_absence(binding_qname, zone_name=zone_name, nsec3s=nsec3s): 487 ↛ 491line 487 didn't jump to line 491 because the condition on line 487 was always true

488 return ValidationResult( 

489 Validation.ABSENT_AUTHENTICATED, detail="authenticated NSEC3 absence" 

490 ) 

491 return ValidationResult( 

492 Validation.UNVALIDATABLE, detail="NSEC3 present but does not prove qname absence" 

493 ) 

494 

495 

496def _has_rrset_of_type(message: dns.message.Message, rdtype: dns.rdatatype.RdataType) -> bool: 

497 for section in (message.answer, message.authority): 

498 for rrset in section: 

499 if rrset.rdtype == rdtype: 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true

500 return True 

501 return False 

502 

503 

504def _collect_validated_nsec3( 

505 message: dns.message.Message, 

506 *, 

507 zone_name: dns.name.Name, 

508 zone_keys: dns.rdataset.Rdataset, 

509 now: float, 

510) -> list[tuple[dns.name.Name, NSEC3]] | None: 

511 """Return ``[(owner_name, nsec3_rdata), ...]`` for *validated* NSEC3 RRsets. 

512 

513 Each NSEC3 RRset in the authority section must carry an RRSIG that validates 

514 against the zone DNSKEY. Returns ``None`` if any present NSEC3 RRset fails 

515 validation (treat the whole proof as unvalidatable); an empty list if there 

516 are no NSEC3 RRsets at all. 

517 """ 

518 import dns.dnssec 

519 import dns.rdatatype 

520 

521 collected: list[tuple[dns.name.Name, NSEC3]] = [] 

522 for rrset in message.authority: 

523 if rrset.rdtype != dns.rdatatype.NSEC3: 

524 continue 

525 rrsig = _find_rrsig(message, rrset.name, dns.rdatatype.NSEC3) 

526 if rrsig is None: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true

527 return None 

528 try: 

529 dns.dnssec.validate(rrset, rrsig, {zone_name: zone_keys}, now=now) 

530 except Exception: # noqa: BLE001 — forged/expired NSEC3 -> unvalidatable. 

531 return None 

532 for rdata in rrset: 

533 collected.append((rrset.name, rdata)) 

534 return collected 

535 

536 

537def _nsec3_owner_hash(owner: dns.name.Name, zone_name: dns.name.Name) -> str: 

538 """The base32hex NSEC3 hash from an NSEC3 owner name (first label).""" 

539 return owner.labels[0].decode("ascii").upper() 

540 

541 

542def _nsec3_next_hash(rdata: NSEC3) -> str: 

543 """The base32hex-encoded next-hashed-owner of an NSEC3 rdata.""" 

544 import base64 

545 

546 # dnspython exposes the raw next-owner bytes as ``rdata.next``. 

547 return base64.b32encode(rdata.next).translate(_B32HEX).decode("ascii").rstrip("=") 

548 

549 

550# RFC 4648 base32 -> base32hex ("extended hex") alphabet translation table. 

551_B32HEX = bytes.maketrans( 

552 b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", 

553 b"0123456789ABCDEFGHIJKLMNOPQRSTUV", 

554) 

555 

556 

557def _hash_name(name: dns.name.Name, rdata: NSEC3) -> str: 

558 """NSEC3-hash ``name`` using the NSEC3 rdata's algorithm/salt/iterations.""" 

559 import dns.dnssec 

560 

561 salt = rdata.salt if rdata.salt is not None else b"" 

562 return dns.dnssec.nsec3_hash(name, salt, rdata.iterations, rdata.algorithm) 

563 

564 

565def _nsec3_matches(name: dns.name.Name, owner_hash: str, rdata: NSEC3) -> bool: 

566 """True iff the NSEC3 owner hash equals H(name) (RFC 5155 'matches').""" 

567 return _hash_name(name, rdata) == owner_hash 

568 

569 

570def _nsec3_covers(name: dns.name.Name, owner_hash: str, next_hash: str, rdata: NSEC3) -> bool: 

571 """True iff H(name) falls in the (owner_hash, next_hash] gap (RFC 5155 'covers'). 

572 

573 Handles the zone-apex wraparound where next_hash <= owner_hash. 

574 """ 

575 target = _hash_name(name, rdata) 

576 if owner_hash < next_hash: 576 ↛ 579line 576 didn't jump to line 579 because the condition on line 576 was always true

577 return owner_hash < target < next_hash 

578 # Wraparound interval (covers the largest..smallest gap including the apex). 

579 return target > owner_hash or target < next_hash 

580 

581 

582def _nsec3_proves_absence( 

583 qname: dns.name.Name, 

584 *, 

585 zone_name: dns.name.Name, 

586 nsec3s: list[tuple[dns.name.Name, NSEC3]], 

587) -> bool: 

588 """Verify an RFC 5155 closest-encloser proof of ``qname``'s non-existence. 

589 

590 The proof requires (a) an NSEC3 that *matches* the closest encloser and 

591 (b) an NSEC3 that *covers* the next-closer name. We search the ancestors of 

592 ``qname`` (down to the zone apex) for the deepest enclosing name that an 

593 NSEC3 matches; its immediate child toward ``qname`` (the next-closer) must 

594 be covered by some NSEC3. This proves no exact match and no wildcard 

595 synthesis path for the binding name. 

596 """ 

597 import dns.name 

598 

599 qlabels = list(qname.labels) 

600 zlabels = list(zone_name.labels) 

601 # Candidate closest-encloser names: proper ancestors of the qname that are 

602 # at or below the zone apex. The apex sits at label-offset 

603 # ``apex_depth = len(qlabels) - len(zlabels)`` into the qname. The closest 

604 # encloser is the *deepest* such ancestor an NSEC3 matches, so iterate from 

605 # the qname's parent (offset 1) down to the apex (offset apex_depth) and take 

606 # the first match. The next-closer is the immediate child of the CE toward 

607 # the qname (one label deeper). 

608 apex_depth = len(qlabels) - len(zlabels) 

609 for depth in range(1, apex_depth + 1): 609 ↛ 627line 609 didn't jump to line 627 because the loop on line 609 didn't complete

610 ce = dns.name.Name(qlabels[depth:]) 

611 next_closer = dns.name.Name(qlabels[depth - 1:]) 

612 ce_matched = False 

613 for owner, rdata in nsec3s: 

614 owner_hash = _nsec3_owner_hash(owner, zone_name) 

615 if _nsec3_matches(ce, owner_hash, rdata): 

616 ce_matched = True 

617 break 

618 if not ce_matched: 

619 continue 

620 # The closest encloser exists; the next-closer must be covered. 

621 for owner, rdata in nsec3s: 621 ↛ 626line 621 didn't jump to line 626 because the loop on line 621 didn't complete

622 owner_hash = _nsec3_owner_hash(owner, zone_name) 

623 next_hash = _nsec3_next_hash(rdata) 

624 if _nsec3_covers(next_closer, owner_hash, next_hash, rdata): 

625 return True 

626 return False 

627 return False 

628 

629 

630def _nsec3_type_present(rdata: NSEC3, rdtype: dns.rdatatype.RdataType) -> bool: 

631 """True iff ``rdtype`` is set in the NSEC3 type bitmap (RFC 4034 §4.1.2).""" 

632 for window, bitmap in rdata.windows: 

633 if window != (rdtype >> 8): 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true

634 continue 

635 byte_index = (rdtype & 0xFF) >> 3 

636 if byte_index >= len(bitmap): 636 ↛ 637line 636 didn't jump to line 637 because the condition on line 636 was never true

637 continue 

638 if bitmap[byte_index] & (0x80 >> (rdtype & 0x07)): 

639 return True 

640 return False 

641 

642 

643def _nsec3_proves_insecure_delegation( 

644 message: dns.message.Message, 

645 *, 

646 child: dns.name.Name, 

647 parent_name: dns.name.Name, 

648 parent_keys: dns.rdataset.Rdataset, 

649 now: float, 

650) -> bool: 

651 """True iff a validated NSEC3 proves ``child`` is an unsigned delegation. 

652 

653 RFC 5155 §3.2: a secure parent denying a DS for a delegated child serves an 

654 NSEC3 that *matches* the child's name whose type bitmap contains ``NS`` but 

655 not ``DS`` (and not ``SOA`` — i.e. a delegation, not the apex). The NSEC3 

656 RRset must validate against the parent's DNSKEY. Opt-out NSEC3 is not 

657 accepted as a proof here (Rev 6 I2 requires opt-out off). 

658 """ 

659 import dns.rdatatype 

660 

661 nsec3s = _collect_validated_nsec3( 

662 message, zone_name=parent_name, zone_keys=parent_keys, now=now 

663 ) 

664 if not nsec3s: 

665 return False 

666 for owner, rdata in nsec3s: 666 ↛ 677line 666 didn't jump to line 677 because the loop on line 666 didn't complete

667 if rdata.flags & 0x01: # opt-out -> not an accepted proof (I2) 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true

668 continue 

669 owner_hash = _nsec3_owner_hash(owner, parent_name) 

670 if not _nsec3_matches(child, owner_hash, rdata): 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true

671 continue 

672 has_ns = _nsec3_type_present(rdata, dns.rdatatype.NS) 

673 has_ds = _nsec3_type_present(rdata, dns.rdatatype.DS) 

674 has_soa = _nsec3_type_present(rdata, dns.rdatatype.SOA) 

675 if has_ns and not has_ds and not has_soa: 675 ↛ 666line 675 didn't jump to line 666 because the condition on line 675 was always true

676 return True 

677 return False 

678 

679 

680def _reject_if_wildcard_synthesized( 

681 txt_rrset: dns.rrset.RRset, txt_rrsig: dns.rrset.RRset, binding_qname: dns.name.Name 

682) -> str | None: 

683 """Return a BOGUS detail string if the binding answer was wildcard-synthesized. 

684 

685 Rev 6 I3: the binding record requires an *exact-match* RRSIG; a record 

686 synthesized from a ``*`` wildcard is rejected. RFC 4035 §5.3.1: an RRSIG's 

687 ``labels`` field carries the label count of the *original* owner name the 

688 signature was generated over. When an answer is synthesized from a wildcard, 

689 ``RRSIG.labels`` is **less** than the number of (non-root) labels in the 

690 queried owner name — that gap is the proof of synthesis. (A validly-signed 

691 wildcard answer still verifies against the zone DNSKEY, so the chain check 

692 alone does not catch it; this label-count comparison does.) 

693 """ 

694 # All RRSIGs covering the binding TXT must be exact-match. The owner name's 

695 # non-root label count is the expected signed-label count. 

696 owner_label_count = len(binding_qname.labels) - 1 # exclude the root label 

697 for rrsig in txt_rrsig: 

698 if rrsig.labels < owner_label_count: 

699 return ( 

700 "binding TXT synthesized from a wildcard " 

701 f"(RRSIG labels={rrsig.labels} < owner labels={owner_label_count}); " 

702 "exact-match RRSIG required (I3)" 

703 ) 

704 return None 

705 

706 

707# --------------------------------------------------------------------------- # 

708# Helpers 

709# --------------------------------------------------------------------------- # 

710 

711 

712def _validation_now() -> float: 

713 """Wall-clock seconds used as the RRSIG validity reference. 

714 

715 A stale/expired RRSIG (inception/expiration outside this instant) fails 

716 ``dns.dnssec.validate`` -> BOGUS. The age-clamp/operator-confirm nuance is a 

717 3b concern; 3a is strict (expired == BOGUS). 

718 """ 

719 import time 

720 

721 return time.time() 

722 

723 

724def _name_eq(a: dns.name.Name, b: dns.name.Name) -> bool: 

725 """Canonical DNS-name equality by lower-cased text. 

726 

727 Compares ``.to_text()`` rather than ``Name.__eq__`` so the match holds even 

728 if two ``dns.name.Name`` instances come from different ``dns.name`` module 

729 objects (``Name.__eq__`` is an ``isinstance`` check that returns 

730 ``NotImplemented`` across a re-import). DNS names are case-insensitive, so 

731 lower-casing is the correct canonical comparison regardless. 

732 """ 

733 return a.to_text().lower() == b.to_text().lower() 

734 

735 

736def _find_rrset( 

737 message: dns.message.Message, name: dns.name.Name, rdtype: dns.rdatatype.RdataType 

738) -> dns.rrset.RRset | None: 

739 """Return the RRset for ``(name, rdtype)`` from answer/authority, or None.""" 

740 for section in (message.answer, message.authority): 

741 for rrset in section: 

742 if _name_eq(rrset.name, name) and rrset.rdtype == rdtype: 

743 return rrset 

744 return None 

745 

746 

747def _find_rrsig( 

748 message: dns.message.Message, name: dns.name.Name, covers: dns.rdatatype.RdataType 

749) -> dns.rrset.RRset | None: 

750 """Return the RRSIG RRset covering ``(name, covers)``, or None.""" 

751 import dns.rdatatype 

752 

753 for section in (message.answer, message.authority): 

754 for rrset in section: 

755 if ( 

756 _name_eq(rrset.name, name) 

757 and rrset.rdtype == dns.rdatatype.RRSIG 

758 and rrset.covers == covers 

759 ): 

760 return rrset 

761 return None 

762 

763 

764def _parse_txt_rrset(txt_rrset: dns.rrset.RRset) -> BindingRecord | None: 

765 """Concatenate a single TXT record's strings and run the grammar parser. 

766 

767 Multiple TXT RRs at the binding name are ambiguous -> reject. Within one RR, 

768 character-strings are concatenated (RFC 1035 long-TXT convention). 

769 """ 

770 records = list(txt_rrset) 

771 if len(records) != 1: 771 ↛ 772line 771 didn't jump to line 772 because the condition on line 771 was never true

772 return None 

773 txt = b"".join(records[0].strings).decode("utf-8", errors="replace") 

774 return parse_binding_record(txt)