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