Skip to content

Commit 20967f0

Browse files
committed
Implementation: include cert retrieval
1 parent 518629e commit 20967f0

6 files changed

Lines changed: 273 additions & 0 deletions

File tree

reference-implementation/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The implementation is split by topic:
1313
- `constants.py` and `errors.py`: protocol constants and exception types
1414
- `crypto.py`: HChaCha20, DNSCrypt's XChaCha20_DJB-Poly1305, HKDF, X25519, Ed25519
1515
- `certificates.py`: certificate parsing, signing, profile extensions, selection
16+
- `certificate_retrieval.py`: certificate queries, responses, and the anti-amplification size rule
1617
- `packets.py` and `transport.py`: DNSCrypt packets, TCP framing, Anonymized DNSCrypt
1718
- `pq.py`: X-Wing, PQ key derivation, tickets, responses, resumed queries
1819
- `dnscrypt.py`: re-exports the public reference API
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Unauthenticated certificate retrieval and its anti-amplification rule."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Sequence
6+
7+
from constants import (
8+
CERTIFICATE_RECORD_TTL,
9+
DNS_CLASS_IN,
10+
DNS_HEADER_SIZE,
11+
DNS_TYPE_OPT,
12+
DNS_TYPE_TXT,
13+
EDNS_PADDING_OPTION_CODE,
14+
EDNS_UDP_PAYLOAD_SIZE,
15+
)
16+
from errors import AmplificationError
17+
18+
19+
def _dns_name(name: str) -> bytes:
20+
"""Encode a domain name as a sequence of length-prefixed labels."""
21+
22+
wire = b""
23+
for label in name.rstrip(".").split("."):
24+
encoded = label.encode("ascii")
25+
if not 1 <= len(encoded) <= 63:
26+
raise ValueError("each DNS label must be 1 to 63 bytes")
27+
wire += bytes([len(encoded)]) + encoded
28+
return wire + b"\x00"
29+
30+
31+
def _edns_padding(padding_len: int) -> bytes:
32+
"""Build an EDNS(0) OPT record carrying a padding option (RFC 7830)."""
33+
34+
option = (
35+
EDNS_PADDING_OPTION_CODE.to_bytes(2, "big")
36+
+ padding_len.to_bytes(2, "big")
37+
+ b"\x00" * padding_len
38+
)
39+
return (
40+
b"\x00" # root owner name
41+
+ DNS_TYPE_OPT.to_bytes(2, "big")
42+
+ EDNS_UDP_PAYLOAD_SIZE.to_bytes(2, "big")
43+
+ b"\x00\x00\x00\x00" # extended rcode, version, flags
44+
+ len(option).to_bytes(2, "big")
45+
+ option
46+
)
47+
48+
49+
def _question_end(packet: bytes) -> int:
50+
"""Return the offset just past the single question of a DNS message."""
51+
52+
if len(packet) < DNS_HEADER_SIZE:
53+
raise ValueError("DNS message is shorter than its header")
54+
if packet[4:6] != b"\x00\x01":
55+
raise ValueError("a certificate query carries exactly one question")
56+
offset = DNS_HEADER_SIZE
57+
while True:
58+
if offset >= len(packet):
59+
raise ValueError("DNS question name is truncated")
60+
label_len = packet[offset]
61+
if label_len & 0xC0:
62+
raise ValueError("unexpected compression pointer in question name")
63+
offset += 1 + label_len
64+
if label_len == 0:
65+
break
66+
if offset + 4 > len(packet):
67+
raise ValueError("DNS question is missing its type and class")
68+
return offset + 4
69+
70+
71+
def _txt_rdata(certificate: bytes) -> bytes:
72+
"""Split a certificate into TXT character-strings of at most 255 bytes."""
73+
74+
chunks = [certificate[i : i + 255] for i in range(0, len(certificate), 255)]
75+
return b"".join(bytes([len(chunk)]) + chunk for chunk in chunks)
76+
77+
78+
def _txt_answer(certificate: bytes, ttl: int) -> bytes:
79+
"""Encode one certificate as a TXT answer record."""
80+
81+
rdata = _txt_rdata(certificate)
82+
return (
83+
b"\xc0\x0c" # owner name: compression pointer to the question
84+
+ DNS_TYPE_TXT.to_bytes(2, "big")
85+
+ DNS_CLASS_IN.to_bytes(2, "big")
86+
+ ttl.to_bytes(4, "big")
87+
+ len(rdata).to_bytes(2, "big")
88+
+ rdata
89+
)
90+
91+
92+
def certificate_query(
93+
provider_name: str,
94+
query_id: bytes = b"\x00\x00",
95+
padded_length: int = 0,
96+
) -> bytes:
97+
"""Build an unencrypted TXT certificate query, optionally EDNS(0)-padded.
98+
99+
A client that wants the larger PQ certificates over UDP pads the query to at
100+
least the expected response size, so the response stays within the request and
101+
passes the anti-amplification check at the resolver and at any relay.
102+
"""
103+
104+
question = (
105+
_dns_name(provider_name)
106+
+ DNS_TYPE_TXT.to_bytes(2, "big")
107+
+ DNS_CLASS_IN.to_bytes(2, "big")
108+
)
109+
additional = b""
110+
arcount = b"\x00\x00"
111+
if padded_length > 0:
112+
opt_overhead = 15 # OPT record header (11) and padding option header (4)
113+
padding = max(0, padded_length - DNS_HEADER_SIZE - len(question) - opt_overhead)
114+
additional = _edns_padding(padding)
115+
arcount = b"\x00\x01"
116+
header = (
117+
query_id
118+
+ b"\x01\x00" # standard query, recursion desired
119+
+ b"\x00\x01" # qdcount
120+
+ b"\x00\x00" # ancount
121+
+ b"\x00\x00" # nscount
122+
+ arcount
123+
)
124+
return header + question + additional
125+
126+
127+
def build_certificate_response(
128+
request: bytes,
129+
certificates: Sequence[bytes],
130+
truncated: bool = False,
131+
ttl: int = CERTIFICATE_RECORD_TTL,
132+
) -> bytes:
133+
"""Assemble a TXT certificate response that echoes the request's question.
134+
135+
Each certificate becomes one TXT answer record. The request's OPT record and
136+
padding are not echoed, exactly as a resolver answers a plain TXT query.
137+
"""
138+
139+
question = request[DNS_HEADER_SIZE : _question_end(request)]
140+
answers = b"".join(_txt_answer(certificate, ttl) for certificate in certificates)
141+
flags = 0x8400 # response, authoritative
142+
flags |= request[2] & 0x01 # preserve recursion-desired
143+
flags |= 0x0080 # recursion available
144+
if truncated:
145+
flags |= 0x0200 # TC: the full answer did not fit
146+
header = (
147+
request[0:2]
148+
+ flags.to_bytes(2, "big")
149+
+ b"\x00\x01" # qdcount
150+
+ len(certificates).to_bytes(2, "big") # ancount
151+
+ b"\x00\x00" # nscount
152+
+ b"\x00\x00" # arcount
153+
)
154+
return header + question + answers
155+
156+
157+
def serve_certificates(
158+
request: bytes,
159+
classical_certificates: Sequence[bytes],
160+
pq_certificates: Sequence[bytes],
161+
over_udp: bool,
162+
) -> bytes:
163+
"""Answer a certificate query under the anti-amplification rule.
164+
165+
The small classical certificates are always returned. The large PQ
166+
certificates are added only when the complete response still fits within the
167+
request that triggered it, because over UDP a response MUST NOT be larger than
168+
its request, or a spoofed query would be amplified. When the PQ certificates do
169+
not fit, the response carries only the classical certificates with the TC bit
170+
set and a PQ-capable client retries over TCP. Over TCP the handshake validates
171+
the source, so the PQ certificates are always included.
172+
"""
173+
174+
full = build_certificate_response(
175+
request, [*classical_certificates, *pq_certificates]
176+
)
177+
if not over_udp or not pq_certificates or len(full) <= len(request):
178+
return full
179+
return build_certificate_response(request, classical_certificates, truncated=True)
180+
181+
182+
def relay_certificate_response(
183+
forwarded_query: bytes, upstream_response: bytes
184+
) -> bytes:
185+
"""Forward a certificate response only if it respects anti-amplification.
186+
187+
An Anonymized DNSCrypt relay forwards the certificate query upstream over UDP
188+
and must never return more bytes to the client than the client sent, so a
189+
response larger than the forwarded query is rejected.
190+
"""
191+
192+
if len(upstream_response) > len(forwarded_query):
193+
raise AmplificationError("certificate response is larger than the query")
194+
return upstream_response

reference-implementation/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,11 @@
3939
TICKET_NONCE_SIZE = 24
4040
TICKET_PLAIN_SIZE = 86
4141

42+
DNS_HEADER_SIZE = 12
43+
DNS_TYPE_TXT = 0x0010
44+
DNS_TYPE_OPT = 0x0029
45+
DNS_CLASS_IN = 0x0001
46+
EDNS_PADDING_OPTION_CODE = 0x000C
47+
EDNS_UDP_PAYLOAD_SIZE = 4096
48+
CERTIFICATE_RECORD_TTL = 86400
49+

reference-implementation/dnscrypt.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from certificates import *
9+
from certificate_retrieval import *
910
from constants import *
1011
from crypto import *
1112
from errors import *

reference-implementation/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ class DecryptionError(DNSCryptError):
1616
class PaddingError(DNSCryptError):
1717
"""ISO/IEC 7816-4 padding is malformed."""
1818

19+
20+
class AmplificationError(DNSCryptError):
21+
"""A response would exceed its request and is dropped to avoid amplification."""
22+

reference-implementation/test_dnscrypt.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,71 @@ def test_pq_appendix_ticket_vector(self):
262262
"2bf202dd3f33d38854450e70a02bd1a317a23bf6d79c5dae406787c9c5f34f52",
263263
)
264264

265+
def test_certificate_retrieval_amplification(self):
266+
"""Check the certificate retrieval anti-amplification size rule."""
267+
268+
provider_seed = d.iota(0x00, 32)
269+
classical = d.DNSCryptCertificate.sign(
270+
provider_signing_seed=provider_seed,
271+
es_version=d.ES_VERSION_XCHACHA20POLY1305,
272+
resolver_pk=d.x25519_public_key(d.iota(0x20, 32)),
273+
client_magic=bytes.fromhex("b1b2b3b4b5b6b7b8"),
274+
serial=1,
275+
ts_start=0x68000000,
276+
ts_end=0x68015180,
277+
).to_bytes()
278+
resolver_key_pair = d.xwing_generate_key_pair_derand(d.iota(0x20, 32))
279+
pq = d.DNSCryptCertificate.sign(
280+
provider_signing_seed=provider_seed,
281+
es_version=d.ES_VERSION_XWING,
282+
resolver_pk=resolver_key_pair.public_key,
283+
client_magic=bytes.fromhex("a1b2c3d4e5f60718"),
284+
serial=2,
285+
ts_start=0x68000000,
286+
ts_end=0x68015180,
287+
extensions=d.pq_profile_extension(),
288+
).to_bytes()
289+
provider_name = "2.dnscrypt-cert.example.com"
290+
291+
# One 1320-byte PQ certificate is about 1338 bytes as a TXT answer record.
292+
self.assertEqual(len(pq), 1320)
293+
base_query = d.certificate_query(provider_name)
294+
self.assertEqual(
295+
len(d.build_certificate_response(base_query, [pq])) - len(base_query),
296+
1338,
297+
)
298+
299+
# A query padded past the response carries the PQ certificate over UDP.
300+
padded = d.certificate_query(provider_name, padded_length=1600)
301+
served = d.serve_certificates(padded, [classical], [pq], over_udp=True)
302+
self.assertEqual(served[6:8], b"\x00\x02") # two answers
303+
self.assertFalse(served[2] & 0x02) # TC not set
304+
self.assertLessEqual(len(served), len(padded))
305+
self.assertEqual(d.relay_certificate_response(padded, served), served)
306+
307+
# An unpadded query is too small: the PQ certificate is withheld with TC set.
308+
small = d.certificate_query(provider_name)
309+
truncated = d.serve_certificates(small, [classical], [pq], over_udp=True)
310+
self.assertEqual(truncated[6:8], b"\x00\x01") # classical only
311+
self.assertTrue(truncated[2] & 0x02) # TC set
312+
with self.assertRaises(d.AmplificationError):
313+
d.relay_certificate_response(small, served)
314+
315+
# Over TCP the source is validated, so the PQ certificate is always sent.
316+
over_tcp = d.serve_certificates(small, [classical], [pq], over_udp=False)
317+
self.assertEqual(over_tcp[6:8], b"\x00\x02")
318+
self.assertFalse(over_tcp[2] & 0x02)
319+
320+
# The rollover set of two classical and two PQ certificates needs a larger
321+
# query, since the response roughly doubles during a key rotation.
322+
rollover_query = d.certificate_query(provider_name, padded_length=3200)
323+
rollover = d.serve_certificates(
324+
rollover_query, [classical, classical], [pq, pq], over_udp=True
325+
)
326+
self.assertEqual(rollover[6:8], b"\x00\x04")
327+
self.assertFalse(rollover[2] & 0x02)
328+
self.assertLessEqual(len(rollover), len(rollover_query))
329+
265330
def test_pq_full_and_resumed_round_trip(self):
266331
"""Check randomized full-PQ and resumed-query round trips."""
267332

0 commit comments

Comments
 (0)