krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.7.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: prefer the already-fetched apex TXT records, but fall back to a direct lookup
 13        // in case the earlier DNS section missed or normalized the record differently.
 14        let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value
 15        async let remoteSPFRecord = queryMatchingTXT(subdomain: domain, prefix: "v=spf1")
 16
 17        // DMARC, DKIM, BIMI, and MTA-STS queries in parallel.
 18        async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
 19        async let dkimResult = queryDKIM(domain: domain)
 20        async let bimiResult = queryMatchingTXT(
 21            subdomain: "default._bimi.\(domain)",
 22            prefix: "v=BIMI1"
 23        )
 24        async let mtaStsResult = queryMTASTS(domain: domain)
 25
 26        let dmarcValue = await dmarcResult
 27        let dkimValue = await dkimResult
 28        let bimiValue = await bimiResult
 29        let mtaSts = await mtaStsResult
 30        let fetchedSPFRecord = await remoteSPFRecord
 31        let spfValue = localSPFRecord ?? fetchedSPFRecord
 32
 33        let spf = EmailSecurityRecord(
 34            found: spfValue != nil,
 35            value: spfValue
 36        )
 37
 38        let dmarc = EmailSecurityRecord(
 39            found: dmarcValue != nil,
 40            value: dmarcValue
 41        )
 42        let dkim = EmailSecurityRecord(
 43            found: dkimValue != nil,
 44            value: dkimValue?.value,
 45            matchedSelector: dkimValue?.selector
 46        )
 47        let bimi = EmailSecurityRecord(
 48            found: bimiValue != nil,
 49            value: bimiValue
 50        )
 51
 52        return EmailSecurityResult(
 53            spf: spf,
 54            dmarc: dmarc,
 55            dkim: dkim,
 56            bimi: bimi,
 57            mtaSts: mtaSts
 58        )
 59    }
 60
 61    /// Query a TXT record for the given subdomain via DoH.
 62    private static func queryTXT(subdomain: String) async -> String? {
 63        do {
 64            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
 65            return records.first?.value
 66        } catch {
 67            return nil
 68        }
 69    }
 70
 71    private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? {
 72        do {
 73            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
 74            return records.first(where: { isMatchingTXTRecord($0.value, prefix: prefix) })?.value
 75        } catch {
 76            return nil
 77        }
 78    }
 79
 80    /// Try common DKIM selectors concurrently and return the first valid result.
 81    private static func queryDKIM(domain: String) async -> (selector: String, value: String)? {
 82        await withTaskGroup(of: (selector: String, value: String?).self) { group in
 83            for selector in dkimSelectors {
 84                group.addTask {
 85                    let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
 86                    return (selector, value)
 87                }
 88            }
 89
 90            for await result in group {
 91                if let value = result.value, !value.isEmpty {
 92                    group.cancelAll()
 93                    return (result.selector, value)
 94                }
 95            }
 96
 97            return nil
 98        }
 99    }
100
101    private static func queryMTASTS(domain: String) async -> MTASTSResult? {
102        let txtValue = await queryMatchingTXT(subdomain: "_mta-sts.\(domain)", prefix: "v=STSv1")
103        guard txtValue != nil else {
104            return nil
105        }
106
107        return MTASTSResult(
108            txtFound: true,
109            policyMode: await fetchMTASTSPolicyMode(domain: domain)
110        )
111    }
112
113    private static func fetchMTASTSPolicyMode(domain: String) async -> String? {
114        guard let url = URL(string: "https://mta-sts.\(domain)/.well-known/mta-sts.txt") else {
115            return nil
116        }
117
118        var request = URLRequest(url: url)
119        request.timeoutInterval = 5
120
121        do {
122            let (data, _) = try await URLSession.shared.data(for: request)
123            let policy = String(decoding: data, as: UTF8.self)
124
125            for line in policy.split(whereSeparator: \.isNewline) {
126                let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
127                guard trimmedLine.lowercased().hasPrefix("mode:") else {
128                    continue
129                }
130
131                let mode = trimmedLine.dropFirst("mode:".count)
132                    .trimmingCharacters(in: .whitespacesAndNewlines)
133                    .lowercased()
134                return ["enforce", "testing", "none"].contains(mode) ? mode : nil
135            }
136        } catch {
137            return nil
138        }
139
140        return nil
141    }
142
143    private static func isMatchingTXTRecord(_ value: String, prefix: String) -> Bool {
144        value
145            .trimmingCharacters(in: .whitespacesAndNewlines)
146            .trimmingCharacters(in: CharacterSet(charactersIn: "\""))
147            .lowercased()
148            .hasPrefix(prefix.lowercased())
149    }
150}