From 8465ef359f88ddef7cad68a0c2e361bb0bfe58ff Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 3 Apr 2026 17:07:27 -0500 Subject: Add BIMI and MTA-STS checks to email security --- DomainDig.xcodeproj/project.pbxproj | 8 +-- DomainDig/ContentView.swift | 33 ++++++++++- DomainDig/DomainViewModel.swift | 18 +++++- DomainDig/EmailSecurityService.swift | 103 ++++++++++++++++++++++++++++++----- DomainDig/HistoryView.swift | 33 ++++++++++- DomainDig/Models.swift | 50 ++++++++++++++++- 6 files changed, 222 insertions(+), 23 deletions(-) diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 94acc4e..68d0d23 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -265,7 +265,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -282,7 +282,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -301,7 +301,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -318,7 +318,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index c44f871..bb93789 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -381,6 +381,8 @@ struct ContentView: View { emailSecurityRow("SPF", record: email.spf) emailSecurityRow("DMARC", record: email.dmarc) emailSecurityRow("DKIM", record: email.dkim) + emailSecurityRow("MTA-STS", mtaSts: email.mtaSts) + emailSecurityRow("BIMI", record: email.bimi) } } } @@ -394,7 +396,7 @@ struct ContentView: View { Text(label) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) - .frame(width: 52, alignment: .leading) + .frame(width: 72, alignment: .leading) Text(record.found ? "✓" : "✗") .font(.system(.caption, design: .monospaced)) .foregroundStyle(record.found ? .green : .red) @@ -411,6 +413,11 @@ struct ContentView: View { expandedEmailField = isExpanded ? nil : label } } + if let selector = record.matchedSelector { + Text("(selector: \(selector))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } } else { Text("No record found") .font(.system(.caption2, design: .monospaced)) @@ -420,6 +427,30 @@ struct ContentView: View { } } + private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .frame(width: 72, alignment: .leading) + Text(mtaSts?.txtFound == true ? "✓" : "✗") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) + if let policyMode = mtaSts?.policyMode { + Text(policyMode) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } else { + Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + // MARK: - SSL Results private var sslResultsSection: some View { diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 0ce2d8c..16eaabd 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -115,6 +115,7 @@ final class DomainViewModel { reachabilityResults: reachabilityResults, ipGeolocation: ipGeolocation, emailSecurity: emailSecurity, + mtaSts: emailSecurity?.mtaSts, ptrRecord: ptrRecord, redirectChain: redirectChain, portScanResults: portScanResults, @@ -482,7 +483,22 @@ final class DomainViewModel { lines.append("--------------") lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")") lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")") - lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")") + let dkimValue = if let selector = email.dkim.matchedSelector, + let value = email.dkim.value { + "\(value) (selector: \(selector))" + } else { + email.dkim.value ?? "No record found" + } + lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)") + let mtaDescription = if let mode = email.mtaSts?.policyMode { + "mode: \(mode)" + } else if email.mtaSts?.txtFound == true { + "Policy unavailable" + } else { + "No record found" + } + lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)") + lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")") } // SSL diff --git a/DomainDig/EmailSecurityService.swift b/DomainDig/EmailSecurityService.swift index 8922993..c6b05b0 100644 --- a/DomainDig/EmailSecurityService.swift +++ b/DomainDig/EmailSecurityService.swift @@ -1,6 +1,11 @@ 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 { @@ -8,12 +13,19 @@ struct EmailSecurityService { let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) - // DMARC and DKIM queries in parallel + // 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 dmarc = EmailSecurityRecord( found: dmarcValue != nil, @@ -21,10 +33,21 @@ struct EmailSecurityService { ) let dkim = EmailSecurityRecord( found: dkimValue != nil, - value: dkimValue + value: dkimValue?.value, + matchedSelector: dkimValue?.selector + ) + let bimi = EmailSecurityRecord( + found: bimiValue != nil, + value: bimiValue ) - return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim) + return EmailSecurityResult( + spf: spf, + dmarc: dmarc, + dkim: dkim, + bimi: bimi, + mtaSts: mtaSts + ) } /// Query a TXT record for the given subdomain via DoH. @@ -37,25 +60,75 @@ struct EmailSecurityService { } } - /// Try common DKIM selectors and return the first found. - private static func queryDKIM(domain: String) async -> String? { - let selectors = ["default", "google", "mail"] - return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in - for (index, selector) in selectors.enumerated() { + 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: { $0.value.hasPrefix(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 (index, value) + return (selector, value) } } - var results: [(Int, String?)] = [] for await result in group { - results.append(result) + if let value = result.value, !value.isEmpty { + group.cancelAll() + return (result.selector, value) + } } - // Return the first (by selector order) that has a value - return results - .sorted { $0.0 < $1.0 } - .first(where: { $0.1 != nil })?.1 + + 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 } } diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 1963509..52b0070 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -254,6 +254,8 @@ struct HistoryDetailView: View { historyEmailRow("SPF", record: email.spf) historyEmailRow("DMARC", record: email.dmarc) historyEmailRow("DKIM", record: email.dkim) + historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts) + historyEmailRow("BIMI", record: email.bimi) } } } @@ -267,7 +269,7 @@ struct HistoryDetailView: View { Text(label) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) - .frame(width: 52, alignment: .leading) + .frame(width: 72, alignment: .leading) Text(record.found ? "✓" : "✗") .font(.system(.caption, design: .monospaced)) .foregroundStyle(record.found ? .green : .red) @@ -284,6 +286,11 @@ struct HistoryDetailView: View { expandedEmailField = isExpanded ? nil : label } } + if let selector = record.matchedSelector { + Text("(selector: \(selector))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } } else { Text("No record found") .font(.system(.caption2, design: .monospaced)) @@ -293,6 +300,30 @@ struct HistoryDetailView: View { } } + private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .frame(width: 72, alignment: .leading) + Text(mtaSts?.txtFound == true ? "✓" : "✗") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) + if let policyMode = mtaSts?.policyMode { + Text(policyMode) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } else { + Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + // MARK: - SSL private var sslSection: some View { diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 5fcc653..2635f41 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -159,11 +159,56 @@ struct EmailSecurityResult: Codable { let spf: EmailSecurityRecord let dmarc: EmailSecurityRecord let dkim: EmailSecurityRecord + let bimi: EmailSecurityRecord + let mtaSts: MTASTSResult? + + init( + spf: EmailSecurityRecord, + dmarc: EmailSecurityRecord, + dkim: EmailSecurityRecord, + bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil), + mtaSts: MTASTSResult? = nil + ) { + self.spf = spf + self.dmarc = dmarc + self.dkim = dkim + self.bimi = bimi + self.mtaSts = mtaSts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + spf = try container.decode(EmailSecurityRecord.self, forKey: .spf) + dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc) + dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim) + bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi) + ?? EmailSecurityRecord(found: false, value: nil) + mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) + } } struct EmailSecurityRecord: Codable { let found: Bool let value: String? + let matchedSelector: String? + + init(found: Bool, value: String?, matchedSelector: String? = nil) { + self.found = found + self.value = value + self.matchedSelector = matchedSelector + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + found = try container.decode(Bool.self, forKey: .found) + value = try container.decodeIfPresent(String.self, forKey: .value) + matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector) + } +} + +struct MTASTSResult: Codable { + let txtFound: Bool + let policyMode: String? } // MARK: - Redirect Chain Models @@ -197,6 +242,7 @@ struct HistoryEntry: Identifiable, Codable { let reachabilityResults: [PortReachability] let ipGeolocation: IPGeolocation? var emailSecurity: EmailSecurityResult? + var mtaSts: MTASTSResult? var ptrRecord: String? var redirectChain: [RedirectHop] var portScanResults: [PortScanResult] @@ -205,7 +251,7 @@ struct HistoryEntry: Identifiable, Codable { init(domain: String, timestamp: Date, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, - emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil, + emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], hstsPreloaded: Bool? = nil) { self.domain = domain @@ -216,6 +262,7 @@ struct HistoryEntry: Identifiable, Codable { self.reachabilityResults = reachabilityResults self.ipGeolocation = ipGeolocation self.emailSecurity = emailSecurity + self.mtaSts = mtaSts ?? emailSecurity?.mtaSts self.ptrRecord = ptrRecord self.redirectChain = redirectChain self.portScanResults = portScanResults @@ -233,6 +280,7 @@ struct HistoryEntry: Identifiable, Codable { reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) + mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] -- cgit v1.2.3