Encrypted Client Hello(ECH)の採用

Encrypted Client Hello(ECH)は、クライアントのハンドシェイク メッセージの Server Name Indication(SNI)フィールドを暗号化する TLS 拡張機能です。Android 17(API レベル 37)以降では、ECH がデフォルトでサポートされています。ECH は、アプリが接続するホスト名をネットワーク仲介者が確認できないようにすることで、ユーザーのウェブ トラフィックのプライバシーを保護します。

アプリ デベロッパー向け

アプリケーションで ECH を採用するには:

  1. ネットワーキング ライブラリで ECH のサポートを確認する: Android で ECH をサポートする ライブラリ バージョンを使用していることを確認します。OkHttp と HttpEngine では近日中にサポートされる予定です。
  2. ネットワーク セキュリティ構成を構成する: デフォルトでは、ライブラリが ECH をサポートしている場合、すべての ドメインで 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 をサポートしていない場合は、ECH GREASE を有効にします。

ECH 構成を取得する

ECH で接続するには、ECH 構成を含むサーバーの HTTPS DNS レコードを解決する必要があります。アプリがシステム 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 の解決パイプラインを管理するライブラリの場合は、HTTPS レコードを個別に取得しながら、標準 API を使用して IP アドレスを解決することをおすすめします。

  1. デフォルト ネットワークの場合は InetAddress.getAllByName、それ以外の場合は Network.getAllByName を使用して、A/AAAA レコードを解決します。
  2. DnsResolver.rawQuery を使用して、生の HTTPS レコードを並行して取得します。クエリタイプとして DnsResolver.TYPE_HTTPS を指定します。
デベロッパーの責任とエッジケース

方法 2 を選択した場合、ライブラリには追加の責任と考慮すべきエッジケースがあります。

  • DNS レコードの解析: DNS レスポンスの生のバイト ペイロードを解析して、rawQueryからEchConfigListを抽出する必要があります。
  • レコードの不一致の処理: A/AAAA クエリと HTTPS クエリの不整合を処理する必要があります。
  • 競合状態: 並列 DNS ルックアップの結果を同期する必要があります。一方のクエリが他方のクエリより先に解決される場合や、HTTPS クエリがタイムアウトした場合は、適切にフォールバックする必要があります(たとえば、HTTPS クエリが失敗した場合は ECH なしの標準 TLS 接続を試行する、ポリシーで有効になっている場合は ECH GREASE を使用するなど)。

TLS の構成

ライブラリが HttpsRecord から ECH 構成リスト (EchConfigList)を取得したら、TLS ハンドシェイクを開始する前に、 SSLSockets または SSLEngines ユーティリティ API を使用してこのリストを渡します。

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 構成が同期しなくなった場合、ハンドシェイクは EchConfigMismatchExceptionjavax.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 をご覧ください。特に、公開名に対して認証を行う必要がある理由について説明しています。