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