diff options
| author | Christian Cleberg <[email protected]> | 2026-04-03 16:59:04 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-03 16:59:04 -0500 |
| commit | 1e13dac952d8f6cce7964aef27e98fa2e9da1eb7 (patch) | |
| tree | a43a164db605c15aecdd9e649d546b21a7ce1dc8 /DomainDig/SSLCheckService.swift | |
| parent | 9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41 (diff) | |
| download | domain-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.
Diffstat (limited to 'DomainDig/SSLCheckService.swift')
| -rw-r--r-- | DomainDig/SSLCheckService.swift | 119 |
1 files changed, 116 insertions, 3 deletions
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) + } + } +} |
