krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.5.0: DomainDig/EmailSecurityService.swift · raw

  1import Foundation
  2
  3struct EmailSecurityService {
  4    private static let dkimSelectors = [
  5        "default", "google", "mail", "selector1", "selector2", "k1",
  6        "smtp", "dkim", "zoho", "mailchimp"
  7    ]
  8
  9    /// Analyze email security records. SPF is parsed from existing TXT records;
 10    /// DMARC and DKIM require additional DoH queries.
 11    static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
 12        // SPF: extract from existing TXT records
 13        let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") })
 14        let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value)
 15
 16        // DMARC, DKIM, BIMI, and MTA-STS queries in parallel.
 17        async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
 18        async let dkimResult = queryDKIM(domain: domain)
 19        async let bimiResult = queryMatchingTXT(
 20            subdomain: "default._bimi.\(domain)",
 21            prefix: "v=BIMI1"
 22        )
 23        async let mtaStsResult = queryMTASTS(domain: domain)
 24
 25        let dmarcValue = await dmarcResult
 26        let dkimValue = await dkimResult
 27        let bimiValue = await bimiResult
 28        let mtaSts = await mtaStsResult
 29
 30        let dmarc = EmailSecurityRecord(
 31            found: dmarcValue != nil,
 32            value: dmarcValue
 33        )
 34        let dkim = EmailSecurityRecord(
 35            found: dkimValue != nil,
 36            value: dkimValue?.value,
 37            matchedSelector: dkimValue?.selector
 38        )
 39        let bimi = EmailSecurityRecord(
 40            found: bimiValue != nil,
 41            value: bimiValue
 42        )
 43
 44        return EmailSecurityResult(
 45            spf: spf,
 46            dmarc: dmarc,
 47            dkim: dkim,
 48            bimi: bimi,
 49            mtaSts: mtaSts
 50        )
 51    }
 52
 53    /// Query a TXT record for the given subdomain via DoH.
 54    private static func queryTXT(subdomain: String) async -> String? {
 55        do {
 56            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
 57            return records.first?.value
 58        } catch {
 59            return nil
 60        }
 61    }
 62
 63    private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? {
 64        do {
 65            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
 66            return records.first(where: { $0.value.hasPrefix(prefix) })?.value
 67        } catch {
 68            return nil
 69        }
 70    }
 71
 72    /// Try common DKIM selectors concurrently and return the first valid result.
 73    private static func queryDKIM(domain: String) async -> (selector: String, value: String)? {
 74        await withTaskGroup(of: (selector: String, value: String?).self) { group in
 75            for selector in dkimSelectors {
 76                group.addTask {
 77                    let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
 78                    return (selector, value)
 79                }
 80            }
 81
 82            for await result in group {
 83                if let value = result.value, !value.isEmpty {
 84                    group.cancelAll()
 85                    return (result.selector, value)
 86                }
 87            }
 88
 89            return nil
 90        }
 91    }
 92
 93    private static func queryMTASTS(domain: String) async -> MTASTSResult? {
 94        let txtValue = await queryMatchingTXT(subdomain: "_mta-sts.\(domain)", prefix: "v=STSv1")
 95        guard txtValue != nil else {
 96            return nil
 97        }
 98
 99        return MTASTSResult(
100            txtFound: true,
101            policyMode: await fetchMTASTSPolicyMode(domain: domain)
102        )
103    }
104
105    private static func fetchMTASTSPolicyMode(domain: String) async -> String? {
106        guard let url = URL(string: "https://mta-sts.\(domain)/.well-known/mta-sts.txt") else {
107            return nil
108        }
109
110        var request = URLRequest(url: url)
111        request.timeoutInterval = 5
112
113        do {
114            let (data, _) = try await URLSession.shared.data(for: request)
115            let policy = String(decoding: data, as: UTF8.self)
116
117            for line in policy.split(whereSeparator: \.isNewline) {
118                let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
119                guard trimmedLine.lowercased().hasPrefix("mode:") else {
120                    continue
121                }
122
123                let mode = trimmedLine.dropFirst("mode:".count)
124                    .trimmingCharacters(in: .whitespacesAndNewlines)
125                    .lowercased()
126                return ["enforce", "testing", "none"].contains(mode) ? mode : nil
127            }
128        } catch {
129            return nil
130        }
131
132        return nil
133    }
134}