xsteer: свой протокол с обликом TLS#
Документ описывает протокол так, как он реализован в этом ядре (src/proto/xsteer/xs*.c), и
называет прямо то, чего протокол не делает.
Вторая реализация того же протокола — на Go, в отдельном репозитории
xsteer: клиент для настольных систем и хаб для
сервера. Протокол развивается там, а сюда переносится отдельной работой;
tests/matrix.sh в том репозитории гоняет все четыре сочетания
половин (хаб на C и на Go против клиента на C и на Go). Перекрёстные клетки —
единственное, что доказывает формат на проводе, а не согласованность реализации с самой
собой.
Что это#
Ядро несёт IP-пакеты внутри потока, который для наблюдателя выглядит как TLS 1.3 на
:443 поверх обычного TCP. Рукопожатие имеет форму ClientHello и ServerHello, каждый
пакет данных — форму записи application_data.
Транспорт при этом поддельный. Заголовок TCP настоящий — поэтому поток проходит там,
где UDP блокируют или режут по скорости, — а семантика остаётся датаграммной: ни
повторных передач, ни окна, ни контроля перегрузки. Так облик достаётся без
TCP-поверх-TCP, то есть без второго контроля перегрузки поверх первого, который и делает
такие туннели медленными на плохом канале. Сегментами владеет само ядро steer через сырые
сокеты (src/proto/obfs/obfs.c), слушающего сокета ядра Linux на порту нет вовсе.
Внутри — Noise IK, тот же паттерн, что у WireGuard, со своими ключами и своими пирами.
Топология — звезда: пиры ходят друг к другу через хаб, который разворачивает их трафик в
пользовательском пространстве, без пересылки в ядре (поэтому и без ip_forward).
Накладные расходы#
| путь | байт сверх внутреннего пакета | MTU туннеля при канале 1500 |
|---|---|---|
| WireGuard поверх UDP | 20 IP + 8 UDP + 32 WG = 60 | 1440 |
| WireGuard поверх поддельного TCP | 20 + 20 + 32 = 72 | 1428 |
| xsteer | 20 IP + 20 TCP + 5 запись + 16 тег = 61 | 1439 |
Облик TLS стоит один байт против эталонного WireGuard поверх UDP. Достаётся это тем, что
из кадра выкинуты индекс получателя (его заменяет четвёрка адрес-порт поддельного TCP) и
счётчик записей (его заменяет номер последовательности того же TCP) — ровно те 12 байт,
которые оплачивают пятибайтовый заголовок записи. Оба приёма закреплены стендом
(tests/xswirematch.c): без них затея теряет смысл.
Рукопожатие#
Noise IK внутри ClientHello с обликом Chrome. Ни одного нового расширения и ни одного лишнего байта — всё едет в полях, которые у браузера уже есть:
| поле ClientHello | что там лежит |
|---|---|
key_share x25519 (32) |
эфемерный ключ e — ровно там, где он был бы в TLS 1.3 |
| набивка ECH (176) | статический ключ пира под es (32+16) и подтверждение нагрузки (16); остальные 128 байт — случайный шум, как у Chrome без настроенного ECH |
legacy_session_id (32) |
аутентификатор по всему Hello: 16 байт открыто (версия, флаги, MTU, время) и 16 байт тега |
cipher_suites |
согласование шифра: выбирает пир, потому что узкое место — роутер, а не VPS (на MIPS программный AES-GCM в разы медленнее ChaCha20) |
Шифротекст от шума неотличим, поэтому GREASE-ECH настоящего Chrome и наше первое
сообщение внутри него — одна и та же строка байтов с точки зрения наблюдателя. Отпечаток
Hello сохраняется побайтово: tests/hellofreeze.c сверяет собранный Hello с заморозкой.
Аутентификатор в legacy_session_id — приём, взятый у Reality, и он же служит хабу
дешёвым предфильтром: один X25519 против сканерного шума, до всякой длинной арифметики.
Хаб не знает, чей это пир, до расшифровки, и это лучше альтернативы: в IK статический
ключ инициатора едет зашифрованным под es = DH(e, S_хаба), хабу нужен только его
собственный статический ключ, один на всех. Явный индекс пира на проводе раскрывал бы
постоянный идентификатор на фиксированном смещении в каждом рукопожатии — ровно тот
признак, по которому опознаётся WireGuard.
Отличий от ванильного Noise ровно два, и оба — конструкция TLS 1.3, а не изобретение:
транспортный nonce не плоский счётчик, а iv XOR смещение в потоке; Split() отдаёт 88
байт (ключ и iv на каждое направление), а не два ключа по 32.
Запись данных#
Заголовок записи — 17 03 03 LL LL. Версия 0x0303 («TLS 1.2») стоит там намеренно: её
же ставит настоящий TLS 1.3 ради посредников, не понимающих версию 1.3, и 0x0304
отличался бы первым байтом каждой записи.
Внутри записи нет ни поля типа, ни поля длины. Длину даёт заголовок записи, а тип — первый
байт открытого текста: у IP-пакета там версия, у служебного кадра — код из XS_CTL_*,
пустой открытый текст означает keepalive. Байта inner content type, который есть в
настоящем TLS 1.3, у нас нет: он лежал бы под AEAD, то есть был бы невидим DPI, а стоил бы
байт на каждый пакет.
nonce выводится из ОТНОСИТЕЛЬНОГО смещения в потоке, а не из абсолютного номера последовательности. Часть межсетевых экранов и CPE прибавляет постоянное Δ ко всем номерам потока; при nonce от абсолютного номера это означало бы, что каждый пакет расшифровывается неверно — тотальный молчаливый отказ. Получатель берёт начальный номер из того SYN, который фактически увидел, и Δ сокращается тождественно.
Заворот 32-битного смещения не обрабатывается, а исключается: соединение выводится из
работы на 1 ГиБ в одну сторону или через 20 минут — что раньше (XS_REL_RETIRE,
XS_AGE_RETIRE_MS). Один и тот же ретайр закрывает три задачи: прямую секретность (новое
соединение — новые эфемерные ключи), пределы AEAD и отсутствие ратчета на проводе — нет
кадра обновления ключей, нет бита фазы ключа, нет кода, который это разбирает. Успешник
поднимается заранее, на девяти десятых порога, потому что три X25519 на MIPS — это
50–100 мс, и делать их в потоке пересылки значит подарить пакетам такую же паузу.
Отдельно от ретайра ключи ратчетятся по объёму потока (xsepoch.c). Номер эпохи на
проводе не передаётся вовсе: обе стороны считают его сами из объёма, поэтому расхождение
проявляется не отказом, а тишиной — ловит его только стенд против векторов реализации на Go
(tests/xsepochmatch.c).
Пачка кадров#
Запись на каждый пакет выдавала бы туннель двумя признаками (сравнение с настоящим трафиком
xhttp — стенд tests/xhttp-compare.sh в репозитории Go). Первый: каждая запись равнялась бы
одному пакету и кончалась ровно на границе сегмента, тогда как у настоящего TLS запись живёт
своей длиной и границ сегментов не соблюдает; проверяется это одной строкой разбора, без
расшифровки. Второй: обратное направление состояло бы из сплошных мелких записей (внутренние
подтверждения TCP), тогда как у настоящей загрузки оно состоит в основном из голых
подтверждений.
Пачка (XS_CTL_BATCH) снимает оба: несколько кадров едут одной записью, запись становится
больше сегмента и разрезается между ними, мелких пакетов на проводе становится в разы
меньше, и голые подтверждения появляются сами — их больше нечем возить. И это дешевле, а
не дороже: шесть полноразмерных пакетов в одной записи стоят 46 байт накладных на пакет
против 61.
Битая пачка отвергается ЦЕЛИКОМ, и в обеих реализациях это устройство, а не обещание:
цепочка длин проходится дважды — первый проход только проверяет, второй (лишь при удачной
проверке) отдаёт кадры. Кадр из битого контейнера не доставляется ни один, потому что
отменить доставленное вызывающему нечем: на отказ он умеет только увеличить счётчик. Там же
проверяется и ЧИСЛО кадров: не больше восьми (XS_BATCH_FRAMES_MAX, BatchFramesMax) — тот
же предел, что на сборке, иначе контейнер на 8191 байт из однобайтовых кадров стоил бы 2730
вызовов обработчика.
Разгрузка сегментации на устройстве#
Одна запись пакета в TUN — это весь приёмный путь ядра: skb, копия из пользовательской памяти,
netfilter, маршрутизация. Одна запись СУПЕР-КАДРА из десятков таких пакетов (общий заголовок, до
64 КБ нагрузки, размер сегмента в метаданных) проходит этот путь один раз на весь кадр, то есть в
пересчёте на пакет на порядок дешевле. Ядро дальше либо отдаёт кадр целиком локальному сокету,
либо режет его на выходе, а на роутере режет сама микросхема (аппаратный TSO есть у mtk_eth_soc и
почти любого современного контроллера).
Обе половины механизма есть в обеих реализациях:
- на запись — склейка соседних сегментов одного потока в один кадр (
tun_gro_push,tun_gro_flush); - на приём —
TUNSETOFFLOADи разбор склеенного ядром обратно на пакеты. Буфер под суперпакет нужен ОДИН на очередь и живёт внутриtun.c: разбор отдаёт наружу по одному пакету, и путь данных о склейке не знает вовсе.
Разгрузка выключается тремя отдельными переменными, и это не отладочный остаток: выигрыш на своём
железе проверить можно, только вернувшись на путь без разгрузки одной переменной. STEER_TUN_NOGSO снимает метаданные целиком, STEER_TUN_NOGRO — только склейку на записи,
STEER_TUN_NORXGSO — только приём склеенного.
Поля заголовка virtio_net_hdr читаются и пишутся в порядке хоста, а не в сетевом: tun
согласовывает порядок только через VIRTIO_F_VERSION_1, которого не объявляет, поэтому ядро читает
их как __virtio16 в режиме legacy. Среди целей сборки есть big-endian, и зашитый little-endian
сломал бы разгрузку именно на нём — «на одной архитектуре пакеты не доходят».
Что проверяет tests/tungromatch: склеивается ли то, что должно, и не склеивается ли то, что
нельзя; пакеты, полученные разбором супер-кадра, побайтово те же, что пришли бы без склейки;
склейка и разбор обратны друг другу на одном наборе (в том числе флаг PSH: он стоит у последнего
пакета, а заголовок кадра берётся у первого); неполная сумма, оставленная ядром устройству,
достраивается до верной.
Чем собирать#
Расширенную половину ядра собирает zig с -O2, а не с умолчанием OpenWrt -Os: через AEAD и
контрольную сумму TCP проходит каждый байт каждого пакета туннеля, а -Os не разворачивает их
циклы (доводы — в шапках build/build-libs.sh, build/build-ext.sh и build/build-ext-sdk.sh).
В пакете роутера -O2 у libsteer.so (шифр, стек, транспорты) и у модулей, включая
steer-xsteer; ядро (steerd) собирается с -Os. Сборка — build/build-libs.sh (разделяемая
раскладка, раздел 1 docs/architecture.md); build/build-ext.sh собирает статические профили
(хаб, микропакет tgws). Рядом лежит рецепт тулчейном SDK OpenWrt — build/build-ext-sdk.sh: те же
опции wolfSSL, но тулчейн у SDK — отдельный на каждую цель, а zig один покрывает все.
Ссылка xs://#
Та же настройка в одну строку. Файл в стиле wg хорош там, где его пишут руками, и плох там, где его передают: в сообщении, в QR-коде, в поле ввода на странице. Ссылка решает ровно эту задачу и ничего кроме — формат ссылки и формат файла описывают ОДНО И ТО ЖЕ и разбираются в одну структуру.
xs://<приватный ключ пира>@<хост>:<порт>?pk=<публичный ключ хаба>&ip=<адрес/длина>#<имя>
| параметр | что задаёт |
|---|---|
pk |
публичный ключ хаба (обязателен) |
ip |
адрес внутри туннеля с длиной префикса (обязателен) |
allowed |
префиксы AllowedIPs через запятую; по умолчанию — сеть из ip |
sni |
имя, которым прикрывается рукопожатие |
mtu |
предел туннеля; по умолчанию выводится из MTU канала |
ka |
PersistentKeepalive в секундах; ka=0 выключает, отсутствие даёт умолчание 25 |
dns |
серверы имён через запятую; на роутере принимаются, но не применяются |
Имя после решётки — только для человека: поля «имя» в конфигурации нет.
Решений здесь четыре, и каждое стоит назвать.
Умолчание allowed — сеть из ip, а не 0.0.0.0/0. Полный туннель это решение человека, а не
то, что должно случаться от краткости ссылки. Полный туннель задаётся явным allowed=0.0.0.0/0.
Ключи — base64url без набивки (43 знака, алфавит с - и _): у обычного base64 есть + и /,
им в ссылке нужна процентная запись, и ссылка становится нечитаемой и ломается при копировании через
мессенджеры. На РАЗБОРЕ принимаются оба алфавита и набивка необязательна — человек вставляет ключ
прямо из wg-конфигурации.
Разбор строгий, как у файла. Неизвестный параметр — отказ с подсказкой, а не «запас на будущее»: опечатка в имени параметра, принятая молча, означает «настроено» без настройки. Повтор параметра — тоже отказ: «последний победил» означало бы, что смысл ссылки зависит от того, как её склеили.
Проверки не переписаны дважды. Ссылка собирается в текст конфигурации и разбирается ОБЩИМ разбором. Так исключено расхождение «ссылка приняла то, что файл отвергает» — самый неприятный вид разницы между двумя представлениями одного понятия.
В ссылке лежит приватный ключ — это не оплошность формата, а его суть: ссылка и есть выданный доступ, целиком. Отсюда два следствия. Её нельзя пересылать открытым каналом. И её нельзя передавать аргументом команды на машине, где есть кто-то ещё: аргументы видны в списке процессов и остаются в истории оболочки — для этого команда читает ссылку со стандартного ввода.
steer xsteer-link /etc/steer/xsteer/home.conf --name дом # файл → ссылка
echo "$L" | steer xsteer-link - # ссылка → файл
echo "$L" | steer xsteer-check --config - # проверить, ничего не записывая
steer xsteer --config "$L" --device xs-home # поднять прямо по ссылке
Направление выбирается по тому, ЧТО пришло, а не отдельным ключом: спрашивать человека, какой вид он
хочет, значило бы заставлять его называть то, что уже видно из аргумента. Ссылку принимает всякое
место, где ждут --config (xsteer, xsteer-check, xsteer-peers, xsteer-hub) — ровно как
conf.LoadAny в половине на Go, иначе ссылка, поднимающая туннель на десктопе, не работала бы на
роутере.
Формат сверяется побайтово с обеих сторон: tests/xslinkmatch.c здесь и
conf/link_cross_test.go там держат ОДИН И ТОТ ЖЕ вектор, поэтому изменение формата в одной половине
валит стенд в обеих, а не тихо расходится.
Что видно снаружи: файл состояния#
Пир пишет своё состояние в <каталог состояния>/xsteer-<имя>.json (по умолчанию /var/lib/steer) —
отдельным файлом, а не по запросу процесса, потому что проверка процесса это запуск процесса, а
состояние спрашивают раз в пять секунд. Читают его сторож и status; на роутере с splify2 — ещё и
интерфейс.
Сторожу этот файл нужен ещё и затем, чтобы узнать туннель в лицо. Один и тот же туннель поднимают
двумя способами: выходом kind: xsteer (его поднимает ядро) и обычным интерфейсом netifd с
proto xsteer (так делает splify2) — и во втором случае в спеке нет слова «xsteer» вовсе, там просто
имя устройства в пуле выхода kind: interface. Признак «наш ли это туннель» поэтому один на оба
способа: есть файл xsteer-<устройство>.json — устройство создал наш процесс. Имя в признак не
годится: приставку xs- задаёт умолчание настройки (device_name), а не правило.
Из этого следуют три решения сторожа, и все три — про то, чего с таким устройством делать НЕЛЬЗЯ.
Пинговать через него наружу: хаб полной звезды имеет право маршрутизировать только между пирами, и
при on_fail: drop проба поставила бы blackhole работающему выходу. Мерить им задержку: то же самое
число, только названное «медленно». И звать ifdown по имени устройства: интерфейс зовётся иначе
(xs0 против xs-xs0), и netifd отвечает на это «Interface not found». Живость берётся из поля
up, а если файл давно не переписывался (процесса нет) — из наличия самого устройства: netifd сносит
его следом за упавшим клиентом.
{"schema":1,"out":"xs-home","up":true,"mtu":1420,"conns":2,
"hub":"203.0.113.7:443","hub_key":"A8ltDzuN","handshake_age":37,
"stream":false,"offload":{"gso":true,"gro":true,"rx":true},
"mtu_confirmed":1420,"resets":3,
"tx_packets":10420,"tx_bytes":9812345,"rx_packets":11003,"rx_bytes":14002211,
"dropped":0,"last_down":"путь молчит"}
Три поля отвечают на вопросы, которые иначе не задать.
offload— что удалось договориться с ядром, а не что просили. Разгрузка это самая крупная прибавка к скорости из всего, что здесь есть (раздел «Разгрузка сегментации на устройстве»), и она НЕОБЯЗАТЕЛЬНА: устройство безmulti_queue, ядро безTUNSETOFFLOAD, переменнаяSTEER_TUN_NOGSOв окружении — и туннель работает ровно так же, только в разы медленнее. Отличить «медленно, потому что канал» от «медленно, потому что разгрузка не встала» иначе нечем: оба состояния выглядят как работающий туннель.resets— сколько раз ПОДНЯТАЯ сессия падала за жизнь процесса, иlast_down— почему в последний раз (хаб не ответил на SYN,рукопожатие не прошло,путь наружу пропал,путь молчит,поток молчит,поток закрылся,в потоке не запись xsteer,смена ключей). Восстановление после обрыва происходит само и быстро — и ровно поэтому снаружи его не видно: человек смотрит на работающий туннель и не знает, что тот за час переподнялся сорок раз. Считаются падения, а не попытки подъёма: попытка, не дошедшая до рукопожатия, ничего не роняла.mtu_confirmed— размер, подтверждённый пробой пути, противmtu, который несётся сейчас. Пока они расходятся, туннель работает на безопасном низу.
Согласование MTU#
Арифметика «MTU канала минус накладные» даёт лишь верхнюю оценку. Настоящий путь бывает уже, и обычно не сообщает об этом никак: слишком большой пакет молча исчезает, ICMP «нужна фрагментация» не приходит. Предел по арифметике, выше настоящего, стоил бы всей скорости: каждый полноразмерный пакет пропадал бы.
Две ступени, и первая без второй недостаточна:
- каждая сторона сообщает свой предел в рукопожатии, обе берут минимум;
- путь проверяется пробами (
XS_CTL_PROBE/XS_CTL_PACK): кадр ровно нужного размера уходит на ту сторону, та отвечает эхом с дошедшим размером.
Поиск идёт делением отрезка, а не лестницей с шагом: лестница останавливается на ступени ниже настоящего предела и теряет разницу из каждого пакета. Пока проба не подтвердила размер, несём безопасный низ (1200): иначе до конца проверки трафик идёт в ту самую чёрную дыру, которую мы и ищем. Проба повторяется раз в две минуты — путь меняется под живой сессией.
Конфигурация#
Формат взят у WireGuard нарочно: человек, поднимавший wg, уже знает этот файл. Отсюда и
пределы заимствования — ключи, поведение которых ядро не реализует, отвергаются с
объяснением, а не принимаются молча: Table, FwMark, PreUp/PostUp/PreDown/
PostDown, SaveConfig, PresharedKey. Скопированный из рабочего wg-quick Table = off
означал бы «настроено», не настроив ничего.
# пир (роутер)
[Interface]
PrivateKey = <32 байта в base64, как wg genkey>
Address = 10.7.0.2/24
MTU = 1439 # необязательно: считается из канала
SNI = www.example.com
Device = xs0
[Peer] # у пира ровно одна секция — хаб
PublicKey = <статический ключ хаба>
Endpoint = 203.0.113.10:443 # адресом, не именем
AllowedIPs = 10.7.0.0/24, 192.168.9.0/24
PersistentKeepalive = 25
Разбор строгий, в отличие от разбора spec.json: там неизвестный ключ пропускается
(спеку пишет управляющий слой, который может быть новее ядра), здесь файл пишет человек
или печатает наш же хаб, и опечатка в AllowedIPs, исчезнувшая молча, — это «туннель
поднялся, половина маршрутов пропала». Неизвестный ключ — отказ с номером строки и, где
можно, с подсказкой по расстоянию Левенштейна.
У хаба есть ещё три ключа — про то, что отвечать неопознанным:
# хаб (VPS)
[Interface]
PrivateKey = <32 байта в base64>
Address = 10.7.0.1/24
ListenPort = 443
Decoy = proxy # alert (умолчание), silent, reset или proxy
DecoyDest = 93.184.216.34:443 # адресом, не именем: разрешать имя пришлось бы из цикла,
# где нет ни одного блокирующего вызова
DecoySNI = www.example.com, cdn.example.net # по каким именам прибор выбирает прикрытие сам
alert — фатальное оповещение TLS, поведение без ключа. silent — не отвечать
вовсе (отличимо сильнее отказа). reset — RST. proxy — отдать соединение настоящему
серверу; DecoyDest при нём обязателен, и файл без него отвергается до подъёма: неверная
настройка защиты, обнаруженная под зондированием, — это защита, которой нет. Одновременно
проксируется не больше 32 соединений (иначе поток зондирования превращается в нашу же атаку на
прикрытие и на собственные дескрипторы), прикрытие ждём 5 секунд, дорожка без байтов живёт 30.
В конфигурации пира эти ключи отвергаются: неопознанные приходят на слушающий порт, а пир
никуда не слушает.
DecoySNI — список имён (до восьми), по которым прикрытие выбирает сам прибор: имя из его
ClientHello ищется в списке, и найденное ведёт к своему адресу, а не к постоянному DecoyDest.
Без этого ключа подлинный сертификат получает только тот, кто спросил имя DecoyDest, а всякий
другой видит расхождение «просил A — получил сертификат B». Имена разрешаются в адреса ровно
один раз, при подъёме хаба, и дальше живут таблицей в памяти: в цикле воркера нет ни одного
блокирующего вызова, а разрешение имени им и было бы. Отсюда три следствия, и все три — не
недоделки, а прямая цена этого решения:
- подстановок вида
*.example.comздесь нет и не будет. Имя из подстановки становится известно только в момент прихода прибора, то есть потребовало бы разрешения из цикла. Разбор отвергает такую запись отдельной причиной, а не общим «недопустимый символ»: её пишут, копируя ключ--decoy-sniреализации на Go, где подстановки есть (там соединение открывает отдельная горутина, которой блокироваться можно); - смена адреса у прикрытия замечается только перезапуском хаба — таблица снята при подъёме;
- имя, которое не разрешилось, хаб не роняет: оно просто не участвует в выборе, по нему
работает
DecoyDest, и об этом есть строка в журнале.
Проверяется это настоящим прибором. tests/probe.sh поднимает хаб в сетевом пространстве,
рядом с ним — настоящий сервер TLS в роли прикрытия, и стучится на порт openssl s_client,
который про наш протокол не знает ничего. Все четыре режима сравниваются между собой: у трёх
рукопожатия нет вовсе, у proxy оно состоялось целиком и с сертификатом прикрытия — это и есть
«порт неотличим от сервера HTTPS», утверждение, которое своим же разбором своих байтов не
проверить. Стенд перенесён из реализации на Go вместе с самой защитой и запускается вместе с
остальными стендами, требующими криптобиблиотеки (make ext-test); без root, netns, openssl или ethtool
он громко пропускается.
Тонкость окружения стенда: на veth ядро не считает контрольные суммы
(CHECKSUM_PARTIAL), а поддельный TCP проверяет их сам — и без выключения разгрузки
(ethtool -K) хаб честно объявляет каждый сегмент прибора битым. Выглядит это как «защита не
работает» на исправном хабе.
Имя, которого в списке нет (а также Hello, который не разобрался, и SNI с посторонними
символами), ведёт к адресу из DecoyDest, а не к отказу. Отказывай хаб
по незнакомому имени, порт начал бы отвечать по-разному на разные имена, и сама эта разница
рассказывала бы прибору, какие имена мы обслуживаем. Ключ имеет смысл только при Decoy = proxy
и в остальных режимах отвергается до подъёма.
Пределы: 32 пира на хаб, 64 префикса на пира, 16 КиБ на файл, до 4 поддельных соединений на пира (по одному на воркер — тогда счётчик nonce остаётся личным для потока, без атомиков и замков в самом опасном месте протокола).
Секреты отделены типом, а не дисциплиной: struct xs_conf, которая единственная
печатается наружу, приватного ключа не содержит вовсе. Обещание «секретов в выводе нет»
держится не на внимательности того, кто добавит поле в JSON.
В спеке ядра это отдельный вид выхода (kind: xsteer), а не свойство существующего:
устройства у него нет — его создаёт наш процесс, — и спека, применённая базовой сборкой
«как интерфейс», дала бы правила, метки и таблицу, ведущую в устройство, которого никто не
создаст. Отказ парсером здесь — единственный честный ответ.
Чего протокол не делает#
Списком, чтобы никто не считал это скрытым.
-
Против активного зондирования защищает только настройка, и по умолчанию она выключена. Прибор, приславший настоящий ClientHello (или «GET / HTTP/1.1», или просто мусор), без ключа
Decoyполучит фатальное оповещение TLS (handshake_failure, семь байт) и разрыв. Это лучше молчания — открытый порт, замолчавший на Hello, отличим сильнее, — но от целенаправленного зондирования не спасает: настоящий сервер с сертификатом для запрошенного имени присылает ServerHello и сертификат, и разница видна однимopenssl s_client.У хаба четыре режима ответа (ключ
Decoy, см. «Конфигурация»), и главный из них —proxy: хаб открывает настоящее соединение к сайту-прикрытию, отдаёт ему присланный ClientHello без единой правки и переливает байты в обе стороны через своё поддельное соединение. Прибор получает подлинный ServerHello, подлинный сертификат и подлинную страницу. Настоящей точки TCP на том же порту для этого не нужно — слушающий сокет ядра отвечал бы SYN-ACK нашим же пирам, — но поддельным TCP ядро steer владеет само, в пользовательском пространстве, и сокета ядра Linux на порту нет вовсе.Чего эта дорожка не даёт, названо прямо: повторных передач у нашего поддельного TCP нет, поэтому на рваном пути прибор увидит обрыв (неотличимо от плохой связи, но подозрительно); прикрытие отвечает через нас, значит круг задержки складывается и прибор, умеющий мерить время до ServerHello, разницу увидит — лечится выбором прикрытия рядом с хабом. Выбор прикрытия по имени из SNI есть и здесь (ключ
DecoySNI), но только по списку имён, разрешённому при подъёме: подстановок вроде*.example.comв этой половине не будет, потому что имя из подстановки становится известно лишь в момент прихода прибора, а в цикле воркера нет ни одного блокирующего вызова. Значит подлинный сертификат мы отдаём тому, кто спросил одно из перечисленных имён или имя самогоDecoyDest; всем прочим уходит сертификатDecoyDest, и расхождение видно. Подробности и остаточные риски —docs/detect.mdв репозитории Go. 2. Отличим от настоящего TLS при целенаправленном анализе — но не по одному признаку, а по остаткам: распределение размеров записей повторяет распределение внутренних пакетов, обмен двусторонний и непрерывный, повторных передач не бывает никогда. 3. Повторов нет. Потерянный сегмент — это потерянный внутренний пакет; восстанавливает его внутренний TCP, как в любом VPN поверх UDP. 4. Снаружи только IPv4 (внутри IPv6 нести можно, но маршрутизации IPv6 в хабе пока нет). Для клиента за v6-only это отказ. 5. Ни набивки, ни сокрытия тайминга; контроля перегрузки нет — как и у WireGuard. 6. Трафик пир↔пир не проходит через firewall хаба — плата за разворот в пользовательском пространстве. 7. Посредник, переписывающий номера последовательности не постоянным сдвигом (TCP-склейка, прозрачный прокси), ломает протокол целиком. 8. Сессия создаётся на первом же поддельном SYN, то есть кем угодно. Вытеснение устроено так, что неподтверждённая сессия уходит первой, а подтверждённую можно забрать только когда она почти мертва — молчит дольше минуты (шесть периодов keepalive хаба: это уже за границей, по которой пир сама считает путь мёртвым, и втрое раньше уборки по простою). Поэтому поток SYN с меняющихся портов не выбивает живые туннели. Цена названа прямо: пока таблица воркера полна живыми и молодыми сессиями, новый пир в приёме получит отказ — до тех пор, пока какая-нибудь из них не замолчит на минуту; отказ печатается через ограничитель и растит свой счётчик, чтобы снаружи это не выглядело как «пир молча не подключается». Память под таблицу поток SYN всё равно занимает, и предел на воркера — единственное, что это держит.
Известные расхождения между реализациями#
Сверять реализации имеет смысл только по проводу, поэтому расхождения перечислены здесь, а не в комментариях по месту.
- Совместимость с прошлым форматом. У обеих половин на Go есть ключ
--no-batch, возвращающий формат до пачек, — он нужен для разговора с хабом предыдущей версии. - Ссылка
xs://. Формат один и сверяется побайтово (см. раздел о ссылке). Расходится место, где ссылка принимается: у половины на Go её принимает и позиционный аргумент подкомандыlink, а здесь направление выбирается по входу, и подкоманда называетсяxsteer-link. Смысл и байты те же. - Защита от зондирования. Четыре режима есть у обеих реализаций и называются одними
словами (
Decoyв файле здесь,--decoyв ключе там), и выбор прикрытия по имени из SNI тоже есть у обеих (DecoySNIздесь,--decoy-sniтам). Расходится одно: подстановки (.example.com, то есть «любое имя в домене») работают только в Go — здесь имена разрешаются в адреса один раз при подъёме, и подстановка потребовала бы разрешения из цикла воркера. См. пункт 1 выше.
Как это проверяется#
Без сети, без прав root и без криптобиблиотеки (make test):
tests/xswirematch.c— арифметика записи, nonce, окно защиты от повтора, правило набора пачки;tests/xsconfmatch.c— разбор конфигурации, включая отвергаемые ключи и подсказки;tests/xsroutematch.c— подбор пира по префиксу и проверка права на адрес источника;tests/xsstreammatch.c,tests/tungromatch.c— режим потока и сборка пачек из очереди TUN;tests/chellomatch.c— разбор Hello и сверка с заморозкой;tests/xslinkmatch.c— ссылкаxs://: равенство файлу, круг без потерь, побайтовая сверка с вектором половины на Go, строгий отказ на каждой негодной ссылке и разбор недоверенной строки под санитайзерами (все обрезки и порча каждого байта).
На настоящей криптобиблиотеке (make ext-test): wolfSSL той же версии и с теми же опциями, что в
релизной сборке (версия и сумма — build/wolfssl/fetch.sh, опции — build/wolfssl/user_settings.h).
Исходники берутся из STEER_WOLFSSL или скачиваются со сверкой суммы; не нашлись и не скачались —
громкий пропуск, а не падение:
tests/scryptomatch.c— слой примитивовsrc/lib/scrypto.hпротив известных векторов (NIST, RFC) и проверка цепочек X.509 с подписями, выпущенными OpenSSL;tests/hellofreeze.c— побайтовая заморозка Hello (сборщик зовёт X25519 слоя);tests/xsepochmatch.c— ратчет ключей по объёму против векторов реализации на Go;tests/xsloop.c— рукопожатие целиком: сборка Hello, ответ хаба, вывод ключей;tests/spokematch.c— освобождение транспортных ключей при неудачном рукопожатии, под AddressSanitizer;tests/hubmatch.c— хаб без сети: арифметика записи (правило набора кадров в пачку против размера строки воркера), поддельный SYN в живую сессию, ограничители на событие, кадр IPv6 и нижняя граница MTU, а также все четыре режима ответа неопознанному — включаяproxyс настоящим прикрытием на петле (присланное уходит ему без правок, ответ режется на сегменты, закрытие прикрытия закрывает нашу сессию, предел одновременных соблюдается). Там же — выбор прикрытия по имени из SNI на настоящем ClientHello: имя из таблицы ведёт к своему адресу (и соединение к нему открывается по-настоящему), незнакомое имя, мусор вместо TLS и Hello, дочитанный наполовину, — кDecoyDest, а любая обрезка и порча любого байта Hello не уводит ни за буфер, ни к чужому адресу (стенд прогоняется и под-fsanitize=address,undefined). И политика вытеснения: неподтверждённая уходит первой, живая — только после минуты молчания, иначе отказ со своим счётчиком и своим ограничителем.
Живьём — tests/run-xsteer.sh и tests/run-xsteer-stream.sh (пространства имён), а
перекрёстные сочетания половин C и Go — tests/matrix.sh в репозитории Go.