krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.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}