Parity with the Android client: drop the "Allow self-signed certificate" escape hatch. The app now performs system TLS trust only — a self-signed appliance must have its CA installed on the device (MDM/profile). A code-only public-key pinning policy remains (no UI). - ServerTrustEvaluator: remove ServerTrustPolicy.allowSelfSignedForHost and its challenge handler; keep .system (default) and .pinPublicKeySHA256. - SettingsView: remove the self-signed toggle/state/prefill; makeConfig uses the default .system policy. - MCMSConnection / APIConfiguration: update usage-sketch + doc comments. - README / GUI-NOTES / MCMS_API.md: document system-trust-only; self-signed boxes need their CA installed on the device. - Add CHANGELOG.md for this session, flagged for the Android port. Note: configs previously persisted with the self-signed policy fail to decode and reset to defaults (one-time re-entry of the server URL). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
6 KiB
Swift
141 lines
6 KiB
Swift
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 2>/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()
|
|
}
|
|
}
|