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

Началось все с того, что в одном небольшом проекте мне понадобилось использовать шифрование. Клиенты должны отправлять Серверу сообщения, защищенные от перехвата. Так как проверка подлинности клиентов в принципе не требовалась, а данные шли только в одну сторону (от Клиентов к Серверу), и кроме того, не хотелось возиться с хранением общих ключей шифрования, пришла идея использовать асимметричную криптографию. Идея выглядит просто: Клиентам сообщается открытый ключ Сервера, которым они шифруют передаваемое для Сервера сообщение. Сервер (и только он) в свою очередь может расшифровать полученное сообщение с использованием известного ему закрытого ключа.

Реализация криптографических примитивов вручную дело неблагодарное и чреватое ошибками, поэтому было решено использовать какую-либо библиотеку с открытым исходным кодом для реализации указанной выше задумки. Так как в проекте уже использовались библиотеки ZeroMQ, ее обертка CZMQ, которые в свою очередь обеспечивали безопасность передачи данных на базе библиотеки libsodium, то выбор пал на нее. Действительно, зачем плодить зависимости, если в ней уже все есть.

О libsodium
Как сказано на официальном сайте, libsodium — это открытая, современная, простая библиотека для шифрования, электронной цифровой подписи, хеширования и др.
Там же приведен внушительный список проектов и компаний, которые используют libsodium, среди которых, например Tox

Итак, беглое чтение документации показало, что библиотека содержит реализацию асимметричного шифрования на эллиптических кривых Public-key authenticated encryption. Кроме того, есть возможность подтвердить подлинность сообщения посредством MAC. Шифрование и генерация MAC выполняется при помощи функции crypto_box_easy, обратная процедура (проверка и расшифровка) — при помощи crypto_box_open_easy.

Из документации:
Оригинал
Using public-key authenticated encryption, Bob can encrypt a confidential message specifically for Alice, using Alice's public key.
Using Bob's public key, Alice can verify that the encrypted message was actually created by Bob and was not tampered with, before eventually decrypting it.
Alice only needs Bob's public key, the nonce and the ciphertext. Bob should never ever share his secret key, even with Alice.
And in order to send messages to Alice, Bob only needs Alice's public key. Alice should never ever share her secret key either, even with Bob.

При помощи шифрования с открытым ключом с поддержкой аутентификации, Боб может зашифровать конфиденциальное сообщение для Алисы, используя её открытый ключ.

Используя открытый ключ Боба, Алиса может еще до расшифровки проверить, что зашифрованное сообщение действительно создано Бобом и не подделано.

Алисе нужен только открытый ключ Боба, nonce и зашифрованное сообщение. Боб обязан держать свой закрытый ключ в секрете даже от Алисы.

Чтобы отправлять сообщения Алисе, Бобу нужен только открытый ключ Алисы. Алиса, в свою очередь, должна держать свой закрытый ключ в тайне даже от Боба.

Вроде бы все просто и понятно. Клиент шифрует сообщение открытым ключом Сервера и подписывает сообщение своим закрытым ключом. Сервер, получив сообщение, проверяет его используя открытый ключ Клиента и расшифровывает его своим закрытым ключом. Кроме Сервера никто не может расшифровать сообщение, так как оно было зашифровано его открытым ключом (по крайней мере это главный принцип асимметричной криптографии). Однако дьявол кроется в деталях.

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

Код теста
#include <string.h>
#include "sodium.h"

#define MESSAGE 		"test"
#define MESSAGE_LEN 	4
#define CIPHERTEXT_LEN (crypto_box_MACBYTES + MESSAGE_LEN)

static bool TestSodium()
{
	unsigned char alice_publickey[crypto_box_PUBLICKEYBYTES];
	unsigned char alice_secretkey[crypto_box_SECRETKEYBYTES];
	crypto_box_keypair(alice_publickey, alice_secretkey);

	unsigned char bob_publickey[crypto_box_PUBLICKEYBYTES];
	unsigned char bob_secretkey[crypto_box_SECRETKEYBYTES];
	crypto_box_keypair(bob_publickey, bob_secretkey);

	unsigned char nonce[crypto_box_NONCEBYTES];
	unsigned char ciphertext[CIPHERTEXT_LEN];
	randombytes_buf(nonce, sizeof nonce);

	// message alice -> bob
	if (crypto_box_easy(ciphertext, (const unsigned char*)MESSAGE, MESSAGE_LEN, nonce, bob_publickey, alice_secretkey) != 0)
	{
		return false;
	}

	unsigned char decrypted[MESSAGE_LEN + 1];
	decrypted[MESSAGE_LEN] = 0;

	// Оригинал
	//if (crypto_box_open_easy(decrypted, ciphertext, CIPHERTEXT_LEN, nonce, alice_publickey, bob_secretkey) != 0)
	// Код с "ошибкой"
	if (crypto_box_open_easy(decrypted, ciphertext, CIPHERTEXT_LEN, nonce, bob_publickey, alice_secretkey) != 0)
	{
		return false;
	}

	if(strcmp((const char*)decrypted, MESSAGE) != 0) return false;

	return true;
}


В тесте для Алисы и Боба сначала случайным образом генерируется пара ключей (crypto_box_keypair), затем опять же случайно заполняется nonce (randombytes_buf). После этого Алиса шифрует свое сообщение для Боба, используя его открытый ключ и формирует MAC при помощи своего закрытого ключа.

// message alice -> bob
if (crypto_box_easy(ciphertext, (const unsigned char*)MESSAGE, MESSAGE_LEN, nonce, bob_publickey, alice_secretkey) != 0)
{
	return false;
}

Однако в процедуре расшифровки я ошибся и передал неверные параметры. Вместо того, чтобы расшифровывать сообщение для Боба его закрытым ключом, я попытался расшифровать сообщение открытым ключом Боба и закрытым ключом Алисы (Copy-paste чтоб его).

// Код с "ошибкой"
if (crypto_box_open_easy(decrypted, ciphertext, CIPHERTEXT_LEN, nonce, bob_publickey, alice_secretkey) != 0)
{
	return false;
}

Каково же было мое удивление, когда сообщение расшифровалось! Это было очень странно и ввело меня в состояние когнитивного диссонанса. Перед глазами была найденная 0-day уязвимость и признание мирового сообщества. Я никак не мог понять, каким образом можно было расшифровать сообщение для Боба без использования его закрытого ключа. А кроме того, ведь была успешно выполнена проверка MAC без использования открытого ключа Алисы!

Первое, что я подумал сделать — это выполнить расшифровку как в оригинальном примере, при этом тоже все прошло без проблем — сообщение было расшифровано и проверено. Таким образом, расшифровать (и проверить!) сообщение можно было любой парой ключей — открытым ключом Боба и закрытым ключом Алисы или наоборот — закрытым ключом Боба и открытым ключом Алисы.

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

Скажу честно, что у меня было мало времени и желания копаться в исходниках libsodium. Ответ нашелся на Stackoverflow. Оказывается, libsodium понимает под «Public-key authenticated encryption» несколько не то, как это представлялось мне.

После подробного рассмотрения, алгоритм шифрования оказался таким:

  1. При помощи алгоритма ECDH формируется общий ключ для симметричного шифра.
  2. Выполняется шифрование сообщения симметричным шифром XSalsa20 с использованием общего ключа, полученного на первом шаге.
  3. Генерируется имитовставка MAC (Poly1305) с использованием того же общего ключа .

Отсюда вытекают следующие выводы и свойства этого алгоритма:

  • Одинаковое сообщение, сформированное Бобом или Алисой (каждый формирует сообщение своим закрытым ключом и открытым ключом собеседника), порождает одинаковое зашифрованное сообщение.
  • Из предыдущего вывода следует то, что нельзя точно сказать кто кому писал сообщение — Боб Алисе или Алиса Бобу.
  • Если закрытый ключ Алисы будет скомпрометирован, то это позволит расшифровать все ранее отправленные сообщения Бобу (а ведь именно это один из плюсов асимметричной криптографии).
  • Если закрытый ключ Алисы будет скомпрометирован, то атакующий сможет подделывать сообщения от Боба для Алисы (даже не зная закрытого ключа Боба).

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

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

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

Надеюсь, что был кому-то полезен. Всем удачи.
Могли бы Вы допустить такую ошибку?

Проголосовало 205 человек. Воздержалось 106 человек.

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

Поделиться с друзьями
-->

Комментарии (15)


  1. JTProg
    04.10.2016 18:31
    -12

    Довольно интересный получился кейс! После этого стоит задуматься на тему «А стоит ли продолжать использовать тот же любимый многими параноиками Tox?»


    1. TimsTims
      04.10.2016 18:42
      +9

      Майор Петров, ну причем здесь Tox?


    1. sumanai
      04.10.2016 21:39
      +1

      Думаю, пишущие Tox люди голосуют за третий вариант в опросе, и подобных ошибок не совершают.


  1. Sheb_Gre
    04.10.2016 19:52
    +1

    Активно используем libsodium в некоторых проектах, приходилось даже перекапывать сырцы вдоль и поперек, дело в том, что в данной библиотеке реализовано несколько алгоритмов и кривых. Для вашего кейса подходит crypto_sign — это реализация Ed25519, она же Эдвардовская кривая. Это также имплементация ассиммтричного шифрования и она является достаточно уровень надежности для шифрования пересылаемых сообщений.


    1. zunzibar
      04.10.2016 19:55

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


  1. mayorovp
    04.10.2016 20:25

    Проблема 1-2 надумана — Алиса и Боб прекрасно знают, какое сообщение они получили, а какое — отправили, а остальных это не касается.


    Проблема 3-4 решается генерацией новых ключей на каждый сеанс связи.


    1. zunzibar
      04.10.2016 20:38
      +2

      Для меня проблема 1 и 2 состоит в том, что если был скомпрометирован закрытый ключ Боба, то злоумышленник может посылать ему сообщения якобы от Алисы даже не зная ее закрытого ключа. И Боб не сможет это узнать.
      Насчет проблемы 3-4 совершенно согласен с Вами (об этом выше написал). Спасибо


    1. leomac
      05.10.2016 09:10
      +4

      Нет, проблема 1-2 в общем случае не надумана.

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

      Тип протоколов, которые позволяют это делать, называется арбитражными протоколами (см начало 2-ой главы Брюса Шнайера ).


  1. xi-tauw
    04.10.2016 20:52
    +1

    Так бывает, если читать документацию между строк.
    1) Public-key authenticated encryption (ваша же ссылка) раздел Precalculation interface — явно указывает, что, вызывая специальные функции, ОБЩИЙ ключ можно подсчитать единожды (из контекста вполне следует, что это указано в противовес постоянному вычислению).
    2) Раздел Sealed boxes в той же главе (Public-key cryptography) что и Public-key authenticated encryption — прямо в разделе Purpose явно написано это используя данные функции даже отправитель не сможет расшифровать свое послание.
    Спорить не буду — можно было явно указать использование общего ключа в описаниях, но все предпосылки дял понимания этого сделаны.


    1. zunzibar
      04.10.2016 20:58
      +2

      Согласен. Ошибка в том и заключалась, что по каком-то причинам я торопился и читал документацию невнимательно. В последнем разделе действительно есть то, что должно было натолкнуть меня на понимание работы. Кроме информации в «Precalculation interface», то ниже есть раздел «Algorithm details», в котором явно указано на использование симметричного шифра.
      Публикацию я как раз и писал под впечатлением от своей ошибки и самоуверенности что я все прекрасно понимаю, хотя это было не так, и я надеюсь это будет лишним предостережением для других разработчиков (ну и усвоенным уроком для меня). Спасибо


  1. Scratch
    04.10.2016 21:07
    +4

    Вся статья про то, что автор невнимательно прочитал документацию. Отлично


  1. grossws
    05.10.2016 01:36
    +1

    Если бы автор пошёл дальше, то там граблей было бы ещё больше. Неудачный padding, подход с authenticate then encrypt или ещё что-нибудь.


    Есть прекрасная статья The Cryptographic Doom Principle:


    When it comes to designing secure protocols, I have a principle that goes like this: if you have to perform any cryptographic operation before verifying the MAC on a message you’ve received, it will somehow inevitably lead to doom.


    1. zunzibar
      05.10.2016 09:01

      Спасибо за наводку, очень познавательно. Что касается «Vaudenay Attack» и проблем с padding. Алгоритм Salsa20 — это поточный шифр и длина зашифрованного сообщения всегда равна длине исходного (по крайней мере в реализации libsodium), так что проблемы с выравниванием там отсутствуют. Кроме того, ситуация, описанная в «SSH Plaintext Recovery» тут тоже не повторяется, так как длина открытого текста вычисляется из длины зашифрованного текста (нужно вычесть длину MAC) и не передается в самом сообщении


  1. iq180
    05.10.2016 18:57
    -2

    Статья о том, что автор не читает документацию и не понимает приципов шифрования.


  1. Ivan_83
    05.10.2016 23:36

    Всё очень просто.
    Секретный ключ это случайное число.
    Публичный ключ это случайное число умноженное на базовую точку из параметров (например secp256, гост2012-а и пр).
    Общий секретный ключ это публичный ключ стороны А умноженный на приватный ключ стороны Б или наоборот.
    По факту это эквивалентно умножению базовой точки на секретный ключ а и далее умножению на секретный ключ б.

    Подписывание производилось общим секретным ключём потому что сальса+поли1305 типа связка, задача МАК в данном случае не удостоверить отправителя а удостоверить что сообщение зашифровано именно этим ключём. Если нужно DSA то это реализуется отдельно: считается хэш, далее он скармливается уже в ECDSA вместе с приватным ключём и нонсом.

    В качестве nonce DJB рекомендует использовать HMAC от сообщения и секретного ключа, я бы туда ещё и время+счётчик добавлял, дабы избежать replay, лучше конечно в само сообщение.

    С эфимерными/одноразовыми ключами ты не сможешь аутентифицировать клиента.

    Но ничего, бывает и много хуже: я как то скрестил chacha20 с XTS и отправил этот патч для GELI во FreeBSD, вот было «рукалицо» когда объяснили :)

    2 JTProg
    Токс ещё и децентрализованный — значит никакой урод тебя не отключит.

    2 Sheb_Gre
    Всю крипту какую использую сам реимплементирую.
    С ECDSA/ГОСТ2001/2012 конечно была жесть в плане объёма и пласта знаний который пришлось поднять.

    2 grossws
    Нет там падинга, там поточный шифр сальса.
    За аутентификацию отвечает мак — поли1305, те об этом уже подумали.