Commit 1e13dac952

1e13dac952d8f6cce7964aef27e98fa2e9da1eb7

parent: 9c691f6c6c

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 21:59 UTC

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.

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 = 5;
268 CURRENT_PROJECT_VERSION = 6;
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.3.0;
285 MARKETING_VERSION = 1.4.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 = 5;
304 CURRENT_PROJECT_VERSION = 6;
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.3.0;
321 MARKETING_VERSION = 1.4.0;
322322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323323 PRODUCT_NAME = "$(TARGET_NAME)";
324324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +60 −2
@@ -433,13 +433,13 @@ struct ContentView: View {
433433 } else if let error = viewModel.sslError {
434434 errorLabel(error)
435435 } else if let info = viewModel.sslInfo {
436 sslDetail(info)
436 sslDetail(info, domain: viewModel.searchedDomain)
437437 }
438438 }
439439 .padding(.top, 16)
440440 }
441441
442 private func sslDetail(_ info: SSLCertificateInfo) -> some View {
442 private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
443443 horizontallyScrollableCard(spacing: 8) {
444444 certRow("Common Name", info.commonName)
445445 certRow("Issuer", info.issuer)
@@ -471,6 +471,41 @@ struct ContentView: View {
471471 }
472472
473473 certRow("Chain Depth", "\(info.chainDepth)")
474 if viewModel.hstsLoading {
475 hstsLoadingRow
476 } else if let hstsPreloaded = viewModel.hstsPreloaded {
477 hstsStatusRow(hstsPreloaded)
478 }
479 if let tlsVersion = info.tlsVersion {
480 certRow("TLS Version", tlsVersion)
481 }
482 if let cipherSuite = info.cipherSuite {
483 certRow("Cipher Suite", cipherSuite)
484 }
485 if !info.chain.isEmpty {
486 VStack(alignment: .leading, spacing: 6) {
487 Text("Certificate Chain")
488 .font(.system(.caption2, design: .monospaced))
489 .foregroundStyle(.secondary)
490 ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in
491 DisclosureGroup {
492 Text(certificate.issuer)
493 .font(.system(.caption, design: .monospaced))
494 .foregroundStyle(.secondary)
495 .textSelection(.enabled)
496 } label: {
497 Text(certificate.subject)
498 .font(.system(.caption, design: .monospaced))
499 .foregroundStyle(.primary)
500 .textSelection(.enabled)
501 }
502 .tint(index == 0 ? .cyan : .secondary)
503 }
504 }
505 }
506 Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!)
507 .font(.system(.caption, design: .monospaced))
508 .foregroundStyle(.cyan)
474509 }
475510 }
476511
@@ -628,6 +663,29 @@ struct ContentView: View {
628663 }
629664 }
630665
666 private var hstsLoadingRow: some View {
667 HStack {
668 Text("HSTS Preload")
669 .font(.system(.caption2, design: .monospaced))
670 .foregroundStyle(.secondary)
671 Spacer()
672 ProgressView()
673 .controlSize(.small)
674 }
675 }
676
677 private func hstsStatusRow(_ isPreloaded: Bool) -> some View {
678 HStack {
679 Text("HSTS Preload")
680 .font(.system(.caption2, design: .monospaced))
681 .foregroundStyle(.secondary)
682 Spacer()
683 Text(isPreloaded ? "Preloaded" : "Not preloaded")
684 .font(.system(.caption, design: .monospaced))
685 .foregroundStyle(isPreloaded ? .green : .secondary)
686 }
687 }
688
631689 private func horizontallyScrollableCard<Content: View>(
632690 spacing: CGFloat = 4,
633691 @ViewBuilder content: () -> Content
DomainDig/DomainViewModel.swift +35 −2
@@ -15,6 +15,8 @@ final class DomainViewModel {
1515 var sslInfo: SSLCertificateInfo?
1616 var sslLoading = false
1717 var sslError: String?
18 var hstsPreloaded: Bool?
19 var hstsLoading = false
1820
1921 // HTTP Headers
2022 var httpHeaders: [HTTPHeader] = []
@@ -115,7 +117,8 @@ final class DomainViewModel {
115117 emailSecurity: emailSecurity,
116118 ptrRecord: ptrRecord,
117119 redirectChain: redirectChain,
118 portScanResults: portScanResults
120 portScanResults: portScanResults,
121 hstsPreloaded: hstsPreloaded
119122 )
120123 history.insert(entry, at: 0)
121124 if history.count > Self.maxHistory {
@@ -145,7 +148,7 @@ final class DomainViewModel {
145148
146149 /// True when all lookups have finished (regardless of success/failure).
147150 var resultsLoaded: Bool {
148 hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading
151 hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
149152 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
150153 && !redirectChainLoading && !portScanLoading
151154 }
@@ -161,6 +164,8 @@ final class DomainViewModel {
161164 sslInfo = nil
162165 sslError = nil
163166 sslLoading = false
167 hstsPreloaded = nil
168 hstsLoading = false
164169 httpHeaders = []
165170 httpHeadersError = nil
166171 httpHeadersLoading = false
@@ -201,6 +206,8 @@ final class DomainViewModel {
201206 sslInfo = nil
202207 sslError = nil
203208 sslLoading = true
209 hstsPreloaded = nil
210 hstsLoading = true
204211 httpHeaders = []
205212 httpHeadersError = nil
206213 httpHeadersLoading = true
@@ -244,6 +251,9 @@ final class DomainViewModel {
244251 group.addTask { @MainActor in
245252 await self.runSSL(domain: target)
246253 }
254 group.addTask { @MainActor in
255 await self.runHSTSPreload(domain: target)
256 }
247257 group.addTask { @MainActor in
248258 await self.runHTTPHeaders(domain: target)
249259 }
@@ -282,6 +292,11 @@ final class DomainViewModel {
282292 sslLoading = false
283293 }
284294
295 private func runHSTSPreload(domain: String) async {
296 hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain)
297 hstsLoading = false
298 }
299
285300 private func runHTTPHeaders(domain: String) async {
286301 do {
287302 let headers = try await HTTPHeadersService.fetch(domain: domain)
@@ -363,6 +378,7 @@ final class DomainViewModel {
363378 dnsSections: dnsSections,
364379 sslInfo: sslInfo,
365380 sslError: sslError,
381 hstsPreloaded: hstsPreloaded,
366382 httpHeaders: httpHeaders,
367383 httpHeadersError: httpHeadersError,
368384 reachabilityResults: reachabilityResults,
@@ -381,6 +397,7 @@ final class DomainViewModel {
381397 dnsSections: [DNSSection],
382398 sslInfo: SSLCertificateInfo?,
383399 sslError: String? = nil,
400 hstsPreloaded: Bool? = nil,
384401 httpHeaders: [HTTPHeader],
385402 httpHeadersError: String? = nil,
386403 reachabilityResults: [PortReachability],
@@ -484,6 +501,22 @@ final class DomainViewModel {
484501 lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
485502 lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
486503 lines.append("Chain Depth: \(info.chainDepth)")
504 if let tlsVersion = info.tlsVersion {
505 lines.append("TLS Version: \(tlsVersion)")
506 }
507 if let cipherSuite = info.cipherSuite {
508 lines.append("Cipher Suite: \(cipherSuite)")
509 }
510 if let hstsPreloaded {
511 lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")")
512 }
513 if !info.chain.isEmpty {
514 lines.append("Certificate Chain:")
515 for certificate in info.chain {
516 lines.append(" Subject: \(certificate.subject)")
517 lines.append(" Issuer: \(certificate.issuer)")
518 }
519 }
487520 } else if let error = sslError {
488521 lines.append("")
489522 lines.append("SSL / TLS Certificate")
DomainDig/Models.swift +51 −1
@@ -57,6 +57,11 @@ struct DNSSection: Identifiable, Codable {
5757// MARK: - SSL Models
5858
5959struct SSLCertificateInfo: Codable {
60 struct CertChainEntry: Codable {
61 let subject: String
62 let issuer: String
63 }
64
6065 let commonName: String
6166 let subjectAltNames: [String]
6267 let issuer: String
@@ -64,6 +69,47 @@ struct SSLCertificateInfo: Codable {
6469 let validUntil: Date
6570 let daysUntilExpiry: Int
6671 let chainDepth: Int
72 let tlsVersion: String?
73 let cipherSuite: String?
74 let chain: [CertChainEntry]
75
76 init(
77 commonName: String,
78 subjectAltNames: [String],
79 issuer: String,
80 validFrom: Date,
81 validUntil: Date,
82 daysUntilExpiry: Int,
83 chainDepth: Int,
84 tlsVersion: String? = nil,
85 cipherSuite: String? = nil,
86 chain: [CertChainEntry] = []
87 ) {
88 self.commonName = commonName
89 self.subjectAltNames = subjectAltNames
90 self.issuer = issuer
91 self.validFrom = validFrom
92 self.validUntil = validUntil
93 self.daysUntilExpiry = daysUntilExpiry
94 self.chainDepth = chainDepth
95 self.tlsVersion = tlsVersion
96 self.cipherSuite = cipherSuite
97 self.chain = chain
98 }
99
100 init(from decoder: Decoder) throws {
101 let container = try decoder.container(keyedBy: CodingKeys.self)
102 commonName = try container.decode(String.self, forKey: .commonName)
103 subjectAltNames = try container.decode([String].self, forKey: .subjectAltNames)
104 issuer = try container.decode(String.self, forKey: .issuer)
105 validFrom = try container.decode(Date.self, forKey: .validFrom)
106 validUntil = try container.decode(Date.self, forKey: .validUntil)
107 daysUntilExpiry = try container.decode(Int.self, forKey: .daysUntilExpiry)
108 chainDepth = try container.decode(Int.self, forKey: .chainDepth)
109 tlsVersion = try container.decodeIfPresent(String.self, forKey: .tlsVersion)
110 cipherSuite = try container.decodeIfPresent(String.self, forKey: .cipherSuite)
111 chain = try container.decodeIfPresent([CertChainEntry].self, forKey: .chain) ?? []
112 }
67113}
68114
69115// MARK: - HTTP Headers Models
@@ -154,12 +200,14 @@ struct HistoryEntry: Identifiable, Codable {
154200 var ptrRecord: String?
155201 var redirectChain: [RedirectHop]
156202 var portScanResults: [PortScanResult]
203 var hstsPreloaded: Bool?
157204
158205 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
159206 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
160207 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
161208 emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
162 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = []) {
209 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
210 hstsPreloaded: Bool? = nil) {
163211 self.domain = domain
164212 self.timestamp = timestamp
165213 self.dnsSections = dnsSections
@@ -171,6 +219,7 @@ struct HistoryEntry: Identifiable, Codable {
171219 self.ptrRecord = ptrRecord
172220 self.redirectChain = redirectChain
173221 self.portScanResults = portScanResults
222 self.hstsPreloaded = hstsPreloaded
174223 }
175224
176225 init(from decoder: Decoder) throws {
@@ -187,6 +236,7 @@ struct HistoryEntry: Identifiable, Codable {
187236 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
188237 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
189238 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
239 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
190240 }
191241}
192242
DomainDig/SSLCheckService.swift +116 −3
@@ -22,10 +22,32 @@ struct SSLCheckService {
2222 throw SSLError.noCertificate
2323 }
2424
25 return try extractCertificateInfo(from: trust)
25 return try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata)
2626 }
2727
28 private static func extractCertificateInfo(from trust: SecTrust) throws -> SSLCertificateInfo {
28 static func checkHSTSPreload(domain: String) async -> Bool? {
29 var components = URLComponents(string: "https://hstspreload.org/api/v2/status")
30 components?.queryItems = [
31 URLQueryItem(name: "domain", value: domain)
32 ]
33
34 guard let url = components?.url else {
35 return nil
36 }
37
38 do {
39 let (data, _) = try await URLSession.shared.data(from: url)
40 let response = try JSONDecoder().decode(HSTSPreloadResponse.self, from: data)
41 return response.status == "preloaded"
42 } catch {
43 return nil
44 }
45 }
46
47 private static func extractCertificateInfo(
48 from trust: SecTrust,
49 metadata: TLSMetadata?
50 ) throws -> SSLCertificateInfo {
2951 let chainCount = SecTrustGetCertificateCount(trust)
3052 guard chainCount > 0,
3153 let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
@@ -69,6 +91,15 @@ struct SSLCheckService {
6991 }
7092 }
7193
94 let chain = certChain.map { certificate in
95 let subject = SecCertificateCopySubjectSummary(certificate) as String? ?? "Unknown"
96 let parsedCertificate = DERCertificateParser.parse(SecCertificateCopyData(certificate) as Data)
97 return SSLCertificateInfo.CertChainEntry(
98 subject: subject,
99 issuer: parsedCertificate.issuerCommonName ?? "Unknown"
100 )
101 }
102
72103 return SSLCertificateInfo(
73104 commonName: commonName,
74105 subjectAltNames: sans,
@@ -76,11 +107,23 @@ struct SSLCheckService {
76107 validFrom: validFrom,
77108 validUntil: validUntil,
78109 daysUntilExpiry: daysUntilExpiry,
79 chainDepth: Int(chainCount)
110 chainDepth: Int(chainCount),
111 tlsVersion: metadata?.tlsVersion,
112 cipherSuite: metadata?.cipherSuite,
113 chain: chain
80114 )
81115 }
82116}
83117
118fileprivate struct TLSMetadata {
119 let tlsVersion: String?
120 let cipherSuite: String?
121}
122
123private struct HSTSPreloadResponse: Decodable {
124 let status: String
125}
126
84127// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
85128
86129private enum DERCertificateParser {
@@ -294,6 +337,7 @@ enum SSLError: LocalizedError {
294337final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
295338 private let lock = NSLock()
296339 private var _serverTrust: SecTrust?
340 private var _tlsMetadata: TLSMetadata?
297341
298342 var serverTrust: SecTrust? {
299343 lock.lock()
@@ -301,6 +345,12 @@ final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendabl
301345 return _serverTrust
302346 }
303347
348 fileprivate var tlsMetadata: TLSMetadata? {
349 lock.lock()
350 defer { lock.unlock() }
351 return _tlsMetadata
352 }
353
304354 func urlSession(
305355 _ session: URLSession,
306356 didReceive challenge: URLAuthenticationChallenge,
@@ -320,3 +370,66 @@ final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendabl
320370 completionHandler(.useCredential, credential)
321371 }
322372}
373
374extension SSLSessionDelegate: URLSessionTaskDelegate {
375 func urlSession(
376 _ session: URLSession,
377 task: URLSessionTask,
378 didFinishCollecting metrics: URLSessionTaskMetrics
379 ) {
380 guard let transaction = metrics.transactionMetrics.last else {
381 return
382 }
383
384 let tlsVersion = transaction.negotiatedTLSProtocolVersion.map {
385 Self.describeTLSVersion($0)
386 }
387 let cipherSuite = transaction.negotiatedTLSCipherSuite.map {
388 Self.describeCipherSuite($0)
389 }
390
391 lock.lock()
392 _tlsMetadata = TLSMetadata(tlsVersion: tlsVersion, cipherSuite: cipherSuite)
393 lock.unlock()
394 }
395
396 private static func describeTLSVersion(_ version: tls_protocol_version_t) -> String {
397 switch version.rawValue {
398 case 0x0301:
399 return "TLS 1.0"
400 case 0x0302:
401 return "TLS 1.1"
402 case 0x0303:
403 return "TLS 1.2"
404 case 0x0304:
405 return "TLS 1.3"
406 default:
407 return String(describing: version)
408 }
409 }
410
411 private static func describeCipherSuite(_ suite: tls_ciphersuite_t) -> String {
412 switch suite.rawValue {
413 case 0x1301:
414 return "TLS_AES_128_GCM_SHA256"
415 case 0x1302:
416 return "TLS_AES_256_GCM_SHA384"
417 case 0x1303:
418 return "TLS_CHACHA20_POLY1305_SHA256"
419 case 0xC02F:
420 return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
421 case 0xC030:
422 return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
423 case 0xC02B:
424 return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
425 case 0xC02C:
426 return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
427 case 0xCCA8:
428 return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
429 case 0xCCA9:
430 return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
431 default:
432 return String(format: "0x%04X", suite.rawValue)
433 }
434 }
435}