From 1e13dac952d8f6cce7964aef27e98fa2e9da1eb7 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 3 Apr 2026 16:59:04 -0500 Subject: 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. --- DomainDig/SSLCheckService.swift | 119 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) (limited to 'DomainDig/SSLCheckService.swift') 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) + } + } +} -- cgit v1.2.3