import Foundation import Security import CryptoKit /// How to evaluate the server's TLS certificate. /// /// MCMS appliances usually present a self-signed or internal-CA certificate. /// Pick the *narrowest* policy that works for your deployment. Never ship /// `.allowSelfSignedForHost` against a host you don't control. enum ServerTrustPolicy: Equatable, Codable { /// Default OS trust evaluation (use this if the box has a real cert or a /// CA your devices already trust via MDM/profile — strongly preferred). case system /// Accept the server's certificate ONLY for this exact host, after a normal /// trust evaluation that ignores the "untrusted root" failure. Scoped escape /// hatch for self-signed boxes on a controlled mgmt network/VPN. case allowSelfSignedForHost(String) /// Certificate/public-key pinning: trust only if the leaf's SHA-256 /// public-key hash (base64) matches one of these. The safest option for a /// fixed appliance — capture the hash once and pin it. case pinPublicKeySHA256([String], host: String) } /// `URLSessionDelegate` that applies a `ServerTrustPolicy`. Retained by the /// `URLSession` it's attached to. final class ServerTrustEvaluator: NSObject, URLSessionDelegate { private let policy: ServerTrustPolicy init(policy: ServerTrustPolicy) { self.policy = policy } 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 } let host = challenge.protectionSpace.host switch policy { case .system: completionHandler(.performDefaultHandling, nil) case .allowSelfSignedForHost(let allowedHost): // Scoped escape hatch for self-signed appliances on a controlled // network. Because a self-signed root fails normal evaluation, this // accepts ANY certificate the allowed host presents — including // expired or name-mismatched ones. The only gate is the hostname, so // use this only on hosts you control; prefer `.pinPublicKeySHA256` // once you can capture the appliance's key hash. guard host == allowedHost else { completionHandler(.cancelAuthenticationChallenge, nil) return } completionHandler(.useCredential, URLCredential(trust: trust)) case .pinPublicKeySHA256(let pins, let pinnedHost): guard host == pinnedHost, let leaf = Self.leafCertificate(from: trust), let hash = Self.publicKeySHA256Base64(for: leaf), pins.contains(hash) else { completionHandler(.cancelAuthenticationChallenge, nil) return } completionHandler(.useCredential, URLCredential(trust: trust)) } } // MARK: - Pinning helpers private static func leafCertificate(from trust: SecTrust) -> SecCertificate? { if #available(iOS 15.0, *) { return (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first } else { return SecTrustGetCertificateAtIndex(trust, 0) } } /// Base64 of SHA-256 over the DER-encoded SubjectPublicKeyInfo's key bits. /// (Capture once with `openssl` and store as a pin; this returns the raw /// public-key hash for comparison.) static func publicKeySHA256Base64(for certificate: SecCertificate) -> String? { guard let key = SecCertificateCopyKey(certificate), let data = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return nil } return CryptoSHA256.base64(of: data) } } /// Minimal SHA-256 wrapper using CryptoKit. enum CryptoSHA256 { static func base64(of data: Data) -> String { let digest = SHA256.hash(data: data) return Data(digest).base64EncodedString() } }