krz/domain-dig

an ios app for DNS & SSL analysis

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

main: 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           let tagLen = readTagAndLength(bytes, offset: offset) {
161            offset = tagLen.contentStart + tagLen.length
162        }
163
164        // Skip serialNumber
165        if let serial = readTagAndLength(bytes, offset: offset) {
166            offset = serial.contentStart + serial.length
167        }
168
169        // Skip signature algorithm
170        if let sigAlg = readTagAndLength(bytes, offset: offset) {
171            offset = sigAlg.contentStart + sigAlg.length
172        }
173
174        // Issuer  a SEQUENCE of SETs of attribute type-value pairs
175        if let issuerSeq = readTagAndLength(bytes, offset: offset) {
176            result.issuerCommonName = extractCommonName(bytes, sequenceStart: issuerSeq.contentStart, length: issuerSeq.length)
177            offset = issuerSeq.contentStart + issuerSeq.length
178        }
179
180        // Validity
181        if let validity = readTagAndLength(bytes, offset: offset) {
182            let (notBefore, notAfter) = extractValidity(bytes, sequenceStart: validity.contentStart, length: validity.length)
183            result.notBefore = notBefore
184            result.notAfter = notAfter
185            offset = validity.contentStart + validity.length
186        }
187
188        // Skip subject
189        if let subject = readTagAndLength(bytes, offset: offset) {
190            offset = subject.contentStart + subject.length
191        }
192
193        // Skip subjectPublicKeyInfo
194        if let spki = readTagAndLength(bytes, offset: offset) {
195            offset = spki.contentStart + spki.length
196        }
197
198        // Extensions are in an explicit tag [3]
199        while offset < tbsContent.contentStart + tbsContent.length {
200            if bytes[offset] == 0xA3 {
201                // Inside the wrapper is a SEQUENCE of SEQUENCE extensions
202                if let extWrapper = readTagAndLength(bytes, offset: offset),
203                   let extsSeq = readTagAndLength(bytes, offset: extWrapper.contentStart) {
204                    result.subjectAltNames = extractSANs(bytes, sequenceStart: extsSeq.contentStart, length: extsSeq.length)
205                }
206                break
207            }
208            // Skip optional implicit tags (issuerUniqueID [1], subjectUniqueID [2])
209            if let tl = readTagAndLength(bytes, offset: offset) {
210                offset = tl.contentStart + tl.length
211            } else {
212                break
213            }
214        }
215
216        return result
217    }
218
219    // OID for commonName: 2.5.4.3 = 55 04 03
220    private static let cnOID: [UInt8] = [0x55, 0x04, 0x03]
221
222    // OID for subjectAltName: 2.5.29.17 = 55 1D 11
223    private static let sanOID: [UInt8] = [0x55, 0x1D, 0x11]
224
225    private static func extractCommonName(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> String? {
226        let end = sequenceStart + length
227        var pos = sequenceStart
228        while pos < end {
229            // Each SET in the issuer
230            guard let setTL = readTagAndLength(bytes, offset: pos) else { break }
231            let setEnd = setTL.contentStart + setTL.length
232
233            // Inside the SET is a SEQUENCE with OID + value
234            if let seqTL = readTagAndLength(bytes, offset: setTL.contentStart) {
235                let seqEnd = seqTL.contentStart + seqTL.length
236                if let oidTL = readTagAndLength(bytes, offset: seqTL.contentStart) {
237                    let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
238                    if oidBytes == cnOID {
239                        let valueStart = oidTL.contentStart + oidTL.length
240                        if let valueTL = readTagAndLength(bytes, offset: valueStart) {
241                            let strBytes = bytes[valueTL.contentStart..<valueTL.contentStart + valueTL.length]
242                            return String(bytes: strBytes, encoding: .utf8)
243                        }
244                    }
245                    _ = seqEnd // suppress unused warning
246                }
247            }
248            pos = setEnd
249        }
250        return nil
251    }
252
253    private static func extractSANs(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> [String] {
254        let end = sequenceStart + length
255        var pos = sequenceStart
256        var sans: [String] = []
257
258        while pos < end {
259            guard let extSeq = readTagAndLength(bytes, offset: pos) else { break }
260            let extEnd = extSeq.contentStart + extSeq.length
261
262            // Each extension is SEQUENCE { OID, [critical], value }
263            if let oidTL = readTagAndLength(bytes, offset: extSeq.contentStart) {
264                let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
265                if oidBytes == sanOID {
266                    var valuePos = oidTL.contentStart + oidTL.length
267                    // Skip optional critical BOOLEAN
268                    if valuePos < extEnd, bytes[valuePos] == 0x01,
269                       let boolTL = readTagAndLength(bytes, offset: valuePos) {
270                        valuePos = boolTL.contentStart + boolTL.length
271                    }
272                    // The value is an OCTET STRING wrapping a SEQUENCE of GeneralNames
273                    if let octetTL = readTagAndLength(bytes, offset: valuePos),
274                       let sanSeq = readTagAndLength(bytes, offset: octetTL.contentStart) {
275                        let sanEnd = sanSeq.contentStart + sanSeq.length
276                        var sanPos = sanSeq.contentStart
277                        while sanPos < sanEnd {
278                            guard let nameTL = readTagAndLength(bytes, offset: sanPos) else { break }
279                            // Context tag [2] = dNSName (IA5String)
280                            if (bytes[sanPos] & 0x1F) == 2 {
281                                let nameBytes = bytes[nameTL.contentStart..<nameTL.contentStart + nameTL.length]
282                                if let name = String(bytes: nameBytes, encoding: .ascii) {
283                                    sans.append(name)
284                                }
285                            }
286                            sanPos = nameTL.contentStart + nameTL.length
287                        }
288                    }
289                }
290            }
291            pos = extEnd
292        }
293        return sans
294    }
295
296    private static func extractValidity(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> (Date?, Date?) {
297        let end = sequenceStart + length
298        var position = sequenceStart
299        var dates: [Date] = []
300
301        while position < end, dates.count < 2 {
302            guard let timeTL = readTagAndLength(bytes, offset: position) else { break }
303            let raw = String(bytes: bytes[timeTL.contentStart..<timeTL.contentStart + timeTL.length], encoding: .ascii)
304            if let raw {
305                dates.append(parseASN1Time(raw))
306            }
307            position = timeTL.contentStart + timeTL.length
308        }
309
310        let notBefore = dates.indices.contains(0) ? dates[0] : nil
311        let notAfter = dates.indices.contains(1) ? dates[1] : nil
312        return (notBefore, notAfter)
313    }
314
315    private static func parseASN1Time(_ string: String) -> Date {
316        let utcFormatter = DateFormatter()
317        utcFormatter.locale = Locale(identifier: "en_US_POSIX")
318        utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
319        utcFormatter.dateFormat = "yyMMddHHmmss'Z'"
320
321        if let date = utcFormatter.date(from: string) {
322            return date
323        }
324
325        let generalizedFormatter = DateFormatter()
326        generalizedFormatter.locale = Locale(identifier: "en_US_POSIX")
327        generalizedFormatter.timeZone = TimeZone(secondsFromGMT: 0)
328        generalizedFormatter.dateFormat = "yyyyMMddHHmmss'Z'"
329
330        return generalizedFormatter.date(from: string) ?? Date.distantFuture
331    }
332
333    private struct TLV {
334        let contentStart: Int
335        let length: Int
336    }
337
338    private static func readSequence(_ bytes: [UInt8], offset: Int) -> TLV? {
339        guard offset < bytes.count, bytes[offset] == 0x30 else { return nil }
340        return readTagAndLength(bytes, offset: offset)
341    }
342
343    private static func readTagAndLength(_ bytes: [UInt8], offset: Int) -> TLV? {
344        guard offset < bytes.count else { return nil }
345        var pos = offset + 1 // skip tag byte
346        guard pos < bytes.count else { return nil }
347
348        let firstLen = bytes[pos]
349        pos += 1
350
351        let length: Int
352        if firstLen < 0x80 {
353            length = Int(firstLen)
354        } else {
355            let numBytes = Int(firstLen & 0x7F)
356            guard numBytes > 0, numBytes <= 4, pos + numBytes <= bytes.count else { return nil }
357            var len = 0
358            for i in 0..<numBytes {
359                len = (len << 8) | Int(bytes[pos + i])
360            }
361            pos += numBytes
362            length = len
363        }
364
365        return TLV(contentStart: pos, length: length)
366    }
367}
368
369enum SSLError: LocalizedError {
370    case noCertificate
371    case connectionFailed
372
373    var errorDescription: String? {
374        switch self {
375        case .noCertificate:
376            return "No certificate found"
377        case .connectionFailed:
378            return "Failed to connect to server"
379        }
380    }
381}
382
383final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
384    private let lock = NSLock()
385    private var storedServerTrust: SecTrust?
386    private var storedTLSMetadata: TLSMetadata?
387
388    var serverTrust: SecTrust? {
389        lock.lock()
390        defer { lock.unlock() }
391        return storedServerTrust
392    }
393
394    fileprivate var tlsMetadata: TLSMetadata? {
395        lock.lock()
396        defer { lock.unlock() }
397        return storedTLSMetadata
398    }
399
400    func urlSession(
401        _ _: URLSession,
402        didReceive challenge: URLAuthenticationChallenge,
403        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
404    ) {
405        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
406              let trust = challenge.protectionSpace.serverTrust else {
407            completionHandler(.performDefaultHandling, nil)
408            return
409        }
410
411        lock.lock()
412        storedServerTrust = trust
413        lock.unlock()
414
415        let credential = URLCredential(trust: trust)
416        completionHandler(.useCredential, credential)
417    }
418}
419
420extension SSLSessionDelegate: URLSessionTaskDelegate {
421    func urlSession(
422        _ _: URLSession,
423        task _: URLSessionTask,
424        didFinishCollecting metrics: URLSessionTaskMetrics
425    ) {
426        guard let transaction = metrics.transactionMetrics.last else {
427            return
428        }
429
430        let tlsVersion = transaction.negotiatedTLSProtocolVersion.map {
431            Self.describeTLSVersion($0)
432        }
433        let cipherSuite = transaction.negotiatedTLSCipherSuite.map {
434            Self.describeCipherSuite($0)
435        }
436
437        lock.lock()
438        storedTLSMetadata = TLSMetadata(tlsVersion: tlsVersion, cipherSuite: cipherSuite)
439        lock.unlock()
440    }
441
442    private static func describeTLSVersion(_ version: tls_protocol_version_t) -> String {
443        switch version.rawValue {
444        case 0x0301:
445            return "TLS 1.0"
446        case 0x0302:
447            return "TLS 1.1"
448        case 0x0303:
449            return "TLS 1.2"
450        case 0x0304:
451            return "TLS 1.3"
452        default:
453            return String(describing: version)
454        }
455    }
456
457    private static func describeCipherSuite(_ suite: tls_ciphersuite_t) -> String {
458        switch suite.rawValue {
459        case 0x1301:
460            return "TLS_AES_128_GCM_SHA256"
461        case 0x1302:
462            return "TLS_AES_256_GCM_SHA384"
463        case 0x1303:
464            return "TLS_CHACHA20_POLY1305_SHA256"
465        case 0xC02F:
466            return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
467        case 0xC030:
468            return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
469        case 0xC02B:
470            return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
471        case 0xC02C:
472            return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
473        case 0xCCA8:
474            return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
475        case 0xCCA9:
476            return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
477        default:
478            return String(format: "0x%04X", suite.rawValue)
479        }
480    }
481}