diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json | 11 | ||||
| -rw-r--r-- | DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json | 36 | ||||
| -rw-r--r-- | DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png | bin | 0 -> 441131 bytes | |||
| -rw-r--r-- | DomainDig/Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 309 | ||||
| -rw-r--r-- | DomainDig/DNSLookupService.swift | 89 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 17 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 157 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 63 | ||||
| -rw-r--r-- | DomainDig/SSLCheckService.swift | 322 |
10 files changed, 1010 insertions, 0 deletions
diff --git a/DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json b/DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json b/DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..87ffca2 --- /dev/null +++ b/DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "domaindig.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png b/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png Binary files differnew file mode 100644 index 0000000..752f230 --- /dev/null +++ b/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png diff --git a/DomainDig/Assets.xcassets/Contents.json b/DomainDig/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DomainDig/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift new file mode 100644 index 0000000..b6588b4 --- /dev/null +++ b/DomainDig/ContentView.swift @@ -0,0 +1,309 @@ +import SwiftUI + +struct ContentView: View { + @State private var viewModel = DomainViewModel() + @FocusState private var domainFieldFocused: Bool + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 0) { + inputSection + if viewModel.hasRun { + if viewModel.resultsLoaded { + HStack { + Spacer() + Button { + shareResults() + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(.body)) + .foregroundStyle(.secondary) + } + } + .padding(.top, 8) + } + dnsResultsSection + sslResultsSection + } else if !viewModel.recentSearches.isEmpty { + recentSearchesSection + } + } + .padding(.horizontal) + .padding(.bottom, 32) + } + .background(Color.black) + .navigationTitle("DomainDig") + .toolbarColorScheme(.dark, for: .navigationBar) + .preferredColorScheme(.dark) + } + .onAppear { + domainFieldFocused = true + } + } + + // MARK: - Input + + private var inputSection: some View { + VStack(spacing: 12) { + TextField("e.g. cleberg.net", text: $viewModel.domain) + .font(.system(.title3, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .padding(12) + .background(Color(.systemGray6)) + .cornerRadius(8) + .focused($domainFieldFocused) + .onSubmit { viewModel.run() } + + Button { + domainFieldFocused = false + viewModel.run() + } label: { + Text("Run") + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.trimmedDomain.isEmpty) + } + .padding(.vertical, 16) + } + + // MARK: - Recent Searches + + private var recentSearchesSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("RECENT") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Button("Clear") { + viewModel.clearRecentSearches() + } + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + + ForEach(viewModel.recentSearches, id: \.self) { domain in + Button { + viewModel.domain = domain + domainFieldFocused = false + viewModel.run() + } label: { + Text(domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + } + .padding(.top, 8) + } + + // MARK: - DNS Results + + private var dnsResultsSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("DNS Records") + + if viewModel.dnsLoading { + ProgressView("Querying DNS…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.dnsError { + errorLabel(error) + } else { + ForEach(viewModel.dnsSections) { section in + dnsRecordSection(section) + } + } + } + .padding(.top, 8) + } + + private func dnsRecordSection(_ section: DNSSection) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(section.recordType.rawValue) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let error = section.error { + errorLabel(error) + } else if section.records.isEmpty { + Text("No records found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + dnsRecordRows(section.records) + } + + // Wildcard sub-section (only shown when records exist) + if !section.wildcardRecords.isEmpty { + Text("*.\(viewModel.searchedDomain)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(.cyan.opacity(0.7)) + .padding(.top, 4) + + dnsRecordRows(section.wildcardRecords) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + + private func dnsRecordRows(_ records: [DNSRecord]) -> some View { + ForEach(records) { record in + HStack(alignment: .top) { + Text(record.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + Text("TTL \(record.ttl)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - SSL Results + + private var sslResultsSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("SSL / TLS Certificate") + + if viewModel.sslLoading { + ProgressView("Checking certificate…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.sslError { + errorLabel(error) + } else if let info = viewModel.sslInfo { + sslDetail(info) + } + } + .padding(.top, 16) + } + + private func sslDetail(_ info: SSLCertificateInfo) -> some View { + VStack(alignment: .leading, spacing: 8) { + certRow("Common Name", info.commonName) + certRow("Issuer", info.issuer) + + // SANs + VStack(alignment: .leading, spacing: 2) { + Text("SANs") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + ForEach(info.subjectAltNames, id: \.self) { san in + Text(san) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + let formatter = DateFormatter.certDate + certRow("Valid From", formatter.string(from: info.validFrom)) + certRow("Valid Until", formatter.string(from: info.validUntil)) + + HStack { + Text("Days Until Expiry") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Text("\(info.daysUntilExpiry)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.bold) + .foregroundStyle(expiryColor(info.daysUntilExpiry)) + } + + certRow("Chain Depth", "\(info.chainDepth)") + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + + // MARK: - Helpers + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(.headline, design: .default)) + .foregroundStyle(.white) + } + + private func certRow(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + private func errorLabel(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.red) + .padding(8) + } + + private func expiryColor(_ days: Int) -> Color { + if days < 30 { return .red } + if days < 60 { return .yellow } + return .green + } + + private func shareResults() { + let text = viewModel.exportText() + + // Write to a named temp file so "Save to Files" uses a proper filename + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyyMMdd_HHmmss" + let timestamp = dateFmt.string(from: Date()) + let filename = "\(timestamp)_domaindigresults.txt" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + + do { + try text.write(to: tempURL, atomically: true, encoding: .utf8) + } catch { + return + } + + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.keyWindow?.rootViewController else { return } + var presenter = rootVC + while let presented = presenter.presentedViewController { + presenter = presented + } + activityVC.popoverPresentationController?.sourceView = presenter.view + presenter.present(activityVC, animated: true) + } +} + +private extension DateFormatter { + static let certDate: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() +} + +#Preview { + ContentView() +} diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift new file mode 100644 index 0000000..85a5275 --- /dev/null +++ b/DomainDig/DNSLookupService.swift @@ -0,0 +1,89 @@ +import Foundation + +struct DNSLookupService { + private static let baseURL = "https://cloudflare-dns.com/dns-query" + + static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { + var components = URLComponents(string: baseURL)! + components.queryItems = [ + URLQueryItem(name: "name", value: domain), + URLQueryItem(name: "type", value: String(recordType.queryType)) + ] + + var request = URLRequest(url: components.url!) + request.setValue("application/dns-json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data) + + guard let answers = dnsResponse.Answer else { + return [] + } + + // Filter answers to only include the requested type + return answers + .filter { $0.type == recordType.queryType } + .map { answer in + let value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + return DNSRecord(value: value, ttl: answer.TTL) + } + } + + /// Record types that support wildcard queries. + private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT] + + static func lookupAll(domain: String) async -> [DNSSection] { + // Each task returns (recordType, apex records, wildcard records). + typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?) + + return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in + for recordType in DNSRecordType.allCases { + group.addTask { + var apexRecords: [DNSRecord] = [] + var wildcardRecords: [DNSRecord] = [] + var lookupError: String? + + // Apex query + do { + apexRecords = try await lookup(domain: domain, recordType: recordType) + } catch { + lookupError = error.localizedDescription + } + + // Wildcard query (only for applicable types, and only if apex didn't fail) + if wildcardTypes.contains(recordType) && lookupError == nil { + do { + wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType) + } catch { + // Wildcard failure is non-fatal; just leave empty + } + } + + return (recordType, apexRecords, wildcardRecords, lookupError) + } + } + + var sections: [DNSSection] = [] + for await result in group { + sections.append(DNSSection( + recordType: result.type, + records: result.records, + wildcardRecords: result.wildcard, + error: result.error + )) + } + + // Sort to maintain consistent order + let order = DNSRecordType.allCases + return sections.sorted { a, b in + (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0) + } + } + } +} diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift new file mode 100644 index 0000000..9c881d8 --- /dev/null +++ b/DomainDig/DomainDigApp.swift @@ -0,0 +1,17 @@ +// +// DomainDigApp.swift +// DomainDig +// +// Created by cmc on 2026-03-10. +// + +import SwiftUI + +@main +struct DomainDigApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift new file mode 100644 index 0000000..2965dde --- /dev/null +++ b/DomainDig/DomainViewModel.swift @@ -0,0 +1,157 @@ +import Foundation +import SwiftUI + +@MainActor +@Observable +final class DomainViewModel { + var domain: String = "" + + var dnsSections: [DNSSection] = [] + var dnsLoading = false + var dnsError: String? + + var sslInfo: SSLCertificateInfo? + var sslLoading = false + var sslError: String? + + var hasRun = false + private(set) var searchedDomain: String = "" + + private static let recentSearchesKey = "recentSearches" + private static let maxRecent = 20 + + var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] + + var trimmedDomain: String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/").first ?? "" + } + + func run() { + let target = trimmedDomain + guard !target.isEmpty else { return } + + addRecentSearch(target) + searchedDomain = target + hasRun = true + dnsSections = [] + dnsError = nil + dnsLoading = true + sslInfo = nil + sslError = nil + sslLoading = true + + Task { + await withTaskGroup(of: Void.self) { group in + group.addTask { @MainActor in + await self.runDNS(domain: target) + } + group.addTask { @MainActor in + await self.runSSL(domain: target) + } + } + } + } + + private func runDNS(domain: String) async { + do { + let sections = await DNSLookupService.lookupAll(domain: domain) + dnsSections = sections + } + dnsLoading = false + } + + private func runSSL(domain: String) async { + do { + let info = try await SSLCheckService.check(domain: domain) + sslInfo = info + } catch { + sslError = error.localizedDescription + } + sslLoading = false + } + + /// True when both lookups have finished (regardless of success/failure). + var resultsLoaded: Bool { + hasRun && !dnsLoading && !sslLoading + } + + // MARK: - Export + + func exportText() -> String { + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyy-MM-dd HH:mm" + let now = dateFmt.string(from: Date()) + + var lines: [String] = [ + "DomainDig Export", + "Domain: \(searchedDomain)", + "Date: \(now)", + "", + "DNS Records", + "-----------" + ] + + for section in dnsSections { + lines.append(section.recordType.rawValue) + if let error = section.error { + lines.append(" Error: \(error)") + } else if section.records.isEmpty { + lines.append(" No records found") + } else { + for record in section.records { + lines.append(" \(record.value) TTL \(record.ttl)") + } + } + if !section.wildcardRecords.isEmpty { + lines.append("*.\(searchedDomain)") + for record in section.wildcardRecords { + lines.append(" \(record.value) TTL \(record.ttl)") + } + } + } + + if let info = sslInfo { + let certDateFmt = DateFormatter() + certDateFmt.dateStyle = .medium + certDateFmt.timeStyle = .none + + lines.append("") + lines.append("SSL / TLS Certificate") + lines.append("---------------------") + lines.append("Common Name: \(info.commonName)") + lines.append("Issuer: \(info.issuer)") + lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))") + lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))") + lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))") + lines.append("Days Until Expiry: \(info.daysUntilExpiry)") + lines.append("Chain Depth: \(info.chainDepth)") + } else if let error = sslError { + lines.append("") + lines.append("SSL / TLS Certificate") + lines.append("---------------------") + lines.append("Error: \(error)") + } + + return lines.joined(separator: "\n") + } + + // MARK: - Recent Searches + + private func addRecentSearch(_ domain: String) { + recentSearches.removeAll { $0.lowercased() == domain.lowercased() } + recentSearches.insert(domain, at: 0) + if recentSearches.count > Self.maxRecent { + recentSearches = Array(recentSearches.prefix(Self.maxRecent)) + } + UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) + } + + func clearRecentSearches() { + recentSearches.removeAll() + UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) + } +} diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift new file mode 100644 index 0000000..53b3c39 --- /dev/null +++ b/DomainDig/Models.swift @@ -0,0 +1,63 @@ +import Foundation + +// MARK: - DNS Models + +enum DNSRecordType: String, CaseIterable { + case A + case AAAA + case MX + case NS + case TXT + case CNAME + + var queryType: Int { + switch self { + case .A: return 1 + case .AAAA: return 28 + case .MX: return 15 + case .NS: return 2 + case .TXT: return 16 + case .CNAME: return 5 + } + } +} + +struct DNSRecord: Identifiable { + let id = UUID() + let value: String + let ttl: Int +} + +struct DNSSection: Identifiable { + let id = UUID() + let recordType: DNSRecordType + var records: [DNSRecord] + var wildcardRecords: [DNSRecord] = [] + var error: String? +} + +// MARK: - SSL Models + +struct SSLCertificateInfo { + let commonName: String + let subjectAltNames: [String] + let issuer: String + let validFrom: Date + let validUntil: Date + let daysUntilExpiry: Int + let chainDepth: Int +} + +// MARK: - Cloudflare DNS-over-HTTPS Response + +struct CloudflareDNSResponse: Decodable { + let Status: Int + let Answer: [CloudflareDNSAnswer]? + + struct CloudflareDNSAnswer: Decodable { + let name: String + let type: Int + let TTL: Int + let data: String + } +} diff --git a/DomainDig/SSLCheckService.swift b/DomainDig/SSLCheckService.swift new file mode 100644 index 0000000..bff41c0 --- /dev/null +++ b/DomainDig/SSLCheckService.swift @@ -0,0 +1,322 @@ +import Foundation +import Security + +struct SSLCheckService { + + static func check(domain: String) async throws -> SSLCertificateInfo { + let delegate = SSLSessionDelegate() + let session = URLSession( + configuration: .ephemeral, + delegate: delegate, + delegateQueue: nil + ) + defer { session.invalidateAndCancel() } + + let url = URL(string: "https://\(domain)")! + let request = URLRequest(url: url, timeoutInterval: 10) + + // We only need to establish the connection to grab the cert + _ = try await session.data(for: request) + + guard let trust = delegate.serverTrust else { + throw SSLError.noCertificate + } + + return try extractCertificateInfo(from: trust) + } + + private static func extractCertificateInfo(from trust: SecTrust) throws -> SSLCertificateInfo { + let chainCount = SecTrustGetCertificateCount(trust) + guard chainCount > 0, + let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let leaf = certChain.first else { + throw SSLError.noCertificate + } + + // Common Name — use subject summary (available on iOS) + let commonName = SecCertificateCopySubjectSummary(leaf) as String? ?? "Unknown" + + // Validity dates + let validFrom: Date + let validUntil: Date + + if let notBefore = SecCertificateCopyNotValidBeforeDate(leaf) as Date? { + validFrom = notBefore + } else { + validFrom = Date.distantPast + } + + if let notAfter = SecCertificateCopyNotValidAfterDate(leaf) as Date? { + validUntil = notAfter + } else { + validUntil = Date.distantFuture + } + + let daysUntilExpiry = Calendar.current.dateComponents([.day], from: Date(), to: validUntil).day ?? 0 + + // Parse the DER-encoded certificate to extract SANs and Issuer + let derData = SecCertificateCopyData(leaf) as Data + let parsed = DERCertificateParser.parse(derData) + + let sans = parsed.subjectAltNames.isEmpty ? [commonName] : parsed.subjectAltNames + + // Issuer: prefer parsed issuer, fall back to chain's next cert summary + var issuer = parsed.issuerCommonName ?? "Unknown" + if issuer == "Unknown" && certChain.count > 1 { + let issuerCert = certChain[1] + if let issuerSummary = SecCertificateCopySubjectSummary(issuerCert) as String? { + issuer = issuerSummary + } + } + + return SSLCertificateInfo( + commonName: commonName, + subjectAltNames: sans, + issuer: issuer, + validFrom: validFrom, + validUntil: validUntil, + daysUntilExpiry: daysUntilExpiry, + chainDepth: Int(chainCount) + ) + } +} + +// MARK: - Minimal DER/ASN.1 parser for X.509 certificate fields + +private enum DERCertificateParser { + struct Result { + var issuerCommonName: String? + var subjectAltNames: [String] = [] + } + + static func parse(_ data: Data) -> Result { + var result = Result() + let bytes = [UInt8](data) + + // X.509 structure: SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue } + // tbsCertificate: SEQUENCE { version, serialNumber, signature, issuer, validity, subject, ... extensions } + guard let tbsRange = readSequence(bytes, offset: 0), + let tbsContent = readSequence(bytes, offset: tbsRange.contentStart) else { + return result + } + + var offset = tbsContent.contentStart + + // Skip version (explicit tag [0]) if present + if offset < bytes.count && (bytes[offset] & 0xE0) == 0xA0 { + if let tagLen = readTagAndLength(bytes, offset: offset) { + offset = tagLen.contentStart + tagLen.length + } + } + + // Skip serialNumber + if let serial = readTagAndLength(bytes, offset: offset) { + offset = serial.contentStart + serial.length + } + + // Skip signature algorithm + if let sigAlg = readTagAndLength(bytes, offset: offset) { + offset = sigAlg.contentStart + sigAlg.length + } + + // Issuer — a SEQUENCE of SETs of attribute type-value pairs + if let issuerSeq = readTagAndLength(bytes, offset: offset) { + result.issuerCommonName = extractCommonName(bytes, sequenceStart: issuerSeq.contentStart, length: issuerSeq.length) + offset = issuerSeq.contentStart + issuerSeq.length + } + + // Skip validity + if let validity = readTagAndLength(bytes, offset: offset) { + offset = validity.contentStart + validity.length + } + + // Skip subject + if let subject = readTagAndLength(bytes, offset: offset) { + offset = subject.contentStart + subject.length + } + + // Skip subjectPublicKeyInfo + if let spki = readTagAndLength(bytes, offset: offset) { + offset = spki.contentStart + spki.length + } + + // Extensions are in an explicit tag [3] + while offset < tbsContent.contentStart + tbsContent.length { + if bytes[offset] == 0xA3 { + if let extWrapper = readTagAndLength(bytes, offset: offset) { + // Inside is a SEQUENCE of SEQUENCE extensions + if let extsSeq = readTagAndLength(bytes, offset: extWrapper.contentStart) { + result.subjectAltNames = extractSANs(bytes, sequenceStart: extsSeq.contentStart, length: extsSeq.length) + } + } + break + } + // Skip optional implicit tags (issuerUniqueID [1], subjectUniqueID [2]) + if let tl = readTagAndLength(bytes, offset: offset) { + offset = tl.contentStart + tl.length + } else { + break + } + } + + return result + } + + // OID for commonName: 2.5.4.3 = 55 04 03 + private static let cnOID: [UInt8] = [0x55, 0x04, 0x03] + + // OID for subjectAltName: 2.5.29.17 = 55 1D 11 + private static let sanOID: [UInt8] = [0x55, 0x1D, 0x11] + + private static func extractCommonName(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> String? { + let end = sequenceStart + length + var pos = sequenceStart + while pos < end { + // Each SET in the issuer + guard let setTL = readTagAndLength(bytes, offset: pos) else { break } + let setEnd = setTL.contentStart + setTL.length + + // Inside the SET is a SEQUENCE with OID + value + if let seqTL = readTagAndLength(bytes, offset: setTL.contentStart) { + let seqEnd = seqTL.contentStart + seqTL.length + if let oidTL = readTagAndLength(bytes, offset: seqTL.contentStart) { + let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length]) + if oidBytes == cnOID { + let valueStart = oidTL.contentStart + oidTL.length + if let valueTL = readTagAndLength(bytes, offset: valueStart) { + let strBytes = bytes[valueTL.contentStart..<valueTL.contentStart + valueTL.length] + return String(bytes: strBytes, encoding: .utf8) + } + } + _ = seqEnd // suppress unused warning + } + } + pos = setEnd + } + return nil + } + + private static func extractSANs(_ bytes: [UInt8], sequenceStart: Int, length: Int) -> [String] { + let end = sequenceStart + length + var pos = sequenceStart + var sans: [String] = [] + + while pos < end { + guard let extSeq = readTagAndLength(bytes, offset: pos) else { break } + let extEnd = extSeq.contentStart + extSeq.length + + // Each extension is SEQUENCE { OID, [critical], value } + if let oidTL = readTagAndLength(bytes, offset: extSeq.contentStart) { + let oidBytes = Array(bytes[oidTL.contentStart..<oidTL.contentStart + oidTL.length]) + if oidBytes == sanOID { + var valuePos = oidTL.contentStart + oidTL.length + // Skip optional critical BOOLEAN + if valuePos < extEnd && bytes[valuePos] == 0x01 { + if let boolTL = readTagAndLength(bytes, offset: valuePos) { + valuePos = boolTL.contentStart + boolTL.length + } + } + // The value is an OCTET STRING wrapping a SEQUENCE of GeneralNames + if let octetTL = readTagAndLength(bytes, offset: valuePos) { + if let sanSeq = readTagAndLength(bytes, offset: octetTL.contentStart) { + let sanEnd = sanSeq.contentStart + sanSeq.length + var sanPos = sanSeq.contentStart + while sanPos < sanEnd { + guard let nameTL = readTagAndLength(bytes, offset: sanPos) else { break } + // Context tag [2] = dNSName (IA5String) + if (bytes[sanPos] & 0x1F) == 2 { + let nameBytes = bytes[nameTL.contentStart..<nameTL.contentStart + nameTL.length] + if let name = String(bytes: nameBytes, encoding: .ascii) { + sans.append(name) + } + } + sanPos = nameTL.contentStart + nameTL.length + } + } + } + } + } + pos = extEnd + } + return sans + } + + private struct TLV { + let contentStart: Int + let length: Int + } + + private static func readSequence(_ bytes: [UInt8], offset: Int) -> TLV? { + guard offset < bytes.count, bytes[offset] == 0x30 else { return nil } + return readTagAndLength(bytes, offset: offset) + } + + private static func readTagAndLength(_ bytes: [UInt8], offset: Int) -> TLV? { + guard offset < bytes.count else { return nil } + var pos = offset + 1 // skip tag byte + guard pos < bytes.count else { return nil } + + let firstLen = bytes[pos] + pos += 1 + + let length: Int + if firstLen < 0x80 { + length = Int(firstLen) + } else { + let numBytes = Int(firstLen & 0x7F) + guard numBytes > 0, numBytes <= 4, pos + numBytes <= bytes.count else { return nil } + var len = 0 + for i in 0..<numBytes { + len = (len << 8) | Int(bytes[pos + i]) + } + pos += numBytes + length = len + } + + return TLV(contentStart: pos, length: length) + } +} + +enum SSLError: LocalizedError { + case noCertificate + case connectionFailed + + var errorDescription: String? { + switch self { + case .noCertificate: + return "No certificate found" + case .connectionFailed: + return "Failed to connect to server" + } + } +} + +final class SSLSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _serverTrust: SecTrust? + + var serverTrust: SecTrust? { + lock.lock() + defer { lock.unlock() } + return _serverTrust + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + + lock.lock() + _serverTrust = trust + lock.unlock() + + let credential = URLCredential(trust: trust) + completionHandler(.useCredential, credential) + } +} |
