summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--DomainDig/Assets.xcassets/AppIcon.appiconset/Contents.json36
-rw-r--r--DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.pngbin0 -> 441131 bytes
-rw-r--r--DomainDig/Assets.xcassets/Contents.json6
-rw-r--r--DomainDig/ContentView.swift309
-rw-r--r--DomainDig/DNSLookupService.swift89
-rw-r--r--DomainDig/DomainDigApp.swift17
-rw-r--r--DomainDig/DomainViewModel.swift157
-rw-r--r--DomainDig/Models.swift63
-rw-r--r--DomainDig/SSLCheckService.swift322
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
new file mode 100644
index 0000000..752f230
--- /dev/null
+++ b/DomainDig/Assets.xcassets/AppIcon.appiconset/domaindig.png
Binary files differ
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)
+ }
+}