krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v1.7.2: 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}