Шифрование должно быть свойством канала, а не обязанностью приложения


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

Протокол использует пакеты со следующей структурой:

ПОЛЕ

РАЗМЕР

НАЗНАЧЕНИЕ

magic

4 байта

0x54435053 («TCPS»)

type

1 байт

Тип сообщения

pubkey

32 байта

Публичный ключ X25519 отправителя

enc_init

32 байта

Зашифрованный init-ключ отправителя (только в KEYXCHG+)

auth_tag

16 байт

Полный тег аутентификации Poly1305 (только в KEYXCHG_AUTH)

Три типа сообщений:

  1. DISCOVER (type=0x01). Минимальный пакет из 37 байт: магика + тип + публичный ключ. Отправляется unicast на IP пира, у которого ещё нет PSK. По сути — «я здесь, вот мой ключ, давай обменяемся».

  2. 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-ключ отправителя, выполнив обратную операцию.

  3. 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. Пакеты сверх лимита молча отбрасываются.

Полный обмен

  1. Клиент отправляет DISCOVER → сервер отвечает KEYXCHG (или KEYXCHG_AUTH, если есть предыдущий PSK)

  2. Сервер тоже может инициировать DISCOVER в ответ

  3. Получатель KEYXCHG пробует расшифровать enc_init текущим и предыдущим init-ключами (на случай ротации), валидирует через tcps_derive_public

  4. Из успешного DH + обоих init-ключей выводится PSK

  5. 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

КЛЮЧ

"TCPS c2s"

(1ULL<<63)

enc_c2s — шифрование клиент→сервер

"TCPS s2c"

`(1ULL<<63)

64`

"TCPS cmac"

`(1ULL<<63)

128`

"TCPS smac"

`(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-байтовым тегом) — от этого подхода отказались по нескольким причинам:

  1. 4 байта = лишь 32 бита безопасности. Подделка с вероятностью 1/2³² реальна для целенаправленного атакующего

  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() — генерация случайной пары X25519

  • tcps_derive_public() — вывод публичного ключа из приватного с clamping

Это полностью устраняет зависимость от kernel crypto API для X25519. Модуль по-прежнему декларирует MODULE_LICENSE("GPL") для совместимости с другими GPL-only символами, но критичная криптография больше не привязана к конкретной версии ядра.

Обработка GSO-пакетов

Generic Segmentation Offload (GSO) — механизм, при котором TCP-стак формирует один большой сегмент (до 64 КБ), который позже разбивается на MTU-размерные пакеты — аппаратно (NIC TSO) или программно. GSO-пакеты обрабатываются следующим образом:

  1. Сегментация: skb_gso_segment() разбивает GSO-пакет на отдельные MTU-сегменты

  2. Шифрование каждого сегмента: ChaCha20 + полный Poly1305 MAC (16 байт) — как для обычных пакетов

  3. Реинжекция: каждый зашифрованный сегмент отправляется через ip_local_out() с маркером TCPS_SKB_MARK = 0x54435053 на skb

  4. Защита от повторной обработки: при повторном входе в tcps_out() модуль проверяет skb->mark == TCPS_SKB_MARK и пропускает уже зашифрованные сегменты

  5. Отключение 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, параметр настраиваемый). При ротации:

  1. Текущий init-ключ копируется в prev_init_key (хранится один предыдущий)

  2. Генерируется новая пара X25519

  3. Новый ключ сохраняется в key_file (если задан)

  4. Всем известным пирам отправляется 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() не справляются, сегмент молча дропается без уведомления приложения.

Параметры модуля

ПАРАМЕТР

ТИП

ПО УМОЛЧАНИЮ

ОПИСАНИЕ

skip_ports

int[]

{22}

Порты, исключаемые из шифрования (до 8 портов)

strict_tofu

int

0

Блокировать изменение ключа пира (1=блокировать)

psk_require_verify

int

0

Требовать ручную верификацию PSK перед использованием

rotate_interval

int

3600

Интервал ротации identity-ключа (секунды)

key_file

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 — форкайте, экспериментируйте, используйте где угодно.