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}