krz/domain-dig

an ios app for DNS & SSL analysis

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

v2.0.0: 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        if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? {
 69            validFrom = notBefore
 70        } else {
 71            validFrom = Date.distantPast
 72        }
 73
 74        if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? {
 75            validUntil = notAfter
 76        } else {
 77            validUntil = Date.distantFuture
 78        }
 79
 80        let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0
 81
 82        // Parse the DER-encoded certificate to extract SANs and Issuer
 83        let derData = SecCertificateCopyData(leaf) as Data
 84        let parsed = DERCertificateParser.parse(derData)
 85
 86        let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames
 87
 88        // Issuer: prefer parsed issuer, fall back to chain's next cert summary
 89        var issuer = parsed.issuerCommonName ?? "Unknown"
 90        if issuer == "Unknown" && certChain.count > 1 {
 91            let issuerCert = certChain[1]
 92            if let issuerSummary = SecCertificateCopySubjectSummary(issuerCert) as String? {
 93                issuer = issuerSummary
 94            }
 95        }
 96
 97        let chain = certChain.map { certificate in
 98            let subject = SecCertificateCopySubjectSummary(certificate) as String? ?? "Unknown"
 99            let parsedCertificate = DERCertificateParser.parse(SecCertificateCopyData(certificate) as Data)
100            return SSLCertificateInfo.CertChainEntry(
101                subject: subject,
102                issuer: parsedCertificate.issuerCommonName ?? "Unknown"
103            )
104        }
105
106        return SSLCertificateInfo(
107            commonName: commonName,
108            subjectAltNames: sans,
109            issuer: issuer,
110            validFrom: validFrom,
111            validUntil: validUntil,
112            daysUntilExpiry: daysUntilExpiry,
113            chainDepth: Int(chainCount),
114            tlsVersion: metadata?.tlsVersion,
115            cipherSuite: metadata?.cipherSuite,
116            chain: chain
117        )
118    }
119}
120
121fileprivate struct TLSMetadata {
122    let tlsVersion: String?
123    let cipherSuite: String?
124}
125
126private struct HSTSPreloadResponse: Decodable {
127    let status: String
128}
129
130// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields
131
132private enum DERCertificateParser {
133    struct Result {
134        var issuerCommonName: String?
135        var subjectAltNames: [String] = []
136    }
137
138    static func parse(_ data: Data) -> Result {
139        var result = Result()
140        let bytes = [UInt8](data)
141
142        // X.509 structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
143        // tbsCertificate: SEQUENCE { version, serialNumber, signature, issuer, validity, subject, ... extensions }
144        guard let tbsRange = readSequence(bytes, offset: 0),
145              let tbsContent = readSequence(bytes, offset: tbsRange.contentStart) else {
146            return result
147        }
148
149        var offset = tbsContent.contentStart
150
151        // Skip version (explicit tag [0]) if present
152        if offset < bytes.count && (bytes[offset] & 0xE0) == 0xA0 {
153            if let tagLen = readTagAndLength(bytes, offset: offset) {
154                offset = tagLen.contentStart + tagLen.length
155            }
156        }
157
158        // Skip serialNumber
159        if let serial = readTagAndLength(bytes, offset: offset) {
160            offset = serial.contentStart + serial.length
161        }
162
163        // Skip signature algorithm
164        if let sigAlg = readTagAndLength(bytes, offset: offset) {
165            offset = sigAlg.contentStart + sigAlg.length
166        }
167
168        // Issuer  a SEQUENCE of SETs of attribute type-value pairs
169        if let issuerSeq = readTagAndLength(bytes, offset: offset) {
170            result.issuerCommonName = extractCommonName(bytes, sequenceStart: issuerSeq.contentStart, length: issuerSeq.length)
171            offset = issuerSeq.contentStart + issuerSeq.length
172        }
173
174        // Skip validity
175        if let validity = readTagAndLength(bytes, offset: offset) {
176            offset = validity.contentStart + validity.length
177        }
178
179        // Skip subject
180        if let subject = readTagAndLength(bytes, offset: offset) {
181            offset = subject.contentStart + subject.length
182        }
183
184        // Skip subjectPublicKeyInfo
185        if let spki = readTagAndLength(bytes, offset: offset) {
186            offset = spki.contentStart + spki.length
187        }
188
189        // Extensions are in an explicit tag [3]
190        while offset < tbsContent.contentStart + tbsContent.length {
191            if bytes[offset] == 0xA3 {
192                if let extWrapper = readTagAndLength(bytes, offset: offset) {
193                    // Inside is a SEQUENCE of SEQUENCE extensions
194                    if let extsSeq = readTagAndLength(bytes, offset: extWrapper.contentStart) {
195                        result.subjectAltNames = extractSANs(bytes, sequenceStart: extsSeq.contentStart, length: extsSeq.length)
196                    }
197                }
198                break
199            }
200            // Skip optional implicit tags (issuerUniqueID [1], subjectUniqueID [2])
201            if let tl = readTagAndLength(bytes, offset: offset) {
202                offset = tl.contentStart + tl.length
203            } else {
204                break
205            }
206        }
207
208        return result
209    }
210
211    // OID for commonName: 2.5.4.3 = 55 04 03
212    private static let cnOID: [UInt8] = [0x55, 0x04, 0x03]
213
214    // OID for subjectAltName: 2.5.29.17 = 55 1D 11
215    private static let sanOID: [UInt8] = [0x55, 0x1D, 0x11]
216
217    private static func extractCommonName(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> String? {
218        let end = sequenceStart + length
219        var pos = sequenceStart
220        while pos < end {
221            // Each SET in the issuer
222            guard let setTL = readTagAndLength(bytes, offset: pos) else { break }
223            let setEnd = setTL.contentStart + setTL.length
224
225            // Inside the SET is a SEQUENCE with OID + value
226            if let seqTL = readTagAndLength(bytes, offset: setTL.contentStart) {
227                let seqEnd = seqTL.contentStart + seqTL.length
228                if let oidTL = readTagAndLength(bytes, offset: seqTL.contentStart) {
229                    let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
230                    if oidBytes == cnOID {
231                        let valueStart = oidTL.contentStart + oidTL.length
232                        if let valueTL = readTagAndLength(bytes, offset: valueStart) {
233                            let strBytes = bytes[valueTL.contentStart..<valueTL.contentStart + valueTL.length]
234                            return String(bytes: strBytes, encoding: .utf8)
235                        }
236                    }
237                    _ = seqEnd // suppress unused warning
238                }
239            }
240            pos = setEnd
241        }
242        return nil
243    }
244
245    private static func extractSANs(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> [String] {
246        let end = sequenceStart + length
247        var pos = sequenceStart
248        var sans: [String] = []
249
250        while pos < end {
251            guard let extSeq = readTagAndLength(bytes, offset: pos) else { break }
252            let extEnd = extSeq.contentStart + extSeq.length
253
254            // Each extension is SEQUENCE { OID, [critical], value }
255            if let oidTL = readTagAndLength(bytes, offset: extSeq.contentStart) {
256                let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length])
257                if oidBytes == sanOID {
258                    var valuePos = oidTL.contentStart + oidTL.length
259                    // Skip optional critical BOOLEAN
260                    if valuePos < extEnd && bytes[valuePos] == 0x01 {
261                        if let boolTL = readTagAndLength(bytes, offset: valuePos) {
262                            valuePos = boolTL.contentStart + boolTL.length
263                        }
264                    }
265                    // The value is an OCTET STRING wrapping a SEQUENCE of GeneralNames
266                    if let octetTL = readTagAndLength(bytes, offset: valuePos) {
267                        if let sanSeq = readTagAndLength(bytes, offset: octetTL.contentStart) {
268                            let sanEnd = sanSeq.contentStart + sanSeq.length
269                            var sanPos = sanSeq.contentStart
270                            while sanPos < sanEnd {
271                                guard let nameTL = readTagAndLength(bytes, offset: sanPos) else { break }
272                                // Context tag [2] = dNSName (IA5String)
273                                if (bytes[sanPos] & 0x1F) == 2 {
274                                    let nameBytes = bytes[nameTL.contentStart..<nameTL.contentStart + nameTL.length]
275                                    if let name = String(bytes: nameBytes, encoding: .ascii) {
276                                        sans.append(name)
277                                    }
278                                }
279                                sanPos = nameTL.contentStart + nameTL.length
280                            }
281                        }
282                    }
283                }
284            }
285            pos = extEnd
286        }
287        return sans
288    }
289
290    private struct TLV {
291        let contentStart: Int
292        let length: Int
293    }
294
295    private static func readSequence(_ bytes: [UInt8], offset: Int) -> TLV? {
296        guard offset < bytes.count, bytes[offset] == 0x30 else { return nil }
297        return readTagAndLength(bytes, offset: offset)
298    }
299
300    private static func readTagAndLength(_ bytes: [UInt8], offset: Int) -> TLV? {
301        guard offset < bytes.count else { return nil }
302        var pos = offset + 1 // skip tag byte
303        guard pos < bytes.count else { return nil }
304
305        let firstLen = bytes[pos]
306        pos += 1
307
308        let length: Int
309        if firstLen < 0x80 {
310            length = Int(firstLen)
311        } else {
312            let numBytes = Int(firstLen & 0x7F)
313            guard numBytes > 0, numBytes <= 4, pos + numBytes <= bytes.count else { return nil }
314            var len = 0
315            for i in 0..<numBytes {
316                len = (len << 8) | Int(bytes[pos + i])
317            }
318            pos += numBytes
319            length = len
320        }
321
322        return TLV(contentStart: pos, length: length)
323    }
324}
325
326enum SSLError: LocalizedError {
327    case noCertificate
328    case connectionFailed
329
330    var errorDescription: String? {
331        switch self {
332        case .noCertificate:
333            return "No certificate found"
334        case .connectionFailed:
335            return "Failed to connect to server"
336        }
337    }
338}
339
340final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
341    private let lock = NSLock()
342    private var _serverTrust: SecTrust?
343    private var _tlsMetadata: TLSMetadata?
344
345    var serverTrust: SecTrust? {
346        lock.lock()
347        defer { lock.unlock() }
348        return _serverTrust
349    }
350
351    fileprivate var tlsMetadata: TLSMetadata? {
352        lock.lock()
353        defer { lock.unlock() }
354        return _tlsMetadata
355    }
356
357    func urlSession(
358        _ _: URLSession,
359        didReceive challenge: URLAuthenticationChallenge,
360        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
361    ) {
362        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
363              let trust = challenge.protectionSpace.serverTrust else {
364            completionHandler(.performDefaultHandling, nil)
365            return
366        }
367
368        lock.lock()
369        _serverTrust = trust
370        lock.unlock()
371
372        let credential = URLCredential(trust: trust)
373        completionHandler(.useCredential, credential)
374    }
375}
376
377extension SSLSessionDelegate: URLSessionTaskDelegate {
378    func urlSession(
379        _ _: URLSession,
380        task _: URLSessionTask,
381        didFinishCollecting metrics: URLSessionTaskMetrics
382    ) {
383        guard let transaction = metrics.transactionMetrics.last else {
384            return
385        }
386
387        let tlsVersion = transaction.negotiatedTLSProtocolVersion.map {
388            Self.describeTLSVersion($0)
389        }
390        let cipherSuite = transaction.negotiatedTLSCipherSuite.map {
391            Self.describeCipherSuite($0)
392        }
393
394        lock.lock()
395        _tlsMetadata = TLSMetadata(tlsVersion: tlsVersion, cipherSuite: cipherSuite)
396        lock.unlock()
397    }
398
399    private static func describeTLSVersion(_ version: tls_protocol_version_t) -> String {
400        switch version.rawValue {
401        case 0x0301:
402            return "TLS 1.0"
403        case 0x0302:
404            return "TLS 1.1"
405        case 0x0303:
406            return "TLS 1.2"
407        case 0x0304:
408            return "TLS 1.3"
409        default:
410            return String(describing: version)
411        }
412    }
413
414    private static func describeCipherSuite(_ suite: tls_ciphersuite_t) -> String {
415        switch suite.rawValue {
416        case 0x1301:
417            return "TLS_AES_128_GCM_SHA256"
418        case 0x1302:
419            return "TLS_AES_256_GCM_SHA384"
420        case 0x1303:
421            return "TLS_CHACHA20_POLY1305_SHA256"
422        case 0xC02F:
423            return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
424        case 0xC030:
425            return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
426        case 0xC02B:
427            return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
428        case 0xC02C:
429            return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
430        case 0xCCA8:
431            return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
432        case 0xCCA9:
433            return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
434        default:
435            return String(format: "0x%04X", suite.rawValue)
436        }
437    }
438}