krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.4.1: DomainDig/SSLCheckService.swift · raw

  1import Foundation
  2import Security
  3
  4struct SSLCheckService {
  5
  6    static func check(domain: String) async -> ServiceResult<SSLCertificateInfo> {
  7        let delegate = SSLSessionDelegate()
  8        let session = URLSession(
  9            configuration: .ephemeral,
 10            delegate: delegate,
 11            delegateQueue: nil
 12        )
 13        defer { session.invalidateAndCancel() }
 14
 15        let url = URL(string: "https://\(domain)")!
 16        let request = URLRequest(url: url, timeoutInterval: 10)
 17
 18        do {
 19            _ = try await session.data(for: request)
 20
 21            guard let trust = delegate.serverTrust else {
 22                return .empty(SSLError.noCertificate.localizedDescription)
 23            }
 24
 25            return .success(try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata))
 26        } catch {
 27            return .error(error.localizedDescription)
 28        }
 29    }
 30
 31    static func checkHSTSPreload(domain: String) async -> Bool? {
 32        var components = URLComponents(string: "https://hstspreload.org/api/v2/status")
 33        components?.queryItems = [
 34            URLQueryItem(name: "domain", value: domain)
 35        ]
 36
 37        guard let url = components?.url else {
 38            return nil
 39        }
 40
 41        do {
 42            let (data, _) = try await URLSession.shared.data(from: url)
 43            let response = try JSONDecoder().decode(HSTSPreloadResponse.self, from: data)
 44            return response.status == "preloaded"
 45        } catch {
 46            return nil
 47        }
 48    }
 49
 50    private static func extractCertificateInfo(
 51        from trust: SecTrust,
 52        metadata: TLSMetadata?
 53    ) throws -> SSLCertificateInfo {
 54        let chainCount = SecTrustGetCertificateCount(trust)
 55        guard chainCount > 0,
 56              let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
 57              let leaf = certChain.first else {
 58            throw SSLError.noCertificate
 59        }
 60
 61        // Common Name  use subject summary (available on iOS)
 62        let commonName = SecCertificateCopySubjectSummary(leaf) as String? ?? "Unknown"
 63
 64        // Validity dates
 65        let validFrom: Date
 66        let validUntil: Date
 67
 68        let derData = SecCertificateCopyData(leaf) as Data
 69        let parsed = DERCertificateParser.parse(derData)
 70
 71        if #available(iOS 18.0, *) {
 72            if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
 73                validFrom = notBefore
 74            } else {
 75                validFrom = parsed.notBefore ?? Date.distantPast
 76            }
 77
 78            if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
 79                validUntil = notAfter
 80            } else {
 81                validUntil = parsed.notAfter ?? Date.distantFuture
 82            }
 83        } else {
 84            validFrom = parsed.notBefore ?? Date.distantPast
 85            validUntil = parsed.notAfter ?? Date.distantFuture
 86        }
 87
 88        let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
 89
 90        let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
 91
 92        // Issuer: prefer parsed issuer, fall back to chain's next cert summary
 93        var issuer = parsed.issuerCommonName ?? "Unknown"
 94        if issuer == "Unknown" && certChain.count > 1 {
 95            let issuerCert = certChain[1]
 96            if let issuerSummary = SecCertificateCopySubjectSummary(issuerCert) as String? {
 97                issuer = issuerSummary
 98            }
 99        }
100
101        let chain = certChain.map { certificate in
102            let subject = SecCertificateCopySubjectSummary(certificate) as String? ?? "Unknown"
103            let parsedCertificate = DERCertificateParser.parse(SecCertificateCopyData(certificate) as Data)
104            return SSLCertificateInfo.CertChainEntry(
105                subject: subject,
106                issuer: parsedCertificate.issuerCommonName ?? "Unknown"
107            )
108        }
109
110        return SSLCertificateInfo(
111            commonName: commonName,
112            subjectAltNames: sans,
113            issuer: issuer,
114            validFrom: validFrom,
115            validUntil: validUntil,
116            daysUntilExpiry: daysUntilExpiry,
117            chainDepth: Int(chainCount),
118            tlsVersion: metadata?.tlsVersion,
119            cipherSuite: metadata?.cipherSuite,
120            chain: chain
121        )
122    }
123
124}
125
126fileprivate struct TLSMetadata {
127    let tlsVersion: String?
128    let cipherSuite: String?
129}
130
131private struct HSTSPreloadResponse: Decodable {
132    let status: String
133}
134
135// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
136
137private enum DERCertificateParser {
138    struct Result {
139        var issuerCommonName: String?
140        var subjectAltNames: [String] = []
141        var notBefore: Date?
142        var notAfter: Date?
143    }
144
145    static func parse(_ data: Data) -> Result {
146        var result = Result()
147        let bytes = [UInt8](data)
148
149        // X.509 structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
150        // tbsCertificate: SEQUENCE { version, serialNumber, signature, issuer, validity, subject, ... extensions }
151        guard let tbsRange = readSequence(bytes, offset: 0),
152              let tbsContent = readSequence(bytes, offset: tbsRange.contentStart) else {
153            return result
154        }
155
156        var offset = tbsContent.contentStart
157
158        // Skip version (explicit tag [0]) if present
159        if offset < bytes.count && (bytes[offset] & 0xE0) == 0xA0 {
160            if let tagLen = readTagAndLength(bytes, offset: offset) {
161                offset = tagLen.contentStart + tagLen.length
162            }
163        }
164
165        // Skip serialNumber
166        if let serial = readTagAndLength(bytes, offset: offset) {
167            offset = serial.contentStart + serial.length
168        }
169
170        // Skip signature algorithm
171        if let sigAlg = readTagAndLength(bytes, offset: offset) {
172            offset = sigAlg.contentStart + sigAlg.length
173        }
174
175        // Issuer  a SEQUENCE of SETs of attribute type-value pairs
176        if let issuerSeq = readTagAndLength(bytes, offset: offset) {
177            result.issuerCommonName = extractCommonName(bytes, sequenceStart: issuerSeq.contentStart, length: issuerSeq.length)
178            offset = issuerSeq.contentStart + issuerSeq.length
179        }
180
181        // Validity
182        if let validity = readTagAndLength(bytes, offset: offset) {
183            let (notBefore, notAfter) = extractValidity(bytes, sequenceStart: validity.contentStart, length: validity.length)
184            result.notBefore = notBefore
185            result.notAfter = notAfter
186            offset = validity.contentStart + validity.length
187        }
188
189        // Skip subject
190        if let subject = readTagAndLength(bytes, offset: offset) {
191            offset = subject.contentStart + subject.length
192        }
193
194        // Skip subjectPublicKeyInfo
195        if let spki = readTagAndLength(bytes, offset: offset) {
196            offset = spki.contentStart + spki.length
197        }
198
199        // Extensions are in an explicit tag [3]
200        while offset < tbsContent.contentStart + tbsContent.length {
201            if bytes[offset] == 0xA3 {
202                if let extWrapper = readTagAndLength(bytes, offset: offset) {
203                    // Inside is a SEQUENCE of SEQUENCE extensions
204                    if let extsSeq = readTagAndLength(bytes, offset: extWrapper.contentStart) {
205                        result.subjectAltNames = extractSANs(bytes, sequenceStart: extsSeq.contentStart, length: extsSeq.length)
206                    }
207                }
208                break
209            }
210            // Skip optional implicit tags (issuerUniqueID [1], subjectUniqueID [2])
211            if let tl = readTagAndLength(bytes, offset: offset) {
212                offset = tl.contentStart + tl.length
213            } else {
214                break
215            }
216        }
217
218        return result
219    }
220
221    // OID for commonName: 2.5.4.3 = 55 04 03
222    private static let cnOID: [UInt8] = [0x55, 0x04, 0x03]
223
224    // OID for subjectAltName: 2.5.29.17 = 55 1D 11
225    private static let sanOID: [UInt8] = [0x55, 0x1D, 0x11]
226
227    private static func extractCommonName(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> String? {
228        let end = sequenceStart + length
229        var pos = sequenceStart
230        while pos < end {
231            // Each SET in the issuer
232            guard let setTL = readTagAndLength(bytes, offset: pos) else { break }
233            let setEnd = setTL.contentStart + setTL.length
234
235            // Inside the SET is a SEQUENCE with OID + value
236            if let seqTL = readTagAndLength(bytes, offset: setTL.contentStart) {
237                let seqEnd = seqTL.contentStart + seqTL.length
238                if let oidTL = readTagAndLength(bytes, offset: seqTL.contentStart) {
239                    let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
240                    if oidBytes == cnOID {
241                        let valueStart = oidTL.contentStart + oidTL.length
242                        if let valueTL = readTagAndLength(bytes, offset: valueStart) {
243                            let strBytes = bytes[valueTL.contentStart..<valueTL.contentStart + valueTL.length]
244                            return String(bytes: strBytes, encoding: .utf8)
245                        }
246                    }
247                    _ = seqEnd // suppress unused warning
248                }
249            }
250            pos = setEnd
251        }
252        return nil
253    }
254
255    private static func extractSANs(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> [String] {
256        let end = sequenceStart + length
257        var pos = sequenceStart
258        var sans: [String] = []
259
260        while pos < end {
261            guard let extSeq = readTagAndLength(bytes, offset: pos) else { break }
262            let extEnd = extSeq.contentStart + extSeq.length
263
264            // Each extension is SEQUENCE { OID, [critical], value }
265            if let oidTL = readTagAndLength(bytes, offset: extSeq.contentStart) {
266                let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
267                if oidBytes == sanOID {
268                    var valuePos = oidTL.contentStart + oidTL.length
269                    // Skip optional critical BOOLEAN
270                    if valuePos < extEnd && bytes[valuePos] == 0x01 {
271                        if let boolTL = readTagAndLength(bytes, offset: valuePos) {
272                            valuePos = boolTL.contentStart + boolTL.length
273                        }
274                    }
275                    // The value is an OCTET STRING wrapping a SEQUENCE of GeneralNames
276                    if let octetTL = readTagAndLength(bytes, offset: valuePos) {
277                        if let sanSeq = readTagAndLength(bytes, offset: octetTL.contentStart) {
278                            let sanEnd = sanSeq.contentStart + sanSeq.length
279                            var sanPos = sanSeq.contentStart
280                            while sanPos < sanEnd {
281                                guard let nameTL = readTagAndLength(bytes, offset: sanPos) else { break }
282                                // Context tag [2] = dNSName (IA5String)
283                                if (bytes[sanPos] & 0x1F) == 2 {
284                                    let nameBytes = bytes[nameTL.contentStart..<nameTL.contentStart + nameTL.length]
285                                    if let name = String(bytes: nameBytes, encoding: .ascii) {
286                                        sans.append(name)
287                                    }
288                                }
289                                sanPos = nameTL.contentStart + nameTL.length
290                            }
291                        }
292                    }
293                }
294            }
295            pos = extEnd
296        }
297        return sans
298    }
299
300    private static func extractValidity(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> (Date?, Date?) {
301        let end = sequenceStart + length
302        var position = sequenceStart
303        var dates: [Date] = []
304
305        while position < end, dates.count < 2 {
306            guard let timeTL = readTagAndLength(bytes, offset: position) else { break }
307            let raw = String(bytes: bytes[timeTL.contentStart..<timeTL.contentStart + timeTL.length], encoding: .ascii)
308            if let raw {
309                dates.append(parseASN1Time(raw))
310            }
311            position = timeTL.contentStart + timeTL.length
312        }
313
314        let notBefore = dates.indices.contains(0) ? dates[0] : nil
315        let notAfter = dates.indices.contains(1) ? dates[1] : nil
316        return (notBefore, notAfter)
317    }
318
319    private static func parseASN1Time(_ string: String) -> Date {
320        let utcFormatter = DateFormatter()
321        utcFormatter.locale = Locale(identifier: "en_US_POSIX")
322        utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
323        utcFormatter.dateFormat = "yyMMddHHmmss'Z'"
324
325        if let date = utcFormatter.date(from: string) {
326            return date
327        }
328
329        let generalizedFormatter = DateFormatter()
330        generalizedFormatter.locale = Locale(identifier: "en_US_POSIX")
331        generalizedFormatter.timeZone = TimeZone(secondsFromGMT: 0)
332        generalizedFormatter.dateFormat = "yyyyMMddHHmmss'Z'"
333
334        return generalizedFormatter.date(from: string) ?? Date.distantFuture
335    }
336
337    private struct TLV {
338        let contentStart: Int
339        let length: Int
340    }
341
342    private static func readSequence(_ bytes: [UInt8], offset: Int) -> TLV? {
343        guard offset < bytes.count, bytes[offset] == 0x30 else { return nil }
344        return readTagAndLength(bytes, offset: offset)
345    }
346
347    private static func readTagAndLength(_ bytes: [UInt8], offset: Int) -> TLV? {
348        guard offset < bytes.count else { return nil }
349        var pos = offset + 1 // skip tag byte
350        guard pos < bytes.count else { return nil }
351
352        let firstLen = bytes[pos]
353        pos += 1
354
355        let length: Int
356        if firstLen < 0x80 {
357            length = Int(firstLen)
358        } else {
359            let numBytes = Int(firstLen & 0x7F)
360            guard numBytes > 0, numBytes <= 4, pos + numBytes <= bytes.count else { return nil }
361            var len = 0
362            for i in 0..<numBytes {
363                len = (len << 8) | Int(bytes[pos + i])
364            }
365            pos += numBytes
366            length = len
367        }
368
369        return TLV(contentStart: pos, length: length)
370    }
371}
372
373enum SSLError: LocalizedError {
374    case noCertificate
375    case connectionFailed
376
377    var errorDescription: String? {
378        switch self {
379        case .noCertificate:
380            return "No certificate found"
381        case .connectionFailed:
382            return "Failed to connect to server"
383        }
384    }
385}
386
387final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
388    private let lock = NSLock()
389    private var _serverTrust: SecTrust?
390    private var _tlsMetadata: TLSMetadata?
391
392    var serverTrust: SecTrust? {
393        lock.lock()
394        defer { lock.unlock() }
395        return _serverTrust
396    }
397
398    fileprivate var tlsMetadata: TLSMetadata? {
399        lock.lock()
400        defer { lock.unlock() }
401        return _tlsMetadata
402    }
403
404    func urlSession(
405        _ _: URLSession,
406        didReceive challenge: URLAuthenticationChallenge,
407        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
408    ) {
409        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
410              let trust = challenge.protectionSpace.serverTrust else {
411            completionHandler(.performDefaultHandling, nil)
412            return
413        }
414
415        lock.lock()
416        _serverTrust = trust
417        lock.unlock()
418
419        let credential = URLCredential(trust: trust)
420        completionHandler(.useCredential, credential)
421    }
422}
423
424extension SSLSessionDelegate: URLSessionTaskDelegate {
425    func urlSession(
426        _ _: URLSession,
427        task _: URLSessionTask,
428        didFinishCollecting metrics: URLSessionTaskMetrics
429    ) {
430        guard let transaction = metrics.transactionMetrics.last else {
431            return
432        }
433
434        let tlsVersion = transaction.negotiatedTLSProtocolVersion.map {
435            Self.describeTLSVersion($0)
436        }
437        let cipherSuite = transaction.negotiatedTLSCipherSuite.map {
438            Self.describeCipherSuite($0)
439        }
440
441        lock.lock()
442        _tlsMetadata = TLSMetadata(tlsVersion: tlsVersion, cipherSuite: cipherSuite)
443        lock.unlock()
444    }
445
446    private static func describeTLSVersion(_ version: tls_protocol_version_t) -> String {
447        switch version.rawValue {
448        case 0x0301:
449            return "TLS 1.0"
450        case 0x0302:
451            return "TLS 1.1"
452        case 0x0303:
453            return "TLS 1.2"
454        case 0x0304:
455            return "TLS 1.3"
456        default:
457            return String(describing: version)
458        }
459    }
460
461    private static func describeCipherSuite(_ suite: tls_ciphersuite_t) -> String {
462        switch suite.rawValue {
463        case 0x1301:
464            return "TLS_AES_128_GCM_SHA256"
465        case 0x1302:
466            return "TLS_AES_256_GCM_SHA384"
467        case 0x1303:
468            return "TLS_CHACHA20_POLY1305_SHA256"
469        case 0xC02F:
470            return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
471        case 0xC030:
472            return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
473        case 0xC02B:
474            return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
475        case 0xC02C:
476            return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
477        case 0xCCA8:
478            return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
479        case 0xCCA9:
480            return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
481        default:
482            return String(format: "0x%04X", suite.rawValue)
483        }
484    }
485}