Files
ProxyIOS/UI/More/CertificateView.swift
Trey t 148bc3887c Add iPad support, auto-pinning, and comprehensive logging
- Adaptive iPhone/iPad layout with NavigationSplitView sidebar
- Auto-detect SSL-pinned domains, fall back to passthrough
- Certificate install via local HTTP server (Safari profile flow)
- App Group-backed CA, per-domain leaf cert LRU cache
- DB-backed config repository, Darwin notification throttling
- Rules engine, breakpoint rules, pinned domain tracking
- os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui
- Fix dyld framework embed, race conditions, thread safety
2026-04-11 12:52:18 -05:00

209 lines
7.7 KiB
Swift

import SwiftUI
import ProxyCore
struct CertificateView: View {
@Environment(AppState.self) private var appState
@State private var showRegenerateConfirmation = false
@State private var isInstallingCert = false
@State private var certServer: CertificateInstallServer?
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateStyle = .medium
return f
}
var body: some View {
List {
Section {
HStack {
Image(systemName: CertificateManager.shared.hasCA ? "checkmark.shield.fill" : "exclamationmark.shield")
.font(.largeTitle)
.foregroundStyle(CertificateManager.shared.hasCA ? .green : .orange)
VStack(alignment: .leading) {
Text(CertificateManager.shared.hasCA
? "Certificate Generated"
: "Certificate not generated")
.font(.headline)
Text("The app owns the shared CA used for HTTPS decryption.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
}
Section("Details") {
LabeledContent("CA Certificate", value: "Proxy CA (\(UIDevice.current.name))")
LabeledContent("Generated", value: formattedDate(CertificateManager.shared.caGeneratedDate))
LabeledContent("Expires", value: formattedDate(CertificateManager.shared.caExpirationDate))
LabeledContent("Fingerprint", value: abbreviatedFingerprint(CertificateManager.shared.caFingerprint))
}
Section("Runtime") {
LabeledContent("Extension Loaded Same CA", value: appState.hasSharedCertificate ? "Yes" : "No")
LabeledContent("HTTPS Inspection Verified", value: appState.isHTTPSInspectionVerified ? "Yes" : "Not Yet")
if let domain = appState.runtimeStatus.lastSuccessfulMITMDomain {
LabeledContent("Last Verified Domain", value: domain)
}
if let lastError = appState.lastRuntimeError {
VStack(alignment: .leading, spacing: 6) {
Text("Latest Error")
.font(.caption.weight(.semibold))
Text(lastError)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section {
Button {
installCertificate()
} label: {
HStack {
Spacer()
if isInstallingCert {
ProgressView()
.padding(.trailing, 8)
}
Text("Install Certificate to Settings")
Spacer()
}
}
.disabled(isInstallingCert || !CertificateManager.shared.hasCA)
} footer: {
Text("Downloads the CA certificate in Safari. After downloading, install it from Settings > General > VPN & Device Management, then enable trust in Settings > General > About > Certificate Trust Settings.")
}
Section {
Button("Regenerate Certificate", role: .destructive) {
showRegenerateConfirmation = true
}
.frame(maxWidth: .infinity)
}
}
.navigationTitle("Certificate")
.confirmationDialog("Regenerate Certificate?", isPresented: $showRegenerateConfirmation) {
Button("Regenerate", role: .destructive) {
CertificateManager.shared.regenerateCA()
appState.isCertificateInstalled = CertificateManager.shared.hasCA
}
} message: {
Text("This will create a new CA certificate. You will need to reinstall and trust it on your device.")
}
.onAppear {
appState.isCertificateInstalled = CertificateManager.shared.hasCA
}
.onDisappear {
certServer?.stop()
certServer = nil
}
}
private func formattedDate(_ date: Date?) -> String {
guard let date else { return "N/A" }
return dateFormatter.string(from: date)
}
private func abbreviatedFingerprint(_ fingerprint: String?) -> String {
guard let fingerprint else { return "N/A" }
if fingerprint.count <= 16 { return fingerprint }
return "\(fingerprint.prefix(8))...\(fingerprint.suffix(8))"
}
private func installCertificate() {
guard let derBytes = CertificateManager.shared.exportCACertificateDER() else { return }
isInstallingCert = true
// Start a local HTTP server that serves the certificate
let server = CertificateInstallServer(certDER: Data(derBytes))
certServer = server
server.start { port in
Task { @MainActor in
// Open Safari to our local server so the certificate can be downloaded and installed.
if let url = URL(string: "http://localhost:\(port)/ProxyCA.cer") {
UIApplication.shared.open(url)
}
isInstallingCert = false
}
}
}
}
// MARK: - Local HTTP server for certificate installation
import Network
final class CertificateInstallServer: @unchecked Sendable {
private let certDER: Data
private var listener: NWListener?
private let queue = DispatchQueue(label: "cert-install-server")
init(certDER: Data) {
self.certDER = certDER
}
func start(onReady: @escaping @Sendable (UInt16) -> Void) {
do {
let params = NWParameters.tcp
listener = try NWListener(using: params, on: .any)
listener?.stateUpdateHandler = { state in
if case .ready = state, let port = self.listener?.port?.rawValue {
DispatchQueue.main.async {
onReady(port)
}
}
}
listener?.newConnectionHandler = { [weak self] connection in
self?.handleConnection(connection)
}
listener?.start(queue: queue)
} catch {
print("[CertInstall] Failed to start server: \(error)")
}
}
func stop() {
listener?.cancel()
listener = nil
}
private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue)
// Read the HTTP request (we don't really need to parse it)
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, _, _ in
guard let self else { return }
// Respond with the certificate as a mobileconfig-style download
let body = self.certDER
let response = """
HTTP/1.1 200 OK\r
Content-Type: application/x-x509-ca-cert\r
Content-Disposition: attachment; filename="ProxyCA.cer"\r
Content-Length: \(body.count)\r
Connection: close\r
\r\n
"""
var responseData = Data(response.utf8)
responseData.append(body)
connection.send(content: responseData, completion: .contentProcessed { _ in
connection.cancel()
// Stop the server after serving one-shot
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.stop()
}
})
}
}
}