Skip to content

DNS over TLS in Swift

A DNS filter that answers some lookups locally has to forward the rest. Forwarded over plain UDP, every domain the device asks for is readable by anything on the path. DNS over TLS (RFC 7858) carries the same queries inside a verified TLS connection on port 853. Blue Guard forwards this way.

The wire format differs from UDP DNS

Over UDP a query is the bare message. Over TCP and TLS each message is prefixed with its length as a two-byte big-endian integer, because a stream has no message boundaries.

func framed(_ query: [UInt8]) -> Data {
    precondition(query.count <= Int(UInt16.max))
    var out = Data()
    out.append(UInt8(query.count >> 8))
    out.append(UInt8(query.count & 0xFF))
    out.append(contentsOf: query)
    return out
}

Reading requires the same care in reverse: read exactly two bytes, decode the length, then read exactly that many. A single read is not guaranteed to return a whole message.

Connecting

Network.framework handles TLS. The server name matters as much as the address, because that is what the certificate is checked against.

import Network

func makeParameters(serverName: String) -> NWParameters {
    let tls = NWProtocolTLS.Options()
    sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, serverName)

    let tcp = NWProtocolTCP.Options()
    tcp.noDelay = true

    return NWParameters(tls: tls, tcp: tcp)
}

Setting the server name gives the resolver its SNI value and tells the framework which name to validate. Connecting to 1.1.1.1 and validating against cloudflare-dns.com is the correct pairing; omitting the name leaves the certificate checked against an IP address, which is not what the resolver presents.

Holding the connection in an actor

A connection is mutable state used from several tasks. An actor serialises access without locks.

actor UpstreamChannel {
    private let host: NWEndpoint.Host
    private let serverName: String
    private var connection: NWConnection?

    init(host: String, serverName: String) {
        self.host = NWEndpoint.Host(host)
        self.serverName = serverName
    }

    func resolve(_ query: [UInt8]) async -> [UInt8]? {
        do {
            let connection = try await connected()
            try await send(framed(query), over: connection)
            return try await readMessage(from: connection)
        } catch {
            connection?.cancel()
            connection = nil
            return nil
        }
    }
}

Two things are deliberate.

Returning [UInt8]? instead of throwing leaves the caller one option: a lookup that cannot be answered over an encrypted connection is not answered at all.

Clearing connection on any failure means the next call reconnects. A half-closed connection that is retained will fail every subsequent lookup.

Bridging the callback API

NWConnection reports readiness through a state handler. A continuation adapts it, and must be resumed exactly once.

private func connected() async throws -> NWConnection {
    if let connection, connection.state == .ready { return connection }

    let connection = NWConnection(
        host: host, port: 853,
        using: makeParameters(serverName: serverName)
    )
    self.connection = connection

    try await withCheckedThrowingContinuation { continuation in
        var resumed = false
        connection.stateUpdateHandler = { state in
            guard !resumed else { return }
            switch state {
            case .ready:
                resumed = true
                continuation.resume()
            case .failed(let error), .waiting(let error):
                resumed = true
                continuation.resume(throwing: error)
            default:
                break
            }
        }
        connection.start(queue: .global())
    }

    return connection
}

The resumed flag is not defensive padding. stateUpdateHandler fires repeatedly, and resuming a continuation twice is a crash.

.waiting is treated as a failure here. It means the path is not currently viable, and for a DNS filter that should surface immediately rather than hang while the system waits for connectivity.

Timeouts

A lookup that never returns blocks whatever is waiting on it. Race the read against a sleep and take whichever finishes first.

func withTimeout<T: Sendable>(
    _ duration: Duration,
    operation: @escaping @Sendable () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: duration)
            throw TimeoutError()
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

cancelAll matters. Without it the losing task keeps running, and on a busy filter those accumulate.

Refusing to fall back

A filter that cannot reach an encrypted resolver could retry the lookup over plain UDP, so that browsing keeps working. That defeats the purpose: an attacker able to cause failures can then read every domain the device asks for.

A lookup that cannot be sent encrypted is therefore not sent. Resolution fails, the app reports no network, and nobody is quietly downgraded to plaintext.

That is a real cost, and documentation should say so before someone meets it as an outage. Block trackers in every app is where Blue Guard states it.