TCP-S: прозрачное шифрование TCP-сокетов на хосте через модуль ядра Linux
Шифрование должно быть свойством канала, а не обязанностью приложения
Я давно озадачивался вопросом: как избавить себя от трудозатрат на внедрение систем централизованного управления сертификатами и при этом дополнить существующий транспорт TCP/IP шифрованием? Представьте: весь маршрут анализируется враждебной средой, где каждый может просматривать содержимое трафика, и для каждой единицы нужны методы сокрытия. Хотелось получить прозрачный, автоматический TLS, но без возни с сертификатами, ALPN и модификацией приложений.
В итоге получилось решение в виде модуля ядра Linux — надстройки прямо над протоколом TCP. Он цепляется за netfilter-хуки LOCAL_OUT и PRE_ROUTING и прозрачно шифрует вообще все TCP-сокеты на хосте. Если модуль установлен на клиенте и сервере, весь трафик между ними автоматически идёт в криптованном виде. Это даёт колоссальный выигрыш по времени внедрения, когда нужно быстро скрыть содержимое соединений от систем анализа и демаскирования.
Обнаружение пиров: TCP-пробник и UDP-протокол
Архитектура TCP-S состоит из двух уровней: обнаружения и шифрования. Ключевой дизайнерский выбор — вынести обмен криптографическим материалом за пределы TCP-опций, где места критически мало, в отдельный UDP-протокол. А в TCP-опциях оставить лишь минимальный маркер поддержки.
TCP-пробник (kind=253)
Для сигнализации о поддержке TCP-S используется экспериментальная TCP-опция с kind=253. Она занимает всего 4 байта:
Байт 0:
253(kind)Байт 1:
4(length)Байты 2–3:
0x5443(магика «TC»)
Модуль добавляет эту опцию к каждому исходящему SYN-пакету (через LOCAL_OUT) и при получении SYN с такой опцией на PRE_ROUTING понимает, что удалённая сторона тоже поддерживает TCP-S. Если SYN-ACK приходит без пробника — соединение удаляется из таблицы отслеживания, и трафик идёт как обычный TCP.
UDP discovery-протокол (порт 54321)
Как только модуль видит SYN с пробником или сам его отправляет, он запускает отдельный механизм обмена ключами — через UDP-пакеты на порт 54321. Это kernel-тред, который работает независимо от TCP-соединений и обеспечивает пиру все необходимые криптографические материалы до того, как пойдёт пользовательский трафик.
Протокол использует пакеты со следующей структурой:
ПОЛЕ | РАЗМЕР | НАЗНАЧЕНИЕ |
|---|---|---|
| 4 байта |
|
| 1 байт | Тип сообщения |
| 32 байта | Публичный ключ X25519 отправителя |
| 32 байта | Зашифрованный init-ключ отправителя (только в KEYXCHG+) |
| 16 байт | Полный тег аутентификации Poly1305 (только в KEYXCHG_AUTH) |
Три типа сообщений:
DISCOVER (type=0x01). Минимальный пакет из 37 байт: магика + тип + публичный ключ. Отправляется unicast на IP пира, у которого ещё нет PSK. По сути — «я здесь, вот мой ключ, давай обменяемся».
KEYXCHG (type=0x02). Пакет из 69 байт, в котором появляется поле
enc_init. Отправитель вычисляет общий DH-секрет:dh_shared = X25519(my_init_key, peer_pubkey), а затем шифрует свой init-ключ через ChaCha20 сdh_sharedв качестве ключа:enc_init = ChaCha20(dh_shared, pos=0, my_init_key). Получатель может расшифровать и восстановить init-ключ отправителя, выполнив обратную операцию.KEYXCHG_AUTH (type=0x03). Полный пакет из 85 байт — это KEYXCHG плюс 16-байтовый
auth_tag. Этот тип используется, когда у сторон уже есть предыдущий PSK (например, при ротации ключей). Тег вычисляется через Poly1305 на предыдущем PSK: AAD покрывает type + pubkey + enc_init (65 байт), payload пуст. Полный 16-байтовый тег Poly1305 записывается вauth_tag— это даёт 128-битную защиту от подделки, на порядок сильнее предыдущего 4-байтового варианта. Тег доказывает, что отправитель знает предыдущий PSK, и защищает от подмены при ротации.
Защита от DoS: rate limiting
Discovery-протокол теперь защищён от флуда: каждый X25519-обмен требует CPU, и злоумышленник мог бы забить ядро запросами. Введены два лимита:
Per-IP: не более 1 пакета за 2 секунды с одного адреса
Global: не более 10 пакетов в секунду на все входящие
Реализовано через tcps_disc_rate_check() с массивом из 16 слотов для per-IP трекинга и spinlock. Пакеты сверх лимита молча отбрасываются.
Полный обмен
Клиент отправляет DISCOVER → сервер отвечает KEYXCHG (или KEYXCHG_AUTH, если есть предыдущий PSK)
Сервер тоже может инициировать DISCOVER в ответ
Получатель KEYXCHG пробует расшифровать
enc_initтекущим и предыдущим init-ключами (на случай ротации), валидирует черезtcps_derive_publicИз успешного DH + обоих init-ключей выводится PSK
Discovery-тред работает циклично: каждые 3 секунды проверяет, нет ли пиров без PSK, и отправляет им DISCOVER
Деривация ключей: от DH до сессионных ключей
Процесс состоит из двух этапов: сначала выводится PSK, затем из PSK — сессионные ключи для конкретного соединения.
Вывод PSK
Функция tcps_derive_psk собирает 96-байтовый буфер:
text
"TCPS-PSK" (8 байт) dh_shared (32 байта) init_key_a (32 байта) || init_key_b (32 байта)
Порядок init-ключей определяется детерминированно — через memcmp публичных ключей: чей ключ меньше, тот и «a». Буфер обрабатывается ChaCha20 с dh_shared в качестве ключа на позиции (1ULL << 62), и первые 32 байта результата становятся PSK. Важно: в деривацию входят оба init-ключа, а не только DH-секрет — это даёт привязку к identity обоих сторон.
Вывод сессионных ключей
Когда TCP-соединение переходит в состояние KEYED (оба ISN известны), вызывается tcps_derive_keys(psk, client_isn, server_isn, ...):
Extract-фаза. Собирается 40-байтовый буфер: client_isn (LE32) server_isn (LE32) psk (32 байта). ChaCha20 с PSK в качестве ключа на позиции 0 шифрует этот буфер, и первые 32 байта результата становятся PRK.
Expand-фаза. Из PRK через kdf_expand выводятся четыре 256-битных ключа:
ЛЕЙБЛ | ПОЗИЦИЯ CHACHA20 | КЛЮЧ |
|---|---|---|
|
|
|
| `(1ULL<<63) | 64` |
| `(1ULL<<63) | 128` |
| `(1ULL<<63) | 192` |
Клиент получает: enc=enc_c2s, dec=enc_s2c, mac_enc=mac_c2s, mac_dec=mac_s2c. Сервер — зеркально. Четыре независимых ключа — это классическое разделение из Noise Framework: компрометация MAC-ключа одного направления не позволяет расшифровать трафик или подделать противоположное направление.
DH fallback и zero-key fallback
Если PSK ещё не готов (discovery не завершился) или не верифицирован при psk_require_verify=1, модуль пытается DH fallback: вычисляет dh_shared = X25519(my_init_key, peer_pubkey) и выводит PSK через tcps_derive_psk_fallback с лейблом "TCPS-FB" на позиции (1ULL<<62)|(1ULL<<55). Этот fallback-PSK слабее полного — он не включает init-ключи, только DH-секрет.
Если и DH невозможен (пир неизвестен), или DH-секрет оказался нулевым, используется zero-key fallback: PSK из 32 нулевых байт. Это обеспечивает формальную совместимость, но не даёт никакой безопасности — в логе появляется предупреждение «using zero fallback».
Потоковое шифрование данных
Весь полезный трафик шифруется ChaCha20 — поточным шифром, который не меняет размер данных и не требует выравнивания. Счётчик шифра привязан к номеру последовательности TCP, что позволяет шифровать пакеты независимо и не ломаться при переупорядочивании.
Позиция для ChaCha20 вычисляется из 64-битного значения: (seq - ISN_base - 1) + seq_hi, где seq_hi отслеживает обёртку 32-битного счётчика. Это даёт монотонно возрастающую позицию в потоке даже при wraparound.
MAC: полный 16-байтовый Poly1305 как суффикс payload
Каждый сегмент с данными (или FIN с данными) несёт 16-байтовый MAC-тег, appended непосредственно к зашифрованному payload. Раньше MAC передавался как отдельная TCP-опция (TM-опция, 8 байт с усечённым 4-байтовым тегом) — от этого подхода отказались по нескольким причинам:
4 байта = лишь 32 бита безопасности. Подделка с вероятностью 1/2³² реальна для целенаправленного атакующего
TCP-опции — ограниченный ресурс. Место в 40-байтовом пространстве опций нужно для MSS, SACK, window scale, и добавление TM-опции создавало фрагментацию
Теперь MAC — это полный 16-байтовый тег Poly1305 (128 бит безопасности), добавляемый как суффикс к payload. Это стандартный подход AEAD: зашифрованные данные + тег аутентификации в едином блоке. MSS автоматически уменьшается на TCPS_MAC_SIZE (16 байт), чтобы accommodate дополнительный тег.
AAD (Additional Authenticated Data) для Poly1305 состоит из 5 байт: 1 байт TCP-флагов + 4 байта номера последовательности (little-endian). Это покрывает флаги FIN/RST, защищая от инъекции пакетов с поддельными флагами. AAD строится функцией tcps_build_aad().
Ключ Poly1305 уникален для каждого пакета: он вычисляется из mac_key через ChaCha20 на позиции pos+32 (где pos — позиция в потоке). Это одноразовый ключ, что предотвращает атаки повторного воспроизведения и подмены порядка.
Отслеживание MAC: peer_has_mac
После первого успешного MAC-верифицирования на соединении устанавливается флаг peer_has_mac = 1. После этого:
Входящие данные без MAC-тега дропаются (NF_DROP) — защита от bit-flipping
Входящие RST без MAC-тега дропаются — защита от RST injection
Это гарантирует, что после установки шифрования атакующий не может «откатить» соединение к открытому режиму
Собственный Poly1305 на 26-битных лимбах
Poly1305 написан полностью с нуля и работает на так называемых 26-битных лимбах. Это позволяет хранить 130-битные элементы поля в пяти машинных словах по 26 бит, оставляя два бита под переносы, и избегать тяжеловесной длинной арифметики. Умножение и редукция делаются простыми целочисленными операциями без единого вызова OpenSSL.
Собственный X25519: никаких GPL-only зависимостей
Ранее код использовал #include <crypto/curve25519.h> из ядра — GPL-only экспорт, который заставлял декларировать MODULE_LICENSE("GPL") и создавал зависимость от конкретной версии kernel crypto API.
Теперь в tcps_crypto.c размещена полная собственная реализация X25519 (~280 строк) на основе donna-стиля:
Арифметика поля
fe25519через__int128— сложение, вычитание, умножение, возведение в квадратMontgomery ladder для скалярного умножения
tcps_dh_shared()— ECDH с проверкой на all-zero (нулевые публичные ключи отклоняются)tcps_gen_keypair()— генерация случайной пары X25519tcps_derive_public()— вывод публичного ключа из приватного с clamping
Это полностью устраняет зависимость от kernel crypto API для X25519. Модуль по-прежнему декларирует MODULE_LICENSE("GPL") для совместимости с другими GPL-only символами, но критичная криптография больше не привязана к конкретной версии ядра.
Обработка GSO-пакетов
Generic Segmentation Offload (GSO) — механизм, при котором TCP-стак формирует один большой сегмент (до 64 КБ), который позже разбивается на MTU-размерные пакеты — аппаратно (NIC TSO) или программно. GSO-пакеты обрабатываются следующим образом:
Сегментация:
skb_gso_segment()разбивает GSO-пакет на отдельные MTU-сегментыШифрование каждого сегмента: ChaCha20 + полный Poly1305 MAC (16 байт) — как для обычных пакетов
Реинжекция: каждый зашифрованный сегмент отправляется через
ip_local_out()с маркеромTCPS_SKB_MARK = 0x54435053на skbЗащита от повторной обработки: при повторном входе в
tcps_out()модуль проверяетskb->mark == TCPS_SKB_MARKи пропускает уже зашифрованные сегментыОтключение GSO на сокете: при первой встрече GSO-пакета на соединении устанавливается
sk->sk_gso_type = 0— последующие пакеты на этом сокете приходят уже сегментированными
Оригинальный GSO-пакет освобождается (kfree_skb), возвращается NF_STOLEN. Это гарантирует, что ни один байт пользовательских данных не уходит в сеть без шифрования.
Аутентификация TOFU (Trust On First Use)
У каждого хоста при загрузке модуля генерируется статический identity init-ключ X25519. По умолчанию он живёт исключительно в оперативной памяти и теряется при выгрузке модуля. Однако при необходимости можно указать параметр key_file=/path/to/key — тогда ключ сохраняется на диск (права 0600) и загружается при следующем старте, обеспечивая стабильный identity между перезагрузками.
При первом соединении с незнакомым IP публичный ключ пира запоминается в кеше пиров (до 64 пиров, TCPS_MAX_PEERS) вместе с выведенным PSK. При всех последующих соединениях PSK берётся из кеша. Если публичный ключ пира изменился:
При
strict_tofu=0(по умолчанию): ключ обновляется, PSK сбрасывается, происходит повторный обмен — в логе предупреждениеПри
strict_tofu=1: новый ключ блокируется, соединение не шифруется — защита от MITM
Для ручной верификации PSK предусмотрен интерфейс /proc/tcps_peers: команда verify <IP> <fingerprint> сверяет первые 8 байт PSK с введённым значением. При psk_require_verify=1 PSK не используется для деривации ключей, пока администратор его не верифицирует — это защита от подмены на этапе первого обмена.
Ротация identity-ключей
Init-ключ автоматически ротируется каждые rotate_interval секунд (по умолчанию 3600, параметр настраиваемый). При ротации:
Текущий init-ключ копируется в
prev_init_key(хранится один предыдущий)Генерируется новая пара X25519
Новый ключ сохраняется в
key_file(если задан)Всем известным пирам отправляется DISCOVER — это инициирует повторный KEYXCHG с новым ключом
Previous init-ключ нужен для обработки «висящих» KEYXCHG-пакетов от пиров, которые ещё не узнали о ротации: при расшифровке enc_init модуль пробует оба ключа. В KEYXCHG_AUTH используется предыдущий PSK для аутентификации обмена — полный 16-байтовый Poly1305 тег гарантирует, что даже при активном перехвате злоумышленник не сможет подменить ключ в процессе ротации.
Важно: ротация затрагивает новые соединения. Уже установленные сессии продолжают работать с прежними сессионными ключами до завершения соединения.
Безопасность и защита от атак
Forward secrecy — ограниченная. Чувствительные данные (DH-секреты, промежуточные буферы KDF, PSK) затираются через memzero_explicit(). Однако identity-ключ статический с ротацией раз в час, а не эфемерный per-connection. Компрометация init-ключа позволяет вычислить PSK и, при знании ISN, восстановить сессионные ключи для соединений в пределах периода ротации.
Downgrade-защита отсутствует. Если злоумышленник вырежет TCP-опцию с пробником из SYN, соединение установится как обычный TCP без шифрования. Для исключения конкретных портов из шифрования предусмотрен параметр skip_ports (по умолчанию порт 22), но принудительного режима «шифровать или рвать» нет. Это осознанный выбор в пользу совместимости.
RST injection — защищено. Для соединений с peer_has_mac=1 входящие RST без MAC-тега дропаются. RST с корректным MAC принимается штатно.
MAC — 128 бит безопасности. Полный 16-байтовый Poly1305 тег даёт 128-битную защиту от подделки — на 96 бит сильнее предыдущего 4-байтового варианта. Подобрать тег случайным перебором невозможно при любых вычислительных ресурсах.
Timing-атаки. Для сравнения MAC используется кастомная функция tcps_ct_memcmp — побайтовое XOR-сравнение без раннего выхода. Однако в нескольких критичных местах используется обычный memcmp(): сравнение публичных ключей пиров, сравнение PSK-fingerprint при верификации. Это потенциальная поверхность для timing-атак.
Отсутствие зависимости от OpenSSL и kernel crypto API (X25519). Вся криптография — собственные реализации ChaCha20, Poly1305 и X25519 в tcps_crypto.c. Ни одной строчки кода OpenSSL или crypto/curve25519.h.
GSO полностью зашифрован. GSO-пакеты сегментируются, шифруются и реинжектируются с маркером TCPS_SKB_MARK, исключающим повторную обработку.
Discovery rate limiting. Per-IP (1/2 сек) и global (10/сек) лимиты защищают от DoS через X25519-вычисления.
Ограничения
IPv4 only. Модуль завязан на
NFPROTO_IPV4,struct iphdr,struct sockaddr_in. IPv6 не поддерживается.TOFU при первом соединении. Как и в SSH, первое подключение происходит без верификации. Рекомендую сверять fingerprint через
/proc/tcps_peersили использоватьpsk_require_verify=1.Максимум 64 пира.
TCPS_MAX_PEERS = 64— хеш-таблица пиров статическая. При большом количестве удалённых хостов возможны коллизии.Pure ACK не аутентифицируются. Пустые ACK без данных не несут MAC. Злоумышленник не может ни подделать, ни расшифровать данные; инъекция пустого ACK не меняет состояние потока.
Нет ротации сессионных ключей. В долгоживущих соединениях ключи не обновляются. При передаче десятков гигабайт это может приближаться к границам безопасности ChaCha20.
Несколько
memcmpвместо constant-time. Сравнение публичных ключей и PSK-fingerprint не защищено от timing-атак.DH fallback слабее полного PSK. Fallback-PSK не включает init-ключи, а zero-key fallback не даёт никакой безопасности.
GSO-ошибки = тихая потеря данных. Если
skb_gso_segment()илиip_local_out()не справляются, сегмент молча дропается без уведомления приложения.
Параметры модуля
ПАРАМЕТР | ТИП | ПО УМОЛЧАНИЮ | ОПИСАНИЕ |
|---|---|---|---|
| int[] | {22} | Порты, исключаемые из шифрования (до 8 портов) |
| int | 0 | Блокировать изменение ключа пира (1=блокировать) |
| int | 0 | Требовать ручную верификацию PSK перед использованием |
| int | 3600 | Интервал ротации identity-ключа (секунды) |
| string | NULL | Путь к файлу для персистенции identity-ключа |
Лицензия
Исходный код распространяется под лицензией MIT (файл LICENSE в репозитории). Сам модуль декларирует MODULE_LICENSE("GPL") — это необходимо для доступа к GPL-only символам ядра. Собственная реализация X25519 устраняет критичную зависимость от crypto/curve25519.h, но GPL-декларация сохранена для совместимости с другими символами.
Установка и работа
Модуль собирается и работает на arm64 и amd64 (код архитектурно-нейтральный C с __int128 для X25519). После загрузки любое TCP-соединение между двумя хостами с модулем начинает автоматически шифроваться — будь то PostgreSQL, HTTP, SSH или любое самописное приложение, работающее поверх TCP.
git clone https://github.com/Last-Guy-In-Stars/TCP-S.git
cd TCP-S
make
sudo insmod tcps.ko # с параметрами по умолчанию
sudo insmod tcps.ko strict_tofu=1 key_file=/etc/tcps.key # для продакшенаПроверить работу проще всего через tcpdump: SYN-пакеты будут содержать опцию с kind=253 и магикой «TC», а payload станет нечитаемым. Логи модуля выводятся через dmesg | grep tcps. Список пиров и статус PSK — через cat /proc/tcps_peers. Верификация — echo "verify 10.0.0.1 aabbccddeeff0011" > /proc/tcps_peers.
Репозиторий: https://github.com/Last-Guy-In-Stars/TCP-S
Лицензия MIT — форкайте, экспериментируйте, используйте где угодно.