initial commit

This commit is contained in:
Jon Vanvik 2026-05-31 14:11:29 +02:00
commit 8de962eeb8
44 changed files with 4843 additions and 0 deletions

105
ServerTrustEvaluator.swift Normal file
View file

@ -0,0 +1,105 @@
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()
}
}