在 Android 中實現 SSL Pinning 與自定義信任鏈的完整指南

🌏 Read the English version


為什麼需要 SSL Pinning?

行動應用安全威脅:

  • 中間人攻擊(MITM):攻擊者攔截應用與伺服器之間的通訊,竊取敏感數據
  • 偽造憑證攻擊:惡意軟體可能在設備上安裝偽造的根憑證
  • 公共 Wi-Fi 風險:不安全的網路環境容易被竊聽
  • 企業內部風險:某些企業防火牆會解密 HTTPS 流量進行檢查

SSL Pinning 的價值:

  • 額外安全層:即使設備的信任鏈被破壞,應用仍能驗證伺服器身份
  • 防止偽造憑證:只信任指定的憑證或公鑰,拒絕其他來源
  • 符合合規要求:金融、醫療等行業的安全規範要求
  • 保護敏感數據:確保用戶隱私和交易安全

什麼是 SSL Pinning?

SSL Pinning 是一種安全技術,它將伺服器的 憑證公鑰 內嵌於應用程式中,讓應用只信任指定伺服器的憑證,即使攻擊者偽造憑證也無法進行中間人攻擊(MITM)。Pinning 可分為以下兩種方式:

  • 憑證 Pinning(Certificate Pinning):將伺服器的整個憑證內嵌於應用中進行校驗
  • 公鑰 Pinning(Public Key Pinning):提取伺服器憑證中的公鑰,僅校驗該公鑰是否匹配

什麼是自定義信任鏈?

自定義信任鏈 通常用於替換 Android 的預設憑證驗證機制。這種方法允許您指定應用信任的根憑證或中繼憑證(Intermediate Certificate)。

常見使用場景:

  • 自簽名憑證:伺服器使用的是自簽名憑證(開發或測試環境)
  • 企業內部 CA:使用企業內部的憑證頒發機構(CA)
  • 特殊中繼憑證:伺服器的憑證信任鏈中包含特定的中繼憑證
  • 測試環境隔離:開發環境使用不同的憑證體系

準備工作

所需工具與資源:

  • 伺服器憑證:獲取 .cer.crt 格式的伺服器憑證
  • OpenSSL:用於檢查和提取憑證相關資訊
  • Android 專案環境
    • Android Studio 最新版本
    • OkHttp 庫(版本 4.x 或以上)
    • Kotlin 或 Java 開發環境

實作步驟

步驟 1:檢查伺服器憑證

首先,檢查伺服器的憑證,確保域名、有效期及簽發機構符合需求。

使用以下 OpenSSL 指令:

openssl x509 -in <your_certificate.cer> -text -noout

確認重點:

  • Subject:檢查域名(CN=your.domain.com)是否正確
  • Issuer:確認憑證的簽發機構(如 Let’s Encrypt, DigiCert)
  • Validity:檢查憑證的有效期(Not Before / Not After)
  • Subject Alternative Name:確認支援的所有域名

範例輸出:

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 04:00:00:00:00:01:54:a8:30:f2:d5
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=US, O=Let's Encrypt, CN=R3
        Validity
            Not Before: Jan  1 00:00:00 2025 GMT
            Not After : Apr  1 23:59:59 2025 GMT
        Subject: CN=api.example.com

步驟 2:提取伺服器公鑰

如果您選擇使用 公鑰 Pinning(建議方式),需要提取伺服器憑證中的公鑰:

openssl x509 -in <your_certificate.cer> -pubkey -noout > public_key.pem

檢查提取的 public_key.pem 檔案:

cat public_key.pem

確保內容類似:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----

步驟 3:生成公鑰的 SHA-256 指紋

生成伺服器公鑰的 SHA-256 指紋,供 SSL Pinning 使用:

openssl x509 -in <your_certificate.cer> -pubkey -noout | 
  openssl rsa -pubin -outform DER | 
  openssl dgst -sha256 -binary | 
  base64

輸出的指紋範例:

sha256/AbCdEfGhIjKlMnOpQrStUvWxYz1234567890AbCdEfGhIjKlMnOpQrStUvWxYz=

記錄此指紋,稍後在 Android 程式中配置。

重要提示: 建議同時記錄主要憑證和備份憑證的指紋,以便憑證輪換時應用仍能正常運作。

步驟 4:配置憑證至 Android 專案

1. 創建資源目錄:

mkdir -p app/src/main/res/raw

2. 複製憑證檔案:

cp <your_certificate.cer> app/src/main/res/raw/server_cert.cer

檔案命名建議:

  • 使用描述性名稱:server_cert.cer, api_certificate.cer
  • 避免特殊字元,僅使用小寫字母、數字和底線
  • 如有多個憑證,使用 cert_primary.cer, cert_backup.cer

步驟 5:實現自定義信任鏈(Kotlin)

如果您需要使用特定的中繼憑證(如 eCA1-to-HRCA1.crt),可以使用以下代碼自定義信任鏈:

import java.security.KeyStore
import java.security.cert.CertificateFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import okhttp3.OkHttpClient

fun createCustomTrustManager(context: Context): X509TrustManager {
    // 初始化 TrustManagerFactory
    val trustManagerFactory = TrustManagerFactory.getInstance(
        TrustManagerFactory.getDefaultAlgorithm()
    )

    // 創建 KeyStore
    val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
    keyStore.load(null, null)

    // 加載自定義憑證
    val certificateFactory = CertificateFactory.getInstance("X.509")
    val inputStream = context.resources.openRawResource(R.raw.server_cert)

    try {
        val certificate = certificateFactory.generateCertificate(inputStream)
        keyStore.setCertificateEntry("server", certificate)
    } finally {
        inputStream.close()
    }

    // 初始化 TrustManager
    trustManagerFactory.init(keyStore)
    val trustManagers = trustManagerFactory.trustManagers

    return trustManagers[0] as X509TrustManager
}

fun createSecureClient(context: Context): OkHttpClient {
    val trustManager = createCustomTrustManager(context)

    // 設置自定義的 SSLContext
    val sslContext = SSLContext.getInstance("TLS")
    sslContext.init(null, arrayOf(trustManager), null)

    // 建立 OkHttpClient
    return OkHttpClient.Builder()
        .sslSocketFactory(sslContext.socketFactory, trustManager)
        .build()
}

步驟 6:實現 SSL Pinning(Kotlin)

使用 OkHttp 配置 SSL Pinning:

import okhttp3.CertificatePinner
import okhttp3.OkHttpClient

fun createPinnedClient(): OkHttpClient {
    val certificatePinner = CertificatePinner.Builder()
        // 主要憑證
        .add(
            "api.example.com",
            "sha256/AbCdEfGhIjKlMnOpQrStUvWxYz1234567890AbCdEfGhIjKlMnOpQrStUvWxYz="
        )
        // 備份憑證(建議)
        .add(
            "api.example.com",
            "sha256/BackupCertificateHashGoesHere1234567890AbCdEfGhIjKlMnOpQrStUvWxYz="
        )
        .build()

    return OkHttpClient.Builder()
        .certificatePinner(certificatePinner)
        .build()
}

步驟 7:結合自定義信任鏈與 SSL Pinning

fun createFullySecureClient(context: Context): OkHttpClient {
    val trustManager = createCustomTrustManager(context)
    val sslContext = SSLContext.getInstance("TLS")
    sslContext.init(null, arrayOf(trustManager), null)

    val certificatePinner = CertificatePinner.Builder()
        .add(
            "api.example.com",
            "sha256/AbCdEfGhIjKlMnOpQrStUvWxYz1234567890AbCdEfGhIjKlMnOpQrStUvWxYz="
        )
        .build()

    return OkHttpClient.Builder()
        .sslSocketFactory(sslContext.socketFactory, trustManager)
        .certificatePinner(certificatePinner)
        .build()
}

Java 實作範例

如果您的專案使用 Java,以下是對應的實作:

import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import okhttp3.CertificatePinner;
import okhttp3.OkHttpClient;

public class SecureClientBuilder {

    public static OkHttpClient createSecureClient(Context context) {
        try {
            // 加載憑證
            CertificateFactory cf = CertificateFactory.getInstance("X.509");
            InputStream certInputStream = context.getResources()
                .openRawResource(R.raw.server_cert);
            Certificate ca = cf.generateCertificate(certInputStream);
            certInputStream.close();

            // 建立 KeyStore
            KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
            keyStore.load(null, null);
            keyStore.setCertificateEntry("server", ca);

            // 建立 TrustManager
            TrustManagerFactory tmf = TrustManagerFactory.getInstance(
                TrustManagerFactory.getDefaultAlgorithm()
            );
            tmf.init(keyStore);

            // 建立 SSLContext
            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(null, tmf.getTrustManagers(), null);

            // 建立 Certificate Pinner
            CertificatePinner certificatePinner = new CertificatePinner.Builder()
                .add("api.example.com",
                     "sha256/AbCdEfGhIjKlMnOpQrStUvWxYz1234567890AbCdEfGhIjKlMnOpQrStUvWxYz=")
                .build();

            // 建立 OkHttpClient
            return new OkHttpClient.Builder()
                .sslSocketFactory(sslContext.getSocketFactory(),
                    (X509TrustManager) tmf.getTrustManagers()[0])
                .certificatePinner(certificatePinner)
                .build();

        } catch (Exception e) {
            throw new RuntimeException("Failed to create secure client", e);
        }
    }
}

測試與驗證

測試 SSL Pinning 是否生效:

  1. 正常連線測試
    val client = createPinnedClient()
    val request = Request.Builder()
        .url("https://api.example.com/test")
        .build()
    
    client.newCall(request).execute().use { response ->
        println("Response: ${response.body?.string()}")
    }
  2. 錯誤憑證測試
    • 修改 Pinning 的 SHA-256 指紋為錯誤值
    • 預期結果:應該拋出 SSLPeerUnverifiedException
  3. 使用 Charles Proxy 測試
    • 安裝 Charles 的根憑證到設備
    • 嘗試攔截應用流量
    • 預期結果:連線失敗,無法攔截

常見問題與解決方案

問題 1:為什麼需要特定憑證(如 eCA1-to-HRCA1.crt)?

原因:

  • 該憑證可能是伺服器憑證信任鏈的一部分(例如中繼憑證)
  • 如果未包含此憑證,應用將無法成功建立信任鏈
  • Android 系統的預設信任庫可能不包含某些企業或特殊 CA

解決方案:

  • 向伺服器管理員索取完整的憑證鏈
  • 將所有中繼憑證都加入到 KeyStore 中
  • 使用 openssl s_client 命令檢查伺服器的完整憑證鏈

問題 2:SSL Pinning 與自定義信任鏈的差異是什麼?

比較:

特性 SSL Pinning 自定義信任鏈
驗證對象 憑證或公鑰的指紋 完整的憑證鏈條
靈活性 較低,需更新應用 較高,可動態管理憑證
安全性 非常高,精確匹配 高,依賴信任鏈
使用場景 防止 MITM 攻擊 自簽名或企業 CA

建議: 可以同時使用兩者,提供雙重保護。

問題 3:選擇公鑰 Pinning 還是憑證 Pinning?

公鑰 Pinning(推薦):

  • ✅ 更靈活,憑證更新時公鑰可保持不變
  • ✅ 減少應用更新頻率
  • ✅ 支援憑證輪換(Rotation)

憑證 Pinning:

  • ✅ 更加嚴格,完全匹配憑證
  • ❌ 憑證更新後必須更新應用
  • ❌ 維護成本較高

問題 4:憑證過期或更新時怎麼辦?

解決方案:

  1. 準備多個 Pin
    val certificatePinner = CertificatePinner.Builder()
        .add("api.example.com", "sha256/current_cert_hash")
        .add("api.example.com", "sha256/backup_cert_hash")
        .build()
  2. 實施 Pin 更新機制
    • 在伺服器端提供 Pin 配置 API
    • 應用啟動時動態更新 Pin 列表
    • 使用 Firebase Remote Config 或類似服務
  3. 監控與告警
    • 設置憑證過期提醒(提前 30-60 天)
    • 監控 SSL Pinning 失敗率
    • 準備緊急更新流程

問題 5:如何在開發環境中測試?

解決方案:

  • 使用 Build Variants 區分開發和生產環境
  • 開發環境停用 Pinning 或使用開發憑證
  • 使用條件編譯:
    val client = if (BuildConfig.DEBUG) {
        // 開發環境:不啟用 Pinning
        OkHttpClient.Builder().build()
    } else {
        // 生產環境:啟用 Pinning
        createPinnedClient()
    }

安全最佳實踐

  1. 使用公鑰 Pinning:提供更好的靈活性
  2. 準備備份 Pin:至少配置 2-3 個有效的 Pin
  3. 定期檢查憑證:設置自動化監控
  4. 實施 Pin 更新機制:透過遠端配置動態更新
  5. 錯誤處理與降級:Pin 驗證失敗時提供適當的錯誤訊息
  6. 日誌記錄:記錄 Pinning 失敗事件,用於安全分析
  7. 結合多層防護:同時使用 SSL Pinning 和應用層加密

實際應用案例

金融應用:

  • 使用 SSL Pinning 保護交易 API
  • 結合設備指紋和生物識別
  • 定期輪換憑證和 Pin

企業應用:

  • 使用自定義信任鏈信任企業 CA
  • 配置內部網路的自簽名憑證
  • 實施憑證透明度(Certificate Transparency)

結論

實現 SSL Pinning 與自定義信任鏈是確保 Android 應用通訊安全的重要步驟。透過本文的指引,您可以:

  • 理解 SSL Pinning 的原理與重要性
  • 正確配置自定義信任鏈
  • 實作公鑰 Pinning(推薦方式)
  • 處理憑證更新與維護問題
  • 遵循安全最佳實踐

下一步建議:

  • 在測試環境驗證 Pinning 配置
  • 實施憑證監控與告警機制
  • 準備憑證輪換計劃
  • 定期進行安全審計

相關文章

Leave a Comment