Coverage for node / src / stigmem_node / federation / dnssec / record.py: 89%
48 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Strict ``v=stigmem1`` DNSSEC binding-record grammar (Rev 6 §7).
3The DNSSEC-signed TXT record at ``_stigmem-fed._key.<canonical-host>`` binds an
4origin's key fingerprint to a monotonic rotation epoch. Two forms:
6 active: ``v=stigmem1; fpr=<key_fpr>; epoch=<n>; prev_fpr=<or-empty>; prev_until=<or-empty>``
7 revoked: ``v=stigmem1; status=revoked; epoch=<n>; fpr=`` (empty fpr)
9The grammar is freeze-safe by construction (Rev 6 §7): unknown ``k=v`` pairs are
10ignored, so adding a field later is a routine zone re-sign rather than a
11re-sign-of-committed-material break.
13Parsing is fail-closed (Rev 6 I10): the FIRST token MUST be ``v=stigmem1``,
14``epoch`` is required and is a non-negative int, the active form requires a
15non-empty ``fpr``, and the revoked form (``status=revoked``) has an empty
16``fpr``. Any violation returns ``None``; no exception escapes.
17"""
19from __future__ import annotations
21from dataclasses import dataclass
23# DNS binding-record version sentinel (the required first token), not a
24# credential — bandit's B105 heuristic flags the embedded '=' as a hardcoded
25# password string.
26_VERSION_TOKEN = "v=stigmem1" # nosec B105
28# Keys this grammar assigns meaning to. A DUPLICATE of any of these is ambiguous
29# and rejected (fail-closed); duplicates of unknown keys are tolerated for the
30# forward-compat path (Rev 6 §7).
31_KNOWN_KEYS = frozenset({"v", "fpr", "epoch", "status", "prev_fpr", "prev_until"})
34@dataclass(frozen=True)
35class BindingRecord:
36 """A parsed, structurally-valid DNSSEC binding record.
38 Validation of the DNSSEC chain itself happens in the validator (3a.4+);
39 this dataclass represents only a record whose *grammar* is well-formed.
40 """
42 fpr: str
43 epoch: int
44 prev_fpr: str = ""
45 prev_until: str = ""
46 revoked: bool = False
49def parse_binding_record(txt: str) -> BindingRecord | None:
50 """Parse a binding-record TXT string. Returns ``None`` on any violation."""
51 if not txt:
52 return None
54 # Split on ';' into tokens; tolerate surrounding whitespace.
55 tokens = [t.strip() for t in txt.split(";")]
56 tokens = [t for t in tokens if t]
57 if not tokens: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 return None
60 # The FIRST token MUST be exactly the version token.
61 if tokens[0] != _VERSION_TOKEN:
62 return None
64 pairs: dict[str, str] = {}
65 for tok in tokens[1:]:
66 if "=" not in tok: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 return None # malformed token (not k=v)
68 key, _, raw_value = tok.partition("=")
69 key = key.strip()
70 if not key: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 return None
72 # Reject a DUPLICATE of any known key (the grammar assigns it meaning, so
73 # a second occurrence is ambiguous -> fail closed). Unknown keys may
74 # repeat for forward-compat; their last-write value is harmless.
75 if key in _KNOWN_KEYS and key in pairs:
76 return None
77 # Keep the raw (unstripped) value alongside the stripped one so the
78 # strict numeric gate below can reject embedded whitespace (e.g.
79 # ``epoch= 5``), which ``int()`` would otherwise silently accept.
80 pairs[key] = raw_value.strip()
81 if key == "epoch":
82 raw_epoch_token = raw_value
84 # epoch is required and must be a strict ASCII non-negative decimal integer.
85 raw_epoch = pairs.get("epoch")
86 if raw_epoch is None:
87 return None
88 # Gate against the RAW value: ``int()`` accepts ``+5``, ``1_000``, Unicode
89 # digits (``٠١``), and surrounding whitespace — none of which are valid here.
90 if not raw_epoch_token.isascii() or not raw_epoch_token.isdigit():
91 return None
92 epoch = int(raw_epoch_token)
94 revoked = pairs.get("status") == "revoked"
95 fpr = pairs.get("fpr", "")
97 if revoked:
98 # Revoked tombstone: fpr must be empty (or omitted).
99 if fpr: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 return None
101 return BindingRecord(fpr="", epoch=epoch, revoked=True)
103 # Active form requires a non-empty fingerprint.
104 if not fpr:
105 return None
107 return BindingRecord(
108 fpr=fpr,
109 epoch=epoch,
110 prev_fpr=pairs.get("prev_fpr", ""),
111 prev_until=pairs.get("prev_until", ""),
112 revoked=False,
113 )