← все записи

Ваше собственное эхо

7 сентября 2026

Проектирую сквозное шифрование для чата двоих. Сервер переносит шифротекст и ключей не имеет — обычная постановка. Ключ рождается, когда двое согласились говорить, и умирает вместе с разговором.

Первая версия схемы выглядела так, и я довольно долго считал её законченной:

согласие    каждая сторона делает эфемерную пару на этот разговор
            и публикует свою половину, подписав долгим ключом личности
открытие    S = ECDH P-256(моя эфемерная, его эфемерная)
            K = HKDF(S, соль = id разговора)
сообщение   AES-GCM(K, nonce, текст) → сервер → вторая сторона расшифровывает
конец       оба стёрли K и эфемерные ключи, расшифровать нечем никому

Здесь всё честно: сервер видит байты, ключей у него нет, долгий ключ личности ничего не шифрует — он только удостоверяет, что эфемерная половина принадлежит той самой личности. Утечка долгого ключа позволяет выдать себя за человека, но не прочитать ни одного его разговора. Мне это нравилось.

Что не так

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

Дальше атака собирается сама. Недобросовестный сервер — или тот, кто его на время получил, — берёт сообщение, которое вы только что отправили, и возвращает его вам же, пометив как входящее от собеседника. Ваше устройство расшифровывает. Успешно. Тег целостности сходится, ключ настоящий, текст — настоящий текст. Криптография промолчит, потому что вопрос «кто это написал» ей никогда и не задавали.

На экране вы видите свою собственную фразу в качестве ответа собеседника.

Прелесть — в дешевизне для атакующего. Ему не нужно ничего подделывать: ни ключа, ни тега, ни подписи. Он переставляет местами то, что и так через него проходит. Ему даже не нужно понимать содержание.

Сообразить, во что это превращается на практике, нетрудно. «Да», отправленное на один вопрос, возвращается ответом на другой. Адрес, который вы назвали сами, приходит к вам как приглашение. Причём если обе стороны отправили похожие короткие реплики, отличить эхо от настоящего ответа по одному тексту нельзя вообще.

Чем чинить

Сначала я думал в сторону подписи: пусть каждое сообщение подписывается долгим ключом отправителя, тогда авторство доказано. Это работает и это плохо.

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

Второй вариант — положить идентификатор отправителя в дополнительные аутентифицируемые данные режима шифрования. Тег тогда считается и по ним, и подменённое поле «от кого» ломает расшифровку. Это уже сильно лучше подписи: ничего не доказывается наружу, потому что проверить может только тот, у кого ключ. Но остаётся неприятное: правильность зависит от того, что реализация не забудет положить туда именно то поле и именно так — а забыть здесь тихо.

Выбранное решение проще обоих. Ключей два, по одному на направление:

K_низ_верх  = HKDF(S, соль = id разговора, info = "low→high")
K_верх_низ  = HKDF(S, соль = id разговора, info = "high→low")

низ и верх — это идентификаторы участников, отсортированные тем же способом, которым нормализуется пара при создании разговора. Порядок детерминированный, обе стороны получают его молча, без переговоров и без лишнего поля в протоколе.

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

Стоимость правки — одна строка info в выводе ключей. Тот же обмен, тот же ECDH, то же количество раундов; просто из общего секрета выводится не один ключ, а два, и они разные.

Что унести к себе

Формулировка, которую я после этого держу в голове: ключ отвечает на вопрос «кто может это прочитать», и не отвечает на вопрос «кто это написал». Если в вашей схеме двое используют один симметричный ключ в обе стороны, то авторство у вас не защищено ничем — и защищать его надо не там, где кажется.

И более общее. Атака здесь не на математику. Все примитивы отработали идеально: обмен ключами корректен, тег сошёлся, текст расшифрован. Сломано соответствие между тем, что доказывает криптография, и тем, что показывает интерфейс. Интерфейс говорит «это сказал он», криптография говорит «это зашифровано ключом этого разговора». Между этими двумя утверждениями — дыра, в которую пролезает сервер-посредник, не подделав ни байта.

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

Досадно было ровно одно: я эту схему перечитывал не раз и каждый раз проверял её на то, «не утечёт ли ключ». Вопрос «а что ключ вообще утверждает» мне в голову не приходил.

← визитка · RSS