dnstt uses a layered protocol. The "tt" in dnstt stands for Turbo Tunnel, which is a name for a design that includes a sequencing/reliability layer somewhere in the middle of the protocol stack, in order to decouple the end-to-end session from the transport used to carry it. In dnstt, the sequencing/reliability layer is provided by the combination of KCP and smux, which dnstt basically treats as a black box. In addition, there is a layer of end-to-end encryption and authentication provided by this implementation of the Noise protocol framework.
The code in the dnstt source code repository glues these layers together, handles encoding packets into DNS queries and responses, and manages efficient polling.
Working through the protocol stack from the bottom up,
DNS over HTTPS (DoH), DNS over TLS (DoT), and UDP are interchangeable transports for DNS messages (queries and responses), which are smallish discrete chunks of data that conform to DNS format requirements.
A DNS message may be a query or a response. You can encode data into a query by requesting the data as if it were a DNS name; for example you can think of looking up the name abc123.t.example.com as sending the message "abc123" to the nameserver for t.example.com. You can encode data into a response by including it in a resource record. (dnstt uses TXT records.)
At this layer, each DNS queries is tagged with a Client ID that associates the message with one of potentially many simultaneous tunnel clients. See details below.
KCP provides a reliable, in-order stream interface over an unreliable packet interface, using sequence numbers and acknowledgements. A typical underlying packet interface would be UDP datagrams; but in our case, it is rather DNS messages carried over DoH, DoT, or UDP. In dnstt, DNS messages contain encoded KCP packets, tagged with a Client ID.
Noise provides confidentiality and authentication of discrete messages over an in-order transport. dnstt uses Noise in a way that authenticates the server but not the client. Noise messages are encoded into the KCP stream in a simple length-prefixed way.
smux provides stream management and multiplexing over a reliable, in-order stream. It enables multiple logical streams to be carried simultaneously over the single encrypted stream provides by KCP and Noise.
dnstt-client opens up a local TCP listener. For each incoming TCP connection, smux opens a new logical stream.
The tunnel server, because it acts as an authoritative DNS resolver, cannot unilaterally send data to the tunnel client; the only way it can send data is by including it in the response to a received query. The client must poll the server periodically, with empty queries if it has nothing better to send, to give the server an opportunity to send. When the server has a lot to send, the client must keep the server supplied with plenty of queries.
The tunnel server may have multiple client sessions operating at the same time. When a query arrives, the server must be able to associate each encoded packet it contains to an ongoing client session (or create a new session if it does not exist yet). Similarly, the server must buffer data for each client until a query arrives, and in filling out the response the server must know which buffer to draw from. To this end, every DNS query is tagged with a Client ID, which is a 64-bit random string generated by the client. The Client ID is a key into the server's mapping on ongoing client sessions; it serves as a "remote address" in place of the client's unknown IP address. Client IDs are not permanent identifiers; they last only as long as the client software runs, and the server expires stale mappings after a while. The Client ID is unnecessary in responses.
Encoding data into queries
As stated above, DNS queries in dnstt carry one or more KCP packets. However here we will treat the upper layer as opaque and consider only how to encode some general binary blobs into a query.
The constraints on the encoding are: data can only be encoded into the name we are looking up; the name must terminate in the zone of the tunnel, in order to be routed correctly; names may consist of only a subset of byte values and do not preserve letter case; the length of each label in a name must be 63 bytes or less; and the total length of the name must be 255 bytes or less, including label terminators.
dnstt's query encoding can represent zero or more packets of up to 223 bytes,
plus arbitrary padding.
DNSPacketConn.send in dnstt-client/dns.go for details.
Encoding proceeds according to the following steps.
As an example, we will show how to encode a single packet containing the data
supercalifragilisticexpialidocious, with a Client ID of
CLIENTID and a tunnel DNS zone of t.example.com.
Length-prefix the packets and add random padding. A length prefix L < 0xe0 means a data packet of L bytes. A length prefix L ≥ 0xe0 means L − 0xe0 bytes of padding (not counting the length of the length prefix itself). Random padding may be used for traffic shaping, and also has a secondary effect of inhibiting caching by intermediate resolvers.
Here, there are 3 bytes of padding (L = 0xe3) followed by a data packet of 0x22 bytes (L = 0x22).
Prefix the Client ID. This step is reversible because the Client ID has a fixed length.
Base32-encode the string so far, without padding and in lower case.
Break the base32-encoded string into labels of at most 63 bytes.
Append the DNS zone.
The receiving side reverses the steps: strip the DNS zone, concatenate the labels, base32-decode, extract the Client ID, then crawl length prefixes to discard padding and extract packets.
Although the encoding permits more than one packet per query, as of tag v0.20200504.0 dnstt-client uses the heuristic of including at most one packet per query.
The resulting encoded name is packed into a DNS query with QTYPE=TXT, QCLASS=IN, and an EDNS(0) OPT resource record that permits the resolver to return large responses.
Encoding data into responses
There are fewer constraints on the format of DNS responses and the encoding is correspondingly simpler. dnstt uses the TXT resource record exclusively. TXT provides a convenient 8-bit-clean container for bytes with only 0.4% overhead (256 bytes needed to encode every 255 bytes).
Prefix each packet to be sent with a 16-bit big-endian length. Concatenate all the length-prefixed packets. There is no provision for padding in the downstream encoding. (You could use EDNS(0) padding if necessary.) dnstt-server uses the greedy approach of packing as many packets into a response as are immediately available and will fit.
To encode the buffer into the TXT resource record,
break it into chunks of at most 255 bytes, and precede each one by a 1-byte length prefix.
Encryption and authentication (Noise)
The Noise layer in dnstt is for end-to-end security between the tunnel client and tunnel server. It is independent of the encryption and authentication provided by DoH or DoT. the DoH or DoT layer only hides the fact that you are using a tunnel; the Noise layer is for security, even against a malicious resolver.
The specific Noise protocol name
used by dnstt is Noise_NK_25519_ChaChaPoly_BLAKE2s.
handshake pattern authenticates the server but not the client.
We use a prologue of
See noise/noise.go for details of the implementation.
The NK handshake pattern requires the server to have a private and public key.
We represent keys as 64-digit hexadecimal strings.
-gen-key command-line option of dnstt-server generates a keypair
and either displays the key strings to the screen,
or (with the
saves them to files.
The file format is a 64-digit hexadecimal string, optionally followed by a newline character.
The Noise layer is sandwiched between the KCP layer, which creates a reliable stream on top of DNS messages, and smux, which provides stream multiplexing and session features. An observer who can see DNS messages, such as the recursive resolver, may read KCP headers, but not smux headers nor the content of the streams inside. The model is similar to what you would get with TLS or SSH over TCP: an observer can see TCP-level ACKs and sequence numbers, but cannot read the stream data.
An alternative model would be to encrypt and authenticate every KCP packet separately, as is done for example in Wireguard or CurveCP. (KCP-over-encryption, rather than encryption-over-KCP.) I decided against this model because space for packets in DNS messages is so constrained; implementing Noise messages as length-prefixed packets on top of the notional transport layer allows authentication tags, for example, to be split across packets. Incidentally, the space crunch is also why I decided to use KCP instead of QUIC for the inner layer; QUIC demands a packet of 1200 bytes during the handshake, and I would have had to invent a fragmentation scheme to fit it into DNS messages.
Does the NK handshake pattern require a separate server pubkey check?, my thread on the Noise mailing list.
Other DNS tunnels
Before designing the dnstt protocol I did a survey of the protocols of some other tunnels.