採用 Encrypted Client Hello (ECH)

經加密的 Client Hello (ECH) 是 TLS 擴充功能,可加密用戶端握手訊息中的伺服器名稱指示 (SNI) 欄位。在 Android 17 (API 級別 37) 以上版本中,系統預設支援 ECH。ECH 可防止網路中介者查看應用程式連線的主機名稱,確保使用者網路流量的隱私。

應用程式開發人員專區

如要在應用程式中採用 ECH,請按照下列步驟操作:

  1. 檢查網路程式庫是否支援 ECH:確認您使用的程式庫版本支援 Android 上的 ECH:
    • OkHttp:從 OkHttp 5.5.0 開始,您可以在 OkHttpClient.Builder 上設定 AndroidDns 或 DnsOverHttps,啟用 ECH 支援功能。詳情請參閱異動記錄。
    • HttpEngine:Android 17 QPR2 (API 級別 37.2) 即將支援這項功能。 不需要進行特殊設定。
    • WebView:日後推出的版本將支援這項功能。
  2. 設定網路安全性設定:如果程式庫支援 ECH,系統預設會為所有網域啟用這項功能。如要停用或強制執行 ECH,請在網路安全性設定中設定 domainEncryption 元素。
  3. 更新目標 SDK 層級:ECH 僅適用於 Android 17 (API 層級 37) 以上版本。

程式庫開發人員專區

如果您要開發自訂 HTTP 網路程式庫或擴充現有程式庫,請與平台 API 互動,實作 ECH 支援功能。

檢查網域加密政策

查詢 ECH 設定或啟動連線前,請先呼叫 NetworkSecurityPolicy.getDomainEncryptionMode,檢查應用程式的網域加密政策。

視傳回的模式而定,處理 ECH 的方式如下:

  • DOMAIN_ENCRYPTION_MODE_DISABLED 和 DOMAIN_ENCRYPTION_MODE_UNKNOWN:請勿擷取 ECH 設定或嘗試 ECH。
  • DOMAIN_ENCRYPTION_MODE_ENABLED 和 DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC:強制執行 ECH。擷取 ECH 設定,並在伺服器支援時使用 ECH。如果伺服器不支援 ECH,請啟用 ECH GREASE。

擷取 ECH 設定

如要連線至 ECH,您必須解析伺服器的 HTTPS DNS 記錄,其中包含 ECH 設定。應用程式使用系統 DNS 時,可以透過下列兩種方法之一擷取這項資料:

方法 1:使用高階 DnsResolver.query API

如果程式庫不需要自訂 DNS 解析機制,可以使用平台的高階 DnsResolver.query API。這個 API 會平行查詢 A/AAAA/HTTPS 記錄,並將結果合併為 HttpsEndpoint。

Kotlin

val resolver = DnsResolver(context, looper)
resolver.query(network, hostname, DnsResolver.TYPE_HTTPS, executor,
    DnsResolver.HTTPS_QUERY_WAIT_AUTO, cancellationSignal,
    object : DnsResolver.Callback<HttpsEndpoint> {
        override fun onAnswer(answer: HttpsEndpoint, rcode: Int) {
            val record = answer.httpsRecords.firstOrNull() ?: return
            val echConfigList = record.echConfigList ?: return
            establishEchConnection(echConfigList)
        }
        override fun onError(error: DnsResolver.DnsException) { /* Handle error */ }
    })

Java

DnsResolver resolver = new DnsResolver(context, looper);
resolver.query(network, hostname, DnsResolver.TYPE_HTTPS, executor,
    DnsResolver.HTTPS_QUERY_WAIT_AUTO, cancellationSignal,
    new DnsResolver.Callback<HttpsEndpoint>() {
        @Override
        public void onAnswer(HttpsEndpoint answer, int rcode) {
            HttpsRecord record = answer.getHttpsRecords().stream().findFirst().orElse(null);
            if (record == null) return;
            EchConfigList echConfigList = record.getEchConfigList();
            if (echConfigList == null) return;
            establishEchConnection(echConfigList);
        }

        @Override
        public void onError(DnsResolver.DnsException error) { /* Handle error */ }
    });

方法 2:使用 getAllByName 和 DnsResolver.rawQuery

對於管理自身通訊端連線和 DNS 解析管道的程式庫,您可能偏好使用標準 API 解析 IP 位址,同時另外擷取 HTTPS 記錄:

  1. 使用 InetAddress.getAllByName 解析預設網路或 Network.getAllByName 的 A/AAAA 記錄。
  2. 使用 DnsResolver.rawQuery 平行擷取原始 HTTPS 記錄。將 DnsResolver.TYPE_HTTPS 指定為查詢類型。
開發人員責任和極端情況

如果選擇方法 2,程式庫必須承擔額外責任,並考量邊緣情況。

  • DNS 記錄剖析:您必須剖析 rawQuery 的 DNS 回應原始位元組酬載,才能擷取 EchConfigList。
  • 處理記錄不符的情況:您必須處理 A/AAAA 和 HTTPS 查詢之間的不一致情況。
  • 競爭條件:您必須同步處理平行 DNS 查詢的結果。如果其中一個查詢先於另一個查詢解析,或 HTTPS 查詢逾時,您必須適當回溯 (例如,如果 HTTPS 查詢失敗,請嘗試建立不含 ECH 的標準 TLS 連線,或使用 ECH GREASE (如果政策已啟用)。

設定 TLS

程式庫從 HttpsRecord 擷取 ECH 設定清單 (EchConfigList) 後,請使用 SSLSockets 或 SSLEngines 公用程式 API 傳遞這份清單,然後再啟動 TLS 交握。

Kotlin

fun establishEchConnection(echConfigList: EchConfigList) {
    val socket = sslSocketFactory.createSocket(ipAddress, port) as SSLSocket
    SSLSockets.setEchConfigList(socket, echConfigList)
    socket.startHandshake()
}

Java

public void establishEchConnection(EchConfigList echConfigList)
    throws IOException {
    SSLSocket socket =
        (SSLSocket) sslSocketFactory.createSocket(ipAddress, port);
    SSLSockets.setEchConfigList(socket, echConfigList);
    socket.startHandshake();
}

處理重試流程

如果伺服器的 ECH 設定不同步,交握就會失敗,並顯示 EchConfigMismatchException (javax.net.ssl.SSLException 的子類別)。伺服器可能會在拒絕要求時加入更新的 ECH 設定,您應使用這些設定建立新連線。如果伺服器提供有效的重試設定,但程式庫未嘗試重試,就必須向呼叫應用程式回報錯誤。

如要處理 ECH 重試,請擷取例外狀況並執行下列步驟:

  1. 在例外狀況中呼叫 EchConfigMismatchException.getPublicHostname。
  2. 使用 HostnameVerifier 驗證傳回的公開主機名稱。 如果顯示 null,請中止連線。
  3. 如果主機名稱驗證成功,請使用 EchConfigMismatchException.getRetryConfigList 檢查更新的設定。
  4. 如有可用的更新設定,請使用新的 EchConfigList 重新嘗試連線。

Kotlin

try {
    socket.startHandshake()
} catch (e: EchConfigMismatchException) {
    val publicName = e.publicHostname ?: throw e
    if (hostnameVerifier.verify(publicName, socket.session)) {
        val retryConfigList = e.retryConfigList
        if (retryConfigList != null) {
            retryConnection(retryConfigList)
        }
    } else {
        throw e // Hostname mismatch
    }
}

Java

try {
    socket.startHandshake();
} catch (EchConfigMismatchException e) {
    String publicName = e.getPublicHostname();
    if (publicName == null) {
        throw e;
    }
    if (hostnameVerifier.verify(publicName, socket.getSession())) {
        EchConfigList retryConfigList = e.getRetryConfigList();
        if (retryConfigList != null) {
            retryConnection(retryConfigList);
        }
    } else {
        throw e; // Hostname mismatch
    }
}

如要進一步瞭解重試流程,請參閱 RFC 9849,特別是為何需要驗證公開名稱。