krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.5.0: DomainDig/SSLCheckService.swift · raw

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