Files
utun2/src/transport_layer/secure_channel_doc.md
T
evgeny 38c9f229bc stcp: ed25519 pubkey exchange in handshake, fix instance destroy segfault, fix chat_tcp_test
- 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
2026-08-07 00:48:27 +03:00

24 KiB
Raw Blame History

secure_channel

1. Назначение

Модуль реализует криптографический слой uTun: защищённый канал между двумя узлами на основе X25519 (Elliptic Curve Diffie-Hellman) для обмена сессионным ключом и AES-128-CCM (Counter with CBC-MAC) для аутентифицированного шифрования трафика. Дополнительно предоставляет Ed25519 для цифровых подписей (аутентификация данных, происходящих от конкретного узла) и стриминговое шифрование (AES-128-CTR) для больших потоков данных, где нужна только конфиденциальность без аутентификации.

Это один из важнейших модулей проекта — каждый ETCP-пакет, проходящий между узлами, шифруется и аутентифицируется через этот канал.

Ключевые криптографические операции:

  1. X25519 ECDH — получение общего секрета из своего приватного и чужого публичного ключа
  2. AES-128-CCM — аутентифицированное шифрование (confidentiality + integrity): nonce(13) + ciphertext + tag(16)
  3. SHA256-based обфускация pubkey — скрытие публичного ключа при передаче в INIT-пакетах (XOR с SHA256(salt || peer_pubkey))
  4. Ed25519 — цифровые подписи данных (one-shot и стриминговая через SHA-512 аккумулятор)
  5. AES-128-CTR — стриминговое шифрование без аутентификации (bulk data)
  6. 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] — приватный ключ X25519
  • uint8_t public_key[32] — публичный ключ X25519

struct secure_channel (sc_context_t) — контекст защищённого канала между двумя конкретными узлами:

  • pk — указатель на SC_MYKEYS (локальные ключи)
  • peer_public_key[32] — публичный ключ пира (заполняется после ECDH)
  • session_key[16] — производный сессионный ключ AES-128
  • initialized — флаг: 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).

Проверки при дешифровании:

  1. ciphertext_len >= SC_NONCE_SIZE + SC_TAG_SIZE + SC_CRC32_SIZE (минимум 13+16+4 = 33 байта)
  2. CCM-тег: несовпадение → SC_ERR_AUTH_FAILED (пакет подделан или повреждён)
  3. 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-провайдера).