- u_async.c: process immediate_queue BEFORE epoll_wait in uasync_poll - stcp_link.c: defensive NULL etcp_conn/etcp_link in stcp_link_close - utun_instance.c: move stcp_server_list_destroy_all to Phase J (before ETCP), add deferred drain in both Phase J and L - stcp.h/c: STCP_HS_ENC_CLIENT 6→38, STCP_HS_ENC_SERVER 7→39, exchange ed25519_pubkey in both handshake directions - etcp_connections.c: copy peer_ed25519 from STCP to ETCP_CONN in etcp_link_enter_ready_tcp; guard link->etcp in tcp_link_close_cb - chat_sync.c: fix lk->conn NULL dereference for TCP links - test_stcp.c: update stcp_server_create/stcp_client_connect callsites - tools/chat_tcp_test: change transport udp→tcp
24 KiB
secure_channel
1. Назначение
Модуль реализует криптографический слой uTun: защищённый канал между двумя узлами на основе X25519 (Elliptic Curve Diffie-Hellman) для обмена сессионным ключом и AES-128-CCM (Counter with CBC-MAC) для аутентифицированного шифрования трафика. Дополнительно предоставляет Ed25519 для цифровых подписей (аутентификация данных, происходящих от конкретного узла) и стриминговое шифрование (AES-128-CTR) для больших потоков данных, где нужна только конфиденциальность без аутентификации.
Это один из важнейших модулей проекта — каждый ETCP-пакет, проходящий между узлами, шифруется и аутентифицируется через этот канал.
Ключевые криптографические операции:
- X25519 ECDH — получение общего секрета из своего приватного и чужого публичного ключа
- AES-128-CCM — аутентифицированное шифрование (confidentiality + integrity): nonce(13) + ciphertext + tag(16)
- SHA256-based обфускация pubkey — скрытие публичного ключа при передаче в INIT-пакетах (XOR с SHA256(salt || peer_pubkey))
- Ed25519 — цифровые подписи данных (one-shot и стриминговая через SHA-512 аккумулятор)
- AES-128-CTR — стриминговое шифрование без аутентификации (bulk data)
- CRC32 — дополнительный контроль целостности расшифрованных данных
2. Как пользоваться
2.1. Типовой сценарий: генерация → обмен → шифрование
// ── Шаг 1: Загрузка или генерация ключей ──
struct SC_MYKEYS my_keys;
// Вариант A: сгенерировать новую пару
sc_generate_keypair(&my_keys);
// Вариант B: загрузить из конфига (hex-строки)
sc_init_local_keys(&my_keys, pubkey_hex, privkey_hex);
// ── Шаг 2: Инициализация контекста ──
sc_context_t ctx;
sc_init_ctx(&ctx, &my_keys);
// Теперь ctx.session_ready == 0 — ждём установки ключа пира
// ── Шаг 3: Установка публичного ключа пира (после получения из сети) ──
uint8_t peer_pubkey[SC_PUBKEY_SIZE];
// ... получить peer_pubkey из INIT-пакета / конфига ...
sc_set_peer_public_key(&ctx, peer_pubkey, SC_PEER_PUBKEY_BIN); // или SC_PEER_PUBKEY_HEX
// Внутри: X25519 ECDH → shared_secret → SHA256(shared_secret || "uTun-v3-session") → session_key
// Теперь ctx.session_ready == 1
// ── Шаг 4: Шифрование / дешифрование ──
uint8_t ciphertext[1500];
size_t ct_len = sizeof(ciphertext);
// Шифрование: nonce(13) || AES-CCM(plaintext+crc32) || tag(16)
sc_encrypt(&ctx, plaintext, plaintext_len, ciphertext, &ct_len);
// Дешифрование: проверка тега (аутентификация) → проверка CRC32 → возврат plaintext
uint8_t decrypted[1500];
size_t pt_len = sizeof(decrypted);
sc_decrypt(&ctx, ciphertext, ct_len, decrypted, &pt_len);
2.2. Обфускация pubkey (INIT-пакеты)
При отправке INIT-пакета публичный ключ не передаётся в открытом виде. Вместо этого он обфусцируется через XOR с SHA256-хэшем от соли и публичного ключа пира:
uint8_t salt[SC_PUBKEY_ENC_SALT_SIZE]; // 8 случайных байт
random_bytes(salt, sizeof(salt));
uint8_t obfuscated[SC_PUBKEY_SIZE]; // 32 байта
sc_obfuscate_pubkey(salt, peer_pubkey, my_pubkey, obfuscated);
// Передаётся в пакете: salt(8) || obfuscated_pubkey(32) = 40 байт (SC_PUBKEY_ENC_SIZE)
Формат пакета с обфусцированным pubkey:
- Заголовок и данные шифруются AES-CCM
- Блок
salt(8) + obfuscated_pubkey(32)добавляется после зашифрованных данных, без шифрования
Деобфускация на приёмной стороне:
// Получатель знает salt из пакета и свой публичный ключ
sc_obfuscate_pubkey(salt, my_pubkey, received_obfuscated, decrypted_pubkey);
// XOR обратим: obfuscated XOR SHA256(salt||my_pubkey) = исходный pubkey отправителя
Важный нюанс реализации: в текущей версии sc_obfuscate_pubkey() вычисляет два SHA256-хэша (SHA256(salt||peer_pubkey) и SHA256(peer_pubkey||salt)), но XOR'ит публичный ключ только с первым. Второй хэш вычисляется, но не используется (возможно, задел на будущее).
2.3. Стриминговое шифрование (AES-128-CTR)
Для больших объёмов данных, где не нужна посылочная аутентификация:
struct sc_stream_state stream;
sc_stream_init(&ctx, &stream, stream_id); // stream_id — произвольный uint32_t
sc_stream_xor(&stream, data_chunk1, len1); // XOR in-place (шифрует/дешифрует)
sc_stream_xor(&stream, data_chunk2, len2);
sc_stream_cleanup(&stream);
Nonce (12 байт) для CTR выводится из SHA256(session_key || stream_id_LE || "uTun3-stream").
2.4. Ed25519 подписи
One-shot:
uint8_t sig[SC_SIGN_SIZE]; // 64 байта
sc_ed25519_sign(privkey, msg, msg_len, sig);
int ok = sc_ed25519_verify(pubkey, msg, msg_len, sig); // SC_OK при успехе
Стриминговая (потоковая) подпись: SHA-512 аккумулятор + one-shot Ed25519 в финале. Подходит для данных любого размера (память O(1)):
struct sc_stream_sign_state state;
// Подпись
sc_stream_sign_init(&ctx, &state);
sc_stream_sign_update(&state, chunk1, len1);
sc_stream_sign_update(&state, chunk2, len2);
sc_stream_sign_final(&state, sig, &sig_len); // автоматический cleanup
// Верификация
sc_stream_sign_verify_init(&state, ed25519_pubkey);
sc_stream_sign_update(&state, chunk1, len1);
sc_stream_sign_update(&state, chunk2, len2);
int result = sc_stream_sign_verify(&state, sig, sig_len); // SC_OK или SC_ERR_AUTH_FAILED
Деривация Ed25519-ключа из X25519: sc_derive_ed25519_pubkey(x25519_privkey, ed25519_pubkey) — SHA-512 от X25519-приватного ключа даёт Ed25519-приватный ключ (32 байта), из которого извлекается Ed25519-публичный ключ. Это позволяет одному ключу обслуживать и ECDH, и подписи.
2.5. Деривация node_id
uint64_t node_id = sc_derive_node_id(private_key);
// SHA256(private_key) → первые 8 байт, старший бит обнулён (0x7FFFFFFFFFFFFFFF)
2.6. SHA256-based транскодирование
sc_sha_transcode(key, key_len, data, data_len);
// data[i] ^= SHA256(key)[i % 32] — простой XOR с SHA256-хэшем ключа
3. API
3.1. Константы
| Константа | Значение | Описание |
|---|---|---|
SC_PRIVKEY_SIZE |
32 | Размер приватного ключа X25519 (байт) |
SC_PUBKEY_SIZE |
32 | Размер публичного ключа X25519 (байт) |
SC_HASH_SIZE |
32 | Размер SHA256-хэша (байт) |
SC_NONCE_SIZE |
13 | Размер nonce для AES-128-CCM. Ровно 13 байт — требование CCM! |
SC_SHARED_SECRET_SIZE |
32 | Размер общего секрета X25519 (= SC_HASH_SIZE) |
SC_SESSION_KEY_SIZE |
16 | Размер сессионного ключа AES-128 (байт) |
SC_TAG_SIZE |
16 | Размер аутентификационного тега CCM (байт) |
SC_CRC32_SIZE |
4 | Размер CRC32 в зашифрованном пакете (байт) |
SC_PUBKEY_ENC_SALT_SIZE |
8 | Размер соли для обфускации pubkey (байт) |
SC_PUBKEY_ENC_SIZE |
40 | Полный размер обфусцированного блока: salt(8) + obfuscated(32) |
SC_STREAM_NONCE_SIZE |
12 | Размер nonce для AES-128-CTR (байт) |
SC_SIGN_SIZE |
64 | Размер Ed25519 подписи (байт) |
SC_OK |
0 | Успех |
SC_ERR_INVALID_ARG |
-1 | Неверные аргументы |
SC_ERR_CRYPTO |
-2 | Криптографическая ошибка (OpenSSL) |
SC_ERR_NOT_INITIALIZED |
-3 | Контекст/сессия не инициализирована |
SC_ERR_AUTH_FAILED |
-4 | Ошибка аутентификации (неверный тег/подпись) |
SC_ERR_CRC_FAILED |
-5 | Ошибка CRC32 (повреждение данных) |
SC_PEER_PUBKEY_BIN |
0 | Флаг: ключ в бинарном формате |
SC_PEER_PUBKEY_HEX |
1 | Флаг: ключ в hex-строке |
3.2. Структуры
struct SC_MYKEYS — локальная пара ключей узла:
uint8_t private_key[32]— приватный ключ X25519uint8_t public_key[32]— публичный ключ X25519
struct secure_channel (sc_context_t) — контекст защищённого канала между двумя конкретными узлами:
pk— указатель наSC_MYKEYS(локальные ключи)peer_public_key[32]— публичный ключ пира (заполняется после ECDH)session_key[16]— производный сессионный ключ AES-128initialized— флаг:sc_init_ctx()вызванpeer_key_set— флаг:sc_set_peer_public_key()вызван успешноsession_ready— флаг: можно шифровать/дешифроватьtx_counter— счётчик отправленных пакетов (используется в nonce)rx_counter— счётчик принятых пакетов
struct sc_stream_state — состояние стримингового шифра (AES-128-CTR):
ectx—EVP_CIPHER_CTX*(opaque)initialized— флаг инициализации
struct sc_stream_sign_state — состояние стриминговой подписи (Ed25519 + SHA-512):
md_ctx—EVP_MD_CTX*(SHA-512 аккумулятор, opaque)pkey—EVP_PKEY*(Ed25519 ключ, opaque)initialized— флаг инициализацииis_sign— 1 = режим подписи, 0 = режим верификации
3.3. Функции
Инициализация ключей и контекста
| Функция | Описание |
|---|---|
sc_init_ctx(ctx, mykeys) |
Инициализирует контекст канала, привязывает локальные ключи. Не делает ECDH — только обнуляет состояние. |
sc_generate_keypair(keys) |
Генерирует новую пару X25519 через EVP_PKEY_keygen. Случайный приватный ключ. |
sc_init_local_keys(mykeys, pub_hex, priv_hex) |
Загружает ключи из hex-строк (обычно из конфига). Валидирует публичный ключ (не all-zero). |
sc_set_peer_public_key(ctx, peer_pubkey, mode) |
Ключевая операция: выполняет X25519 ECDH, выводит session_key, устанавливает session_ready=1. mode: SC_PEER_PUBKEY_BIN или SC_PEER_PUBKEY_HEX. |
sc_compute_public_key_from_private(privkey, pubkey_out) |
Вычисляет публичный ключ из приватного (X25519). |
Вывод session_key: SHA256(X25519_shared_secret || "uTun-v3-session") → truncate to 16 bytes
Шифрование / дешифрование
| Функция | Описание |
|---|---|
sc_encrypt(ctx, plaintext, pt_len, ciphertext, &ct_len) |
AES-128-CCM шифрование. Формат вывода: nonce(13) || AES-CCM(plaintext + CRC32(plaintext)) || tag(16). Инкрементирует tx_counter. |
sc_decrypt(ctx, ciphertext, ct_len, plaintext, &pt_len) |
AES-128-CCM дешифрование. Проверяет CCM-тег → проверяет CRC32. Инкрементирует rx_counter при успехе. |
Формат nonce: SHA256(random_seed(8) || tx_counter(8) || tv_sec(4) || tv_usec(4)) — truncated to 13 bytes. Использует OpenSSL SHA256 (не sha256.h).
Проверки при дешифровании:
ciphertext_len >= SC_NONCE_SIZE + SC_TAG_SIZE + SC_CRC32_SIZE(минимум 13+16+4 = 33 байта)- CCM-тег: несовпадение →
SC_ERR_AUTH_FAILED(пакет подделан или повреждён) - CRC32: несовпадение →
SC_ERR_CRC_FAILED(повреждение данных после расшифровки)
Обфускация pubkey
| Функция | Описание |
|---|---|
sc_obfuscate_pubkey(salt, peer_pubkey, pubkey, output) |
output[i] = pubkey[i] ^ SHA256(salt || peer_pubkey)[i] для i < 32. XOR обратим — повторный вызов с теми же salt/peer_pubkey восстанавливает исходный pubkey. |
Стриминговое шифрование (AES-128-CTR)
| Функция | Описание |
|---|---|
sc_stream_init(ctx, state, stream_id) |
Инициализирует CTR-контекст. Nonce из SHA256(session_key || stream_id_LE || "uTun3-stream"). |
sc_stream_xor(state, data, len) |
XOR данных с keystream in-place. Нулевая длина — no-op. |
sc_stream_cleanup(state) |
Освобождает EVP_CIPHER_CTX. |
Ed25519 подписи (one-shot)
| Функция | Описание |
|---|---|
sc_ed25519_sign(privkey[32], msg, msg_len, sig_out[64]) |
Подпись сообщения. privkey — 32-байтный Ed25519-приватный ключ (НЕ X25519). |
sc_ed25519_verify(pubkey[32], msg, msg_len, sig[64]) |
Проверка подписи. Возвращает SC_OK или SC_ERR_AUTH_FAILED. |
Ed25519 подписи (стриминг)
| Функция | Описание |
|---|---|
sc_stream_sign_init(ctx, state) |
Инициализирует подпись. Извлекает Ed25519-ключ из X25519 через SHA512(x25519_privkey). |
sc_stream_sign_verify_init(state, ed25519_pubkey) |
Инициализирует верификацию с переданным Ed25519 pubkey. |
sc_stream_sign_update(state, data, len) |
SHA-512 update (инкрементальный). Нулевая длина — no-op. |
sc_stream_sign_final(state, sig_out, &sig_len) |
Завершает SHA-512 → one-shot Ed25519 подпись хэша → автоматический cleanup. sig_len всегда 64. |
sc_stream_sign_verify(state, sig, sig_len) |
Завершает SHA-512 → one-shot Ed25519 проверка → автоматический cleanup. Возвращает SC_OK или SC_ERR_AUTH_FAILED. |
sc_stream_sign_cleanup(state) |
Ручная очистка (если не вызван final/verify). |
Потоковая схема: Ed25519 внутри делает SHA-512 от данных, поэтому для стриминга нужно было бы грузить все данные в память. Вместо этого: SHA-512(данные) делается инкрементально через EVP_DigestUpdate, затем 64-байтный хэш подписывается one-shot EVP_DigestSign. Результат: Ed25519(SHA-512(данные)) — консистентно с обеих сторон. Память O(1).
Деривация
| Функция | Описание |
|---|---|
sc_derive_ed25519_pubkey(x25519_privkey, ed25519_pubkey_out) |
Из X25519-приватного ключа (32 байта) выводит Ed25519-публичный ключ (32 байта) через SHA512(x25519_privkey). |
sc_derive_node_id(private_key) |
SHA256(private_key) → первые 8 байт как uint64_t, старший бит обнулён. Уникальный идентификатор узла. |
Утилиты
| Функция | Описание |
|---|---|
sc_sha_transcode(key, key_len, data, data_len) |
data[i] ^= SHA256(key)[i % 32]. Простой XOR с хэшем ключа. |
3.4. Зависимости
| Зависимость | Использование |
|---|---|
OpenSSL EVP (openssl/evp.h) |
X25519 keygen/derive (EVP_PKEY), AES-128-CCM, AES-128-CTR, Ed25519 sign/verify, SHA-512 |
OpenSSL SHA (openssl/sha.h) |
SHA256_Init/Update/Final для построения nonce в sc_build_nonce() |
../lib/sha256.h (sc_sha256_*) |
SHA256 для вывода session_key, stream nonce, obfuscate pubkey, sha_transcode, node_id |
../lib/debug_config.h |
DEBUG_ERROR, DEBUG_WARN, DEBUG_INFO, DEBUG_DEBUG (категория DEBUG_CATEGORY_CRYPTO) |
../lib/platform_compat.h |
random_bytes() (для соли и random seed), utun_gettimeofday() (для nonce) |
crc32.h (crc32_calc) |
Контроль целостности в шифрованных пакетах |
3.5. Отсутствующие функции
sc_rekey()упоминается в AGENTS.md, но не реализована в текущей версии. Ротация сессионного ключа не поддерживается — после ECDH ключ фиксирован на всё время сессии.
4. Формат зашифрованного пакета
┌──────────────┬─────────────────────────────────────┬──────────────┐
│ nonce (13) │ AES-CCM(plaintext || CRC32(pt)) │ tag (16) │
│ 13 bytes │ pt_len + 4 bytes │ 16 bytes │
└──────────────┴─────────────────────────────────────┴──────────────┘
- nonce: 13 байт, уникальный для каждого пакета (SHA256 от seed + counter + времени)
- ciphertext: AES-128-CCM in-place шифрование
plaintext || CRC32(plaintext) - tag: 16 байт, аутентификационный тег CCM (обеспечивает integrity + authenticity)
Порядок проверки при дешифровании: CCM-тег → если ОК, извлечение plaintext → CRC32 проверка.
Формат INIT-пакета
┌───────────────────────────────────────────────────────┬──────────────────────────────────────┐
│ AES-CCM(header(3) || data) + auth tag │ salt(8) || obfuscated_pubkey(32) │
│ Зашифрованная часть (включает auth tag) │ Незашифрованная часть (SC_PUBKEY_ENC_SIZE=40) │
└───────────────────────────────────────────────────────┴──────────────────────────────────────┘
- Зашифрованная часть: ETCP-заголовок (timestamp uint16 + flag_up uint8) + данные, всё зашифровано AES-128-CCM с auth tag
- Незашифрованная часть:
salt(8) + pubkey XOR SHA256(salt || expected_peer_pubkey) - Получатель восстанавливает pubkey:
obfuscated XOR SHA256(salt || my_pubkey) = sender_pubkey(XOR обратим)
5. Состояния и flow
┌─────────────┐ sc_init_ctx() ┌──────────────┐
│ Не создан │ ──────────────────────→│ initialized │
└─────────────┘ │ session_ready=0│
└──────┬───────┘
│ sc_set_peer_public_key()
│ (X25519 ECDH → session_key)
↓
┌──────────────┐
│ session_ready=1│
│ tx_counter=0 │
│ rx_counter=0 │
└──────┬───────┘
│ sc_encrypt() / sc_decrypt()
↓
tx_counter++, rx_counter++
session_readyстановится 1 только после успешного ECDH черезsc_set_peer_public_key()- Контекст привязан к конкретному пиру (peer_public_key фиксирован)
- Для смены пира нужен новый контекст
- Счётчики не синхронизируются между сторонами — каждая сторона считает свои tx/rx независимо
6. Примечания по безопасности
- Nonce должен быть уникальным для каждого шифрования с одним ключом. Здесь nonce строится из
SHA256(random_seed || counter || время). При случайном seed и монотонном counter'е коллизии крайне маловероятны. - CRC32 — не криптографическая защита. Он дополнительно ловит битовые ошибки после расшифровки (belt-and-suspenders), но основная защита целостности — CCM-тег.
- Обфускация pubkey — не шифрование. Это XOR с детерминированным хэшем, обратима для любого, кто знает salt и второй pubkey. Защищает от пассивного наблюдения (не видно публичный ключ в открытом виде), но не от активной подмены.
- OpenSSL EVP API используется для всех криптоопераций (реализации FIPS-сертифицированы при использовании FIPS-провайдера).