import Foundation import Security import CryptoKit /// How to evaluate the server's TLS certificate. /// /// The appliance must present a CA-trusted certificate — a real cert, or an /// internal CA installed on the device via MDM/configuration profile. There is /// deliberately NO "accept self-signed" escape hatch: the app never bypasses /// trust evaluation (parity with the Android client). enum ServerTrustPolicy: Equatable, Codable { /// Default OS trust evaluation. The default policy and the only one the UI /// produces. case system /// Certificate/public-key pinning: trust only if the leaf's SHA-256 /// SubjectPublicKeyInfo hash (base64) matches one of these. Code-only (no UI); /// the strictest 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 .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**, i.e. the /// standard public-key pin. `SecKeyCopyExternalRepresentation` returns only the /// raw key (PKCS#1 `RSAPublicKey` for RSA, the `04||X||Y` point for EC) — NOT /// the SPKI — so we prepend the algorithm-specific ASN.1 SPKI header before /// hashing. The result matches the canonical capture: /// /// openssl s_client -connect host:443 /dev/null \ /// | openssl x509 -pubkey -noout \ /// | openssl pkey -pubin -outform der \ /// | openssl dgst -sha256 -binary | base64 /// /// Returns nil for key types/sizes without a known header (caller fails closed). static func publicKeySHA256Base64(for certificate: SecCertificate) -> String? { guard let key = SecCertificateCopyKey(certificate), let raw = SecKeyCopyExternalRepresentation(key, nil) as Data?, let attrs = SecKeyCopyAttributes(key) as? [String: Any], let keyType = attrs[kSecAttrKeyType as String] as? String, let keySize = attrs[kSecAttrKeySizeInBits as String] as? Int, let header = spkiHeader(keyType: keyType, keySizeInBits: keySize) else { return nil } var spki = Data(header) spki.append(raw) return CryptoSHA256.base64(of: spki) } /// ASN.1 SubjectPublicKeyInfo prefix for the raw key returned by /// `SecKeyCopyExternalRepresentation`, keyed by (algorithm, key size). These /// are the fixed, well-known DER headers; the raw key bits follow. private static func spkiHeader(keyType: String, keySizeInBits: Int) -> [UInt8]? { if keyType == (kSecAttrKeyTypeRSA as String) { switch keySizeInBits { case 2048: return rsa2048SPKIHeader case 3072: return rsa3072SPKIHeader case 4096: return rsa4096SPKIHeader default: return nil } } if keyType == (kSecAttrKeyTypeECSECPrimeRandom as String) { switch keySizeInBits { case 256: return ecP256SPKIHeader case 384: return ecP384SPKIHeader default: return nil } } return nil } private static let rsa2048SPKIHeader: [UInt8] = [ 0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00] private static let rsa3072SPKIHeader: [UInt8] = [ 0x30, 0x82, 0x01, 0xa2, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x8f, 0x00] private static let rsa4096SPKIHeader: [UInt8] = [ 0x30, 0x82, 0x02, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x02, 0x0f, 0x00] private static let ecP256SPKIHeader: [UInt8] = [ 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00] private static let ecP384SPKIHeader: [UInt8] = [ 0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00] } /// 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() } }