iOS SSL Pinning 與憑證驗證完整指南
前言
iOS 應用透過 HTTPS 與伺服器通訊時,預設信任系統內建的根憑證機構(Root CA)。但在以下情況,預設信任機制可能不足:
- 企業內網環境:使用自簽憑證或內部 CA
- 高安全需求:金融、醫療、政府應用
- 防範中間人攻擊:公共 Wi-Fi、惡意代理伺服器
- 防止憑證偽造:駭客透過竊取或偽造憑證攔截流量
SSL Pinning(憑證固定)透過將伺服器憑證或公鑰內嵌於應用中,即使設備的信任鏈被破壞,應用仍能驗證伺服器身份。
核心概念
SSL Pinning 類型
1. 憑證固定(Certificate Pinning) – 驗證完整憑證 – 優點:最嚴格的安全性 – 缺點:憑證更新時必須更新應用
2. 公鑰固定(Public Key Pinning) – 僅驗證公鑰部分 – 優點:憑證更新時無需更新應用(公鑰不變) – 缺點:實作稍複雜
iOS 憑證驗證機制
iOS 透過 URLSessionDelegate 的 urlSession(_: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
方法二:使用瀏覽器
- Chrome/Safari 訪問
https://api.example.com - 點擊網址列鎖頭圖示 → 查看憑證
- 匯出為 .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:加入憑證到專案
- 將
server.cer拖曳至 Xcode 專案 - 確認勾選「Target Membership」
- 憑證會自動加入 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 測試
- 安裝 Charles SSL 憑證到設備
- 啟用 SSL Proxying
- 執行應用並發送請求
- 驗證 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 – URLSessionDelegate – Apple Developer – Certificate, Key, and Trust Services – OWASP – Certificate and Public Key Pinning