- 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
209 lines
7.7 KiB
Swift
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()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|