Внедрение протокола Encrypted Client Hello (ECH)

Encrypted Client Hello (ECH) — это расширение TLS, которое шифрует поле Server Name Indication (SNI) в сообщении рукопожатия клиента. В Android 17 (уровень API 37) и выше ECH поддерживается по умолчанию. ECH помогает обеспечить конфиденциальность веб-трафика пользователей, не позволяя сетевым посредникам видеть имена хостов, к которым подключается приложение.

Для разработчиков приложений

Чтобы внедрить ECH в ваше приложение:

  1. Проверьте свою библиотеку для работы с сетью на наличие поддержки ECH : убедитесь, что вы используете версию библиотеки, поддерживающую ECH на Android. Поддержка ECH скоро появится в OkHttp и HttpEngine.
  2. Настройка параметров сетевой безопасности : по умолчанию ECH включен для всех доменов, если ваша библиотека его поддерживает. Если вам необходимо отключить или принудительно включить ECH, настройте элемент domainEncryption в параметрах сетевой безопасности .
  3. Обновите целевой уровень SDK : ECH доступен только на Android 17 (уровень API 37) и выше.

Для разработчиков библиотек

Если вы разрабатываете собственную библиотеку для работы с HTTP-сетями или расширяете существующую, вам следует реализовать поддержку ECH, взаимодействуя с API платформы.

Проверьте политику шифрования домена.

Перед запросом конфигураций 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 необходимо разрешить DNS-запись HTTPS сервера, содержащую конфигурации ECH. Если приложения используют системный DNS, эти данные можно получить одним из двух способов:

Метод 1: Использование высокоуровневого API DnsResolver.query

Если вашей библиотеке не требуются собственные механизмы разрешения DNS, вы можете использовать высокоуровневый API DnsResolver.query платформы. Этот API выполняет параллельные запросы к записям A/AAAA/HTTPS и объединяет результаты в HttpsEndpoint .

Котлин

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, может быть предпочтительнее разрешать IP-адреса с помощью стандартных API, получая при этом HTTPS-запись отдельно:

  1. Для разрешения записей A/AAAA используйте InetAddress.getAllByName для сети по умолчанию или Network.getAllByName .
  2. Получите необработанную HTTPS-запись параллельно, используя DnsResolver.rawQuery . Укажите DnsResolver.TYPE_HTTPS в качестве типа запроса.
Ответственность разработчика и нестандартные ситуации

Если вы выберете Метод 2, у вашей библиотеки появятся дополнительные обязанности и нестандартные ситуации, которые необходимо учитывать.

  • Анализ DNS-записей : Для извлечения EchConfigList необходимо проанализировать необработанные байтовые данные ответа DNS от rawQuery .
  • Обработка несоответствий в записях : Необходимо обрабатывать несоответствия между запросами A/AAAA и HTTPS.
  • Состояние гонки : Необходимо синхронизировать результаты параллельных DNS-запросов. Если один запрос разрешается раньше другого или если запрос HTTPS истекает по таймауту, необходимо соответствующим образом переключиться на резервный вариант (например, попытаться установить стандартное TLS-соединение без ECH, если запрос HTTPS не удается, или использовать ECH GREASE, если он включен политикой).

Настройка TLS

После того как библиотека получит список конфигураций ECH ( EchConfigList ) из HttpsRecord , передайте этот список, используя API утилит SSLSockets или SSLEngines перед началом рукопожатия TLS.

Котлин

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 .

Котлин

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 , в частности , почему необходима аутентификация для публичного имени .