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
This commit is contained in:
@@ -7,7 +7,8 @@ import NIOSSL
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Manages root CA generation, leaf certificate signing, and an LRU certificate cache.
|
||||
/// 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()
|
||||
|
||||
@@ -15,16 +16,16 @@ public final class CertificateManager: @unchecked Sendable {
|
||||
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 let keychainCAKeyTag = "com.treyt.proxyapp.ca.privatekey"
|
||||
private let keychainCACertTag = "com.treyt.proxyapp.ca.cert"
|
||||
|
||||
private init() {
|
||||
loadOrGenerateCA()
|
||||
loadOrGenerateCAIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
@@ -32,7 +33,25 @@ public final class CertificateManager: @unchecked Sendable {
|
||||
public var hasCA: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return rootCACert != nil
|
||||
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.
|
||||
@@ -40,41 +59,57 @@ public final class CertificateManager: @unchecked Sendable {
|
||||
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
|
||||
}
|
||||
|
||||
let (leafCert, leafKey) = try generateLeaf(domain: domain, caKey: caKey, caCert: caCert)
|
||||
ProxyLogger.cert.info("TLS: generating leaf cert for \(domain), CA issuer=\(String(describing: caCert.subject))")
|
||||
|
||||
// Serialize to DER/PEM for NIOSSL
|
||||
var serializer = DER.Serializer()
|
||||
try leafCert.serialize(into: &serializer)
|
||||
let leafDER = serializer.serializedBytes
|
||||
let nioLeafCert = try NIOSSLCertificate(bytes: leafDER, format: .der)
|
||||
let leafKeyPEM = leafKey.pemRepresentation
|
||||
let nioLeafKey = try NIOSSLPrivateKey(bytes: [UInt8](leafKeyPEM.utf8), format: .pem)
|
||||
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)")
|
||||
|
||||
certCache[domain] = (nioLeafCert, nioLeafKey)
|
||||
cacheOrder.append(domain)
|
||||
var serializer = DER.Serializer()
|
||||
try leafCert.serialize(into: &serializer)
|
||||
let leafDER = serializer.serializedBytes
|
||||
ProxyLogger.cert.debug("TLS: leaf DER size=\(leafDER.count) bytes")
|
||||
|
||||
while cacheOrder.count > ProxyConstants.certificateCacheSize {
|
||||
let evicted = cacheOrder.removeFirst()
|
||||
certCache.removeValue(forKey: evicted)
|
||||
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
|
||||
}
|
||||
|
||||
return try makeServerContext(cert: nioLeafCert, key: nioLeafKey)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -85,63 +120,173 @@ public final class CertificateManager: @unchecked Sendable {
|
||||
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 caNotValidAfter: Date? {
|
||||
public var caGeneratedDate: Date? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
// notValidAfter is a Time, not directly a Date — we stored the date when generating
|
||||
return nil // Will be set properly after we store dates
|
||||
refreshFromDiskLocked()
|
||||
return rootCACert?.notValidBefore
|
||||
}
|
||||
|
||||
// MARK: - CA Generation
|
||||
public var caExpirationDate: Date? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
refreshFromDiskLocked()
|
||||
return rootCACert?.notValidAfter
|
||||
}
|
||||
|
||||
private func loadOrGenerateCA() {
|
||||
if loadCAFromKeychain() { return }
|
||||
public func regenerateCA() {
|
||||
guard canGenerateCA else {
|
||||
ProxyLogger.cert.error("Refusing to regenerate CA from extension context")
|
||||
return
|
||||
}
|
||||
|
||||
lock.lock()
|
||||
clearStateLocked()
|
||||
deleteStoredCALocked()
|
||||
|
||||
do {
|
||||
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)
|
||||
)
|
||||
|
||||
self.rootCAKey = key
|
||||
self.rootCACert = cert
|
||||
|
||||
var serializer = DER.Serializer()
|
||||
try cert.serialize(into: &serializer)
|
||||
let der = serializer.serializedBytes
|
||||
self.rootCANIOSSL = try NIOSSLCertificate(bytes: der, format: .der)
|
||||
|
||||
saveCAToKeychain(key: key, certDER: der)
|
||||
print("[CertificateManager] Generated new root CA")
|
||||
try generateAndStoreCALocked()
|
||||
} catch {
|
||||
print("[CertificateManager] Failed to generate CA: \(error)")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,81 +344,16 @@ public final class CertificateManager: @unchecked Sendable {
|
||||
return try NIOSSLContext(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private func loadCAFromKeychain() -> Bool {
|
||||
let keyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainCAKeyTag,
|
||||
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
var keyResult: AnyObject?
|
||||
guard SecItemCopyMatching(keyQuery as CFDictionary, &keyResult) == errSecSuccess,
|
||||
let keyData = keyResult as? Data else { return false }
|
||||
|
||||
let certQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainCACertTag,
|
||||
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
var certResult: AnyObject?
|
||||
guard SecItemCopyMatching(certQuery as CFDictionary, &certResult) == errSecSuccess,
|
||||
let certData = certResult as? Data else { return false }
|
||||
|
||||
do {
|
||||
let key = try P256.Signing.PrivateKey(rawRepresentation: keyData)
|
||||
let cert = try Certificate(derEncoded: [UInt8](certData))
|
||||
let nioCert = try NIOSSLCertificate(bytes: [UInt8](certData), format: .der)
|
||||
|
||||
self.rootCAKey = key
|
||||
self.rootCACert = cert
|
||||
self.rootCANIOSSL = nioCert
|
||||
print("[CertificateManager] Loaded CA from Keychain")
|
||||
return true
|
||||
} catch {
|
||||
print("[CertificateManager] Failed to load CA from Keychain: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCAToKeychain(key: P256.Signing.PrivateKey, certDER: [UInt8]) {
|
||||
let keyData = key.rawRepresentation
|
||||
|
||||
// Delete existing entries
|
||||
for tag in [keychainCAKeyTag, keychainCACertTag] {
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: tag,
|
||||
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
|
||||
// Save key
|
||||
let addKeyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainCAKeyTag,
|
||||
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
|
||||
kSecValueData as String: keyData,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
SecItemAdd(addKeyQuery as CFDictionary, nil)
|
||||
|
||||
// Save cert
|
||||
let addCertQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainCACertTag,
|
||||
kSecAttrAccessGroup as String: ProxyConstants.appGroupIdentifier,
|
||||
kSecValueData as String: Data(certDER),
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
SecItemAdd(addCertQuery as CFDictionary, nil)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -283,8 +363,6 @@ public final class CertificateManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
public enum CertificateError: Error {
|
||||
case notImplemented
|
||||
case generationFailed
|
||||
case caNotFound
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user