Стандартный SslStream в .NET хорош всем, кроме одного: он не даёт управлять тем, как выглядит ваш ClientHello. Набор шифров, порядок расширений, GREASE, ALPS, постквантовые группы определяются платформой (SChannel, OpenSSL) и почти не настраиваются. Обычно это не проблема. Но если клиент должен выглядеть в TLS-отпечатках (JA3/JA4) как Chrome, SslStream не подходит.
У Chrome свой TLS-стек, BoringSSL, форк OpenSSL от Google. Я собрал proof-of-concept BoringSslConsole: консольное приложение на .NET 8 и обёртку BoringSslStream, которая даёт доступ к BoringSSL как к обычному Stream.
Исходники: https://github.com/optinsoft/BoringSslConsole
Главное архитектурное требование к обёртке такое: BoringSSL не должен выполнять никаких сетевых операций. Весь ввод-вывод идёт через управляемый innerStream. Ниже объясню, зачем это нужно и как устроено.
Зачем нужен «бессетевой» BoringSSL
BoringSSL умеет сам работать с сетью: у него есть сокетные и файловые BIO (BIO_new_socket, BIO_new_fd). Если бы я их использовал, библиотека сама бы гоняла данные через сокет, и не было бы возможности реализовать асинхронность, а также подставить свой транспорт.
Мне нужно было другое: чтобы TLS-слой работал поверх любого Stream. Это может быть NetworkStream, туннель через прокси (CONNECT, SOCKS), MemoryStream в тестах или другой слой поверх TLS. Поэтому нативная часть отвечает только за состояние TLS: рукопожатие, шифрование и расшифровку записей. А байты в сеть и из сети гоняет C#.
Архитектура
TcpClient
|
NetworkStream (innerStream)
|
BoringSslStream (C#): качает байты между потоком и буферами
|
BoringSSL, memory BIO (чистое TLS-состояние, без сети)
|
HTTP/1.1 или HTTP/2
Между C# и BoringSSL стоят два in-memory буфера (BIO_s_mem):
- входной: сюда C# кладёт байты, прочитанные из
innerStream(pms_ssl_feed_read); - выходной: отсюда C# забирает байты, которые нужно отправить в
innerStream(pms_ssl_take_write, размер можно узнать черезpms_ssl_pending_write).
Нативная DLL (proxymap_boringssl.dll) экспортирует небольшой плоский C-API: pms_ssl_create, pms_ssl_connect, pms_ssl_read, pms_ssl_write, плюс функции прокачки буферов и несколько геттеров (версия протокола, шифр, выбранный ALPN, DER-сертификат сервера, последняя ошибка). Все параметры ClientHello передаются из C# строками и числами.
BoringSslStream наследуется от Stream, поэтому подставляется везде, где ждут обычный поток. Всё полностью асинхронно, с CancellationToken.
Главный цикл: прокачка байтов
Весь механизм умещается в один паттерн. Вызываем операцию BoringSSL, сбрасываем накопившийся выход в сеть, а если библиотека просит данных, читаем их из сети и отдаём обратно. Вот рукопожатие:
while (true)
{
int result = Native.pms_ssl_connect(_connection);
await FlushWriteBioAsync(cancellationToken);
switch (result)
{
case Native.PMS_SSL_OK:
ValidateServerCertificate(_hostname);
_authenticated = true;
return;
case Native.PMS_SSL_WANT_READ:
await ReadFromNetworkAsync(cancellationToken);
break;
case Native.PMS_SSL_WANT_WRITE:
break; // выход уже сброшен в сеть
}
}
FlushWriteBioAsync забирает из выходного буфера всё накопленное и пишет в innerStream. ReadFromNetworkAsync читает из innerStream и скармливает байты входному буферу. Тот же цикл повторяется в ReadAsync и WriteAsync.
Сбрасывать выход нужно после каждого вызова BoringSSL, а не только во время рукопожатия: даже SSL_read может сам порождать TLS-записи (например, KeyUpdate или алерты).
Буферы берутся из ArrayPool<byte>, а указатели передаются в нативный код через Memory<T>.Pin(), без лишних копирований.
Синхронизация
В классе четыре SemaphoreSlim: _readLock и _writeLock сериализуют соответственно чтения и записи, а _networkReadLock и _networkWriteLock защищают обмен с innerStream. Благодаря этому одновременные вызовы ReadAsync и WriteAsync не ломают друг другу работу с потоком и буферами. Рукопожатие берёт сразу оба внешних лока.
Оговорка: этого достаточно для PoC, но полноценный full-duplex поверх одного SSL* требует аккуратности и на стороне нативной библиотеки, а не только в C#. Об этом ещё скажу в ограничениях.
Пресет «как Chrome»
Конструктор BoringSslStream принимает набор параметров, по умолчанию заполненных значениями Chrome:
- cipher list: ECDHE-варианты AES-GCM и ChaCha20-Poly1305, плюс легаси-хвост;
- GREASE и ECH GREASE;
- ALPN
h2:http/1.1и ALPSh2; - алгоритмы подписи, включая постквантовые
ML-DSA-44/65/87; - SCT,
status_request, сжатие сертификатов Brotli; trust_anchorsв виде hex-строки.
Тонкая работа с расширениями (перестановка, динамическое формирование ALPN/ALPS, паддинг status_request) выполнена на C++ стороне. Brotli подключён статически, чтобы всё собиралось в одну автономную DLL и не было DllNotFoundException в рантайме.
На tls.peet.ws совпал JA4-отпечаток Chrome. А вот JA3 не совпадает, и так и должно быть. Начиная с Chrome 110 браузер случайно перемешивает порядок TLS-расширений в ClientHello при каждом соединении. JA3 строится из списков шифров, расширений, групп и форматов точек в том порядке, в котором они пришли, поэтому у настоящего Chrome JA3-хеш каждый раз новый, и сравнивать его с каким-то «эталоном» бессмысленно. Наш клиент делает то же самое: нативный слой переставляет расширения, и JA3 меняется от соединения к соединению, как у браузера.
JA4 устроен иначе: перед хешированием он сортирует шифры и расширения (и отбрасывает GREASE), так что перестановка на него не влияет. Именно поэтому JA4 стабилен и годится для проверки: если он совпал, набор шифров, расширений, алгоритмов подписи и ALPN такой же, как у Chrome.
Проверка сертификата
BoringSSL здесь только согласует канал, а решение о доверии принимает .NET. Сразу после успешного рукопожатия нативный слой отдаёт DER-сертификат сервера, и дальше:
using var certificate = new X509Certificate2(memory.Span);
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1")); // Server Auth
if (!chain.Build(certificate))
throw new AuthenticationException(...);
if (!certificate.MatchesHostname(hostname))
throw new AuthenticationException(...);
Используется системное хранилище корневых сертификатов, включена онлайн-проверка отзыва, имя хоста проверяется через MatchesHostname (доступен в .NET 8, учитывает SAN и правила для wildcard).
Легко проверить на badssl.com: expired.badssl.com падает на построении цепочки, untrusted-root.badssl.com на неизвестном корне, revoked.badssl.com на проверке отзыва. Нужные хосты закомментированы в Program.cs, достаточно сменить одну константу.
HTTP/2 руками
Раз ALPN договаривается о h2, пришлось написать минимальный HTTP/2-клиент прямо в Program.cs, без сторонних библиотек.
Отправка. Сначала 24-байтный connection preface, затем пустой SETTINGS. Потом заголовки: крошечный HpackEncoder пишет индексированные поля из статической таблицы (:method: GET это индекс 2, :scheme: https это 7) и литералы без индексирования для :authority, :path и остального. Блок оборачивается в 9-байтный заголовок кадра HEADERS с флагами END_STREAM | END_HEADERS на стриме 1.
Приём. Читаем строго по 9 байт заголовка кадра, парсим длину (24 бита), тип, флаги и stream id, затем читаем ровно столько байт payload. Дальше разбираем по типу:
DATAпишем в файл и в консоль, наEND_STREAMвыходим;HEADERSвыводим статус (см. ниже) и тоже смотрим наEND_STREAM;SETTINGSбез флага ACK подтверждаемSETTINGS ACK, как требует RFC;RST_STREAMиGOAWAYпечатаем с расшифровкой кода ошибки;WINDOW_UPDATEигнорируем.
Полноценного HPACK-декодера нет, поэтому статус ответа определяется эвристикой: ищем в блоке байты 0x88–0x8E, соответствующие индексированным статусам из статической таблицы (0x88 это 200, 0x8D это 404). Для демонстрации хватает, но это именно хак.
Если ALPN вернул http/1.1, всё проще: уходит обычный текстовый запрос с Connection: close, ответ читается до закрытия потока.
Запуск
git clone --recursive https://github.com/optinsoft/BoringSslConsole.git
cd Native
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
cd ..
dotnet run --project BoringSslConsole
Для сборки на Windows нужны .NET 8 SDK, Visual Studio 2022 с C++ tools, CMake 3.22+, Ninja, Git, NASM и Go (последние два требуются самому BoringSSL). BoringSSL и Brotli подключены как git-submodule, DLL после сборки копируется в выходной каталог .NET. Ответ выводится в консоль и сохраняется в ./output/<host>.txt.
Итоги и ограничения
Получился компактный асинхронный Stream, внутри которого Chrome-подобный TLS-стек с настоящей проверкой сертификата, а сеть полностью в руках .NET. Поверх него можно строить прокси, HTTP-клиент или тестовые инструменты, и транспорт при этом можно менять, не трогая TLS-слой.
Честный список того, чего здесь нет:
- HTTP/2 минимальный: нет HPACK-декодера,
CONTINUATION, управления окнами и мультиплексирования. - Совпадает TLS-отпечаток, а не весь клиент. У HTTP/2 есть собственный отпечаток (значения
SETTINGS,WINDOW_UPDATE, порядок псевдозаголовков), и здесь он отличается от Chrome. - Потокобезопасность требует отдельной проверки. Блокировки в C# разводят чтение, запись и сетевые операции, но безопасность одновременных
SSL_readиSSL_writeна одномSSL*зависит и от нативного слоя. ОсвобождениеSSL*вDisposeтакже стоит синхронизировать с идущими операциями. - Валидация цепочки: из нативного слоя берётся только листовой сертификат, поэтому построение цепочки может зависеть от AIA-загрузки промежуточных, а онлайн-проверка отзыва блокирует поток внутри
async-кода. - Платформа: сборка требует тяжёлого тулчейна, а нативная часть пока только под Windows x64.
Это PoC, но архитектурная идея, то есть «BoringSSL как чистая TLS-машина, а ввод-вывод снаружи», на мой взгляд, получилась удачной и легко переносится на другие транспорты.
Проект на GitHub: optinsoft/BoringSslConsole