Commit 8465ef359f

8465ef359f88ddef7cad68a0c2e361bb0bfe58ff

parent: 1e13dac952

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 22:07 UTC

Add BIMI and MTA-STS checks to email security

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -265,7 +265,7 @@
265265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
266266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
267267 CODE_SIGN_STYLE = Automatic;
268 CURRENT_PROJECT_VERSION = 6;
268 CURRENT_PROJECT_VERSION = 7;
269269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
270270 ENABLE_PREVIEWS = YES;
271271 GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
282282 "$(inherited)",
283283 "@executable_path/Frameworks",
284284 );
285 MARKETING_VERSION = 1.4.0;
285 MARKETING_VERSION = 1.5.0;
286286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
287287 PRODUCT_NAME = "$(TARGET_NAME)";
288288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
301301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
302302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
303303 CODE_SIGN_STYLE = Automatic;
304 CURRENT_PROJECT_VERSION = 6;
304 CURRENT_PROJECT_VERSION = 7;
305305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
306306 ENABLE_PREVIEWS = YES;
307307 GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
318318 "$(inherited)",
319319 "@executable_path/Frameworks",
320320 );
321 MARKETING_VERSION = 1.4.0;
321 MARKETING_VERSION = 1.5.0;
322322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323323 PRODUCT_NAME = "$(TARGET_NAME)";
324324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +32 −1
@@ -381,6 +381,8 @@ struct ContentView: View {
381381 emailSecurityRow("SPF", record: email.spf)
382382 emailSecurityRow("DMARC", record: email.dmarc)
383383 emailSecurityRow("DKIM", record: email.dkim)
384 emailSecurityRow("MTA-STS", mtaSts: email.mtaSts)
385 emailSecurityRow("BIMI", record: email.bimi)
384386 }
385387 }
386388 }
@@ -394,7 +396,7 @@ struct ContentView: View {
394396 Text(label)
395397 .font(.system(.caption, design: .monospaced))
396398 .fontWeight(.semibold)
397 .frame(width: 52, alignment: .leading)
399 .frame(width: 72, alignment: .leading)
398400 Text(record.found ? "✓" : "✗")
399401 .font(.system(.caption, design: .monospaced))
400402 .foregroundStyle(record.found ? .green : .red)
@@ -411,6 +413,11 @@ struct ContentView: View {
411413 expandedEmailField = isExpanded ? nil : label
412414 }
413415 }
416 if let selector = record.matchedSelector {
417 Text("(selector: \(selector))")
418 .font(.system(.caption2, design: .monospaced))
419 .foregroundStyle(.secondary)
420 }
414421 } else {
415422 Text("No record found")
416423 .font(.system(.caption2, design: .monospaced))
@@ -420,6 +427,30 @@ struct ContentView: View {
420427 }
421428 }
422429
430 private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
431 VStack(alignment: .leading, spacing: 2) {
432 HStack(spacing: 8) {
433 Text(label)
434 .font(.system(.caption, design: .monospaced))
435 .fontWeight(.semibold)
436 .frame(width: 72, alignment: .leading)
437 Text(mtaSts?.txtFound == true ? "✓" : "✗")
438 .font(.system(.caption, design: .monospaced))
439 .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
440 if let policyMode = mtaSts?.policyMode {
441 Text(policyMode)
442 .font(.system(.caption2, design: .monospaced))
443 .foregroundStyle(.primary)
444 .textSelection(.enabled)
445 } else {
446 Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
447 .font(.system(.caption2, design: .monospaced))
448 .foregroundStyle(.secondary)
449 }
450 }
451 }
452 }
453
423454 // MARK: - SSL Results
424455
425456 private var sslResultsSection: some View {
DomainDig/DomainViewModel.swift +17 −1
@@ -115,6 +115,7 @@ final class DomainViewModel {
115115 reachabilityResults: reachabilityResults,
116116 ipGeolocation: ipGeolocation,
117117 emailSecurity: emailSecurity,
118 mtaSts: emailSecurity?.mtaSts,
118119 ptrRecord: ptrRecord,
119120 redirectChain: redirectChain,
120121 portScanResults: portScanResults,
@@ -482,7 +483,22 @@ final class DomainViewModel {
482483 lines.append("--------------")
483484 lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")")
484485 lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")")
485 lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")")
486 let dkimValue = if let selector = email.dkim.matchedSelector,
487 let value = email.dkim.value {
488 "\(value) (selector: \(selector))"
489 } else {
490 email.dkim.value ?? "No record found"
491 }
492 lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)")
493 let mtaDescription = if let mode = email.mtaSts?.policyMode {
494 "mode: \(mode)"
495 } else if email.mtaSts?.txtFound == true {
496 "Policy unavailable"
497 } else {
498 "No record found"
499 }
500 lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)")
501 lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")")
486502 }
487503
488504 // SSL
DomainDig/EmailSecurityService.swift +88 −15
@@ -1,6 +1,11 @@
11import Foundation
22
33struct EmailSecurityService {
4 private static let dkimSelectors = [
5 "default", "google", "mail", "selector1", "selector2", "k1",
6 "smtp", "dkim", "zoho", "mailchimp"
7 ]
8
49 /// Analyze email security records. SPF is parsed from existing TXT records;
510 /// DMARC and DKIM require additional DoH queries.
611 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
@@ -8,12 +13,19 @@ struct EmailSecurityService {
813 let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") })
914 let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value)
1015
11 // DMARC and DKIM queries in parallel
16 // DMARC, DKIM, BIMI, and MTA-STS queries in parallel.
1217 async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
1318 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)
1424
1525 let dmarcValue = await dmarcResult
1626 let dkimValue = await dkimResult
27 let bimiValue = await bimiResult
28 let mtaSts = await mtaStsResult
1729
1830 let dmarc = EmailSecurityRecord(
1931 found: dmarcValue != nil,
@@ -21,10 +33,21 @@ struct EmailSecurityService {
2133 )
2234 let dkim = EmailSecurityRecord(
2335 found: dkimValue != nil,
24 value: dkimValue
36 value: dkimValue?.value,
37 matchedSelector: dkimValue?.selector
38 )
39 let bimi = EmailSecurityRecord(
40 found: bimiValue != nil,
41 value: bimiValue
2542 )
2643
27 return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim)
44 return EmailSecurityResult(
45 spf: spf,
46 dmarc: dmarc,
47 dkim: dkim,
48 bimi: bimi,
49 mtaSts: mtaSts
50 )
2851 }
2952
3053 /// Query a TXT record for the given subdomain via DoH.
@@ -37,25 +60,75 @@ struct EmailSecurityService {
3760 }
3861 }
3962
40 /// Try common DKIM selectors and return the first found.
41 private static func queryDKIM(domain: String) async -> String? {
42 let selectors = ["default", "google", "mail"]
43 return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in
44 for (index, selector) in selectors.enumerated() {
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 {
4576 group.addTask {
4677 let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
47 return (index, value)
78 return (selector, value)
4879 }
4980 }
5081
51 var results: [(Int, String?)] = []
5282 for await result in group {
53 results.append(result)
83 if let value = result.value, !value.isEmpty {
84 group.cancelAll()
85 return (result.selector, value)
86 }
5487 }
55 // Return the first (by selector order) that has a value
56 return results
57 .sorted { $0.0 < $1.0 }
58 .first(where: { $0.1 != nil })?.1
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
5997 }
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
60133 }
61134}
DomainDig/HistoryView.swift +32 −1
@@ -254,6 +254,8 @@ struct HistoryDetailView: View {
254254 historyEmailRow("SPF", record: email.spf)
255255 historyEmailRow("DMARC", record: email.dmarc)
256256 historyEmailRow("DKIM", record: email.dkim)
257 historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts)
258 historyEmailRow("BIMI", record: email.bimi)
257259 }
258260 }
259261 }
@@ -267,7 +269,7 @@ struct HistoryDetailView: View {
267269 Text(label)
268270 .font(.system(.caption, design: .monospaced))
269271 .fontWeight(.semibold)
270 .frame(width: 52, alignment: .leading)
272 .frame(width: 72, alignment: .leading)
271273 Text(record.found ? "✓" : "✗")
272274 .font(.system(.caption, design: .monospaced))
273275 .foregroundStyle(record.found ? .green : .red)
@@ -284,6 +286,11 @@ struct HistoryDetailView: View {
284286 expandedEmailField = isExpanded ? nil : label
285287 }
286288 }
289 if let selector = record.matchedSelector {
290 Text("(selector: \(selector))")
291 .font(.system(.caption2, design: .monospaced))
292 .foregroundStyle(.secondary)
293 }
287294 } else {
288295 Text("No record found")
289296 .font(.system(.caption2, design: .monospaced))
@@ -293,6 +300,30 @@ struct HistoryDetailView: View {
293300 }
294301 }
295302
303 private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View {
304 VStack(alignment: .leading, spacing: 2) {
305 HStack(spacing: 8) {
306 Text(label)
307 .font(.system(.caption, design: .monospaced))
308 .fontWeight(.semibold)
309 .frame(width: 72, alignment: .leading)
310 Text(mtaSts?.txtFound == true ? "✓" : "✗")
311 .font(.system(.caption, design: .monospaced))
312 .foregroundStyle(mtaSts?.txtFound == true ? .green : .red)
313 if let policyMode = mtaSts?.policyMode {
314 Text(policyMode)
315 .font(.system(.caption2, design: .monospaced))
316 .foregroundStyle(.primary)
317 .textSelection(.enabled)
318 } else {
319 Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found")
320 .font(.system(.caption2, design: .monospaced))
321 .foregroundStyle(.secondary)
322 }
323 }
324 }
325 }
326
296327 // MARK: - SSL
297328
298329 private var sslSection: some View {
DomainDig/Models.swift +49 −1
@@ -159,11 +159,56 @@ struct EmailSecurityResult: Codable {
159159 let spf: EmailSecurityRecord
160160 let dmarc: EmailSecurityRecord
161161 let dkim: EmailSecurityRecord
162 let bimi: EmailSecurityRecord
163 let mtaSts: MTASTSResult?
164
165 init(
166 spf: EmailSecurityRecord,
167 dmarc: EmailSecurityRecord,
168 dkim: EmailSecurityRecord,
169 bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil),
170 mtaSts: MTASTSResult? = nil
171 ) {
172 self.spf = spf
173 self.dmarc = dmarc
174 self.dkim = dkim
175 self.bimi = bimi
176 self.mtaSts = mtaSts
177 }
178
179 init(from decoder: Decoder) throws {
180 let container = try decoder.container(keyedBy: CodingKeys.self)
181 spf = try container.decode(EmailSecurityRecord.self, forKey: .spf)
182 dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc)
183 dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim)
184 bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi)
185 ?? EmailSecurityRecord(found: false, value: nil)
186 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts)
187 }
162188}
163189
164190struct EmailSecurityRecord: Codable {
165191 let found: Bool
166192 let value: String?
193 let matchedSelector: String?
194
195 init(found: Bool, value: String?, matchedSelector: String? = nil) {
196 self.found = found
197 self.value = value
198 self.matchedSelector = matchedSelector
199 }
200
201 init(from decoder: Decoder) throws {
202 let container = try decoder.container(keyedBy: CodingKeys.self)
203 found = try container.decode(Bool.self, forKey: .found)
204 value = try container.decodeIfPresent(String.self, forKey: .value)
205 matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector)
206 }
207}
208
209struct MTASTSResult: Codable {
210 let txtFound: Bool
211 let policyMode: String?
167212}
168213
169214// MARK: - Redirect Chain Models
@@ -197,6 +242,7 @@ struct HistoryEntry: Identifiable, Codable {
197242 let reachabilityResults: [PortReachability]
198243 let ipGeolocation: IPGeolocation?
199244 var emailSecurity: EmailSecurityResult?
245 var mtaSts: MTASTSResult?
200246 var ptrRecord: String?
201247 var redirectChain: [RedirectHop]
202248 var portScanResults: [PortScanResult]
@@ -205,7 +251,7 @@ struct HistoryEntry: Identifiable, Codable {
205251 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
206252 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
207253 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
208 emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
254 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
209255 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
210256 hstsPreloaded: Bool? = nil) {
211257 self.domain = domain
@@ -216,6 +262,7 @@ struct HistoryEntry: Identifiable, Codable {
216262 self.reachabilityResults = reachabilityResults
217263 self.ipGeolocation = ipGeolocation
218264 self.emailSecurity = emailSecurity
265 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
219266 self.ptrRecord = ptrRecord
220267 self.redirectChain = redirectChain
221268 self.portScanResults = portScanResults
@@ -233,6 +280,7 @@ struct HistoryEntry: Identifiable, Codable {
233280 reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults)
234281 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
235282 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
283 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
236284 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
237285 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
238286 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []