krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v1.7.1: DomainDig/DNSLookupService.swift · raw

  1import Foundation
  2
  3enum DNSResolverOption: String, CaseIterable, Identifiable {
  4    case cloudflare
  5    case google
  6    case quad9
  7    case custom
  8
  9    static let userDefaultsKey = "dnsResolverURL"
 10    static let defaultURLString = "https://cloudflare-dns.com/dns-query"
 11
 12    var id: String { rawValue }
 13
 14    var title: String {
 15        switch self {
 16        case .cloudflare: return "Cloudflare"
 17        case .google: return "Google"
 18        case .quad9: return "Quad9"
 19        case .custom: return "Custom"
 20        }
 21    }
 22
 23    var urlString: String? {
 24        switch self {
 25        case .cloudflare: return Self.defaultURLString
 26        case .google: return "https://dns.google/dns-query"
 27        case .quad9: return "https://dns.quad9.net/dns-query"
 28        case .custom: return nil
 29        }
 30    }
 31
 32    static func option(for urlString: String) -> DNSResolverOption {
 33        let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
 34        return Self.allCases.first(where: { $0.urlString == trimmedURL }) ?? .custom
 35    }
 36
 37    static func isValidCustomURL(_ urlString: String) -> Bool {
 38        let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
 39        guard trimmedURL.hasPrefix("https://") else {
 40            return false
 41        }
 42        return URL(string: trimmedURL) != nil
 43    }
 44
 45    static func resolvedURLString(from storedValue: String?) -> String {
 46        guard let storedValue else {
 47            return defaultURLString
 48        }
 49
 50        let trimmedURL = storedValue.trimmingCharacters(in: .whitespacesAndNewlines)
 51        guard !trimmedURL.isEmpty else {
 52            return defaultURLString
 53        }
 54
 55        return isValidCustomURL(trimmedURL) ? trimmedURL : defaultURLString
 56    }
 57}
 58
 59struct DNSLookupService {
 60    private static let rrsigQueryType = 46
 61    private static let dnskeyQueryType = 48
 62    private static let internetClass = 1
 63
 64    static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
 65        try await lookup(
 66            domain: domain,
 67            recordType: recordType,
 68            resolverURLString: currentResolverURLString()
 69        )
 70    }
 71
 72    static func lookup(
 73        domain: String,
 74        recordType: DNSRecordType,
 75        resolverURLString: String
 76    ) async throws -> [DNSRecord] {
 77        let response = try await lookupResponse(
 78            domain: domain,
 79            queryType: recordType.queryType,
 80            resolverURLString: resolverURLString
 81        )
 82
 83        return response.answers
 84            .filter { $0.type == recordType.queryType }
 85            .map { answer in
 86                let value: String
 87                if recordType.usesRawDataValue {
 88                    value = answer.data
 89                } else {
 90                    value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
 91                }
 92                return DNSRecord(value: value, ttl: answer.TTL)
 93            }
 94    }
 95
 96    static func lookupAll(domain: String) async -> [DNSSection] {
 97        typealias Result = (
 98            type: DNSRecordType,
 99            records: [DNSRecord],
100            wildcard: [DNSRecord],
101            dnssecSigned: Bool?,
102            error: String?
103        )
104
105        let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA]
106        let resolverURLString = currentResolverURLString()
107        let dnssecSigned = try? await lookupDNSSECStatus(
108            domain: domain,
109            resolverURLString: resolverURLString
110        )
111
112        return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
113            for recordType in DNSRecordType.allCases {
114                let shouldQueryWildcard = wildcardTypes.contains(recordType)
115                group.addTask {
116                    var apexRecords: [DNSRecord] = []
117                    var wildcardRecords: [DNSRecord] = []
118                    var lookupError: String?
119
120                    do {
121                        apexRecords = try await lookup(
122                            domain: domain,
123                            recordType: recordType,
124                            resolverURLString: resolverURLString
125                        )
126                    } catch {
127                        lookupError = error.localizedDescription
128                    }
129
130                    if shouldQueryWildcard && lookupError == nil {
131                        do {
132                            wildcardRecords = try await lookup(
133                                domain: "*.\(domain)",
134                                recordType: recordType,
135                                resolverURLString: resolverURLString
136                            )
137                        } catch {
138                            // Wildcard failure is non-fatal; just leave empty.
139                        }
140                    }
141
142                    return (recordType, apexRecords, wildcardRecords, dnssecSigned, lookupError)
143                }
144            }
145
146            var sections: [DNSSection] = []
147            for await result in group {
148                sections.append(DNSSection(
149                    recordType: result.type,
150                    records: result.records,
151                    wildcardRecords: result.wildcard,
152                    dnssecSigned: result.dnssecSigned,
153                    error: result.error
154                ))
155            }
156
157            let order = DNSRecordType.allCases
158            return sections.sorted { a, b in
159                (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
160            }
161        }
162    }
163
164    private static func lookupResponse(
165        domain: String,
166        queryType: Int,
167        resolverURLString: String,
168        includeDNSSECData: Bool = false
169    ) async throws -> DNSLookupResponse {
170        let resolverURL = try validatedResolverURL(from: resolverURLString)
171        var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
172        components.queryItems = [
173            URLQueryItem(name: "name", value: domain),
174            URLQueryItem(name: "type", value: String(queryType))
175        ]
176        if includeDNSSECData {
177            components.queryItems?.append(URLQueryItem(name: "do", value: "1"))
178        }
179
180        var request = URLRequest(url: components.url!)
181        request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
182
183        let (data, response) = try await URLSession.shared.data(for: request)
184
185        guard let httpResponse = response as? HTTPURLResponse,
186              httpResponse.statusCode == 200 else {
187            return try await lookupResponseViaRFC8484(
188                domain: domain,
189                queryType: queryType,
190                resolverURL: resolverURL,
191                includeDNSSECData: includeDNSSECData
192            )
193        }
194
195        let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
196
197        return DNSLookupResponse(
198            answers: dnsResponse.Answer ?? [],
199            authenticatedData: dnsResponse.AD ?? false
200        )
201    }
202
203    private static func lookupDNSSECStatus(
204        domain: String,
205        resolverURLString: String
206    ) async throws -> Bool {
207        // Query SOA with the DNSSEC OK bit set. The resolver validates the full
208        // DNSSEC chain and reflects the result in the AD (Authenticated Data) bit
209        // of the response flags. This is more reliable than querying DNSKEY directly,
210        // because resolvers don't always set AD on DNSKEY queries and many zones
211        // don't return DNSKEY records via DoH JSON.
212        let response = try await lookupResponse(
213            domain: domain,
214            queryType: 6, // SOA
215            resolverURLString: resolverURLString,
216            includeDNSSECData: true
217        )
218        return response.authenticatedData
219    }
220
221    private static func currentResolverURLString() -> String {
222        let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey)
223        return DNSResolverOption.resolvedURLString(from: storedValue)
224    }
225
226    private static func validatedResolverURL(from urlString: String) throws -> URL {
227        guard let url = URL(string: urlString) else {
228            throw URLError(.badURL)
229        }
230        return url
231    }
232
233    private static func lookupResponseViaRFC8484(
234        domain: String,
235        queryType: Int,
236        resolverURL: URL,
237        includeDNSSECData: Bool
238    ) async throws -> DNSLookupResponse {
239        let queryData = try buildDNSQueryMessage(
240            domain: domain,
241            queryType: queryType,
242            dnssecOK: includeDNSSECData
243        )
244        let encodedQuery = base64URLEncodedString(for: queryData)
245
246        var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
247        components.queryItems = [URLQueryItem(name: "dns", value: encodedQuery)]
248
249        var request = URLRequest(url: components.url!)
250        request.setValue("application/dns-message", forHTTPHeaderField: "Accept")
251
252        let (data, response) = try await URLSession.shared.data(for: request)
253
254        guard let httpResponse = response as? HTTPURLResponse,
255              httpResponse.statusCode == 200 else {
256            throw URLError(.badServerResponse)
257        }
258
259        return try parseDNSMessage(data)
260    }
261
262    private static func buildDNSQueryMessage(domain: String, queryType: Int, dnssecOK: Bool = false) throws -> Data {
263        let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines)
264        let labels = normalizedName.split(separator: ".")
265
266        var data = Data()
267        data.appendUInt16(UInt16.random(in: UInt16.min ... UInt16.max))
268        data.appendUInt16(0x0100)
269        data.appendUInt16(1)
270        data.appendUInt16(0)
271        data.appendUInt16(0)
272        data.appendUInt16(dnssecOK ? 1 : 0)
273
274        for label in labels {
275            guard let labelData = label.data(using: .utf8),
276                  labelData.count <= 63 else {
277                throw URLError(.badURL)
278            }
279            data.append(UInt8(labelData.count))
280            data.append(labelData)
281        }
282
283        data.append(0)
284        data.appendUInt16(UInt16(queryType))
285        data.appendUInt16(UInt16(internetClass))
286
287        if dnssecOK {
288            data.appendUInt16(0)
289            data.appendUInt16(1)
290            data.appendUInt16(0)
291            data.appendUInt16(0)
292            data.appendUInt16(11)
293            data.appendUInt16(10)
294            data.appendUInt16(8_192)
295            data.appendUInt16(32_768)
296            data.appendUInt16(0)
297        }
298
299        return data
300    }
301
302    private static func base64URLEncodedString(for data: Data) -> String {
303        data.base64EncodedString()
304            .replacingOccurrences(of: "+", with: "-")
305            .replacingOccurrences(of: "/", with: "_")
306            .replacingOccurrences(of: "=", with: "")
307    }
308
309    private static func parseDNSMessage(_ data: Data) throws -> DNSLookupResponse {
310        guard data.count >= 12 else {
311            throw URLError(.cannotParseResponse)
312        }
313
314        let flags = readUInt16(in: data, at: 2)
315        let answerCount = Int(readUInt16(in: data, at: 6))
316        let questionCount = Int(readUInt16(in: data, at: 4))
317        var offset = 12
318
319        for _ in 0 ..< questionCount {
320            _ = try readDomainName(in: data, offset: &offset)
321            offset += 4
322        }
323
324        var answers: [CloudflareDNSResponse.CloudflareDNSAnswer] = []
325        for _ in 0 ..< answerCount {
326            let name = try readDomainName(in: data, offset: &offset)
327            let type = Int(readUInt16(in: data, at: offset))
328            offset += 2
329            _ = readUInt16(in: data, at: offset)
330            offset += 2
331            let ttl = Int(readUInt32(in: data, at: offset))
332            offset += 4
333            let dataLength = Int(readUInt16(in: data, at: offset))
334            offset += 2
335
336            guard offset + dataLength <= data.count else {
337                throw URLError(.cannotParseResponse)
338            }
339
340            let recordDataOffset = offset
341            let recordData = data.subdata(in: recordDataOffset ..< (recordDataOffset + dataLength))
342            offset += dataLength
343
344            let parsedValue = try parseRecordData(
345                from: data,
346                recordType: type,
347                recordDataOffset: recordDataOffset,
348                recordData: recordData
349            )
350
351            answers.append(.init(
352                name: name,
353                type: type,
354                TTL: ttl,
355                data: parsedValue
356            ))
357        }
358
359        return DNSLookupResponse(
360            answers: answers,
361            authenticatedData: (flags & 0x0020) != 0
362        )
363    }
364
365    private static func parseRecordData(
366        from message: Data,
367        recordType: Int,
368        recordDataOffset: Int,
369        recordData: Data
370    ) throws -> String {
371        switch recordType {
372        case 1:
373            guard recordData.count == 4 else { throw URLError(.cannotParseResponse) }
374            return recordData.map(String.init).joined(separator: ".")
375        case 2, 5:
376            var offset = recordDataOffset
377            return try readDomainName(in: message, offset: &offset)
378        case 15:
379            guard recordData.count >= 3 else { throw URLError(.cannotParseResponse) }
380            let preference = readUInt16(in: recordData, at: 0)
381            var exchangeOffset = recordDataOffset + 2
382            let exchange = try readDomainName(in: message, offset: &exchangeOffset)
383            return "\(preference) \(exchange)"
384        case 16:
385            return try parseTXTData(recordData)
386        case 28:
387            guard recordData.count == 16 else { throw URLError(.cannotParseResponse) }
388            return stride(from: 0, to: 16, by: 2)
389                .map { index in
390                    String(format: "%x", readUInt16(in: recordData, at: index))
391                }
392                .joined(separator: ":")
393        case 6:
394            var offset = recordDataOffset
395            let mname = try readDomainName(in: message, offset: &offset)
396            let rname = try readDomainName(in: message, offset: &offset)
397            let serial = readUInt32(in: message, at: offset)
398            let refresh = readUInt32(in: message, at: offset + 4)
399            let retry = readUInt32(in: message, at: offset + 8)
400            let expire = readUInt32(in: message, at: offset + 12)
401            let minimum = readUInt32(in: message, at: offset + 16)
402            return "\(mname) \(rname) \(serial) \(refresh) \(retry) \(expire) \(minimum)"
403        case 33:
404            guard recordData.count >= 7 else { throw URLError(.cannotParseResponse) }
405            let priority = readUInt16(in: recordData, at: 0)
406            let weight = readUInt16(in: recordData, at: 2)
407            let port = readUInt16(in: recordData, at: 4)
408            var targetOffset = recordDataOffset + 6
409            let target = try readDomainName(in: message, offset: &targetOffset)
410            return "\(priority) \(weight) \(port) \(target)"
411        case 43:
412            guard recordData.count >= 4 else { throw URLError(.cannotParseResponse) }
413            let keyTag = readUInt16(in: recordData, at: 0)
414            let algorithm = recordData[2]
415            let digestType = recordData[3]
416            let digest = recordData.dropFirst(4).map { String(format: "%02X", $0) }.joined()
417            return "\(keyTag) \(algorithm) \(digestType) \(digest)"
418        case 46:
419            return "RRSIG"
420        case 257:
421            guard recordData.count >= 2 else { throw URLError(.cannotParseResponse) }
422            let flags = recordData[0]
423            let tagLength = Int(recordData[1])
424            guard recordData.count >= 2 + tagLength else {
425                throw URLError(.cannotParseResponse)
426            }
427            let tagData = recordData.subdata(in: 2 ..< (2 + tagLength))
428            let valueData = recordData.dropFirst(2 + tagLength)
429            let tag = String(decoding: tagData, as: UTF8.self)
430            let value = String(decoding: valueData, as: UTF8.self)
431            return "\(flags) \(tag) \"\(value)\""
432        default:
433            return recordData.base64EncodedString()
434        }
435    }
436
437    private static func parseTXTData(_ data: Data) throws -> String {
438        var offset = 0
439        var strings: [String] = []
440
441        while offset < data.count {
442            let count = Int(data[offset])
443            offset += 1
444            guard offset + count <= data.count else {
445                throw URLError(.cannotParseResponse)
446            }
447            let stringData = data.subdata(in: offset ..< (offset + count))
448            strings.append(String(decoding: stringData, as: UTF8.self))
449            offset += count
450        }
451
452        return strings.joined()
453    }
454
455    private static func readDomainName(in data: Data, offset: inout Int) throws -> String {
456        var labels: [String] = []
457        var currentOffset = offset
458        var jumped = false
459        var seenOffsets = Set<Int>()
460
461        while true {
462            guard currentOffset < data.count else {
463                throw URLError(.cannotParseResponse)
464            }
465
466            let length = Int(data[currentOffset])
467
468            if length == 0 {
469                if !jumped {
470                    offset = currentOffset + 1
471                }
472                break
473            }
474
475            if length & 0xC0 == 0xC0 {
476                guard currentOffset + 1 < data.count else {
477                    throw URLError(.cannotParseResponse)
478                }
479
480                let pointer = ((length & 0x3F) << 8) | Int(data[currentOffset + 1])
481                guard seenOffsets.insert(pointer).inserted else {
482                    throw URLError(.cannotParseResponse)
483                }
484
485                if !jumped {
486                    offset = currentOffset + 2
487                }
488                currentOffset = pointer
489                jumped = true
490                continue
491            }
492
493            let labelStart = currentOffset + 1
494            let labelEnd = labelStart + length
495            guard labelEnd <= data.count else {
496                throw URLError(.cannotParseResponse)
497            }
498
499            let labelData = data.subdata(in: labelStart ..< labelEnd)
500            labels.append(String(decoding: labelData, as: UTF8.self))
501            currentOffset = labelEnd
502        }
503
504        return labels.joined(separator: ".")
505    }
506
507    private static func readUInt16(in data: Data, at offset: Int) -> UInt16 {
508        let upper = UInt16(data[offset]) << 8
509        let lower = UInt16(data[offset + 1])
510        return upper | lower
511    }
512
513    private static func readUInt32(in data: Data, at offset: Int) -> UInt32 {
514        let first = UInt32(data[offset]) << 24
515        let second = UInt32(data[offset + 1]) << 16
516        let third = UInt32(data[offset + 2]) << 8
517        let fourth = UInt32(data[offset + 3])
518        return first | second | third | fourth
519    }
520}
521
522private struct DNSLookupResponse {
523    let answers: [CloudflareDNSResponse.CloudflareDNSAnswer]
524    let authenticatedData: Bool
525}
526
527private extension Data {
528    mutating func appendUInt16(_ value: UInt16) {
529        append(UInt8((value >> 8) & 0xFF))
530        append(UInt8(value & 0xFF))
531    }
532}