В предыдущей части мы познакомились с тем, что такое симметричное и ассиметричное шифрование, посмотрели на алгоритм ассиметричного шифрования на основе задачи о рюкзаке, кратко пробежались по его реализации на C#. В конце была упомянута мысль, что эту серию статей можно продолжить и на коленке собрать безопасный простой мессенджер. Как вы помните, ассиметричное шифрование позволяет установить безопасное соединение "в одну сторону", - чтобы сервер мог безопасно отправить клиенту ответ, понадобится либо создать пару приватный-публичный ключ на клиенте, либо выкручиваться каким-то другим способом. Чтобы всё усложнить (и получить больше опыта!), мы выберем второй вариант и реализуем популярный алгоритм симметриченого шифрования AES на C++. В этой второй части мы кратко пробежимся по реализации, посмотрим, как запускать тесты при помощи фреймворка googletest в сборочной системе autoconf, и попробуем скомпилировать наш проект в WebAssembly. Пристёгивайтесь, будет жарко!

Чего не будет в этой статье:

❌ Полного описания алгоритма AES.

✅ Скажу, где искать информацию.

❌ Кучи кода.

✅ Лишь пробегусь по классам.

❌ Исчерпывающего справочника по autoconf.

✅ Тут только про то, как тестировать при помощи него проекты с точки зрения пользователя вашего кода.

❌ Как настроить autoconf, чтобы он использовал компилятор emscripten, а не gcc.

✅ Мы соберем C++ библиотеку отдельно, не меняя configure.ac или Makefile.am.

❌ Установка autoconf.

✅ Рассмотрим опции для пользователей Windows.

Вступление

Клиент-сервер

Ещё раз: наша долгосрочная задача — сделать безопасный мессенджер. Его устройство будет простым до невозможности: пользователи смогут зарегистрироваться, отправить другому пользователю сообщение и получить список всех своих пришедших сообщений. Есть клиент (веб‑ или android‑приложение, etc), и есть сервер, где все сообщения будут храниться. Мы хотим всё сделать безопасно, потому применим такой подход — при начале общения клиент запрашивает у сервера публичный ключ K1 (или этот ключ встроен в клиент), затем генерирует ключ K2 алгоритма симметричного шифрования, ключ K2 шифрует публичным ключом сервера K1 и отправляет серверу. Сервер принимает и дешифрует — получает ключ симметричного шифрования K2. Дальнейшее общение между клиентом и сервером происходит при помощи определенного алгоритма симметричного шифрования, ключ к которому (K2) есть у каждой стороны. Теперь не только клиент может безопасно передавать свои данные серверу, но и сервер — клиенту.

Что такое AES?

AES — хороший алгоритм симметричного шифрования, именно его мы и возьмём для своего мессенджера. На хабре как минимум 3 статьи, которые о нём рассказывают, так что кратко: появился он как победитель в соревновании США, где надо было придумать стойкий, быстрый и простой блочный алгоритм шифрования, который ещё можно было бы и реализовать эффективно (параллельно). Тогда пара ребят, фамилия одного из которых начинается на R, придумали этот алгоритм, и он выиграл. С тех пор он стал популярным стандартом, что отражено в его названии.

Какой язык выбрать?

Итак, и сервер, и клиент должны уметь работать с 2мя вещами — с выбранным нами алгоритмом ассиметричного шифрования (который мы реализовали в предыдущей части — здесь мы будем называть его BackpackCrypto) и с алгоритмом AES. Первое мы уже сделали в C#, но это не означает, что все наше приложение (которое будет состоять из нескольких частей) будет написано именно на нём. Давайте, чтобы было интереснее, AES сделаем на C++. Это позволит нам выиграть в оптимизации (наверное). Так или иначе, сейчас мы быстро рассмотрим, на каких языках мы можем написать приложение:

Какие технологии мы будет применять для реализации сервера и клиента?
Какие технологии мы будет применять для реализации сервера и клиента?

Сервер можно будет организовать в микросервисной архитектуре, что позволит нам писать части бэкенда на разных языках. Клиентом может выступать как html5-приложение, написанное на Godot, так и Android-приложение, но во втором случае проблем будет больше (простого способа вызвать C# из java я не нашёл).

Теперь мы разобрали, что может быть нашим клиентом. В этой статье:

  1. Кратко пройдёмся по реализации AES в С++.

  2. Посмотрим, как можно собирать и тестировать код при помощи autoconf (вместо CMake).

  3. Посмотрим, как можно собирать C++ библиотеки в WebAssebly, чтобы потом вызывать их из JavaScript при помощи emscripten. Тем самым мы убедимся, что сможем написать веб-клиент, у которого будет доступ к написанному нами AES.

AES

Алгоритм

AES — алгоритм блочного шифрования. Ключ может быть длины 128 или 256 бит. Возьмём первый вариант. Алгоритм разбивает исходный текст на блоки, после чего к каждому блоку применяется несколько одинаковых раундов преобразования, которые также требуют значение ключа. Для этого ключ вносится в таблицу байт 4×4. По ключу при помощи несложных операций образуется ещё 10 раундовых ключей. Для каждого раунда над блоком (помимо первого) используется свой раундовый ключ. Операция получения раундовых ключей по основному ключу детерминирована и может быть выполнена как при шифровке данных, так и при дешифровке. Раундовые ключи одинаковы для всех блоков. Каждый блок исходного текста подвергается одним и тем же изменениям, после чего все блоки сворачиваются обратно в строку и получается набор байт, который и является не чем иным как шифртекстом. Иными словами, два одинаковых блока plain‑текста будут преобразованы в два других одинаковых блока шифртекста.

Реализация

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

Кратко пройдусь по классам, которые получились в результате:
  • class Matrix — матрица с шаблонным типом элементов, для создания которой можно сделать свои аллокаторы.

  • struct Err — структура с ошибками, которые будет выбрасывать код библиотеки в нехороших ситуациях.

  • class ElemPtr & struct MatrixPtr — эти вещи нужны для того, чтобы к элементам матрицы можно было обращаться при помощи операции []. Это было необязательно, но для будущего применения класса Matrix может быть полезно.

  • struct Allocator — абстрактная структура аллокатора, если для матрицы вам нужен свой аллокатор, надо будет реализовывать эти методы.

  • struct ArrayAllocator : public Allocator — реализация аллокатора, которая хранит все элементы матрицы в массиве.

    На этом мы подготовили почву для непосредственно AES — теперь мы можем использовать матрицу с выбранным аллокатором, кидать ошибки и обращаться к элементам матрицы удобным для кода способом.

  • class Byte final — обёртка над unsigned char, которая реализует операции над полем + получение обратного элемента, что и требуется для раундовых преобразований и вычисления матрицы SBox.

  • class StateMatrix : public Matrix<Byte> — матрица состояния 4×4, здесь реализация всех раундовых преобразований шифровки и дешифровки:

    • ShiftRows & InvShiftRows — сдвиг строк.

    • SubBytes & InvSubBytes — замена байт.

    • MixColumns & InvMixColumns — линейное преобразование.

    • void Add(const StateMatrix&); — побитно складывается с другой матрицей.

  • class SBoxMatrix : public Matrix<Byte> — матрица, которая используется для хранения байтовых отображений в следующих 2х классах.

  • class SBox final & class InvSBox final — классы, которые вычисляют и хранят соответственно матрицы SBox и InvSBox.

  • class MixColumns final — как и предыдущие 2 предоставляет сугубо статические методы. Здесь хранятся матрицы для линейных преобразований и реализована сама логика этих и обратных операций. Класс StateMatrix использует этот и предыдущие классы, чтобы выполнить раундовые операции над блоком.

  • class RconMatrix : protected Matrix<Byte> — эта матрица используется в алгоритме расписания ключей, она прячет все операции над матрицей, предоставляя только возможность получить столбец по индексу.

  • class Key final — загружает ключ из base64-строки в матрицу 4×4 и сразу считает расписание ключей. АгрегируетStateMatrix.

  • Файл "base64.h" — содержит 2 статические функции для преобразования байтовых строк в base64-строки и обратно.

    Все операции готовы! Осталось, собственно, преобразовать исходный текст блоки и вызвать функции для каждого блока:

  • class Unroller final — класс, который предоставляет статические функции для развертывания строки в вектор матриц и наоборот — сворачивания вектора матриц состояний в строку.

  • class AdvancedEncryptionStandard final — класс, в котором 2 статические функции — расшифровка и шифровка.

Из-за того, что мы не стали говнокодить, а разбили функциональность на несколько классов, мы теперь можем протестировать каждую операцию, убедиться, что она выполняет то, что должна, а код шифровки и дешифровки (в последнем классе) выглядит очень просто:

#include "../Err.h"
#include "AES.h"
#include "Key.h"
#include "StateMatrix.h"
#include "Unroller.h"

using namespace SM::AES;
using namespace std;

string AdvancedEncryptionStandard::Cipher(string plain_text, string key_base64)
{
    Key key = Key(key_base64.c_str());
    vector<StateMatrix> blocks = Unroller::ToBlocksPlain(plain_text.c_str());

    for (size_t block_i = 0; block_i < blocks.size(); block_i++) {
        StateMatrix& block = blocks[block_i];
        // First round:
        block.Add(key.GetKey());
        // 9 rounds of ciphering:
        for (int round_i = 0; round_i < key.ROUNDS_COUNT - 1; round_i++) {
            block.SubBytes();
            block.ShiftRows();
            block.MixColumns();
            block.Add(key.GetRoundKey(round_i));
        }
        // Final round:
        block.SubBytes();
        block.ShiftRows();
        block.Add(key.GetRoundKey(key.ROUNDS_COUNT - 1));
    }

    return Unroller::ToStringBase64(blocks);
}

Красота, не так ли?

Ресурсы

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

https://habr.com/ru/post/534620/

Разбирает алгоритм, описывает преимущества и атаки. Как начало (вход в тему) подойдёт, но как полноценный документ, из которого можно понять всё на 100% - нет.

https://habr.com/ru/post/112733/

Очень подробно описаны операции над байтами (сложение и умножение), показано, как можно вычислить матрицу SBox. Единственная статья, где правильно сказано, как делать дешифровку.

https://bit.nmu.org.ua/ua/student/metod/cryptology/лекция 9.pdf

Документ, где в доступном виде рассказано, как строка байтов располагается в матрицах-блоках. Хороший плюс - рассказано, как реализовать операции MixColumns и InvMixColumns.

https://www.youtube.com/watch?v=CxU4ROAYGzs

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

Ещё пара советов:
  • Реализация base64 на C++ расположена здесь.

  • Как считать матрицу SBox самому, а не копипастить в свой код — здесь.

  • Ну и последнее — все операции над блоком обратимы, а это значит, что для дешифрования надо выполнить их в обратном порядке (применяя сначала последний раундовый ключ, потом предпоследний и так далее до основного).

string AdvancedEncryptionStandard::Decipher(string cipher_base64_text, string key_base64) {
    Key key = Key(key_base64.c_str());
    vector<StateMatrix> blocks = Unroller::ToBlocksBase64(cipher_base64_text.c_str());

    for (size_t block_i = 0; block_i < blocks.size(); block_i++) {
        StateMatrix& block = blocks[block_i];
        // Inversion of final round:
        block.Add(key.GetRoundKey(key.ROUNDS_COUNT - 1));
        block.InvShiftRows();
        block.InvSubBytes();
        // 9 rounds of deciphering:
        for (int round_i = key.ROUNDS_COUNT - 2; round_i >= 0; round_i--) {
            block.Add(key.GetRoundKey(round_i));
            block.InvMixColumns();
            block.InvShiftRows();
            block.InvSubBytes();
        }
        // Inversion of first round:
        block.Add(key.GetKey());
    }

    return Unroller::ToStringPlain(blocks);
}

Сборочная система autoconf

В этой секции кратко расскажу про то, как собирать проекты, использующие эту сборочную систему.

Autoconf по сути за вас составляет make‑файлы, которые вы затем в обычном порядке можете использовать, чтобы собрать своё приложение.

  1. Инфа по этой сборочной системе (краткое введение) — здесь.

  2. Как внедрить googletest — здесь.

Я не буду сравнивать CMake и autoconf, а лишь скажу, что CMake позволяет собирать проекты на разных платформах, а autoconf доступен лишь пользователям линукса.

Я - пользователь windows!

Тогда надо установить линукс). Тут есть несколько дополнительных вариантов, например, поставить виртуальную машину. Но можно даже проще — собрать код в докере! Для этого не нужно будет долго скачивать образ всей ОС, а лишь образ докера, но вы вряд ли сможете как‑то просто извлечь собранные бинарники из контейнера.

Для этого - устанавливаете докер, после чего заходите в новый контейнер в терминал и пользуетесь им как настоящим линуксом:

docker run -it --entrypoint /bin/sh prantlf/alpine-make-gcc:latest

Вы получите оболочку чуть-чуть попроще чем bash, но логика та же самая - выполняйте команды и смотрите результат. Все команды дальше я тестировал в этом окружении, так что вы можете их проверить как на Linux, так и на Windows.

Для примера я покажу, как я собираю и тестирую свой же (этот) проект. Скачаем с git-а:

apk update && apk add git  # если у вас ОС alpine и вы хотите поставить git
git clone --recursive https://url.git
cd sparse-matrix/

Очистим проект от лишних файлов и создадим ./configure:

autoreconf -fisv 
ls ./configure  # проверить, что файл появился

Теперь создадим Makefile:

./configure
ls Makefile  # проверить, что файл появился

Всё! Осталось только запустить тесты:

make check
cat tests/test.log  # посмотреть инфу по пройденным тестам

Emscripten

Осталось самое интересное - мы попробуем собрать библиотеку в WebAssembly, а потом вызвать функции из JavaScript.

Установка

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

Статические функции

Мы не будем использовать всю C++ библиотеку в JavaScript, вместо этого мы определим лишь те функции, которые хотим вызывать. Это позволит нам контролировать, что должно попасть в JavaScript, а что - нет. Статические функции будут выполнять роль обёртки над библиотекой, что позволит нам поменять типы параметров с std::string на const char *.

В корневой папке проекта (где лежат файлы Makefile.am & configure.ac) создаём папку wasm. Здесь мы будем хранить файлы веб-страницы. Заходим в эту папку и создаём текстовый файл aes.cpp:

#include <string>
#include "../include/smatrix/aes/AES.h"
#include <emscripten/emscripten.h>

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE const char * DoCipher(char * message, char * key) {
    return SM::AES::AdvancedEncryptionStandard::Cipher(std::string(message), std::string(key)).c_str();
}

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

Чтобы экспортировать функцию в JavaScript и вызвать её оттуда по нормальному имени без танцов с бубнами (по типу Module.__Z3myFunctionName()) есть 2 пути:

  1. Пометить функцию для экспорта прямо в исходниках при помощи EMSCRIPTEN_KEEPALIVE из <emscripten/emscripten.h> (как мы сделали только что),

  2. Или указать функцию для экспорта в опциях компилятора (через EXPORTED_FUNCTIONS).

Сборка в wasm

Если вы ранее выполнили команду source ./emsdk_env.sh в папке с Emscripten, то в PATH появится парочка команд. Одной из них — em++ — мы и воспользуемся, чтобы собрать wasm‑файл. Вот краткое описание некоторых флагов, которые принимает этот компилятор:

  • -std=c++20 — версия C++.

  • -Wno-braced-scalar-init — можно передать некоторые аргументы, как у обычного компилятора. Тут отключается warning.

  • -s STANDALONE_WASM — сделать wasm‑файл максимально портируемым (но при использовании в веб‑странице можно получить ошибку!).

  • -s EXPORTED_FUNCTIONS="['_DoCipher']" — экспортировать функцию.

  • -s EXPORTED_RUNTIME_METHODS="['cwrap']" — служебные функции wasm, к которым мы хотим иметь доступ из JavaScript.

  • -fsanitize=address — подробные сообщения об ошибках с памятью. Помогает хотя бы локализовать возникновение segfault.

em++ -Wno-braced-scalar-init -std=c++20 -s EXPORTED_RUNTIME_METHODS="['cwrap']" aes.cpp ../include/smatrix/aes/*.cpp

Первым указываем файл со статическими функциями, затем все остальные модули, которые необходимо собрать, чтобы статические функции не потеряли реализации. Если их не указать, скорее всего, em++ упадёт из‑за ошибок линковки. В моём случае все файлы библиотеки относительно корневой папки проекта располагались в include/smatrix/aes/, так что я воспользовался wildcard, который позволяет отобрать все файлы с указанным расширением.

После компиляции в текущей папке появились файлы a.out.js и a.out.wasm.

Веб-страница

Мы бы могли руками заружать a.out.wasm файл и вообще не пользоваться a.out.js, но тогда возникнет исключение, потому что файл wasm получился слишком большой (> 4 Кб). Благо, у нас есть другая опция - использовать скрипт a.out.js, после чего получить верхнеуровневый доступ к модулю.

В этой же папке создадим index.html страничку, которая будет писать в консоль результат шифровки:

<html>
<body>
<script src="a.out.js"></script>

    Some text.

<script>
    Module.onRuntimeInitialized = function() {
        alert("Check console!");
        const myStrMessage = 'Hello, world!';
        const myKey = 'K34VFiiu0qar9xWICc9PPA==';

        var DoCipher =  Module.cwrap('DoCipher', 'string', ['string', 'string']);
        const result = DoCipher(myStrMessage, myKey);

        console.log(result);
    }
</script>
</body>
</html>

При помощи Module.cwrap получаем саму функцию, которую можем вызывать. Module.cwrap требует аргументы:

  • 'DoCipher' — имя функции для обёртки.

  • 'string' — тип возвращаемого значения.

  • ['string', 'string'] — типы аргументов.

Запуск сервера

Осталось только проверить, что страничка работает правильно. Запускаем сервер:

python3 -m http.server

В браузере открываем localhost:8000 и сразу открываем средства разработчика. Если всё правильно, то в окне появится alert, а результат в консоли должен быть такой, как на картинке:

В консоль попала зашифрованная строка.
В консоль попала зашифрованная строка.

Итоги

Сегодня мы разобрали реализацию AES на C++, посмотрели, как запускать тесты при помощи autoconf и узнали, что C++ код можно экспортировать в JavaScript. В следующих статьях этой серии (если они будут) мы посмотрим ещё много чего интересного, а может, сделаем свой мессенджер!

Если вы не знаете, где хранить свой код:

Посмотрите одну их моих предыдущих статей, где я рассказываю, как поднять свой GitLab на RedOs.

Если вам нужен проект, который получился в итоге:

Пишите в личку (в диалоги на хабре) и я скину код архивом или ссылкой.

Полезные ссылки

  • Введение в wasm. Если впервые сталкиваетесь с этой технологией — очень хорошая обзорная статья.

  • Emscripten Tutorial.

  • Compiling a C/C++ Module to WebAssembly. Компилятор может выдавать целые html файлы с терминалом для откладки, при этом начальные html шаблоны можно конфигурировать.

  • Больше возможностей компиляции.

  • Ccall & cwrap. Официальная документация Emscripten.

  • Типы данных JavaScript. Чтобы посмотреть, как они называются.

  • std::string в JavaScript. Ответ на stackoverflow.


Спасибо за внимание.

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


  1. emaxx
    00.00.0000 00:00
    +3

    А где же главный совет - никогда не используйте наколеночные реализации криптографии с реальными данными?

    В криптографии много граблей, которые невозможно заметить без знания кучи теории и стандартных атак, или хотя бы без детального анализа общепризнанных реализаций. Конкретно для AES наглядный пример в https://www.winmill.com/blog/2022/08/05/incorrect-aes-implementation-leaves-system-vulnerable/, или можно посмотреть на старые уязвимости типа CVE-2016-7440 в wolfSSL.


    1. sci_nov
      00.00.0000 00:00
      +1

      А есть ли где-нибудь систематизированное описание "граблей" на русском языке?


      1. Number571
        00.00.0000 00:00
        +2

        Думаю "Практическая криптография" от Шнайера и Фергюсона подойдёт.


        1. sci_nov
          00.00.0000 00:00
          +1

          Шнайер + Фергюсон = Шнеерсон :).


      1. emaxx
        00.00.0000 00:00
        +2

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

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


        1. sci_nov
          00.00.0000 00:00
          +1

          При наличии образования можно и изучить, там ничего сверхъестественного нет, просто эти знания да, надо накопить опытом, не всё сразу очевидно.


  1. BigGiraffe
    00.00.0000 00:00
    +1

    Сервер принимает и дешифрует — получает ключ симметричного шифрования K2.

    С ключом не дешифруют, а расшифровывают :) Знакомые мне криптографы очень обижаются, когда путают два этих понятия. Терминология с википедии не даст соврать.


  1. CadMan77
    00.00.0000 00:00
    +1

    Недавно меня начала интересовать тема шифрования. Увидел тут цикл ваших статей, обрадовался и решил внимательно прочитать его с самого начала. Судя по ссылкам в статьях самым началом цикла является "задача о рюкзаке". Вот на попытке чтения статьи описывающей задачу моё знакомство с циклом и застопорилось. Статья про задачу не выглядит законченной...
    UPDATE:
    Vivaldi for Android почему-то не показал спец-знаки.