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 @@
265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
267 CODE_SIGN_STYLE = Automatic; 267 CODE_SIGN_STYLE = Automatic;
268 CURRENT_PROJECT_VERSION = 5; 268 CURRENT_PROJECT_VERSION = 6;
269 DEVELOPMENT_TEAM = ZCNAX3VL9D; 269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
270 ENABLE_PREVIEWS = YES; 270 ENABLE_PREVIEWS = YES;
271 GENERATE_INFOPLIST_FILE = YES; 271 GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
282 "$(inherited)", 282 "$(inherited)",
283 "@executable_path/Frameworks", 283 "@executable_path/Frameworks",
284 ); 284 );
285 MARKETING_VERSION = 1.3.0; 285 MARKETING_VERSION = 1.4.0;
286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
287 PRODUCT_NAME = "$(TARGET_NAME)"; 287 PRODUCT_NAME = "$(TARGET_NAME)";
288 STRING_CATALOG_GENERATE_SYMBOLS = YES; 288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
303 CODE_SIGN_STYLE = Automatic; 303 CODE_SIGN_STYLE = Automatic;
304 CURRENT_PROJECT_VERSION = 5; 304 CURRENT_PROJECT_VERSION = 6;
305 DEVELOPMENT_TEAM = ZCNAX3VL9D; 305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
306 ENABLE_PREVIEWS = YES; 306 ENABLE_PREVIEWS = YES;
307 GENERATE_INFOPLIST_FILE = YES; 307 GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
318 "$(inherited)", 318 "$(inherited)",
319 "@executable_path/Frameworks", 319 "@executable_path/Frameworks",
320 ); 320 );
321 MARKETING_VERSION = 1.3.0; 321 MARKETING_VERSION = 1.4.0;
322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323 PRODUCT_NAME = "$(TARGET_NAME)"; 323 PRODUCT_NAME = "$(TARGET_NAME)";
324 STRING_CATALOG_GENERATE_SYMBOLS = YES; 324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +60 −2
@@ -433,13 +433,13 @@ struct ContentView: View {
433 } else if let error = viewModel.sslError { 433 } else if let error = viewModel.sslError {
434 errorLabel(error) 434 errorLabel(error)
435 } else if let info = viewModel.sslInfo { 435 } else if let info = viewModel.sslInfo {
436 sslDetail(info) 436 sslDetail(info, domain: viewModel.searchedDomain)
437 } 437 }
438 } 438 }
439 .padding(.top, 16) 439 .padding(.top, 16)
440 } 440 }
441 441
442 private func sslDetail(_ info: SSLCertificateInfo) -> some View { 442 private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View {
443 horizontallyScrollableCard(spacing: 8) { 443 horizontallyScrollableCard(spacing: 8) {
444 certRow("Common Name", info.commonName) 444 certRow("Common Name", info.commonName)
445 certRow("Issuer", info.issuer) 445 certRow("Issuer", info.issuer)
@@ -471,6 +471,41 @@ struct ContentView: View {
471 } 471 }
472 472
473 certRow("Chain Depth", "\(info.chainDepth)") 473 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)
474 } 509 }
475 } 510 }
476 511
@@ -628,6 +663,29 @@ struct ContentView: View {
628 } 663 }
629 } 664 }
630 665
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
631 private func horizontallyScrollableCard<Content: View>( 689 private func horizontallyScrollableCard<Content: View>(
632 spacing: CGFloat = 4, 690 spacing: CGFloat = 4,
633 @ViewBuilder content: () -> Content 691 @ViewBuilder content: () -> Content
DomainDig/DomainViewModel.swift +35 −2
@@ -15,6 +15,8 @@ final class DomainViewModel {
15 var sslInfo: SSLCertificateInfo? 15 var sslInfo: SSLCertificateInfo?
16 var sslLoading = false 16 var sslLoading = false
17 var sslError: String? 17 var sslError: String?
18 var hstsPreloaded: Bool?
19 var hstsLoading = false
18 20
19 // HTTP Headers 21 // HTTP Headers
20 var httpHeaders: [HTTPHeader] = [] 22 var httpHeaders: [HTTPHeader] = []
@@ -115,7 +117,8 @@ final class DomainViewModel {
115 emailSecurity: emailSecurity, 117 emailSecurity: emailSecurity,
116 ptrRecord: ptrRecord, 118 ptrRecord: ptrRecord,
117 redirectChain: redirectChain, 119 redirectChain: redirectChain,
118 portScanResults: portScanResults 120 portScanResults: portScanResults,
121 hstsPreloaded: hstsPreloaded
119 ) 122 )
120 history.insert(entry, at: 0) 123 history.insert(entry, at: 0)
121 if history.count > Self.maxHistory { 124 if history.count > Self.maxHistory {
@@ -145,7 +148,7 @@ final class DomainViewModel {
145 148
146 /// True when all lookups have finished (regardless of success/failure). 149 /// True when all lookups have finished (regardless of success/failure).
147 var resultsLoaded: Bool { 150 var resultsLoaded: Bool {
148 hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading 151 hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
149 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading 152 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
150 && !redirectChainLoading && !portScanLoading 153 && !redirectChainLoading && !portScanLoading
151 } 154 }
@@ -161,6 +164,8 @@ final class DomainViewModel {
161 sslInfo = nil 164 sslInfo = nil
162 sslError = nil 165 sslError = nil
163 sslLoading = false 166 sslLoading = false
167 hstsPreloaded = nil
168 hstsLoading = false
164 httpHeaders = [] 169 httpHeaders = []
165 httpHeadersError = nil 170 httpHeadersError = nil
166 httpHeadersLoading = false 171 httpHeadersLoading = false
@@ -201,6 +206,8 @@ final class DomainViewModel {
201 sslInfo = nil 206 sslInfo = nil
202 sslError = nil 207 sslError = nil
203 sslLoading = true 208 sslLoading = true
209 hstsPreloaded = nil
210 hstsLoading = true
204 httpHeaders = [] 211 httpHeaders = []
205 httpHeadersError = nil 212 httpHeadersError = nil
206 httpHeadersLoading = true 213 httpHeadersLoading = true
@@ -244,6 +251,9 @@ final class DomainViewModel {
244 group.addTask { @MainActor in 251 group.addTask { @MainActor in
245 await self.runSSL(domain: target) 252 await self.runSSL(domain: target)
246 } 253 }
254 group.addTask { @MainActor in
255 await self.runHSTSPreload(domain: target)
256 }
247 group.addTask { @MainActor in 257 group.addTask { @MainActor in
248 await self.runHTTPHeaders(domain: target) 258 await self.runHTTPHeaders(domain: target)
249 } 259 }
@@ -282,6 +292,11 @@ final class DomainViewModel {
282 sslLoading = false 292 sslLoading = false
283 } 293 }
284 294
295 private func runHSTSPreload(domain: String) async {
296 hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain)
297 hstsLoading = false
298 }
299
285 private func runHTTPHeaders(domain: String) async { 300 private func runHTTPHeaders(domain: String) async {
286 do { 301 do {
287 let headers = try await HTTPHeadersService.fetch(domain: domain) 302 let headers = try await HTTPHeadersService.fetch(domain: domain)
@@ -363,6 +378,7 @@ final class DomainViewModel {
363 dnsSections: dnsSections, 378 dnsSections: dnsSections,
364 sslInfo: sslInfo, 379 sslInfo: sslInfo,
365 sslError: sslError, 380 sslError: sslError,
381 hstsPreloaded: hstsPreloaded,
366 httpHeaders: httpHeaders, 382 httpHeaders: httpHeaders,
367 httpHeadersError: httpHeadersError, 383 httpHeadersError: httpHeadersError,
368 reachabilityResults: reachabilityResults, 384 reachabilityResults: reachabilityResults,
@@ -381,6 +397,7 @@ final class DomainViewModel {
381 dnsSections: [DNSSection], 397 dnsSections: [DNSSection],
382 sslInfo: SSLCertificateInfo?, 398 sslInfo: SSLCertificateInfo?,
383 sslError: String? = nil, 399 sslError: String? = nil,
400 hstsPreloaded: Bool? = nil,
384 httpHeaders: [HTTPHeader], 401 httpHeaders: [HTTPHeader],
385 httpHeadersError: String? = nil, 402 httpHeadersError: String? = nil,
386 reachabilityResults: [PortReachability], 403 reachabilityResults: [PortReachability],
@@ -484,6 +501,22 @@ final class DomainViewModel {
484 lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))") 501 lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
485 lines.append("Days Until Expiry: \(info.daysUntilExpiry)") 502 lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
486 lines.append("Chain Depth: \(info.chainDepth)") 503 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 }
487 } else if let error = sslError { 520 } else if let error = sslError {
488 lines.append("") 521 lines.append("")
489 lines.append("SSL / TLS Certificate") 522 lines.append("SSL / TLS Certificate")
DomainDig/Models.swift +51 −1
@@ -57,6 +57,11 @@ struct DNSSection: Identifiable, Codable {
57// MARK: - SSL Models 57// MARK: - SSL Models
58 58
59struct SSLCertificateInfo: Codable { 59struct SSLCertificateInfo: Codable {
60 struct CertChainEntry: Codable {
61 let subject: String
62 let issuer: String
63 }
64
60 let commonName: String 65 let commonName: String
61 let subjectAltNames: [String] 66 let subjectAltNames: [String]
62 let issuer: String 67 let issuer: String
@@ -64,6 +69,47 @@ struct SSLCertificateInfo: Codable {
64 let validUntil: Date 69 let validUntil: Date
65 let daysUntilExpiry: Int 70 let daysUntilExpiry: Int
66 let chainDepth: Int 71 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 }
67} 113}
68 114
69// MARK: - HTTP Headers Models 115// MARK: - HTTP Headers Models
@@ -154,12 +200,14 @@ struct HistoryEntry: Identifiable, Codable {
154 var ptrRecord: String? 200 var ptrRecord: String?
155 var redirectChain: [RedirectHop] 201 var redirectChain: [RedirectHop]
156 var portScanResults: [PortScanResult] 202 var portScanResults: [PortScanResult]
203 var hstsPreloaded: Bool?
157 204
158 init(domain: String, timestamp: Date, dnsSections: [DNSSection], 205 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
159 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], 206 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
160 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, 207 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
161 emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil, 208 emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
162 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = []) { 209 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
210 hstsPreloaded: Bool? = nil) {
163 self.domain = domain 211 self.domain = domain
164 self.timestamp = timestamp 212 self.timestamp = timestamp
165 self.dnsSections = dnsSections 213 self.dnsSections = dnsSections
@@ -171,6 +219,7 @@ struct HistoryEntry: Identifiable, Codable {
171 self.ptrRecord = ptrRecord 219 self.ptrRecord = ptrRecord
172 self.redirectChain = redirectChain 220 self.redirectChain = redirectChain
173 self.portScanResults = portScanResults 221 self.portScanResults = portScanResults
222 self.hstsPreloaded = hstsPreloaded
174 } 223 }
175 224
176 init(from decoder: Decoder) throws { 225 init(from decoder: Decoder) throws {
@@ -187,6 +236,7 @@ struct HistoryEntry: Identifiable, Codable {
187 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) 236 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
188 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] 237 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
189 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] 238 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
239 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
190 } 240 }
191} 241}
192 242
DomainDig/SSLCheckService.swift +116 −3
@@ -22,10 +22,32 @@ struct SSLCheckService {
22 throw SSLError.noCertificate 22 throw SSLError.noCertificate
23 } 23 }
24 24
25 return try extractCertificateInfo(from: trust) 25 return try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata)
26 } 26 }
27 27
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 {
29 let chainCount = SecTrustGetCertificateCount(trust) 51 let chainCount = SecTrustGetCertificateCount(trust)
30 guard chainCount > 0, 52 guard chainCount > 0,
31 let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], 53 let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
@@ -69,6 +91,15 @@ struct SSLCheckService {
69 } 91 }
70 } 92 }
71 93
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
72 return SSLCertificateInfo( 103 return SSLCertificateInfo(
73 commonName: commonName, 104 commonName: commonName,
74 subjectAltNames: sans, 105 subjectAltNames: sans,
@@ -76,11 +107,23 @@ struct SSLCheckService {
76 validFrom: validFrom, 107 validFrom: validFrom,
77 validUntil: validUntil, 108 validUntil: validUntil,
78 daysUntilExpiry: daysUntilExpiry, 109 daysUntilExpiry: daysUntilExpiry,
79 chainDepth: Int(chainCount) 110 chainDepth: Int(chainCount),
111 tlsVersion: metadata?.tlsVersion,
112 cipherSuite: metadata?.cipherSuite,
113 chain: chain
80 ) 114 )
81 } 115 }
82} 116}
83 117
118fileprivate struct TLSMetadata {
119 let tlsVersion: String?
120 let cipherSuite: String?
121}
122
123private struct HSTSPreloadResponse: Decodable {
124 let status: String
125}
126
84// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields 127// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
85 128
86private enum DERCertificateParser { 129private enum DERCertificateParser {
@@ -294,6 +337,7 @@ enum SSLError: LocalizedError {
294final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable { 337final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
295 private let lock = NSLock() 338 private let lock = NSLock()
296 private var _serverTrust: SecTrust? 339 private var _serverTrust: SecTrust?
340 private var _tlsMetadata: TLSMetadata?
297 341
298 var serverTrust: SecTrust? { 342 var serverTrust: SecTrust? {
299 lock.lock() 343 lock.lock()
@@ -301,6 +345,12 @@ final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendabl
301 return _serverTrust 345 return _serverTrust
302 } 346 }
303 347
348 fileprivate var tlsMetadata: TLSMetadata? {
349 lock.lock()
350 defer { lock.unlock() }
351 return _tlsMetadata
352 }
353
304 func urlSession( 354 func urlSession(
305 _ session: URLSession, 355 _ session: URLSession,
306 didReceive challenge: URLAuthenticationChallenge, 356 didReceive challenge: URLAuthenticationChallenge,
@@ -320,3 +370,66 @@ final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendabl
320 completionHandler(.useCredential, credential) 370 completionHandler(.useCredential, credential)
321 } 371 }
322} 372}
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}