diff options
| author | Christian Cleberg <[email protected]> | 2026-04-03 16:59:04 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-03 16:59:04 -0500 |
| commit | 1e13dac952d8f6cce7964aef27e98fa2e9da1eb7 (patch) | |
| tree | a43a164db605c15aecdd9e649d546b21a7ce1dc8 /DomainDig | |
| parent | 9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41 (diff) | |
| download | domain-dig-1e13dac952d8f6cce7964aef27e98fa2e9da1eb7.tar.gz domain-dig-1e13dac952d8f6cce7964aef27e98fa2e9da1eb7.tar.bz2 domain-dig-1e13dac952d8f6cce7964aef27e98fa2e9da1eb7.zip | |
Release 1.4.0v1.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.
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/ContentView.swift | 62 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 37 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 52 | ||||
| -rw-r--r-- | DomainDig/SSLCheckService.swift | 119 |
4 files changed, 262 insertions, 8 deletions
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 @@ -245,6 +252,9 @@ final class DomainViewModel { await self.runSSL(domain: target) } group.addTask { @MainActor in + await self.runHSTSPreload(domain: target) + } + group.addTask { @MainActor in await self.runHTTPHeaders(domain: target) } group.addTask { @MainActor in @@ -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) + } + } +} |
