krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.9.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 -> ServiceResult<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        let result = EmailSecurityResult(
 53            spf: spf,
 54            dmarc: dmarc,
 55            dkim: dkim,
 56            bimi: bimi,
 57            mtaSts: mtaSts
 58        )
 59
 60        let hasAnyRecord = result.spf.found || result.dmarc.found || result.dkim.found || result.bimi.found || result.mtaSts?.txtFound == true
 61        return hasAnyRecord ? .success(result) : .empty("No email security records found")
 62    }
 63
 64    /// Query a TXT record for the given subdomain via DoH.
 65    private static func queryTXT(subdomain: String) async -> String? {
 66        do {
 67            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
 68            return records.first?.value
 69        } catch {
 70            return nil
 71        }
 72    }
 73
 74    private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? {
 75        do {
 76            let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
 77            return records.first(where: { isMatchingTXTRecord($0.value, prefix: prefix) })?.value
 78        } catch {
 79            return nil
 80        }
 81    }
 82
 83    /// Try common DKIM selectors concurrently and return the first valid result.
 84    private static func queryDKIM(domain: String) async -> (selector: String, value: String)? {
 85        await withTaskGroup(of: (selector: String, value: String?).self) { group in
 86            for selector in dkimSelectors {
 87                group.addTask {
 88                    let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
 89                    return (selector, value)
 90                }
 91            }
 92
 93            for await result in group {
 94                if let value = result.value, !value.isEmpty {
 95                    group.cancelAll()
 96                    return (result.selector, value)
 97                }
 98            }
 99
100            return nil
101        }
102    }
103
104    private static func queryMTASTS(domain: String) async -> MTASTSResult? {
105        let txtValue = await queryMatchingTXT(subdomain: "_mta-sts.\(domain)", prefix: "v=STSv1")
106        guard txtValue != nil else {
107            return nil
108        }
109
110        return MTASTSResult(
111            txtFound: true,
112            policyMode: await fetchMTASTSPolicyMode(domain: domain)
113        )
114    }
115
116    private static func fetchMTASTSPolicyMode(domain: String) async -> String? {
117        guard let url = URL(string: "https://mta-sts.\(domain)/.well-known/mta-sts.txt") else {
118            return nil
119        }
120
121        var request = URLRequest(url: url)
122        request.timeoutInterval = 5
123
124        do {
125            let (data, _) = try await URLSession.shared.data(for: request)
126            let policy = String(decoding: data, as: UTF8.self)
127
128            for line in policy.split(whereSeparator: \.isNewline) {
129                let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
130                guard trimmedLine.lowercased().hasPrefix("mode:") else {
131                    continue
132                }
133
134                let mode = trimmedLine.dropFirst("mode:".count)
135                    .trimmingCharacters(in: .whitespacesAndNewlines)
136                    .lowercased()
137                return ["enforce", "testing", "none"].contains(mode) ? mode : nil
138            }
139        } catch {
140            return nil
141        }
142
143        return nil
144    }
145
146    private static func isMatchingTXTRecord(_ value: String, prefix: String) -> Bool {
147        value
148            .trimmingCharacters(in: .whitespacesAndNewlines)
149            .trimmingCharacters(in: CharacterSet(charactersIn: "\""))
150            .lowercased()
151            .hasPrefix(prefix.lowercased())
152    }
153}