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: 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 で接続するには、ECH 構成を含むサーバーの HTTPS DNS レコードを解決する必要があります。アプリがシステム DNS を使用している場合、このデータは次の 2 つの方法のいずれかで取得できます。

方法 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 の解決パイプラインを管理するライブラリでは、HTTPS レコードを個別に取得しながら、標準 API を使用して IP アドレスを解決することが望ましい場合があります。

  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)を取得したら、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 構成が同期しなくなった場合、ハンドシェイクは 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(特に公開名の認証が必要な理由)をご覧ください。