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}