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}