---
tags: [security, network]
verified: 2026-09-13
---

# Обмен ключами: X25519, KEM, вывод ключей и привязка к транскрипту

## Откуда берётся ключ

[AEAD](./crypto-aead.md) разбирает, что делают с ключом, и нигде не говорит, откуда он взялся: в TLS, WireGuard и age он там уже есть. Эта статья закрывает предыдущий шаг — как две стороны получают одинаковый секрет по каналу, который противник читает целиком, и что превращает этот секрет в набор ключей с разными ролями. Понятия ключа, nonce, AAD и тега здесь не переопределяются; всё, что про их использование, осталось в соседней заметке.

Задача формулируется неприятно прямо. Канал прослушивается полностью, всё отправленное противник видит; в конце обмена у обеих сторон должно оказаться одно и то же значение, которого у него нет. Никакой предварительно розданной тайны при этом может не быть вовсе. Решение существует с 1976 года и опирается на асимметрию между вычислением и его обращением: возвести в степень дёшево, вернуть показатель — дорого.

Дальше всё интересное растёт из двух фактов. Первый: общий секрет получается с кем-то, а не с кем нужно, — обмен ключами не аутентифицирует никого, и вся конструкция рукопожатий существует ради того, чтобы это исправить. Второй: полученное значение не годится в качестве ключа — оно неравномерно и не размечено по ролям, и между ним и рабочим ключом обязан стоять вывод ключей. Разделы ниже идут в этом порядке: сначала как добывается секрет, потом почему его мало, потом как из него получаются ключи.

---

## Общий секрет по открытому каналу

### Одна конструкция, две арифметики

Диффи-Хеллман — это утверждение про коммутативность. В группе с генератором `g` каждая сторона выбирает случайный секрет, публикует `g^x`, возводит полученное от партнёра значение в свой секрет и получает `g^xy`. Сторон две, операций две, порядок не важен — вот и вся схема. Различаются только группы, и вместе с группой различается то, что в ней трудно.

В конечнополевом варианте (FFC, finite field cryptography) группа — подгруппа мультипликативной группы вычетов по большому простому модулю. Трудная задача — дискретный логарифм в этом поле, и решается она решетом числового поля, алгоритмом субэкспоненциальной сложности. Существеннее самой сложности устройство алгоритма: у решета есть тяжёлая первая фаза, зависящая только от модуля, и дешёвая вторая, зависящая от конкретного значения. Авторы [Logjam](https://weakdh.org/) на этом и построили атаку — «the first step in the number field sieve… is dependent only on this prime». Поскольку в реальности все используют одни и те же стандартные простые числа, разовое вложение в предвычисление окупается на всём интернете сразу: по их оценке взлом единственного самого популярного 1024-битного простого дал бы пассивное чтение трафика 18% из миллиона популярнейших HTTPS-доменов, а второго — 66% VPN-серверов и 26% SSH-серверов.

На эллиптической кривой группа — точки кривой над конечным полем, а трудная задача — дискретный логарифм в этой группе, для которого субэкспоненциального алгоритма не известно: лучшее, что есть, — методы вида ρ-Полларда со сложностью порядка корня из размера группы. Отсюда разница в размерах, и она не косметическая. [RFC 9142](https://www.rfc-editor.org/rfc/rfc9142.html), таблица 4, оценивает 2048-битную MODP-группу (`group14`) в 112 бит стойкости, 3072-битную — в 128. [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748.html), §7, оценивает curve25519 в «slightly under the standard 128-bit level».

| | Открытое значение | Стойкость | Что трудно |
| --- | --- | --- | --- |
| MODP `group14` (2048 бит) | 256 байт | ~112 бит | дискретный логарифм в поле, решето числового поля |
| MODP `group15` (3072 бита) | 384 байта | ~128 бит | то же |
| X25519 (curve25519) | 32 байта | чуть меньше 128 бит | дискретный логарифм на кривой, ρ-Поллард |

Двенадцатикратная разница в размере при равной стойкости — это и есть причина, по которой эллиптический вариант вытеснил конечнополевой везде, где был выбор.

Вердикт: конечнополевой DH сегодня — легаси, и держится он не на технических достоинствах, а на требованиях совместимости. Показательно, что живее всего он именно там, где это записано в стандарте: [RFC 9142](https://www.rfc-editor.org/rfc/rfc9142.html), §3.2.2, делает `diffie-hellman-group14-sha256` обязательным к реализации в SSH («the diffie-hellman-group14-sha256 method MUST be implemented»), тогда как `curve25519-sha256` там всего лишь SHOULD. В TLS 1.3 FFDHE-группы из RFC 7919 остались определены, но из первого сообщения ушли: OpenSSL 3.5 (8 апреля 2025) отправляет в ClientHello ключевые доли только для X25519MLKEM768 и X25519. Разница между «долями» и «списком поддерживаемых групп» здесь существенна: в умолчании `TLS_DEFAULT_GROUP_LIST` (`ssl/t1_lib.c` ветки `openssl-3.5`) `ffdhe2048` и `ffdhe3072` остались — просто без звёздочки, которой помечены группы, отправляемые долями. Клиент их по-прежнему объявляет, и сервер волен выбрать FFDHE, заплатив лишним раундом через HelloRetryRequest. Новую систему на конечнополевом DH строить не за чем; поддерживать его приходится ровно столько, сколько живут собеседники, которые ничего другого не умеют.

### X25519: чего в нём нет

X25519, описанный в [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748.html), ценен не скоростью, а вычеркнутыми строчками. Каждое его конструктивное решение убирает из реализации место, где принято ошибаться.

Кривая взята в форме Монтгомери, и работа идёт лестницей Монтгомери — алгоритмом, который на каждой итерации выполняет одни и те же операции независимо от бита секрета. RFC формулирует следствие в §5.1: «the same sequence of field operations is performed for all values of the secret key, thus eliminating a common source of side-channel leakage». Ветвлений по секрету нет не потому, что программист был аккуратен, а потому, что их негде разместить.

Лестница работает только с u-координатой, и наружу тоже выдаётся только она. Это убирает целый слой формата: нет точек с двумя координатами, нет сжатия и разжатия, нет разбора кодировки. Главное — нет отдельного шага «проверить, что присланная точка лежит на нашей кривой». Именно этот шаг регулярно забывали в реализациях коротких кривых Вейерштрасса, и цена известна: Ягер, Швенк и Соморовский в [«Practical Invalid Curve Attacks on TLS-ECDH»](https://link.springer.com/chapter/10.1007/978-3-319-24174-6_21) (ESORICS 2015) восстанавливали статический закрытый ключ сервера, подсовывая точки с кривых, отличающихся от настоящей, — проверка отсутствовала в Java JSSE и Bouncy Castle.

Формат входа при этом описан так, что отвергать вообще нечего. RFC 7748, §5, требует маскировать старший бит последнего байта и добавляет: «Implementations MUST accept non-canonical values and process them as if they had been reduced modulo the field prime». Бернштейн в [описании Curve25519](https://cr.yp.to/ecdh.html) отвечает на вопрос о валидации открытых ключей одним словом — «Don't», и объясняет: «The Curve25519 function was carefully designed to allow all 32-byte strings as Diffie-Hellman public keys». Держится это на порядках: у кривой 8 × простое, у её квадратичного твиста 4 × простое, поэтому точка, случайно оказавшаяся на твисте, не даёт противнику ничего.

Проверить обе половины утверждения можно за минуту (вывод получен на pyca/cryptography 41.0.7 поверх OpenSSL):

```bash
python3 - <<'PY'
import os
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
a = X25519PrivateKey.generate()

# произвольные 32 байта — валидный открытый ключ, обмен проходит
print(a.exchange(X25519PublicKey.from_public_bytes(os.urandom(32))).hex()[:16])

# точка порядка 8 — обмен даёт нулевой секрет, и библиотека отказывается его отдать
low = (325606250916557431795983626356110631294008115727848805560023387167927233504)
try:
    a.exchange(X25519PublicKey.from_public_bytes(low.to_bytes(32, 'little')))
except ValueError as e:
    print('отказ:', e)
PY
```

Наконец, клампинг: закрытый ключ — это 32 случайных байта, у которых принудительно обнуляются три младших бита и старший, а второй по старшинству выставляется в единицу (RFC 7748, §5). Рационала RFC не даёт — только битовые операции; за ним приходится идти в [статью о Curve25519](https://cr.yp.to/ecdh/curve25519-20060209.pdf), где обе меры перечислены среди проектных решений: «multiply the secret key by a small power of 2 to account for cofactors in the curve group and the twist group» и «use a fixed position for the leading 1 in the secret key». Первое делает скаляр кратным восьми и гасит кофактор вместе с подгруппами малого порядка — §3 сводит это к «secret keys n by definition have n mod 8 = 0 and thus n mod b = 0», где `b` пробегает возможные малые порядки. Второе задаёт лестнице постоянные 255 удвоений (§5). Первая мера избавляет от проверок протокол, вторая — реализацию лестницы.

Альтернатива X25519 — короткие кривые Вейерштрасса NIST, P-256 и P-384, и выбор между ними не вполне технический. [RFC 10042](https://www.rfc-editor.org/rfc/rfc10042.html), §5, называет обе стороны разом: X25519 «is generally considered easier to implement securely without side channels than its NIST counterparts (with P256 and P384)», но «NIST curves are sometimes preferred for regulatory compliance». Третьего аргумента в этом сравнении нет: стойкость сопоставима, разница в скорости не решает, а всё перечисленное выше — отсутствие ветвлений, отсутствие валидации точки, отсутствие формата, в котором можно ошибиться, — на P-256 приходится обеспечивать руками. Вердикт: X25519 везде, где нет внешнего требования; NIST-кривые только там, где их требует сертификация. Выбор тянется и дальше, в постквантовый гибрид: [RFC 10024](https://www.rfc-editor.org/rfc/rfc10024.html) определяет `SecP256r1MLKEM768` и `SecP384r1MLKEM1024` рядом с X25519MLKEM768, и порядок слагаемых в них обратный не случайно — FIPS требует, чтобы первый секрет давала сертифицированная реализация одобренной схемы, поэтому «for SecP256r1MLKEM768 and SecP384r1MLKEM1024, the ECDHE implementation must be certified, whereas the ML-KEM implementation does not require certification. In contrast, for X25519MLKEM768, the ML-KEM implementation must be certified». В SSH то же различие выражено отдельными методами — `mlkem768nistp256-sha256` и `mlkem1024nistp384-sha384` (RFC 10042, §2.3).

Что конструкция не гарантирует, разобрано ниже, в разделе про поломки: contributory behaviour и уникальность открытого ключа как идентификатора остаются на протоколе, и здесь RFC 7748 и Бернштейн расходятся в акцентах.

---

## Обмен ключами ничего не говорит о собеседнике

Чистый DH заканчивается общим секретом с тем, кто был на другом конце. Кто это был, схема не сообщает, и противник посередине отрабатывает без всяких ухищрений: он проводит два независимых обмена, с каждой стороной свой, и дальше расшифровывает и перешифровывает. Обе стороны видят корректный обмен, оба секрета настоящие. Ошибки нет ни у кого — просто вопрос «с кем» не задавался.

Ответить на него можно тремя способами, и различаются они тем, что у сторон есть заранее.

![Путь от эфемерных ключей к рабочим: слева и справа стороны, между ними канал; открытые доли пересекают канал, закрытые ключи и общий секрет не пересекают его никогда; всё пересёкшее накапливается в транскрипте, который подписывается или входит в вычисление тега; внизу общий секрет проходит extract и расходится через expand на ключи с разными метками](./assets/crypto-key-exchange-handshake-path.svg)

### Транскрипт и три способа его закрыть

Общее у всех трёх ответов одно: стороны считают транскрипт — хеш всего, что прошло по каналу с самого первого байта, — и привязывают к нему доказательство подлинности. Смысл именно в этом: подписывается не эфемерный ключ и не секрет, а весь разговор целиком, включая согласование алгоритмов. Иначе противник правит то, что не покрыто, а покрытое оставляет в покое.

Подпись транскрипта долгоживущим ключом — путь TLS. Сообщение CertificateVerify ([RFC 9846](https://www.rfc-editor.org/rfc/rfc9846.html), §4.5.2) содержит подпись над `Transcript-Hash(Handshake Context, Certificate)`, и подписывается не сам хеш, а конкатенация из 64 байт `0x20`, контекстной строки `"TLS 1.3, server CertificateVerify"`, нулевого байта-разделителя и содержимого. Обе добавки — заплатки по следам прошлых провалов, и RFC называет их прямо: паддинг стирает управляемый противником 32-байтный префикс, из-за которого в прежних версиях можно было выпросить подпись сообщения с выбранным началом, а контекстная строка «is used to provide separation between signatures made in different contexts, helping against potential cross-protocol attacks». Сертификат при этом ручается за имя, а подпись — за то, что владелец имени участвовал именно в этом разговоре.

Доказательство знания заранее известного открытого ключа — путь SSH и WireGuard. В SSH обмен завершается вычислением exchange hash `H = hash(V_C ‖ V_S ‖ I_C ‖ I_S ‖ K_S ‖ e ‖ f ‖ K)` ([RFC 4253](https://www.rfc-editor.org/rfc/rfc4253.html), §8) — в него входят строки версий, оба сообщения `SSH_MSG_KEXINIT` с перечнями алгоритмов, ключ хоста и обе публичные доли; сервер подписывает `H` ключом хоста. Клиент обязан сверить ключ хоста с тем, что знал раньше, и RFC 4253 честно проговаривает альтернативу: «C is also allowed to accept the key without verification; however, doing so will render the protocol insecure against active attacks». Это и есть TOFU из `known_hosts` — не свойство криптографии, а осознанная уступка.

WireGuard обходится [вовсе без подписи](https://www.wireguard.com/protocol/). Его рукопожатие — паттерн Noise `IKpsk2`, где роль транскрипта играет переменная `hash`, начинающаяся со статического открытого ключа отвечающего и вбирающая каждое поле каждого сообщения. Эта переменная подаётся как AAD в каждый AEAD рукопожатия, так что проверка тега и есть проверка транскрипта. Аутентификацию даёт отдельная операция `DH(initiator.static_private, responder.static_public)`, подмешанная в цепочку: расшифровать следующее поле сумеет только тот, у кого есть закрытый ключ, парный известному открытому. Инициатор обязан знать открытый ключ отвечающего до начала — без него не собрать даже стартовое значение `hash`, — и поэтому MITM в WireGuard невозможен по построению, ценой того, что незнакомцу подключиться нельзя в принципе.

| Что есть у сторон заранее | Чем закрывается транскрипт | Кто так делает | Цена |
| --- | --- | --- | --- |
| Доверенный корень PKI | подпись долгоживущим ключом, сертификат за имя | TLS | нужна инфраструктура сертификатов и их отзыв |
| Открытый ключ партнёра | DH со статическим ключом, транскрипт в AAD или в подписи | WireGuard, SSH | ключи надо раздать заранее |
| Низкоэнтропийный общий секрет | PAKE или подмешивание PSK в вывод ключей | PAKE-протоколы, PSK в WireGuard | защита от перебора нужна отдельно |

Вердикт: выбор задаётся не вкусом, а тем, знает ли клиент собеседника заранее. Публичный сервис, к которому приходят незнакомцы, обязан идти через PKI; закрытая сеть с фиксированным списком участников должна брать модель WireGuard, потому что она убирает и PKI, и возможность MITM разом; пароль — всегда худший из трёх и требует PAKE, а не самодельной схемы.

Что происходит, когда привязка к транскрипту неполна, подробно разобрано в статье про AEAD на примере Terrapin (CVE-2023-48795): счётчик пакетов в SSH пересекал границу включения шифрования, ни к чему не привязанный, и это позволяло вырезать сообщение из рукопожатия. Повторять разбор здесь незачем, но вывод стоит держать в голове при чтении дальнейшего: транскрипт должен покрывать всё, включая границы фаз и согласование алгоритмов.

### Пароль как общий секрет

Пароль в качестве входа в обмен ключами — отдельный жанр, потому что энтропии в нём мало и любая схема, позволяющая проверить догадку офлайн, проиграна заранее. Задача PAKE — сделать так, чтобы каждая попытка угадать пароль стоила противнику одного активного прогона протокола. У CFRG два результата: сбалансированный CPace (обе стороны знают пароль) — [draft-irtf-cfrg-cpace](https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/), ревизия 21, на дату сверки в очереди RFC-редактора и ещё не RFC, — и асимметричный OPAQUE, где сервер хранит не пароль, а проверочные данные: он опубликован как [RFC 9807](https://www.rfc-editor.org/rfc/rfc9807.html) (IRTF, информационный, июль 2025).

Вердикт: самостоятельно собирать аутентификацию по паролю поверх DH не нужно — либо готовый PAKE, либо пароль вообще не участвует в обмене ключами, а проверяется внутри уже аутентифицированного канала, как это делает SSH с `password`-методом поверх проверенного ключа хоста.

---

## KEM: другой интерфейс к той же задаче

![Разница ролей: в Диффи-Хеллмане обе стороны делают одно и то же — генерируют пару и применяют свой закрытый ключ к чужому открытому; в KEM роли расходятся, одна сторона генерирует пару и выполняет Decaps, вторая по чужому открытому ключу выполняет Encaps и получает шифртекст вместе с секретом, который никогда не покидает сторон](./assets/crypto-key-exchange-dh-vs-kem.svg)

В Диффи-Хеллмане роли сторон одинаковы: каждая генерирует пару и применяет свой закрытый ключ к чужому открытому. KEM — механизм инкапсуляции ключа — разводит роли. Одна сторона выполняет `KeyGen` и публикует ключ инкапсуляции; вторая выполняет `Encaps` от этого ключа и получает две вещи сразу: шифртекст, который отправляется обратно, и общий секрет, который никуда не отправляется. Первая применяет `Decaps` к шифртексту и получает тот же секрет.

Разведённые роли соблазняют прочитать это как «доставку секрета владельцу открытого ключа», и для KEM вообще так и есть, а для ML-KEM — нет. [NIST SP 800-227](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-227.pdf) (сентябрь 2025), §2.2, разделяет эти случаи явно: обобщённая конструкция KEM из схемы шифрования (`KEMFROMPKE` там же) — чистый транспорт, отправитель выбирает ключ сам и шифрует его чужим открытым ключом; в ML-KEM иначе — «the shared secret key in ML-KEM is a function of both the randomness provided by Bob and the (randomly generated) encapsulation key of Alice. Therefore, ML-KEM could be viewed as a key-agreement scheme». То есть в ML-KEM вклад в секрет вносят обе стороны — вопрос, к которому статья ещё вернётся ниже, в разделе про поломки, уже про X25519. Различие интерфейсов при этом не так велико, как кажется, и читается в обе стороны: §5.1.1 того же документа показывает обратное преобразование — если в DH стороны ходят по очереди, открытая доля первой оказывается ключом инкапсуляции, а доля второй шифртекстом, и ECDH читается как KEM.

Стандартизация постквантовой схемы ушла к KEM не из вкусовых соображений. Решётчатые конструкции шумные — вся их стойкость держится на добавленной погрешности, — и прямого аналога «обе стороны возводят в степень» в них нет: симметричная схема даёт сторонам лишь приближённо равные значения, которые приходится доводить до совпадения отдельной процедурой. Процедура эта существует и называется примирением (reconciliation): её предложил Пайкерт в [«Lattice Cryptography for the Internet»](https://eprint.iacr.org/2014/070) (2014) — «a simple, low-bandwidth reconciliation technique that allows two parties who "approximately agree" on a secret value to reach exact agreement», — на ней построен NewHope, и в таком виде постквантовый обмен ключами успел поездить в Chrome под именем CECPQ1 в 2016 году. [FIPS 203](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.pdf) (финальная редакция, август 2024) идёт другим путём: сначала из задачи MLWE строится схема шифрования с открытым ключом K-PKE, затем она преобразованием Фудзисаки—Окамото превращается в KEM, и §3.2 отмечает, что именно преобразование даёт стойкость в существенно более сильной модели — «the resulting KEM provides security in a significantly more general attack model than the PKE scheme… ML-KEM is believed to satisfy so-called IND-CCA2 security». Сама K-PKE при этом объявлена непригодной к самостоятельному использованию. Почему выбор пал на KEM, а не на схему с примирением, FIPS 203 не объясняет — слова «Диффи-Хеллман» в стандарте нет вовсе, — так что связка «шум мешает симметричной схеме» выше реконструирована, а не взята из источника.

Трудная задача здесь — Module Learning With Errors: восстановить секрет из набора «зашумлённых» линейных уравнений над модулем над кольцом многочленов. Никакой связи с дискретным логарифмом у неё нет, и в этом весь смысл: машина, ломающая одно, не ломает другое.

Цена — размеры. Секрет остаётся 32-байтным, а вот всё, что летит по проводу, вырастает разительно.

| ML-KEM-768 (FIPS 203, табл. 3) | Байт | X25519 |
| --- | --- | --- |
| Ключ инкапсуляции (уходит от инициатора) | 1184 | 32 |
| Шифртекст (уходит в ответ) | 1088 | 32 |
| Ключ декапсуляции (не уходит никуда) | 2400 | 32 |
| Общий секрет | 32 | 32 |

Дальше эта арифметика бьёт в неожиданное место. Гибридная доля X25519MLKEM768 занимает 1216 байт, и ClientHello с ней перестаёт помещаться в один сегмент на пути с типичной MTU 1500. Cloudflare, выкатывая постквантовый обмен к origin-серверам, [описывает последствие](https://blog.cloudflare.com/post-quantum-to-origins/) так: «it used to be so exceedingly rare to see a split ClientHello that there is plenty of software and hardware out there that falsely assumes it never happens», и называет протокольную оссификацию главной трудностью перехода; в их сканировании меньше 0.34% origin-серверов не устанавливали соединение, когда постквантовая доля приходила в первом ClientHello. Вычислительно же решётчатая схема дешевле, чем принято думать, — с оговоркой, что мерили не ML-KEM. Замеры опубликованы в сентябре 2023 года, до финальной редакции FIPS 203, и в таблице у Cloudflare стоят `Kyber768` (31 тыс. операций в секунду у клиента, 70 тыс. у сервера) и черновой гибрид `X25519Kyber768Draft00` (11 тыс. и 14 тыс.) против `X25519` (17 тыс. и 17 тыс.): Kyber768 обгоняет X25519, а гибрид медленнее обоих ровно потому, что делает обе работы. Прямых замеров ML-KEM-768 в том же стенде нет. Платят за постквантовый обмен байтами, а не тактами.

Ещё одно практическое отличие от X25519 — вход надо проверять. FIPS 203, §7.2, задаёт проверку ключа инкапсуляции из двух шагов: длина обязана равняться `384k + 32`, и перекодировка коэффициентов туда-обратно обязана дать исходные байты. [RFC 10024](https://www.rfc-editor.org/rfc/rfc10024.html) переносит требование в TLS: «the server MUST perform the encapsulation key check described in Section 7.2 of NIST-FIPS-203 on the client's encapsulation key and abort with an illegal_parameter alert if it fails». Привычка «ничего не проверяем, все строки валидны», выработанная на X25519, на ML-KEM не переносится.

---

## Гибрид отвечает на конкретную угрозу

Гибридный обмен — это не «два алгоритма на всякий случай». Это ответ на одну названную угрозу: записанный сегодня трафик расшифруют потом. [RFC 9954](https://www.rfc-editor.org/rfc/rfc9954.html) «Hybrid key exchange in TLS 1.3» (IETF, информационный, июль 2026) формулирует её в §1.2: «If a cryptographic assumption is broken due to the advent of a quantum computer or some other cryptanalytic breakthrough, confidentiality of information can be broken retroactively by any adversary who has passively recorded handshakes and encrypted communications». Определение гибрида оттуда же, §1.1: две и более схем на разных предположениях, и итоговый ключ стоек, «as long as at least one of the component key exchange algorithms remains unbroken». Классическая половина страхует от изъяна в свежей решётчатой схеме, постквантовая — от будущей машины; убрать любую из них означает довериться одному предположению целиком.

Механика сборки проще, чем кажется, и различается между протоколами. В TLS ([RFC 10024](https://www.rfc-editor.org/rfc/rfc10024.html), Proposed Standard, август 2026, кодовая точка `0x11EC`) клиентская доля — это ключ инкапсуляции ML-KEM, за которым идёт доля X25519; серверная — шифртекст ML-KEM и доля X25519; общий секрет получается простой конкатенацией двух 32-байтных секретов в 64 байта, без всякого хеширования. Хеширования там нет потому, что дальше секрет попадает на вход `HKDF-Extract` в key schedule, который и делает всю работу; RFC 9954, §6, отмечает, что конструкция из §3.3 соответствует dual-PRF-комбинатору и сохраняет стойкость при условии, что хеш-функция ведёт себя как dual-PRF, — и оговаривает границу применимости: «under the assumption that shared secrets are fixed length once the combination is fixed». Общее правило сформулировано в NIST SP 800-227, §4.6.2, и слово в слово про этот случай: «Any protocol using multi-algorithm KEMs with a concatenation key combiner should ensure that the final shared secret key from the key combiner is passed through a KDF before use».

В SSH ([RFC 10042](https://www.rfc-editor.org/rfc/rfc10042.html), IETF, информационный, август 2026) решение другое: `K = HASH(K_PQ ‖ K_CL)`. Документ сам объясняет, чем это отличается от TLS, в §2.4 — «in [RFC9954], the classical and post-quantum exchanged secrets are concatenated and used in the key schedule, whereas in this document they are concatenated and hashed before being used in SSH's key derivation methodology». Причина — устройство вывода ключей в SSH, к которому мы вернёмся ниже: отдельного шага извлечения там нет, и его приходится изображать хешем.

Несимметричность перехода — то, что объясняет наблюдаемые умолчания. Ключ и подпись стареют по-разному. Записанное рукопожатие можно расшифровать через пятнадцать лет, а подпись подделывают в момент проверки: подделанная задним числом подпись к разговору, который давно закончился, никому не нужна. [RFC 9958](https://www.rfc-editor.org/rfc/rfc9958.html) «Post-Quantum Cryptography for Engineers» (июнь 2026) формулирует именно так: для аутентификации «signatures have a very short lifetime between signing and verifying (such as during a TLS handshake)», и оговаривает исключение — подпись прошивки или дистрибутива живёт десятилетиями и срочна наравне с шифрованием. Добавьте сюда, что миграция подписей тянет за собой сертификаты, удостоверяющие центры и HSM, а миграция обмена ключами не тянет ничего, кроме двух реализаций, — и картина умолчаний становится единственно возможной.

Она и наблюдается. Постквантовый обмен ключами включён по умолчанию: `mlkem768x25519-sha256` в OpenSSH с версии 10.0, X25519MLKEM768 в OpenSSL с 3.5. Постквантовые подписи — нет: гибридный тип `mldsa44-ed25519` появился в OpenSSH 10.4 и остаётся выключенным (подробности — в [заметке про SSH](./ssh-advanced.md), там же про предупреждение `WarnWeakCrypto` о непостквантовом обмене). Доля трафика следует за умолчаниями: Cloudflare [сообщает](https://blog.cloudflare.com/radar-origin-pq-key-transparency-aspa/) о росте клиентской поддержки постквантового шифрования «from under 3% at the start of 2024, to over 60% in February 2026».

Отдельно стоит заметить, что гибридом занимаются не только асимметричные схемы. Необязательный preshared key в WireGuard подмешивается в цепочку ключей ровно с этой целью — авторы называют её «an additional layer of symmetric-key crypto… for, say, post-quantum resistance». Симметричный секрет квантовому компьютеру не по зубам, и 32 байта, розданные заранее, дают тот же эффект, что ML-KEM, — при условии, что раздать их есть как.

Сроки же, по которым всё это планируется, на дату сверки формально не утверждены: [NIST IR 8547](https://csrc.nist.gov/pubs/ir/8547/ipd) «Transition to Post-Quantum Cryptography Standards» остаётся первоначальным публичным черновиком (ноябрь 2024, комментарии собраны в январе 2025); финальной редакции не опубликовано. Читать его таблицы 3 и 4 надо целиком, обе строки. Первая, которую обычно и цитируют: классика на 112 битах стойкости — «Deprecated after 2030, Disallowed after 2035». Вторая касается предмета этой статьи напрямую: «≥ 128 bits of security strength — Disallowed after 2035», без промежуточной стадии, то есть после 2035 года запрещаются и X25519, и P-256. Ссылаться на эти даты как на решённое нельзя.

---

## Из одного секрета — много ключей

### extract и expand — разные операции

Общий секрет использовать как ключ напрямую нельзя по двум независимым причинам, и их полезно не смешивать.

Первая — он неравномерен. Результат DH — это элемент группы, а не случайная битовая строка: у него есть структура, часть значений недостижима, распределение не плоское. [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869.html), §3.3, говорит об этом без обиняков: «if the IKM is a Diffie-Hellman value… then the extract part SHOULD NOT be skipped. Doing so would result in using the Diffie-Hellman value g^{xy} itself (which is NOT a uniformly random or pseudorandom string) as the key PRK for HMAC». Именно эту работу делает `HKDF-Extract`: собирает энтропию, размазанную по неравномерному входу, в одну равномерную строку длиной с выход хеша.

Вторая — у секрета нет ролей. Ключей нужно много: свой в каждую сторону, отдельные для рукопожатия и для данных, отдельные для экспорта и возобновления. Это работа `HKDF-Expand`, который из одного псевдослучайного ключа разворачивает произвольное число выходов, различаемых меткой.

Разделение операций не декоративно: у них разные предположения и разные входы, и RFC 9846, §F.1.1, фиксирует порядок их применения как правило — «one SHOULD NOT use an output of HKDF-Extract as an input to another application of HKDF-Extract without an HKDF-Expand in between», тогда как многократный `Expand` от одних входов законен, «as long as these are differentiated via the key and/or the labels».

Пропускать `extract` можно ровно тогда, когда вход уже равномерен. Канонический пример — секрет ML-KEM: FIPS 203, §3.3, разрешает использовать его без всякой обработки, «can be used directly as a shared secret key for symmetric cryptography», потому что он выходит из хеш-функции, а не из группы. NIST SP 800-227, §4.3, возводит это в свойство всего класса: «KEMs by definition output a key K that is ready to use» — в отличие от схем вроде Диффи-Хеллмана, где вывод ключа приходится делать отдельным шагом. Практического значения это, впрочем, почти не имеет: в гибриде рядом с ним стоит неравномерный секрет X25519, и extract всё равно нужен.

### Соль и метка: разделение доменов

У `extract` есть второй вход — соль, у `expand` — `info`. Оба необязательны в определении и оба важны.

Соль не секретна и может повторяться; её задача — развести применения хеш-функции между собой. RFC 5869, §3.1: она обеспечивает «independence between different uses of the hash function» и поддерживает извлечение, не зависящее от источника. Типовой приём — взять солью то, что и так известно обеим сторонам и уникально для сеанса: открытые доли, нонсы, текущее состояние ключевого расписания.

`info` отвечает за разделение доменов. RFC 5869, §3.2, объясняет, что его задача — привязать вывод к контексту, и что он «may prevent the derivation of the same keying material for different contexts (when the same input key material is used in such different contexts)». Без этого один и тот же секрет в двух ролях даёт один и тот же ключ, и материал начинает протекать между контекстами — а из этого растут и путаница между направлениями передачи, и приём подписи, сделанной для другой цели.

### Три решения одной задачи

Один и тот же механизм, развёрнутый тремя способами. Смотреть здесь стоит не на формулы, а на то, чем управляется вывод.

TLS 1.3 (RFC 9846, §7.1) строит ключевое расписание конвейером: каждый новый секрет добавляется вызовом `HKDF-Extract`, где солью служит текущее состояние, а IKM — новый секрет. Из PSK получается Early Secret, из асимметричного секрета — Handshake Secret, дальше Main Secret; на каждом уровне `Derive-Secret` отводит именованные ветки. Ключевое в `Derive-Secret` — то, что контекстом ему служит хеш транскрипта:

```text
Derive-Secret(Secret, Label, Messages) =
    HKDF-Expand-Label(Secret, Label, Transcript-Hash(Messages), Hash.length)
```

То есть каждый рабочий ключ TLS привязан не только к секрету, но и ко всему разговору, который к нему привёл. Метки при этом — строки вида `"c hs traffic"`, `"s ap traffic"`, `"key"`, `"iv"`, и все они получают префикс `"tls13 "` внутри структуры `HkdfLabel`, что разводит домены между версиями и расширениями протокола.

WireGuard разворачивает то же самое в цепочку. Его KDF — три вызова HMAC-BLAKE2s подряд: `temp = HMAC(chaining_key, input)`, затем `chaining_key = HMAC(temp, 0x1)` и при необходимости `key = HMAC(temp, chaining_key ‖ 0x2)`. Это ровно HKDF: первый вызов — extract, где солью служит текущий chaining key, остальные — expand с однобайтовым счётчиком. Разница с TLS не в схеме, а в том, сколько раз она применяется: каждый DH, каждая эфемерная доля и preshared key по очереди вмешиваются в цепочку, и после последнего шага из неё вынимаются два транспортных ключа — свой на каждое направление. Транскрипт при этом ведётся отдельной переменной и уходит в AAD, а не в KDF.

age пользуется HKDF напрямую и показывает третий приём — соль как привязка к участникам. В блоке получателя ключ обёртки выводится с `salt = ephemeral share ‖ recipient` и `info = "age-encryption.org/v1/X25519"`; ключ полезной нагрузки выводится отдельно, с меткой `"payload"` (эта вторая формула уже разобрана в [статье про AEAD](./crypto-aead.md), повторять её здесь незачем). Спецификация дополнительно требует свежий эфемерный секрет «for each stanza and each file» и прерывание, если общий секрет оказался нулевым.

| | Что подаётся в extract | Чем солится | Чем разделяются роли |
| --- | --- | --- | --- |
| TLS 1.3 | PSK, затем асимметричный секрет | текущее состояние расписания | метка `"tls13 "` + имя, контекст — хеш транскрипта |
| WireGuard | каждый DH и PSK по очереди | текущий chaining key | позиция в цепочке и счётчик `0x1`/`0x2` |
| age | общий секрет X25519 | эфемерная доля ‖ ключ получателя | `info` с URI протокола, отдельная метка на полезную нагрузку |
| SSH (RFC 4253) | extract отсутствует | — | буква `"A"`…`"F"` в хеше |

Строка про SSH здесь выбивается, и намеренно. Вывод ключей там — прямой хеш: `HASH(K ‖ H ‖ "A" ‖ session_id)` и так далее до `"F"` (RFC 4253, §7.2), где единственная буква разводит шесть ролей. Схема предшествует HKDF и несёт его недостатки: сам RFC рядом предупреждает, что «this process will lose entropy if the amount of entropy in K is larger than the internal state size of HASH». Оценка, данная этому решению в 2026 году, содержится в приложении A к RFC 10042: «it probably would be a good approach for SSH to move from basic hashing everywhere to use proper KDFs with extract/expand, but that should be a separate effort».

Вердикт для собственных протоколов и форматов: HKDF с непустыми солью и `info`, метка — строка, однозначно называющая протокол, версию, роль и направление. Самодельный вывод ключей хешированием конкатенации работает ровно до первой добавленной роли.

---

## Где ломается

Три класса отказов, и ни один не про стойкость алгоритма.

**Эфемерный ключ, который не эфемерен.** «Эфемерный» — свойство дисциплины, а не имени переменной: пара генерируется на одно рукопожатие и уничтожается после. Сервер, кеширующий пару ради экономии, ломает прямую секретность и открывает дорогу атакам, которым нужно много обращений с одним и тем же ключом. [Raccoon](https://raccoon-attack.com/) — именно этот случай: тайминговая атака на вывод ключей в TLS 1.2 требует, чтобы «the server uses ephemeral cipher suites (TLS-DHE) and reuses ephemeral keys for multiple connections». Ответ спецификации оказался запретом, и он свежий: [RFC 9846](https://www.rfc-editor.org/rfc/rfc9846.html) перечисляет «forbid the reuse of KeyShare values between connections» среди технических изменений относительно RFC 8446 и формулирует требование как «Clients and Servers MUST NOT reuse a key share for multiple connections» — с оговоркой, что принимающая сторона обязана переиспользование партнёром терпеть, поскольку прежняя редакция его разрешала. Если в коде эфемерный ключ живёт в поле объекта дольше одного рукопожатия, это ошибка независимо от алгоритма.

**Отсутствие разделения доменов.** Один ключ в двух ролях и подпись, принятая не в том контексте, для которого делалась, — самая дешёвая из возможных ошибок и самая частая. Против неё работают контекстная строка в CertificateVerify, префикс `"tls13 "` в HkdfLabel, URI протокола в `info` у age. Проверка своего протокола на этот дефект простая: выписать каждый выводимый ключ и каждую подпись вместе с полным набором входов и посмотреть, есть ли две записи, отличающиеся только намерением автора.

Сюда же относится менее очевидный случай — кодировка. RFC 10042, §5, объясняет, почему гибридные секреты в SSH кодируются байтовыми массивами фиксированной длины, а не целыми: переменная длина меняет число обрабатываемых блоков хеша и «could leak the most significant bit of the derived secret and/or allow detection of when the most significant bytes are zero», что уже приводило к Lucky Thirteen и Raccoon. Разделение доменов — это и про то, что входы KDF должны разбираться однозначно.

**Инварианты X25519, которые не инварианты протокола.** RFC 7748, §7, говорит прямо: «Protocol designers using Diffie-Hellman over the curves defined in this document must not assume "contributory behaviour"» — точка малого порядка на входе обнуляет вклад второй стороны, и обнаружить это можно только проверкой результата на ноль, которую §6.1 объявляет необязательной («Both MAY check… whether K is the all-zero value and abort if so»). Бернштейн считает проверку лишней для собственно Диффи-Хеллмана и нужной лишь «unusual non-Diffie-Hellman elliptic-curve protocols». Противоречия тут нет, есть разница в области действия: пока общий секрет служит только тем, чтобы разговаривать с партнёром, навязанное нулевое значение вредит одному противнику; как только протокол трактует знание секрета как доказательство чего-либо, проверка обязательна. Практика разошлась в пользу проверки — Go в `crypto/ecdh` [возвращает ошибку](https://pkg.go.dev/crypto/ecdh) на нулевой результат, спецификация age требует прервать работу, `pyca/cryptography` бросает исключение (проверено выше).

Второе предупреждение из того же §7 пропускают чаще: «for each public key, there are several publicly computable public keys that are equivalent to it, i.e., they produce the same shared secrets», и потому «using a public key as an identifier and knowledge of a shared secret as proof of ownership (without including the public keys in the key derivation) might lead to subtle vulnerabilities». Лечится это одной строкой — включить обе публичные доли в вывод ключей, что и делают age своей солью `ephemeral share ‖ recipient`, WireGuard хешем рукопожатия и SSH exchange hash'ем. Протокол, выводящий ключ из одного только общего секрета, этой защиты лишён.

---

## Сводка решений

| Вопрос | Ответ |
| --- | --- |
| Конечнополевой DH в новой системе? | Нет. Только ради совместимости с тем, что не умеет иначе |
| Какая кривая по умолчанию? | X25519: нет ветвлений по секрету, нет валидации точки, нет формата для ошибок. NIST-кривые — только там, где их требует сертификация |
| Проверять входящие открытые ключи? | X25519 — нет; ML-KEM — да, проверка ключа инкапсуляции обязательна |
| Кто аутентифицирует стороны? | Не обмен ключами. PKI, заранее известный открытый ключ или PAKE |
| Что подписывать? | Транскрипт целиком, включая согласование алгоритмов, с контекстной строкой |
| Гибрид с ML-KEM? | Да для обмена ключами, уже сегодня и по умолчанию. Для подписей — нет, кроме долгоживущих |
| Секрет прямо в ключ? | Нет. `extract`, затем `expand` с меткой на каждую роль; пропускать extract можно только для равномерного входа |
| Соль и `info` оставить пустыми? | Нет. Соль — публичные доли или состояние расписания, `info` — протокол, версия, роль, направление |
| Кешировать эфемерную пару? | Нет; в TLS 1.3 это прямо запрещено |

---

## Источники

- [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748.html) — X25519 и X448: лестница Монтгомери и постоянное время (§5.1), клампинг и приём неканонических значений (§5), проверка на нулевой результат (§6.1), contributory behaviour и эквивалентные открытые ключи (§7)
- [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869.html) — HKDF: роль соли (§3.1), роль `info` и разделение доменов (§3.2), почему extract нельзя пропускать для DH-значения (§3.3)
- [RFC 9846](https://www.rfc-editor.org/rfc/rfc9846.html) — TLS 1.3 (июль 2026, отменяет RFC 8446): ключевое расписание (§7.1), CertificateVerify и контекстные строки (§4.5.2), запрет переиспользования key share (§1.2, §4.3.8), порядок extract и expand (§F.1.1)
- [RFC 9954](https://www.rfc-editor.org/rfc/rfc9954.html) — гибридный обмен ключами в TLS 1.3 (июль 2026): определение гибрида (§1.1), угроза «записать сейчас, расшифровать потом» (§1.2), конкатенация секретов (§3.3) и её обоснование через dual-PRF с оговоркой о фиксированной длине (§6)
- [RFC 10024](https://www.rfc-editor.org/rfc/rfc10024.html) — X25519MLKEM768 и родственные группы для TLS 1.3 (август 2026): состав долей, конкатенация секретов, обязательная проверка ключа инкапсуляции, `SecP256r1MLKEM768` и `SecP384r1MLKEM1024` и порядок секретов в них под требования сертификации
- [RFC 10042](https://www.rfc-editor.org/rfc/rfc10042.html) — гибридные методы обмена ключами для SSH (август 2026): `K = HASH(K_PQ ‖ K_CL)` и отличие от TLS (§2.4), методы на NIST-кривых (§2.3), X25519 против NIST-кривых, кодировка секретов и побочные каналы (§5), разобранные альтернативы комбинирования (приложение A)
- [RFC 9958](https://www.rfc-editor.org/rfc/rfc9958.html) — постквантовая криптография для инженеров (июнь 2026): асимметрия между срочностью перехода для шифрования и для подписей
- [RFC 4253](https://www.rfc-editor.org/rfc/rfc4253.html) — транспортный уровень SSH: вывод ключей по буквам `"A"`…`"F"` (§7.2), exchange hash и подпись над ним (§8)
- [RFC 9142](https://www.rfc-editor.org/rfc/rfc9142.html) — методы обмена ключами в SSH: стойкость MODP-групп (табл. 4), `diffie-hellman-group14-sha256` как обязательный к реализации (§3.2.2)
- [RFC 9807](https://www.rfc-editor.org/rfc/rfc9807.html) — OPAQUE, асимметричный PAKE (IRTF, июль 2025)
- [draft-irtf-cfrg-cpace](https://datatracker.ietf.org/doc/draft-irtf-cfrg-cpace/) — CPace, сбалансированный PAKE; статус документа (ревизия 21, ещё не RFC)
- [FIPS 203](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.pdf) — ML-KEM: MLWE и преобразование Фудзисаки—Окамото (§3.2), размеры ключей и шифртекстов (табл. 3), использование секрета без дополнительного вывода (§3.3), проверка ключа инкапсуляции (§7.2)
- [NIST SP 800-227](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-227.pdf) — рекомендации по KEM (финал, сентябрь 2025): определение KEM, key agreement против key transport и статус ML-KEM (§2.2), готовность секрета к использованию (§4.3), одобренные комбинаторы ключей и требование KDF после конкатенации (§4.6.2), построение KEM из Диффи-Хеллмана (§5.1.1)
- [NIST IR 8547 ipd](https://csrc.nist.gov/pubs/ir/8547/ipd) — предполагаемые сроки вывода классических алгоритмов, таблицы 3 и 4; на дату сверки первоначальный публичный черновик
- [Logjam / weakdh.org](https://weakdh.org/) — предвычисление решета числового поля по простому числу, доля затронутых серверов
- [Raccoon Attack](https://raccoon-attack.com/) — что даёт противнику переиспользование эфемерного ключа
- [T. Jager, J. Schwenk, J. Somorovsky, Practical Invalid Curve Attacks on TLS-ECDH](https://link.springer.com/chapter/10.1007/978-3-319-24174-6_21) — цена пропущенной проверки принадлежности точки кривой (ESORICS 2015)
- [D. J. Bernstein, Curve25519](https://cr.yp.to/ecdh.html) — «How do I validate Curve25519 public keys? Don't», порядки кривой и твиста, оговорка про contributory behaviour
- [D. J. Bernstein, Curve25519: new Diffie-Hellman speed records](https://cr.yp.to/ecdh/curve25519-20060209.pdf) — проектные решения, из которых следует клампинг (§1), малые порядки и `n mod 8 = 0` (§3), постоянные 255 удвоений в лестнице (§5)
- [C. Peikert, Lattice Cryptography for the Internet](https://eprint.iacr.org/2014/070) — примирение (reconciliation) как альтернативный путь к решётчатому обмену ключами
- [WireGuard: Protocol & Cryptography](https://www.wireguard.com/protocol/) — рукопожатие `Noise_IKpsk2`, цепочка на BLAKE2s, транскрипт в AAD, preshared key как симметричный гибрид
- [Спецификация формата age](https://github.com/C2SP/C2SP/blob/main/age.md) — блок получателя X25519: соль из эфемерной доли и ключа получателя, `info` с URI, требование свежего эфемерного секрета
- [OpenSSH — release notes](https://www.openssh.com/releasenotes.html) — 10.0 (`mlkem768x25519-sha256` по умолчанию), 10.4 (гибридные подписи), 10.5 (11 августа 2026, последняя на дату сверки)
- [OpenSSL 3.5 release notes](https://openssl-library.org/news/openssl-3.5-notes/) — поддержка ML-KEM и смена умолчаний по группам и ключевым долям (8 апреля 2025)
- [Cloudflare: post-quantum to origins](https://blog.cloudflare.com/post-quantum-to-origins/) — разрезанный ClientHello, оссификация, доля не отвечающих origin-серверов, замеры производительности
- [Cloudflare: Radar, origin PQ и key transparency](https://blog.cloudflare.com/radar-origin-pq-key-transparency-aspa/) — динамика доли клиентов с постквантовым обменом ключами (февраль 2026)
- [Go, `crypto/ecdh`](https://pkg.go.dev/crypto/ecdh) — ошибка при нулевом результате X25519

---

## Связанные заметки

- [AEAD: аутентифицированное шифрование, nonce и лимиты](./crypto-aead.md) — что происходит с ключом после того, как он выведен, и разбор Terrapin как примера неполной привязки к транскрипту
- [ChaCha20 и Poly1305: устройство связки и почему она такая](./crypto-chacha20-poly1305.md) — что делает с выведенным здесь ключом самый распространённый AEAD: ARX, многочлен Poly1305 и конструкция RFC 8439
- [SSH углублённо](./ssh-advanced.md) — умолчания OpenSSH по обмену ключами и подписям, `known_hosts` как реализация модели «ключ партнёра известен заранее»
- [Обратный прокси и TLS: nginx vs Caddy](./reverse-proxy-tls.md) — где рукопожатие TLS заканчивается на практике и чей сертификат участвует в подписи транскрипта
- [OpenVPN против WireGuard](./openvpn-vs-wireguard.md) — два способа аутентифицировать туннель: PKI управляющего канала против статических ключей Noise
- [Самохостинг за NAT: VPS как точка входа](./selfhosting-behind-nat.md) — рукопожатие WireGuard как наблюдаемый признак живого туннеля
- [AES-GCM против ChaCha20-Poly1305: как выбирать](./crypto-aes-vs-chacha20.md) — та же развилка «свой выбор против сертификации», но в симметричной части: список одобренных FIPS функций против RFC
- [KDF для паролей: почему медленно и с памятью](./crypto-password-kdf.md) — зеркальная задача: HKDF выводит ключи из уже случайного секрета и обязан быть быстрым, парольный KDF — из человеческого пароля и обязан быть медленным
- [Криптографические примитивы — план цикла статей](./crypto-knowledge-base-plan.md) — состав цикла, порядок статей и сквозные решения
