splify2
Протоколы

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 «нужна фрагментация» не приходит. Предел по арифметике, выше настоящего, стоил бы всей скорости: каждый полноразмерный пакет пропадал бы.

Две ступени, и первая без второй недостаточна:

  1. каждая сторона сообщает свой предел в рукопожатии, обе берут минимум;
  2. путь проверяется пробами (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), а не свойство существующего: устройства у него нет — его создаёт наш процесс, — и спека, применённая базовой сборкой «как интерфейс», дала бы правила, метки и таблицу, ведущую в устройство, которого никто не создаст. Отказ парсером здесь — единственный честный ответ.

Чего протокол не делает#

Списком, чтобы никто не считал это скрытым.

  1. Против активного зондирования защищает только настройка, и по умолчанию она выключена. Прибор, приславший настоящий 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.