krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

1e13dac952d8f6cce7964aef27e98fa2e9da1eb7

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-03T21:59:04Z

Release 1.4.0

Add richer SSL/TLS inspection details including negotiated TLS version,
cipher suite, full certificate chain display, crt.sh lookup, and HSTS
preload status. Persist HSTS preload in history/export and run the
preload check in parallel with the SSL lookup.
 DomainDig.xcodeproj/project.pbxproj |   8 +--
 DomainDig/ContentView.swift         |  62 ++++++++++++++++++-
 DomainDig/DomainViewModel.swift     |  37 ++++++++++-
 DomainDig/Models.swift              |  52 +++++++++++++++-
 DomainDig/SSLCheckService.swift     | 119 +++++++++++++++++++++++++++++++++++-
 5 files changed, 266 insertions(+), 12 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 4e7cbc6..94acc4e 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 = 5;
+				CURRENT_PROJECT_VERSION = 6;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.3.0;
+				MARKETING_VERSION = 1.4.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 = 5;
+				CURRENT_PROJECT_VERSION = 6;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.3.0;
+				MARKETING_VERSION = 1.4.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 87d8a85..c44f871 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -433,13 +433,13 @@ struct ContentView: View {
             } else if let error = viewModel.sslError {
                 errorLabel(error)
             } else if let info = viewModel.sslInfo {
-                sslDetail(info)
+                sslDetail(info, domain: viewModel.searchedDomain)
             }
         }
         .padding(.top, 16)
     }
 
-    private func sslDetail(_ info: SSLCertificateInfo) -> some View {
+    private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
         horizontallyScrollableCard(spacing: 8) {
             certRow("Common Name", info.commonName)
             certRow("Issuer", info.issuer)
@@ -471,6 +471,41 @@ struct ContentView: View {
             }
 
             certRow("Chain Depth", "\(info.chainDepth)")
+            if viewModel.hstsLoading {
+                hstsLoadingRow
+            } else if let hstsPreloaded = viewModel.hstsPreloaded {
+                hstsStatusRow(hstsPreloaded)
+            }
+            if let tlsVersion = info.tlsVersion {
+                certRow("TLS Version", tlsVersion)
+            }
+            if let cipherSuite = info.cipherSuite {
+                certRow("Cipher Suite", cipherSuite)
+            }
+            if !info.chain.isEmpty {
+                VStack(alignment: .leading, spacing: 6) {
+                    Text("Certificate Chain")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(.secondary)
+                    ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in
+                        DisclosureGroup {
+                            Text(certificate.issuer)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.secondary)
+                                .textSelection(.enabled)
+                        } label: {
+                            Text(certificate.subject)
+                                .font(.system(.caption, design: .monospaced))
+                                .foregroundStyle(.primary)
+                                .textSelection(.enabled)
+                        }
+                        .tint(index == 0 ? .cyan : .secondary)
+                    }
+                }
+            }
+            Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
+                .font(.system(.caption, design: .monospaced))
+                .foregroundStyle(.cyan)
         }
     }
 
@@ -628,6 +663,29 @@ struct ContentView: View {
         }
     }
 
+    private var hstsLoadingRow: some View {
+        HStack {
+            Text("HSTS Preload")
+                .font(.system(.caption2, design: .monospaced))
+                .foregroundStyle(.secondary)
+            Spacer()
+            ProgressView()
+                .controlSize(.small)
+        }
+    }
+
+    private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
+        HStack {
+            Text("HSTS Preload")
+                .font(.system(.caption2, design: .monospaced))
+                .foregroundStyle(.secondary)
+            Spacer()
+            Text(isPreloaded ? "Preloaded" : "Not preloaded")
+                .font(.system(.caption, design: .monospaced))
+                .foregroundStyle(isPreloaded ? .green : .secondary)
+        }
+    }
+
     private func horizontallyScrollableCard<Content: View>(
         spacing: CGFloat = 4,
         @ViewBuilder content: () -> Content
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index afb7e3c..0ce2d8c 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -15,6 +15,8 @@ final class DomainViewModel {
     var sslInfo: SSLCertificateInfo?
     var sslLoading = false
     var sslError: String?
+    var hstsPreloaded: Bool?
+    var hstsLoading = false
 
     // HTTP Headers
     var httpHeaders: [HTTPHeader] = []
@@ -115,7 +117,8 @@ final class DomainViewModel {
             emailSecurity: emailSecurity,
             ptrRecord: ptrRecord,
             redirectChain: redirectChain,
-            portScanResults: portScanResults
+            portScanResults: portScanResults,
+            hstsPreloaded: hstsPreloaded
         )
         history.insert(entry, at: 0)
         if history.count > Self.maxHistory {
@@ -145,7 +148,7 @@ final class DomainViewModel {
 
     /// True when all lookups have finished (regardless of success/failure).
     var resultsLoaded: Bool {
-        hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading
+        hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
             && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
             && !redirectChainLoading && !portScanLoading
     }
@@ -161,6 +164,8 @@ final class DomainViewModel {
         sslInfo = nil
         sslError = nil
         sslLoading = false
+        hstsPreloaded = nil
+        hstsLoading = false
         httpHeaders = []
         httpHeadersError = nil
         httpHeadersLoading = false
@@ -201,6 +206,8 @@ final class DomainViewModel {
         sslInfo = nil
         sslError = nil
         sslLoading = true
+        hstsPreloaded = nil
+        hstsLoading = true
         httpHeaders = []
         httpHeadersError = nil
         httpHeadersLoading = true
@@ -244,6 +251,9 @@ final class DomainViewModel {
                 group.addTask { @MainActor in
                     await self.runSSL(domain: target)
                 }
+                group.addTask { @MainActor in
+                    await self.runHSTSPreload(domain: target)
+                }
                 group.addTask { @MainActor in
                     await self.runHTTPHeaders(domain: target)
                 }
@@ -282,6 +292,11 @@ final class DomainViewModel {
         sslLoading = false
     }
 
+    private func runHSTSPreload(domain: String) async {
+        hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain)
+        hstsLoading = false
+    }
+
     private func runHTTPHeaders(domain: String) async {
         do {
             let headers = try await HTTPHeadersService.fetch(domain: domain)
@@ -363,6 +378,7 @@ final class DomainViewModel {
             dnsSections: dnsSections,
             sslInfo: sslInfo,
             sslError: sslError,
+            hstsPreloaded: hstsPreloaded,
             httpHeaders: httpHeaders,
             httpHeadersError: httpHeadersError,
             reachabilityResults: reachabilityResults,
@@ -381,6 +397,7 @@ final class DomainViewModel {
         dnsSections: [DNSSection],
         sslInfo: SSLCertificateInfo?,
         sslError: String? = nil,
+        hstsPreloaded: Bool? = nil,
         httpHeaders: [HTTPHeader],
         httpHeadersError: String? = nil,
         reachabilityResults: [PortReachability],
@@ -484,6 +501,22 @@ final class DomainViewModel {
             lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
             lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
             lines.append("Chain Depth: \(info.chainDepth)")
+            if let tlsVersion = info.tlsVersion {
+                lines.append("TLS Version: \(tlsVersion)")
+            }
+            if let cipherSuite = info.cipherSuite {
+                lines.append("Cipher Suite: \(cipherSuite)")
+            }
+            if let hstsPreloaded {
+                lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")")
+            }
+            if !info.chain.isEmpty {
+                lines.append("Certificate Chain:")
+                for certificate in info.chain {
+                    lines.append("  Subject: \(certificate.subject)")
+                    lines.append("  Issuer: \(certificate.issuer)")
+                }
+            }
         } else if let error = sslError {
             lines.append("")
             lines.append("SSL / TLS Certificate")
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index cfc60c4..5fcc653 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -57,6 +57,11 @@ struct DNSSection: Identifiable, Codable {
 // MARK: - SSL Models
 
 struct SSLCertificateInfo: Codable {
+    struct CertChainEntry: Codable {
+        let subject: String
+        let issuer: String
+    }
+
     let commonName: String
     let subjectAltNames: [String]
     let issuer: String
@@ -64,6 +69,47 @@ struct SSLCertificateInfo: Codable {
     let validUntil: Date
     let daysUntilExpiry: Int
     let chainDepth: Int
+    let tlsVersion: String?
+    let cipherSuite: String?
+    let chain: [CertChainEntry]
+
+    init(
+        commonName: String,
+        subjectAltNames: [String],
+        issuer: String,
+        validFrom: Date,
+        validUntil: Date,
+        daysUntilExpiry: Int,
+        chainDepth: Int,
+        tlsVersion: String? = nil,
+        cipherSuite: String? = nil,
+        chain: [CertChainEntry] = []
+    ) {
+        self.commonName = commonName
+        self.subjectAltNames = subjectAltNames
+        self.issuer = issuer
+        self.validFrom = validFrom
+        self.validUntil = validUntil
+        self.daysUntilExpiry = daysUntilExpiry
+        self.chainDepth = chainDepth
+        self.tlsVersion = tlsVersion
+        self.cipherSuite = cipherSuite
+        self.chain = chain
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        commonName = try container.decode(String.self, forKey: .commonName)
+        subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
+        issuer = try container.decode(String.self, forKey: .issuer)
+        validFrom = try container.decode(Date.self, forKey: .validFrom)
+        validUntil = try container.decode(Date.self, forKey: .validUntil)
+        daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
+        chainDepth = try container.decode(Int.self, forKey: .chainDepth)
+        tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
+        cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
+        chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
+    }
 }
 
 // MARK: - HTTP Headers Models
@@ -154,12 +200,14 @@ struct HistoryEntry: Identifiable, Codable {
     var ptrRecord: String?
     var redirectChain: [RedirectHop]
     var portScanResults: [PortScanResult]
+    var hstsPreloaded: Bool?
 
     init(domain: String, timestamp: Date, dnsSections: [DNSSection],
          sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
          reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
          emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
-         redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = []) {
+         redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
+         hstsPreloaded: Bool? = nil) {
         self.domain = domain
         self.timestamp = timestamp
         self.dnsSections = dnsSections
@@ -171,6 +219,7 @@ struct HistoryEntry: Identifiable, Codable {
         self.ptrRecord = ptrRecord
         self.redirectChain = redirectChain
         self.portScanResults = portScanResults
+        self.hstsPreloaded = hstsPreloaded
     }
 
     init(from decoder: Decoder) throws {
@@ -187,6 +236,7 @@ struct HistoryEntry: Identifiable, Codable {
         ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
         redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
         portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
+        hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
     }
 }
 
diff --git a/DomainDig/SSLCheckService.swift b/DomainDig/SSLCheckService.swift
index bff41c0..f227063 100644
--- a/DomainDig/SSLCheckService.swift
+++ b/DomainDig/SSLCheckService.swift
@@ -22,10 +22,32 @@ struct SSLCheckService {
             throw SSLError.noCertificate
         }
 
-        return try extractCertificateInfo(from: trust)
+        return try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata)
     }
 
-    private static func extractCertificateInfo(from trust: SecTrust) throws -> SSLCertificateInfo {
+    static func checkHSTSPreload(domain: String) async -> Bool? {
+        var components = URLComponents(string: "https://hstspreload.org/api/v2/status")
+        components?.queryItems = [
+            URLQueryItem(name: "domain", value: domain)
+        ]
+
+        guard let url = components?.url else {
+            return nil
+        }
+
+        do {
+            let (data, _) = try await URLSession.shared.data(from: url)
+            let response = try JSONDecoder().decode(HSTSPreloadResponse.self, from: data)
+            return response.status == "preloaded"
+        } catch {
+            return nil
+        }
+    }
+
+    private static func extractCertificateInfo(
+        from trust: SecTrust,
+        metadata: TLSMetadata?
+    ) throws -> SSLCertificateInfo {
         let chainCount = SecTrustGetCertificateCount(trust)
         guard chainCount > 0,
               let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
@@ -69,6 +91,15 @@ struct SSLCheckService {
             }
         }
 
+        let chain = certChain.map { certificate in
+            let subject = SecCertificateCopySubjectSummary(certificate) as String? ?? "Unknown"
+            let parsedCertificate = DERCertificateParser.parse(SecCertificateCopyData(certificate) as Data)
+            return SSLCertificateInfo.CertChainEntry(
+                subject: subject,
+                issuer: parsedCertificate.issuerCommonName ?? "Unknown"
+            )
+        }
+
         return SSLCertificateInfo(
             commonName: commonName,
             subjectAltNames: sans,
@@ -76,11 +107,23 @@ struct SSLCheckService {
             validFrom: validFrom,
             validUntil: validUntil,
             daysUntilExpiry: daysUntilExpiry,
-            chainDepth: Int(chainCount)
+            chainDepth: Int(chainCount),
+            tlsVersion: metadata?.tlsVersion,
+            cipherSuite: metadata?.cipherSuite,
+            chain: chain
         )
     }
 }
 
+fileprivate struct TLSMetadata {
+    let tlsVersion: String?
+    let cipherSuite: String?
+}
+
+private struct HSTSPreloadResponse: Decodable {
+    let status: String
+}
+
 // MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
 
 private enum DERCertificateParser {
@@ -294,6 +337,7 @@ enum SSLError: LocalizedError {
 final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
     private let lock = NSLock()
     private var _serverTrust: SecTrust?
+    private var _tlsMetadata: TLSMetadata?
 
     var serverTrust: SecTrust? {
         lock.lock()
@@ -301,6 +345,12 @@ final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendabl
         return _serverTrust
     }
 
+    fileprivate var tlsMetadata: TLSMetadata? {
+        lock.lock()
+        defer { lock.unlock() }
+        return _tlsMetadata
+    }
+
     func urlSession(
         _ session: URLSession,
         didReceive challenge: URLAuthenticationChallenge,
@@ -320,3 +370,66 @@ final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendabl
         completionHandler(.useCredential, credential)
     }
 }
+
+extension SSLSessionDelegate: URLSessionTaskDelegate {
+    func urlSession(
+        _ session: URLSession,
+        task: URLSessionTask,
+        didFinishCollecting metrics: URLSessionTaskMetrics
+    ) {
+        guard let transaction = metrics.transactionMetrics.last else {
+            return
+        }
+
+        let tlsVersion = transaction.negotiatedTLSProtocolVersion.map {
+            Self.describeTLSVersion($0)
+        }
+        let cipherSuite = transaction.negotiatedTLSCipherSuite.map {
+            Self.describeCipherSuite($0)
+        }
+
+        lock.lock()
+        _tlsMetadata = TLSMetadata(tlsVersion: tlsVersion, cipherSuite: cipherSuite)
+        lock.unlock()
+    }
+
+    private static func describeTLSVersion(_ version: tls_protocol_version_t) -> String {
+        switch version.rawValue {
+        case 0x0301:
+            return "TLS 1.0"
+        case 0x0302:
+            return "TLS 1.1"
+        case 0x0303:
+            return "TLS 1.2"
+        case 0x0304:
+            return "TLS 1.3"
+        default:
+            return String(describing: version)
+        }
+    }
+
+    private static func describeCipherSuite(_ suite: tls_ciphersuite_t) -> String {
+        switch suite.rawValue {
+        case 0x1301:
+            return "TLS_AES_128_GCM_SHA256"
+        case 0x1302:
+            return "TLS_AES_256_GCM_SHA384"
+        case 0x1303:
+            return "TLS_CHACHA20_POLY1305_SHA256"
+        case 0xC02F:
+            return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
+        case 0xC030:
+            return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
+        case 0xC02B:
+            return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
+        case 0xC02C:
+            return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
+        case 0xCCA8:
+            return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
+        case 0xCCA9:
+            return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
+        default:
+            return String(format: "0x%04X", suite.rawValue)
+        }
+    }
+}