Files
ProxyIOS/ProxyCore/Sources/ProxyEngine/CertificateManager.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

369 lines
12 KiB
Swift

import Foundation
import X509
import Crypto
import SwiftASN1
import NIOSSL
#if canImport(UIKit)
import UIKit
#endif
/// Manages the shared MITM root CA. The app owns generation and writes the CA
/// into the App Group container; the extension only loads that shared identity.
public final class CertificateManager: @unchecked Sendable {
public static let shared = CertificateManager()
private let lock = NSLock()
private var rootCAKey: P256.Signing.PrivateKey?
private var rootCACert: Certificate?
private var rootCANIOSSL: NIOSSLCertificate?
private var caFingerprintCache: String?
private var certificateMTime: Date?
private var keyMTime: Date?
// LRU cache for generated leaf certificates
private var certCache: [String: (NIOSSLCertificate, NIOSSLPrivateKey)] = [:]
private var cacheOrder: [String] = []
private init() {
loadOrGenerateCAIfNeeded()
}
// MARK: - Public API
public var hasCA: Bool {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
return rootCACert != nil && rootCAKey != nil
}
public var caFingerprint: String? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
return caFingerprintCache
}
public var canGenerateCA: Bool {
Bundle.main.infoDictionary?["NSExtension"] == nil
}
public func reloadSharedCA() {
lock.lock()
refreshFromDiskLocked(force: true)
lock.unlock()
}
/// Get or generate a leaf certificate + key for the given domain.
public func tlsServerContext(for domain: String) throws -> NIOSSLContext {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
if let cached = certCache[domain] {
cacheOrder.removeAll { $0 == domain }
cacheOrder.append(domain)
ProxyLogger.cert.debug("TLS context CACHE HIT for \(domain)")
return try makeServerContext(cert: cached.0, key: cached.1)
}
guard let caKey = rootCAKey, let caCert = rootCACert else {
ProxyLogger.cert.error("TLS context FAILED for \(domain): no CA loaded. hasKey=\(self.rootCAKey != nil) hasCert=\(self.rootCACert != nil)")
throw CertificateError.caNotFound
}
ProxyLogger.cert.info("TLS: generating leaf cert for \(domain), CA issuer=\(String(describing: caCert.subject))")
do {
let (leafCert, leafKey) = try generateLeaf(domain: domain, caKey: caKey, caCert: caCert)
ProxyLogger.cert.info("TLS: leaf cert generated for \(domain), SAN=\(domain), notBefore=\(leafCert.notValidBefore), notAfter=\(leafCert.notValidAfter)")
var serializer = DER.Serializer()
try leafCert.serialize(into: &serializer)
let leafDER = serializer.serializedBytes
ProxyLogger.cert.debug("TLS: leaf DER size=\(leafDER.count) bytes")
let nioLeafCert = try NIOSSLCertificate(bytes: leafDER, format: .der)
let leafKeyPEM = leafKey.pemRepresentation
let nioLeafKey = try NIOSSLPrivateKey(bytes: [UInt8](leafKeyPEM.utf8), format: .pem)
certCache[domain] = (nioLeafCert, nioLeafKey)
cacheOrder.append(domain)
while cacheOrder.count > ProxyConstants.certificateCacheSize {
let evicted = cacheOrder.removeFirst()
certCache.removeValue(forKey: evicted)
}
let ctx = try makeServerContext(cert: nioLeafCert, key: nioLeafKey)
ProxyLogger.cert.info("TLS: server context READY for \(domain)")
return ctx
} catch {
ProxyLogger.cert.error("TLS: leaf cert/context FAILED for \(domain): \(error)")
throw error
}
}
/// Export the root CA as DER data for user installation.
public func exportCACertificateDER() -> [UInt8]? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
guard let cert = rootCACert else { return nil }
var serializer = DER.Serializer()
try? cert.serialize(into: &serializer)
return serializer.serializedBytes
}
/// Export as PEM for display.
public func exportCACertificatePEM() -> String? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
guard let cert = rootCACert else { return nil }
guard let pem = try? cert.serializeAsPEM() else { return nil }
return pem.pemString
}
public var caGeneratedDate: Date? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
return rootCACert?.notValidBefore
}
public var caExpirationDate: Date? {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked()
return rootCACert?.notValidAfter
}
public func regenerateCA() {
guard canGenerateCA else {
ProxyLogger.cert.error("Refusing to regenerate CA from extension context")
return
}
lock.lock()
clearStateLocked()
deleteStoredCALocked()
do {
try generateAndStoreCALocked()
} catch {
ProxyLogger.cert.error("CA regeneration failed: \(error.localizedDescription)")
}
lock.unlock()
}
// MARK: - CA bootstrap
private func loadOrGenerateCAIfNeeded() {
lock.lock()
defer { lock.unlock() }
refreshFromDiskLocked(force: true)
guard rootCACert == nil || rootCAKey == nil else { return }
guard canGenerateCA else {
ProxyLogger.cert.info("Shared CA not found; extension will remain passthrough-only")
return
}
do {
try generateAndStoreCALocked()
} catch {
ProxyLogger.cert.error("Failed to generate shared CA: \(error.localizedDescription)")
}
}
private func refreshFromDiskLocked(force: Bool = false) {
let certURL = AppGroupPaths.caCertificateURL
let keyURL = AppGroupPaths.caPrivateKeyURL
let certExists = FileManager.default.fileExists(atPath: certURL.path)
let keyExists = FileManager.default.fileExists(atPath: keyURL.path)
guard certExists, keyExists else {
if rootCACert != nil || rootCAKey != nil {
ProxyLogger.cert.info("Shared CA files missing; clearing in-memory state")
}
clearStateLocked()
return
}
let currentCertMTime = modificationDate(for: certURL)
let currentKeyMTime = modificationDate(for: keyURL)
if !force, currentCertMTime == certificateMTime, currentKeyMTime == keyMTime {
return
}
do {
let certData = try Data(contentsOf: certURL)
let keyData = try Data(contentsOf: keyURL)
let key = try P256.Signing.PrivateKey(rawRepresentation: keyData)
let cert = try Certificate(derEncoded: [UInt8](certData))
let nioCert = try NIOSSLCertificate(bytes: [UInt8](certData), format: .der)
rootCAKey = key
rootCACert = cert
rootCANIOSSL = nioCert
certificateMTime = currentCertMTime
keyMTime = currentKeyMTime
caFingerprintCache = fingerprint(for: certData)
certCache.removeAll()
cacheOrder.removeAll()
ProxyLogger.cert.info("Loaded shared CA from App Group container")
} catch {
ProxyLogger.cert.error("Failed to load shared CA: \(error.localizedDescription)")
clearStateLocked()
}
}
private func generateAndStoreCALocked() throws {
let key = P256.Signing.PrivateKey()
let name = try DistinguishedName {
CommonName("Proxy CA (\(deviceName()))")
OrganizationName("ProxyApp")
}
let now = Date()
let twoYearsLater = now.addingTimeInterval(365 * 24 * 3600 * 2)
let extensions = try Certificate.Extensions {
Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0))
Critical(KeyUsage(keyCertSign: true, cRLSign: true))
}
let cert = try Certificate(
version: .v3,
serialNumber: Certificate.SerialNumber(),
publicKey: .init(key.publicKey),
notValidBefore: now,
notValidAfter: twoYearsLater,
issuer: name,
subject: name,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: .init(key)
)
var serializer = DER.Serializer()
try cert.serialize(into: &serializer)
let der = serializer.serializedBytes
try FileManager.default.createDirectory(
at: AppGroupPaths.certificatesDirectory,
withIntermediateDirectories: true,
attributes: nil
)
try Data(der).write(to: AppGroupPaths.caCertificateURL, options: .atomic)
try key.rawRepresentation.write(to: AppGroupPaths.caPrivateKeyURL, options: .atomic)
rootCAKey = key
rootCACert = cert
rootCANIOSSL = try NIOSSLCertificate(bytes: der, format: .der)
certificateMTime = modificationDate(for: AppGroupPaths.caCertificateURL)
keyMTime = modificationDate(for: AppGroupPaths.caPrivateKeyURL)
caFingerprintCache = fingerprint(for: Data(der))
certCache.removeAll()
cacheOrder.removeAll()
ProxyLogger.cert.info("Generated new shared root CA")
}
private func clearStateLocked() {
rootCAKey = nil
rootCACert = nil
rootCANIOSSL = nil
caFingerprintCache = nil
certificateMTime = nil
keyMTime = nil
certCache.removeAll()
cacheOrder.removeAll()
}
private func deleteStoredCALocked() {
for url in [AppGroupPaths.caCertificateURL, AppGroupPaths.caPrivateKeyURL] {
try? FileManager.default.removeItem(at: url)
}
}
// MARK: - Leaf Certificate Generation
private func generateLeaf(
domain: String,
caKey: P256.Signing.PrivateKey,
caCert: Certificate
) throws -> (Certificate, P256.Signing.PrivateKey) {
let leafKey = P256.Signing.PrivateKey()
let now = Date()
let oneYearLater = now.addingTimeInterval(365 * 24 * 3600)
let extensions = try Certificate.Extensions {
Critical(BasicConstraints.notCertificateAuthority)
Critical(KeyUsage(digitalSignature: true))
try ExtendedKeyUsage([.serverAuth])
SubjectAlternativeNames([.dnsName(domain)])
}
let leafName = try DistinguishedName {
CommonName(domain)
OrganizationName("ProxyApp")
}
let cert = try Certificate(
version: .v3,
serialNumber: Certificate.SerialNumber(),
publicKey: .init(leafKey.publicKey),
notValidBefore: now,
notValidAfter: oneYearLater,
issuer: caCert.subject,
subject: leafName,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: .init(caKey)
)
return (cert, leafKey)
}
// MARK: - TLS Context
private func makeServerContext(cert: NIOSSLCertificate, key: NIOSSLPrivateKey) throws -> NIOSSLContext {
var certs = [cert]
if let caCert = rootCANIOSSL {
certs.append(caCert)
}
var config = TLSConfiguration.makeServerConfiguration(
certificateChain: certs.map { .certificate($0) },
privateKey: .privateKey(key)
)
config.applicationProtocols = ["http/1.1"]
return try NIOSSLContext(configuration: config)
}
// MARK: - Helpers
private func modificationDate(for url: URL) -> Date? {
(try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? nil
}
private func fingerprint(for data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private func deviceName() -> String {
#if canImport(UIKit)
return UIDevice.current.name
#else
return Host.current().localizedName ?? "Unknown"
#endif
}
public enum CertificateError: Error {
case caNotFound
}
}