採用 Encrypted Client Hello (ECH)

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

應用程式開發人員專區

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

  1. 檢查網路程式庫是否支援 ECH:請確認您使用的程式庫版本支援 Android 上的 ECH。OkHttp 和 HttpEngine 即將支援這項功能。
  2. 設定網路安全性設定:如果程式庫支援 ECH,所有網域都會預設啟用這項功能。如要停用或強制執行 ECH,請在網路安全性設定中設定 domainEncryption 元素。
  3. 更新目標 SDK 層級:ECH 僅適用於 Android 17 (API 層級 37) 以上版本。

程式庫開發人員專區

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

檢查網域加密政策

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

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

  • DOMAIN_ENCRYPTION_MODE_DISABLEDDOMAIN_ENCRYPTION_MODE_UNKNOWN:請勿擷取 ECH 設定或嘗試 ECH。
  • DOMAIN_ENCRYPTION_MODE_ENABLEDDOMAIN_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:使用 getAllByNameDnsResolver.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) 後,請使用 SSLSocketsSSLEngines 公用程式 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,特別是為何需要驗證公開名稱