Commit 1e13dac952
Verified · cmc
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 | ||
| 59 | struct SSLCertificateInfo: Codable { | 59 | struct 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 | ||
| 118 | fileprivate struct TLSMetadata { | ||
| 119 | let tlsVersion: String? | ||
| 120 | let cipherSuite: String? | ||
| 121 | } | ||
| 122 | |||
| 123 | private 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 | ||
| 86 | private enum DERCertificateParser { | 129 | private enum DERCertificateParser { |
| @@ -294,6 +337,7 @@ enum SSLError: LocalizedError { | |||
| 294 | final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable { | 337 | final 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 | |||
| 374 | extension 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 | } | ||