iOS SSL Pinning 與憑證驗證完整指南

🌏 Read the English version


iOS SSL Pinning 與憑證驗證完整指南

前言

iOS 應用透過 HTTPS 與伺服器通訊時,預設信任系統內建的根憑證機構(Root CA)。但在以下情況,預設信任機制可能不足:

  • 企業內網環境:使用自簽憑證或內部 CA
  • 高安全需求:金融、醫療、政府應用
  • 防範中間人攻擊:公共 Wi-Fi、惡意代理伺服器
  • 防止憑證偽造:駭客透過竊取或偽造憑證攔截流量

SSL Pinning(憑證固定)透過將伺服器憑證或公鑰內嵌於應用中,即使設備的信任鏈被破壞,應用仍能驗證伺服器身份。

核心概念

SSL Pinning 類型

1. 憑證固定(Certificate Pinning) – 驗證完整憑證 – 優點:最嚴格的安全性 – 缺點:憑證更新時必須更新應用

2. 公鑰固定(Public Key Pinning) – 僅驗證公鑰部分 – 優點:憑證更新時無需更新應用(公鑰不變) – 缺點:實作稍複雜

iOS 憑證驗證機制

iOS 透過 URLSessionDelegateurlSession(_:didReceive:completionHandler:) 方法處理憑證驗證。應用可攔截預設驗證流程,加入自定義邏輯。

準備工作

環境需求

  • Xcode 14.0+
  • iOS 13.0+
  • Swift 5.5+
  • 伺服器 SSL 憑證(.cer 或 .crt 格式)

取得伺服器憑證

方法一:使用 OpenSSL

# 下載憑證
openssl s_client -connect api.example.com:443 -showcerts < /dev/null | \
  openssl x509 -outform DER -out server.cer

# 查看憑證資訊
openssl x509 -in server.cer -inform DER -text -noout

方法二:使用瀏覽器

  1. Chrome/Safari 訪問 https://api.example.com
  2. 點擊網址列鎖頭圖示 → 查看憑證
  3. 匯出為 .cer 格式

方法三:使用 macOS Keychain

# 使用 security 指令
security find-certificate -c "api.example.com" -p > server.pem

# 轉換為 DER 格式
openssl x509 -in server.pem -outform DER -out server.cer

提取公鑰 SHA-256 指紋

# 從憑證提取公鑰並計算 SHA-256
openssl x509 -in server.cer -inform DER -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  base64

# 輸出範例:
# 5Rw3Md4hN/7k3FJ8xK2eZpQ4L9nV6tY8sW1cX3bA5g0=

實作步驟

步驟 1:加入憑證到專案

  1. server.cer 拖曳至 Xcode 專案
  2. 確認勾選「Target Membership」
  3. 憑證會自動加入 App Bundle

步驟 2:實作 URLSession Delegate

建立自定義 URLSessionDelegate 處理憑證驗證:

import Foundation
import Security

class SSLPinningManager: NSObject, URLSessionDelegate {

    // MARK: - Properties

    /// 信任的憑證檔名
    private let trustedCertificates: [String]

    /// 是否啟用 SSL Pinning
    private let enablePinning: Bool

    // MARK: - Initialization

    init(certificates: [String], enablePinning: Bool = true) {
        self.trustedCertificates = certificates
        self.enablePinning = enablePinning
        super.init()
    }

    // MARK: - URLSessionDelegate

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        // 僅處理伺服器信任驗證
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // 如果未啟用 Pinning,使用預設驗證
        guard enablePinning else {
            completionHandler(.performDefaultHandling, nil)
            return
        }

        // 取得伺服器信任物件
        guard let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // 執行憑證固定驗證
        if validateCertificate(serverTrust: serverTrust) {
            let credential = URLCredential(trust: serverTrust)
            completionHandler(.useCredential, credential)
        } else {
            completionHandler(.cancelAuthenticationChallenge, nil)
        }
    }

    // MARK: - Certificate Validation

    /// 驗證伺服器憑證
    private func validateCertificate(serverTrust: SecTrust) -> Bool {
        // 載入本地信任的憑證
        guard let localCertificates = loadCertificates() else {
            print("❌ 無法載入本地憑證")
            return false
        }

        // 設定驗證策略(使用預設 SSL 策略)
        let policy = SecPolicyCreateSSL(true, nil)
        SecTrustSetPolicies(serverTrust, policy)

        // 設定錨點憑證(本地信任的憑證)
        SecTrustSetAnchorCertificates(serverTrust, localCertificates as CFArray)
        SecTrustSetAnchorCertificatesOnly(serverTrust, true)

        // 評估信任鏈
        var error: CFError?
        let isValid = SecTrustEvaluateWithError(serverTrust, &error)

        if !isValid, let error = error {
            print("❌ 憑證驗證失敗:\(error.localizedDescription)")
        }

        return isValid
    }

    /// 從 Bundle 載入憑證
    private func loadCertificates() -> [SecCertificate]? {
        var certificates: [SecCertificate] = []

        for certName in trustedCertificates {
            guard let certPath = Bundle.main.path(forResource: certName, ofType: "cer"),
                  let certData = try? Data(contentsOf: URL(fileURLWithPath: certPath)),
                  let certificate = SecCertificateCreateWithData(nil, certData as CFData) else {
                print("❌ 無法載入憑證:\(certName).cer")
                continue
            }

            certificates.append(certificate)
        }

        return certificates.isEmpty ? nil : certificates
    }
}

步驟 3:使用自定義 URLSession

import Foundation

class APIClient {

    // MARK: - Properties

    private let session: URLSession
    private let baseURL: String

    // MARK: - Initialization

    init(baseURL: String, enableSSLPinning: Bool = true) {
        self.baseURL = baseURL

        // 設定 SSL Pinning
        let pinningManager = SSLPinningManager(
            certificates: ["server"],  // 不含副檔名
            enablePinning: enableSSLPinning
        )

        let configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 30
        configuration.timeoutIntervalForResource = 60

        self.session = URLSession(
            configuration: configuration,
            delegate: pinningManager,
            delegateQueue: nil
        )
    }

    // MARK: - API Methods

    func fetchData(endpoint: String) async throws -> Data {
        guard let url = URL(string: baseURL + endpoint) else {
            throw URLError(.badURL)
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw URLError(.init(rawValue: httpResponse.statusCode))
        }

        return data
    }
}

步驟 4:實際使用範例

import SwiftUI

struct ContentView: View {
    @State private var result: String = "等待請求..."
    @State private var isLoading: Bool = false

    var body: some View {
        VStack(spacing: 20) {
            Text(result)
                .padding()

            Button("測試 SSL Pinning") {
                testSSLPinning()
            }
            .disabled(isLoading)
        }
        .padding()
    }

    private func testSSLPinning() {
        isLoading = true

        Task {
            do {
                let client = APIClient(
                    baseURL: "https://api.example.com",
                    enableSSLPinning: true
                )

                let data = try await client.fetchData(endpoint: "/users")

                await MainActor.run {
                    result = "✅ 成功:收到 \(data.count) bytes"
                    isLoading = false
                }
            } catch {
                await MainActor.run {
                    result = "❌ 失敗:\(error.localizedDescription)"
                    isLoading = false
                }
            }
        }
    }
}

公鑰固定實作(進階)

公鑰固定比憑證固定更靈活,憑證更新時無需重新發佈應用。

extension SSLPinningManager {

    /// 使用公鑰 SHA-256 指紋驗證
    func validatePublicKey(serverTrust: SecTrust, pinnedHashes: [String]) -> Bool {
        // 取得伺服器憑證鏈
        guard let certificates = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate] else {
            return false
        }

        // 檢查每個憑證的公鑰
        for certificate in certificates {
            guard let publicKey = SecCertificateCopyKey(certificate),
                  let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data? else {
                continue
            }

            // 計算 SHA-256 指紋
            let hash = publicKeyData.sha256().base64EncodedString()

            // 比對固定的公鑰指紋
            if pinnedHashes.contains(hash) {
                print("✅ 公鑰驗證通過:\(hash)")
                return true
            }
        }

        print("❌ 公鑰驗證失敗:未找到匹配的指紋")
        return false
    }
}

// MARK: - Data Extension

extension Data {
    func sha256() -> Data {
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        self.withUnsafeBytes {
            _ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &hash)
        }
        return Data(hash)
    }
}

使用公鑰固定:

let pinningManager = SSLPinningManager(
    certificates: [],
    enablePinning: true
)

// 在 urlSession(_:didReceive:completionHandler:) 中
let pinnedHashes = [
    "5Rw3Md4hN/7k3FJ8xK2eZpQ4L9nV6tY8sW1cX3bA5g0=",
    "Ab1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV1wX2yZ3aB4c="  // 備用公鑰
]

if pinningManager.validatePublicKey(serverTrust: serverTrust, pinnedHashes: pinnedHashes) {
    completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
    completionHandler(.cancelAuthenticationChallenge, nil)
}

測試與驗證

測試正確的憑證

func testValidCertificate() async {
    let client = APIClient(
        baseURL: "https://api.example.com",
        enableSSLPinning: true
    )

    do {
        let data = try await client.fetchData(endpoint: "/health")
        print("✅ 憑證驗證通過,收到:\(data.count) bytes")
    } catch {
        print("❌ 請求失敗:\(error)")
    }
}

測試錯誤的憑證

使用不同的伺服器或過期憑證測試 Pinning 是否生效:

func testInvalidCertificate() async {
    let client = APIClient(
        baseURL: "https://wrong-server.com",  // 使用不同伺服器
        enableSSLPinning: true
    )

    do {
        _ = try await client.fetchData(endpoint: "/test")
        print("❌ 警告:驗證應該失敗但卻通過了")
    } catch {
        print("✅ 正確:憑證驗證失敗 - \(error)")
    }
}

使用 Charles Proxy 測試

  1. 安裝 Charles SSL 憑證到設備
  2. 啟用 SSL Proxying
  3. 執行應用並發送請求
  4. 驗證 SSL Pinning 是否攔截中間人攻擊

預期結果: – 未啟用 Pinning:請求成功(可被攔截) – 啟用 Pinning:請求失敗(憑證驗證失敗)

常見問題

Q1:憑證更新後應用無法連線

原因: 使用憑證固定時,伺服器憑證更新會導致驗證失敗。

解決方案: 1. 使用公鑰固定(公鑰通常不變) 2. 在應用中包含多個憑證(舊憑證 + 新憑證) 3. 實作遠端設定功能,動態更新固定的公鑰

Q2:如何在開發與正式環境切換

class AppConfig {
    static var isProduction: Bool {
        #if DEBUG
        return false
        #else
        return true
        #endif
    }

    static var enableSSLPinning: Bool {
        // 開發環境停用 Pinning 方便測試
        return isProduction
    }
}

let client = APIClient(
    baseURL: AppConfig.baseURL,
    enableSSLPinning: AppConfig.enableSSLPinning
)

Q3:App Store 審核是否會受影響

SSL Pinning 不會影響審核,但需注意: – 確保應用能正常連線到伺服器 – 提供測試帳號與說明文件 – 避免固定 Apple 服務的憑證(如 iCloud)

Q4:如何處理憑證鏈驗證

完整驗證伺服器憑證鏈:

private func validateCertificateChain(serverTrust: SecTrust) -> Bool {
    // 取得憑證鏈
    guard let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate] else {
        return false
    }

    print("📋 憑證鏈包含 \(chain.count) 個憑證")

    // 檢查每個憑證
    for (index, cert) in chain.enumerated() {
        if let summary = SecCertificateCopySubjectSummary(cert) as String? {
            print("  [\(index)] \(summary)")
        }
    }

    // 驗證葉憑證(伺服器憑證)
    if let leafCert = chain.first {
        return validateLeafCertificate(leafCert)
    }

    return false
}

Q5:效能影響

SSL Pinning 對效能影響極小: – 憑證載入:僅在初始化時執行一次 – 驗證時間:增加 < 10ms – 記憶體開銷:每個憑證 ~2-5KB

最佳實踐

1. 使用憑證備份

在應用中包含主要憑證和備份憑證:

let pinningManager = SSLPinningManager(
    certificates: [
        "server-primary",     // 主要憑證
        "server-backup"       // 備份憑證
    ],
    enablePinning: true
)

2. 實作降級機制

當 Pinning 失敗時提供降級選項:

class AdaptiveAPIClient {
    private var sslPinningFailed = false

    func fetchData(endpoint: String) async throws -> Data {
        do {
            // 嘗試使用 SSL Pinning
            let client = APIClient(baseURL: baseURL, enableSSLPinning: !sslPinningFailed)
            return try await client.fetchData(endpoint: endpoint)
        } catch {
            // 記錄失敗並重試(不使用 Pinning)
            if !sslPinningFailed {
                sslPinningFailed = true
                print("⚠️ SSL Pinning 失敗,降級為標準驗證")
                return try await fetchData(endpoint: endpoint)
            }
            throw error
        }
    }
}

3. 記錄與監控

記錄憑證驗證結果用於問題追蹤:

func logCertificateInfo(serverTrust: SecTrust) {
    guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
        return
    }

    let summary = SecCertificateCopySubjectSummary(certificate) as String? ?? "Unknown"
    let data = SecCertificateCopyData(certificate) as Data
    let hash = data.sha256().base64EncodedString()

    print("""
    📋 憑證資訊:
       主體:\(summary)
       SHA-256:\(hash)
       大小:\(data.count) bytes
    """)

    // 發送到分析服務
    Analytics.log(event: "ssl_pinning_validation", parameters: [
        "certificate_hash": hash,
        "result": "success"
    ])
}

4. 多層驗證

結合憑證固定與公鑰固定:

func hybridValidation(serverTrust: SecTrust) -> Bool {
    // 優先使用憑證固定
    if validateCertificate(serverTrust: serverTrust) {
        return true
    }

    // 備用公鑰固定
    if validatePublicKey(serverTrust: serverTrust, pinnedHashes: backupHashes) {
        print("⚠️ 憑證驗證失敗,但公鑰驗證通過")
        return true
    }

    return false
}

總結

iOS SSL Pinning 實作要點:

核心技術: – 使用 URLSessionDelegate 攔截憑證驗證 – 透過 SecTrust API 執行自定義驗證 – 將憑證加入 App Bundle 作為信任錨點

安全考量: – 憑證固定:最嚴格,但憑證更新需更新應用 – 公鑰固定:更靈活,適合憑證定期更新的場景 – 混合驗證:結合兩種方式提供降級機制

實務建議: – 開發環境停用 Pinning 方便測試 – 包含備份憑證應對緊急情況 – 實作遠端設定動態調整驗證策略 – 記錄驗證結果用於問題追蹤

透過正確實作 SSL Pinning,iOS 應用可有效防範中間人攻擊,確保 HTTPS 通訊安全。在高安全需求場景(金融、醫療、政府應用),SSL Pinning 是不可或缺的安全措施。

相關資源:Apple Developer – URLSessionDelegateApple Developer – Certificate, Key, and Trust ServicesOWASP – Certificate and Public Key Pinning

相關文章

Leave a Comment