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