В 2017 году российской компанией КриптоПро был предложен новый AEAD режим шифрования Multilinear Galois Mode (MGM). Спустя некоторое время он был принят в российские и международные стандарты. Так как в интернете я смог найти только общее описание работы этого режима шифрования без конкретной реализации (ссылки в конце статьи), то я решил попробовать свои силы и реализовать очень упрощённую версию своими руками...
Для начала разберёмся со вспомогательными, но важными для понимания вещами.
Шифрование vs режим шифрования
Шифрование - это отображение вида, которое осуществляет преобразование открытого текста P в шифротекст С с иcпользованием ключа шифрования K. Примером такого отображения может служить "шифр Цезаря", о котором можно почитать в Википедии.
Режим шифрования - это подход к шифрованию открытого текста P, при котором он разбивается на блоки текста заведомо фиксированной длины. Далее каждый блок шифруется некоторым отображением (ключ К - общий и заранее выбран), а потом они собираются обратно вместе в шифротекст С.
AEAD-режим блочного шифрования
AEAD - это класс блочных режимов шифрования, при котором часть сообщения шифруется, часть остается открытой, и всё сообщение целиком аутентифицировано. Иными словами, если, например, проводить аналогию с почтовыми письмами, то внутренность письма вы шифруете (запечатываете), адрес отправителя и адрес получателя оставляете снаружи конверта (не зашифрованными), чтобы почтовые службы смогли корректно обработать ваше письмо, и ещё дополнительно вы делаете приписку, с помощью которой получатель сможет проверить (аутентифицировать), что все данные как внутри, так и снаружи письма были сохранены.
Арифметика полей Галуа GF(2^n)
Арифметика полей Галуа встречается повсеместно в криптографии, и понимание принципов её работы в программном коде необходимо. Режим шифрования AEAD-MGM от чисел из полей Галуа GF(2n) требует реализации только 2 операций - сложения и умножения
.
Примеры, которые приводятся дальше в этой части, я взял из вспомогательной статьи.
Она рекомендуется к прочтению, чтобы сложить более полное понимание об арифметике полей Галуа GF(2n).
Теперь перейдём к реализации. Для начала определим класс чисел в полях Галуа GF(2n):
// Galois Field Number
// для удобства показа идей и реализации тип uint32_t выбран специально
class Number
{
public:
Number():
value(0)
{}
Number(uint32_t value_):
value(value_)
{}
operator uint32_t() const
{ return value; }
public:
friend Number operator+(Number left, Number right);
friend Number operator*(Number left, Number right);
public:
friend std::ostream& operator<<(std::ostream& os, Number number)
{ return os << number.value; }
public:
uint32_t value;
};
Теперь определим операцию сложения в поле GF(2n), которая по сути своей является обычной побитовой операцией XOR:
class Number
{ ... }
Number operator+(Number left, Number right)
{ return left.value ^ right.value; }
(операция в полях GF(2n) также является побитовой операцией XOR)
Чтобы определить операцию умножения , нам сначала нужно понять, как отличие в операции
влияет на неё. Из школы нам известен алгоритм умножения в столбик:
Как мы видим, операцию мы здесь применяем для того, чтобы сложить два промежуточных результата от умножения 17 на 5, и 17 на 20. Если представить число 25 как
, то становится видно, что операция
используется для сложения результатов умножения числа 17 на числа при разных степенях 10. Если мы применим теперь наши рассуждения для разложений чисел по основанию 2, то результат не изменится.
Теперь, вспоминая что в полях GF(2n) является XOR, получим
Или
Таким образом получаем первую часть нашего метода умножения в полях GF(2n):
// возвращаемый тип uint64_t потому, что
// если a < 2^32 и b < 2^32, то a * b < 2^(32 + 32) = 2^64, но не 2^32
uint64_t MulMod2(uint32_t left, uint32_t right)
{
uint64_t result = 0;
uint64_t offset = 0;
while(right)
{
if (right & 1)
result ^= left << offset;
++offset;
right >>= 1;
}
return result;
}
Замечу, что если мы хотим перемножать числа, которые не помещаются в машинное слово процессора (например, 128 разрядные), то нам стоит немного переписать этот метод. На этот раз мы будем раскладывать не только правое число, но и левое, суммируя по модулю 2 кол-во единиц при одинаковой степени 2.
// uint128_t - некий абстрактный целочисленный тип содержащий 128 бит
// uint256_t - 256 бит
bool testbit(uint256_t value, size_t offset)
{ return value & (1 << offset); }
void setbit(uint256_t& value, size_t offset)
{ value |= 1 << offset; }
uint256_t MulMod2(uint128_t left, uint128_t right)
{
uint256_t result = 0;
for(size_t k = 0; k < 256; ++k)
{
size_t sum = 0;
for(size_t i = 0, j = k; i <= k; ++i, --j)
{
if (testbit(left, i) && testbit(right, j))
sum ^= 1;
}
if (sum)
setbit(result, k);
}
return result;
}
Операции используемые здесь проще в реализации и легче проверяются.
Вторая часть операции умножения в полях Галуа заключается в том, чтобы как бы "вместить обратно" число, полученное от MulMod2 в интервал [1, 2n). Для этого вводится специальный порождающий многочлен - число в интервале [2n, 2n + 2n), на которое мы делим результат от MulMod2. Полученное при этом число будет лежать в интервале [1, 2n).
Об остатке от деления
Из школьной программы мы знаем, что для обычной арифметики остаток от деления на число m лежит в интервале [0, m). Почему же тогда арифметика в полях Галуа GF(2n) для порождающего многочлена (2n + x), 0 < x < 2n не даёт нам получить остаток (2n + y), 0 < y < 2n?
Ответ тут в том, что если бы мы получили остаток (2n + y), то тогда мы бы смогли бы применить следующую операцию
Так как 0 < x < 2n и 0 < y < 2n, а операция XOR сохраняет количество разрядов в числе, то z будет принадлежать интервалу [0, 2n). Так как 0 не встречается, то его отбрасываем, получая интервал [1, 2n). По итогу остаток (2n + y) преобразовался в остаток z, который принадлежит интервалу [1, 2n), а это противоречит предположению, что мы можем получить остаток (2n + y)
Вспомним школьное деление столбиком

Если это переписать в немного другом виде, то получится
Остатки всегда должны быть меньше 12 * 10x, а результаты делений 13/12, 15/12, 30/12 должны лежать в интервале [0, 10), так как 10 - основание системы счисления.
В двоичной системе счисления происходит всё то же самое:

Теперь посмотрим на то же самое деление в столбик, если мы заменим на

Проверим, что полученный нами ответ удовлетворяет MulMod2
Как видно, наш подход к делению оказался верным. Осталось только реализовать это в коде.
Для реализации воспользуемся тем свойством алгебры полей Галуа GF(2n), что если есть два числа
то мы можем ввести
Если сравнить это с делением столбиком, то а - делимое, b - делитель, q - бит, который мы должны записать в частное, - промежуточный результат для следующей итерации... Как только
станет меньше b, мы должны вернуть остаток. Итого код получается следующим:
size_t maxbit(uint64_t value)
{
size_t retval = 0;
while(value)
{ value >>= 1; ++retval; }
return retval;
}
uint32_t ModGenP(uint64_t value, uint64_t gen_polynom)
{
while (true)
{
size_t vmaxbit = maxbit(value);
size_t pmaxbit = maxbit(gen_polynom);
if (vmaxbit < pmaxbit)
return (uint32_t) value;
size_t quotient = 1 << (vmaxbit - pmaxbit);
value ^= gen_polynom * quotient;
}
}
Теперь совмещая всё вместе, получим итоговый код для операции умножения в полях Галуа GF(2n):
class Number { ... }
Number operator*(Number left, Number right)
{
// порождающий полином: x^32 + x^22 + x^2 + x^1 + 1
uint64_t gen_p = (1ULL << 32) + (1 << 22) + (1 << 2) + (1 << 1) + 1;
uint64_t value = MulMod2(left.value, right.value);
return ModGenP(value, gen_p);
}
AEAD-MGM
Теперь, разобравшись со всеми сложными, но важными моментами, мы готовы перейти к процессу шифрования AEAD-MGM...
Основной файл с описанием, на который я буду опираться, можно найти здесь:
По сути процесс шифрования представляет собой:
Генерацию nonce (уникального для каждого обмена сообщениями)
Генерацию
и
Генерацию массивов Y, Z, H.
Деление входных данных (A и P) на блоки выбранного нами размера (32 бита)
Преобразование
и генерацию имитовставки Т
Для упрощения реализации в коде будем считать, что
P = N блоков по 32 бита информации в каждом
A = M блоков по 32 бита информации в каждом
Максимальный размер сообщений
Доопределим incr_l, incr_r и ещё несколько вспомогательных функций:
uint32_t incr_l(uint32_t input)
{
uint16_t L = (input & 0xffff0000) >> 16;
uint16_t R = (input & 0x0000ffff);
++L;
return (L << 16) + R;
}
uint32_t incr_r(uint32_t input)
{
uint16_t L = (input & 0xffff0000) >> 16;
uint16_t R = (input & 0x0000ffff);
++R;
return (L << 16) + R;
}
uint32_t MSB(uint32_t number, size_t size)
{ return number & (0xffffffff - ((1 << size) - 1)); }
// nonce - одноразовый вспомогательный вектор, который должен быть
// уникальным для каждого обмена сообщениями. Его длина должна быть (N - 1),
// т.е. в нашем случае (32 - 1) = 31 бит. В рекомендациях предлагается его
// генерация на основе счётчика, но, так как мы хотим использовать его только 1 раз,
// то возьмём просто какое-то подходящее значение.
constexpr uint32_t nonce()
{
return 0x5678; // 0x5 = 0101 (первый бит не используется)
}
constexpr uint32_t Get0orN()
{ return (0 << 31) + nonce(); }
constexpr uint32_t Get1orN()
{ return (1 << 31) + nonce(); }
// Функция шифрования E_k. Для простоты реализации мы ничего не шифруем.
uint32_t Еncrypt(uint32_t input)
{
return input;
}
// функции заполнения массивов Y, Z, H
void FillY(Number* Y, size_t count)
{
Y[0] = Shifr(Get0orN());
for(size_t i = 1; i < count; ++i)
Y[i] = incr_r(Y[i - 1]);
}
void FillZ(Number* Z, size_t count)
{
Z[0] = Shifr(Get1orN());
for(size_t i = 1; i < count; ++i)
Z[i] = incr_l(Z[i - 1]);
}
void FillH(Number* H, Number* Z, size_t count)
{
for(size_t i = 0; i < count; ++i)
H[i] = Shifr(Z[i]);
}
Ну и наконец сам алгоритм:
int main()
{
constexpr size_t N = 10;
constexpr size_t M = 10;
Number Y[N];
Number Z[N + M + 1];
FillY(Y, N);
FillZ(Z, N + M + 1);
Number P[N] = {0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10};
Number C[N];
for(size_t i = 0; i < N; ++i)
C[i] = Number(Shifr(Y[i])) + P[i];
Number A[M] = {0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10};
Number H[N + M + 1];
FillH(H, Z, N + M + 1);
// массив промежуточных результатов умножения
Number V[21];
for(size_t i = 0; i < M; ++i)
V[i] = H[i + 0] * A[i];
for(size_t i = 0; i < N; ++i)
V[i] = H[i + M] * P[i];
uint16_t lenC = N * 32;
uint16_t lenA = M * 32;
uint32_t lenS = (lenA << 16) + lenC;
V[N + M] = H[N + M] * Number(lenS);
Number sum = V[0];
for(size_t i = 1; i <= N + M; ++i)
sum = sum + V[i];
// имитовставка
uint32_t T = MSB(Shifr(sum), 11);
for(size_t i = 0; i < N; ++i)
std::cout << C[i] << ' ';
std::cout << std::endl;
std::cout << "T = 0x" << std::hex << T << std::endl;
return 0;
}
Безусловно, режим шифрования AEAD-MGM в такой реализации не покрывает множество тонких моментов, но для понимания идей работы его, я думаю, вполне достаточно.
Полный код на С++:
Итоговый код
#include <iostream>
#include <cstdint>
// Galois Field Number
// для удобства показа идей и реализации тип uint32_t выбран специально
class Number
{
public:
Number():
value(0)
{}
Number(uint32_t value_):
value(value_)
{}
operator uint32_t() const
{ return value; }
public:
friend Number operator+(Number left, Number right);
friend Number operator*(Number left, Number right);
public:
friend std::ostream& operator<<(std::ostream& os, Number number)
{ return os << number.value; }
public:
uint32_t value;
};
Number operator+(Number left, Number right)
{ return left.value ^ right.value; }
bool testbit(uint64_t value, size_t offset)
{ return value & (1 << offset); }
void setbit(uint64_t& value, size_t offset)
{ value |= 1 << offset; }
uint64_t MulMod2(uint32_t left, uint32_t right)
{
uint64_t result = 0;
uint64_t offset = 0;
while(right)
{
if (right & 1)
result ^= left << offset;
++offset;
right >>= 1;
}
return result;
}
size_t maxbit(uint64_t value)
{
size_t retval = 0;
while(value)
{ value >>= 1; ++retval; }
return retval;
}
uint32_t ModGenP(uint64_t value, uint64_t gen_polynom)
{
while (true)
{
size_t vmaxbit = maxbit(value);
size_t pmaxbit = maxbit(gen_polynom);
if (vmaxbit < pmaxbit)
return (uint32_t) value;
size_t quotient = 1 << (vmaxbit - pmaxbit);
value ^= gen_polynom * quotient;
}
}
Number operator*(Number left, Number right)
{
uint64_t gen_p = (1ULL << 32) + (1 << 22) + (1 << 2) + (1 << 1) + 1;
uint64_t value = MulMod2(left.value, right.value);
return ModGenP(value, gen_p);
}
uint32_t incr_l(uint32_t input)
{
uint16_t L = (input & 0xffff0000) >> 16;
uint16_t R = (input & 0x0000ffff);
++L;
return (L << 16) + R;
}
uint32_t incr_r(uint32_t input)
{
uint16_t L = (input & 0xffff0000) >> 16;
uint16_t R = (input & 0x0000ffff);
++R;
return (L << 16) + R;
}
uint32_t MSB(uint32_t number, size_t size)
{ return number & (0xffffffff - ((1 << size) - 1)); }
constexpr uint32_t Get0orN()
{
constexpr uint32_t nonce = 0x5678;
constexpr uint32_t zero = 0;
return (zero << 31) + nonce;
}
constexpr uint32_t Get1orN()
{
constexpr uint32_t nonce = 0x5678;
constexpr uint32_t one = 1;
return (one << 31) + nonce;
}
uint32_t Shifr(uint32_t input)
{
return input;
}
void FillY(Number* Y, size_t count)
{
Y[0] = Shifr(Get0orN());
for(size_t i = 1; i < count; ++i)
Y[i] = incr_r(Y[i - 1]);
}
void FillZ(Number* Z, size_t count)
{
Z[0] = Shifr(Get1orN());
for(size_t i = 1; i < count; ++i)
Z[i] = incr_l(Z[i - 1]);
}
void FillH(Number* H, Number* Z, size_t count)
{
for(size_t i = 0; i < count; ++i)
H[i] = Shifr(Z[i]);
}
int main()
{
constexpr size_t N = 10;
constexpr size_t M = 10;
Number Y[N];
Number Z[N + M + 1];
FillY(Y, N);
FillZ(Z, N + M + 1);
Number P[N] = {0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10};
Number C[N];
for(size_t i = 0; i < N; ++i)
C[i] = Number(Shifr(Y[i])) + P[i];
Number A[M] = {0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10};
Number H[N + M + 1];
FillH(H, Z, N + M + 1);
// массив промежуточных результатов умножения
Number V[21];
for(size_t i = 0; i < M; ++i)
V[i] = H[i + 0] * A[i];
for(size_t i = 0; i < N; ++i)
V[i] = H[i + M] * P[i];
uint16_t lenC = N * 32;
uint16_t lenA = M * 32;
uint32_t lenS = (lenA << 16) + lenC;
V[N + M] = H[N + M] * Number(lenS);
Number sum = V[0];
for(size_t i = 1; i <= N + M; ++i)
sum = sum + V[i];
// имитовставка
uint32_t T = MSB(Shifr(sum), 11);
for(size_t i = 0; i < N; ++i)
std::cout << C[i] << ' ';
std::cout << std::endl;
std::cout << "T = 0x" << std::hex << T << std::endl;
return 0;
}
Ссылки на статьи
AEAD-MGM
Арифметика полей Галуа GF(2n):