summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-03 16:59:04 -0500
committerChristian Cleberg <[email protected]>2026-04-03 16:59:04 -0500
commit1e13dac952d8f6cce7964aef27e98fa2e9da1eb7 (patch)
treea43a164db605c15aecdd9e649d546b21a7ce1dc8
parent9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41 (diff)
downloaddomain-dig-1.4.0.tar.gz
domain-dig-1.4.0.tar.bz2
domain-dig-1.4.0.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.
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/ContentView.swift62
-rw-r--r--DomainDig/DomainViewModel.swift37
-rw-r--r--DomainDig/Models.swift52
-rw-r--r--DomainDig/SSLCheckService.swift119
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
@@ -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)
+ }
+ }
+}