Skip to content

Commit d412d3b

Browse files
committed
Add a Python reference implementation
1 parent eed9ce1 commit d412d3b

12 files changed

Lines changed: 1603 additions & 0 deletions

File tree

reference/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# DNSCrypt Reference Implementation
2+
3+
This directory contains a small Python reference implementation for the
4+
DNSCrypt v2 protocol.
5+
6+
The code is intended to be read alongside the specification.
7+
8+
It keeps the specification names and favors direct field construction
9+
over abstraction.
10+
11+
The implementation is split by topic:
12+
13+
- `constants.py` and `errors.py`: protocol constants and exception types
14+
- `crypto.py`: HChaCha20, DNSCrypt's XChaCha20_DJB-Poly1305, HKDF, X25519, Ed25519
15+
- `certificates.py`: certificate parsing, signing, profile extensions, selection
16+
- `packets.py` and `transport.py`: DNSCrypt packets, TCP framing, Anonymized DNSCrypt
17+
- `pq.py`: X-Wing, PQ key derivation, tickets, responses, resumed queries
18+
- `dnscrypt.py`: re-exports the public reference API

reference/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Reference Python implementation of DNSCrypt protocol building blocks."""
2+

reference/certificates.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"""DNSCrypt certificate parsing, signing, and selection."""
2+
3+
from __future__ import annotations
4+
5+
import struct
6+
from dataclasses import dataclass, replace
7+
from typing import Iterable, Sequence
8+
9+
from .constants import (
10+
AEAD_ID_XCHACHA20_DJB_POLY1305,
11+
CERT_MAGIC,
12+
CLIENT_MAGIC_SIZE,
13+
ES_VERSION_XCHACHA20POLY1305,
14+
ES_VERSION_XSALSA20POLY1305,
15+
ES_VERSION_XWING,
16+
KDF_ID_HKDF_SHA256,
17+
PQ_PROFILE_EXTENSION_MAGIC,
18+
PQ_PROFILE_EXTENSION_VERSION,
19+
PROTOCOL_MINOR_VERSION,
20+
PUBLIC_KEY_SIZE,
21+
SIGNATURE_SIZE,
22+
XWING_CIPHERTEXT_SIZE,
23+
XWING_PUBLIC_KEY_SIZE,
24+
)
25+
from .crypto import _require_size, ed25519_sign, ed25519_verify
26+
from .errors import CertificateError
27+
28+
29+
def resolver_pk_len_for_es_version(es_version: bytes) -> int:
30+
"""Return the certificate `<resolver-pk>` length for an encryption system."""
31+
32+
if es_version in (ES_VERSION_XSALSA20POLY1305, ES_VERSION_XCHACHA20POLY1305):
33+
return PUBLIC_KEY_SIZE
34+
if es_version == ES_VERSION_XWING:
35+
return XWING_PUBLIC_KEY_SIZE
36+
raise CertificateError(f"unsupported es-version: {es_version.hex()}")
37+
38+
39+
def client_pk_len_for_es_version(es_version: bytes) -> int:
40+
"""Return the query `<client-pk>` length for an encryption system."""
41+
42+
if es_version in (ES_VERSION_XSALSA20POLY1305, ES_VERSION_XCHACHA20POLY1305):
43+
return PUBLIC_KEY_SIZE
44+
if es_version == ES_VERSION_XWING:
45+
return XWING_CIPHERTEXT_SIZE
46+
raise CertificateError(f"unsupported es-version: {es_version.hex()}")
47+
48+
49+
@dataclass(frozen=True)
50+
class PQProfileExtension:
51+
"""Parsed fields of the signed PQ profile extension."""
52+
53+
ext_version: int
54+
es_version: bytes
55+
kdf_id: int
56+
aead_id: int
57+
resolver_pk_len: int
58+
client_kex_len: int
59+
60+
61+
def pq_profile_extension(
62+
es_version: bytes = ES_VERSION_XWING,
63+
resolver_pk_len: int = XWING_PUBLIC_KEY_SIZE,
64+
client_kex_len: int = XWING_CIPHERTEXT_SIZE,
65+
) -> bytes:
66+
"""Build the signed 12-byte PQ profile extension."""
67+
68+
_require_size("es_version", es_version, 2)
69+
return (
70+
PQ_PROFILE_EXTENSION_MAGIC
71+
+ bytes([PQ_PROFILE_EXTENSION_VERSION])
72+
+ es_version
73+
+ bytes([KDF_ID_HKDF_SHA256, AEAD_ID_XCHACHA20_DJB_POLY1305])
74+
+ resolver_pk_len.to_bytes(2, "big")
75+
+ client_kex_len.to_bytes(2, "big")
76+
)
77+
78+
79+
def parse_pq_profile_extension(
80+
extensions: bytes, expected_es_version: bytes = ES_VERSION_XWING
81+
) -> PQProfileExtension:
82+
"""Parse and validate the signed PQ profile extension."""
83+
84+
if len(extensions) != 12:
85+
raise CertificateError("PQ profile extension must be 12 bytes")
86+
if extensions[:3] != PQ_PROFILE_EXTENSION_MAGIC:
87+
raise CertificateError("PQ profile extension magic is invalid")
88+
parsed = PQProfileExtension(
89+
ext_version=extensions[3],
90+
es_version=extensions[4:6],
91+
kdf_id=extensions[6],
92+
aead_id=extensions[7],
93+
resolver_pk_len=int.from_bytes(extensions[8:10], "big"),
94+
client_kex_len=int.from_bytes(extensions[10:12], "big"),
95+
)
96+
if parsed.ext_version != PQ_PROFILE_EXTENSION_VERSION:
97+
raise CertificateError("unsupported PQ profile extension version")
98+
if parsed.es_version != expected_es_version:
99+
raise CertificateError("PQ extension es-version does not match certificate")
100+
if parsed.kdf_id != KDF_ID_HKDF_SHA256:
101+
raise CertificateError("unsupported PQ KDF")
102+
if parsed.aead_id != AEAD_ID_XCHACHA20_DJB_POLY1305:
103+
raise CertificateError("unsupported PQ AEAD")
104+
if parsed.resolver_pk_len != resolver_pk_len_for_es_version(expected_es_version):
105+
raise CertificateError("PQ resolver-pk length mismatch")
106+
if parsed.client_kex_len != client_pk_len_for_es_version(expected_es_version):
107+
raise CertificateError("PQ client-kex length mismatch")
108+
return parsed
109+
110+
111+
@dataclass(frozen=True)
112+
class DNSCryptCertificate:
113+
"""DNSCrypt certificate fields after parsing."""
114+
115+
es_version: bytes
116+
protocol_minor_version: bytes
117+
signature: bytes
118+
resolver_pk: bytes
119+
client_magic: bytes
120+
serial: int
121+
ts_start: int
122+
ts_end: int
123+
extensions: bytes = b""
124+
125+
def signed_data(self) -> bytes:
126+
"""Return the byte string covered by the Ed25519 signature."""
127+
128+
return (
129+
self.resolver_pk
130+
+ self.client_magic
131+
+ self.serial.to_bytes(4, "big")
132+
+ self.ts_start.to_bytes(4, "big")
133+
+ self.ts_end.to_bytes(4, "big")
134+
+ self.extensions
135+
)
136+
137+
def to_bytes(self) -> bytes:
138+
"""Serialize the certificate as it appears in a TXT record."""
139+
140+
return (
141+
CERT_MAGIC
142+
+ self.es_version
143+
+ self.protocol_minor_version
144+
+ self.signature
145+
+ self.signed_data()
146+
)
147+
148+
def verify(self, provider_public_key: bytes) -> None:
149+
"""Verify the signature, validity interval shape, and PQ extension."""
150+
151+
ed25519_verify(provider_public_key, self.signature, self.signed_data())
152+
if self.ts_start >= self.ts_end:
153+
raise CertificateError("certificate validity start must precede end")
154+
if self.es_version == ES_VERSION_XWING:
155+
parse_pq_profile_extension(self.extensions, self.es_version)
156+
157+
@classmethod
158+
def sign(
159+
cls,
160+
provider_signing_seed: bytes,
161+
es_version: bytes,
162+
resolver_pk: bytes,
163+
client_magic: bytes,
164+
serial: int,
165+
ts_start: int,
166+
ts_end: int,
167+
extensions: bytes = b"",
168+
protocol_minor_version: bytes = PROTOCOL_MINOR_VERSION,
169+
) -> "DNSCryptCertificate":
170+
"""Create and sign a DNSCrypt certificate."""
171+
172+
_require_size("es_version", es_version, 2)
173+
_require_size("protocol_minor_version", protocol_minor_version, 2)
174+
_require_size("client_magic", client_magic, CLIENT_MAGIC_SIZE)
175+
if len(resolver_pk) != resolver_pk_len_for_es_version(es_version):
176+
raise CertificateError("resolver-pk length does not match es-version")
177+
unsigned = cls(
178+
es_version=es_version,
179+
protocol_minor_version=protocol_minor_version,
180+
signature=b"\x00" * SIGNATURE_SIZE,
181+
resolver_pk=resolver_pk,
182+
client_magic=client_magic,
183+
serial=serial,
184+
ts_start=ts_start,
185+
ts_end=ts_end,
186+
extensions=extensions,
187+
)
188+
signature = ed25519_sign(provider_signing_seed, unsigned.signed_data())
189+
return replace(unsigned, signature=signature)
190+
191+
@classmethod
192+
def from_bytes(cls, certificate: bytes) -> "DNSCryptCertificate":
193+
"""Parse a DNSCrypt certificate from its wire bytes."""
194+
195+
resolver_pk_start = len(CERT_MAGIC) + 2 + 2 + SIGNATURE_SIZE
196+
if len(certificate) < resolver_pk_start:
197+
raise CertificateError("certificate is too short")
198+
if certificate[: len(CERT_MAGIC)] != CERT_MAGIC:
199+
raise CertificateError("invalid certificate magic")
200+
es_version = certificate[4:6]
201+
protocol_minor_version = certificate[6:8]
202+
signature = certificate[8:resolver_pk_start]
203+
resolver_pk_len = resolver_pk_len_for_es_version(es_version)
204+
tail_start = resolver_pk_start + resolver_pk_len
205+
tail_format = f">{CLIENT_MAGIC_SIZE}sIII"
206+
tail_end = tail_start + struct.calcsize(tail_format)
207+
if len(certificate) < tail_end:
208+
raise CertificateError("certificate is truncated")
209+
resolver_pk = certificate[resolver_pk_start:tail_start]
210+
client_magic, serial, ts_start, ts_end = struct.unpack(
211+
tail_format, certificate[tail_start:tail_end]
212+
)
213+
return cls(
214+
es_version=es_version,
215+
protocol_minor_version=protocol_minor_version,
216+
signature=signature,
217+
resolver_pk=resolver_pk,
218+
client_magic=client_magic,
219+
serial=serial,
220+
ts_start=ts_start,
221+
ts_end=ts_end,
222+
extensions=certificate[tail_end:],
223+
)
224+
225+
226+
def choose_certificate(
227+
certificates: Iterable[DNSCryptCertificate],
228+
provider_public_key: bytes,
229+
now: int,
230+
supported_es_versions: Sequence[bytes] = (
231+
ES_VERSION_XCHACHA20POLY1305,
232+
ES_VERSION_XWING,
233+
),
234+
) -> DNSCryptCertificate:
235+
"""Choose the valid certificate with the highest serial number."""
236+
237+
usable = []
238+
for certificate in certificates:
239+
if certificate.es_version not in supported_es_versions:
240+
continue
241+
certificate.verify(provider_public_key)
242+
if certificate.ts_start <= now <= certificate.ts_end:
243+
usable.append(certificate)
244+
if not usable:
245+
raise CertificateError("no usable certificate")
246+
return max(usable, key=lambda c: c.serial)

reference/constants.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Protocol constants for the DNSCrypt reference implementation."""
2+
3+
CERT_MAGIC = b"DNSC"
4+
RESOLVER_MAGIC = bytes.fromhex("7236666e76576a38")
5+
ANON_MAGIC = b"\xff" * 8 + b"\x00\x00"
6+
RESUME_MAGIC = b"PQResume"
7+
8+
ES_VERSION_XSALSA20POLY1305 = b"\x00\x01"
9+
ES_VERSION_XCHACHA20POLY1305 = b"\x00\x02"
10+
ES_VERSION_XWING = b"\x00\x03"
11+
PROTOCOL_MINOR_VERSION = b"\x00\x00"
12+
13+
CLIENT_MAGIC_SIZE = 8
14+
PUBLIC_KEY_SIZE = 32
15+
SIGNATURE_SIZE = 64
16+
CLIENT_NONCE_SIZE = 12
17+
RESOLVER_NONCE_SIZE = 12
18+
NONCE_SIZE = CLIENT_NONCE_SIZE + RESOLVER_NONCE_SIZE
19+
TAG_SIZE = 16
20+
PADDING_BLOCK_SIZE = 64
21+
MIN_UDP_QUERY_LEN = 256
22+
23+
XWING_MLKEM_PUBLIC_KEY_SIZE = 1184
24+
XWING_MLKEM_CIPHERTEXT_SIZE = 1088
25+
XWING_X25519_KEY_SIZE = 32
26+
XWING_PUBLIC_KEY_SIZE = XWING_MLKEM_PUBLIC_KEY_SIZE + XWING_X25519_KEY_SIZE
27+
XWING_CIPHERTEXT_SIZE = XWING_MLKEM_CIPHERTEXT_SIZE + XWING_X25519_KEY_SIZE
28+
XWING_SHARED_SECRET_SIZE = 32
29+
XWING_LABEL = b"\\.//^\\"
30+
31+
PQ_PROFILE_EXTENSION_MAGIC = b"PQD"
32+
PQ_PROFILE_EXTENSION_VERSION = 1
33+
KDF_ID_HKDF_SHA256 = 1
34+
AEAD_ID_XCHACHA20_DJB_POLY1305 = 1
35+
PQ_CONTROL_MAGIC = b"PQDR"
36+
PQ_CONTROL_VERSION = 1
37+
38+
TICKET_KEY_ID_SIZE = 4
39+
TICKET_NONCE_SIZE = 24
40+
TICKET_PLAIN_SIZE = 86
41+

0 commit comments

Comments
 (0)