Показаны сообщения с ярлыком Кузнечик. Показать все сообщения
Показаны сообщения с ярлыком Кузнечик. Показать все сообщения

пятница, 18 июня 2021 г.

GF2p8 Аффинные преобразования (ч. 5): блочный шифр ГОСТ "Кузнечик"

Блочный шифр "Кузнечик". Умножение в изоморфном представлении поля GF(2^8)

Весь цикл сообщений посвященный аффинным преобразованиям мы затеяли ради того чтобы представить этот и подобные ему алгоритмы. Ниже мы рассматриваем преобразование алгоритма шифрования ГОСТ Р 34.12-2015 Кузнечик к изоморфному представлению поля. Целью преобразования является ускорение работы за счет использования инструкций Intel GFNI.

Алгоритм зашифровывания выглядит, как последовательное примение частей преобразования: наложение ключа, биективное нелинейное преобразование S-Box, Линейные преобразования. Линейные преобразования можно свести к умножению в поле GF(2^8)/0x1C3. Можно записать линейные преобразование, как умножение вектора состояния (uint8x16_t, 16 байт) на матрицу коэффициентов (16х16 элементов). Алгоритм линейных преобразований (L) выглядит, как умножение матрицы на вектор:

// Линейное преобразование ГОСТ "Кузнечик"
uint8x16_t LMT[16];
// пересчет матрицы линейного преобразования
for(i=0; i<16; i++)
    LMT_iso[i] = _mm_gf2p8affine_epi64_epi8(LMT[i], M, 0);
// пересчет коэффициентов S-Box
for(i=0; i<256; i++)
    sbox_iso[i]= affine_byte(M, sbox[affine_byte(Mr, i)]);
/*! Линейное преобразование
В цикле выполняется умножение с редуцированием по полиному 0x11B */
uint8x16_t LS(uint8x16_t s) {
    uint8x16_t r = {0};
    int i;
    for(i=0; i<16; i++) {
        uint8_t v = sbox_iso[s[i]];
        r ^= _mm_gf2p8mul_epi8(_mm_set1_epi8(v), LMT_iso[i]);
    }
    return r;
}
/*! Зашифровывание */
uint8x16_t kuzn_encrypt(uint8x16_t* K, const uint8x16_t a)
{
    int i;
    a = _mm_gf2p8affine_epi64_epi8(a, M, 0);
    uint8x16_t s = a ^ K[0];
    for (i=0; i<9; i++)
        s = LS(s) ^ K[i+1];
    return _mm_gf2p8affine_epi64_epi8(s, Mr, 0);
}

Получается простое описание. Преобразование констант из матрицы коэффициентов LMT, как и аффинные преобразования S-Box, можно выполнить статически. Попробую алгоритм выразить формулой. Я убежден, что для программистов проще читать код, чем разбираться в формулах. Я применяю формулы там, где запись дает понимание. В формуле видны все места, куда надо вставить прямое и обратное преобразование. В частности, надо выполнить аффинные преобразования ключа K из представления поля 0x1C3 в 0x11B. Если дана функция двух переменных, то следует выполнить преобразования обеих переменных. Для разработчиков важно уяснить, что прямую и обратную матрицу добавляют в формулу в сочетании M-1·M·А, а потом, используя линейные преобразования, "вдвигают" матрицу внутрь формулы: M-1·А·M. Или же применяют к уравнению (слева) одно и тоже линейное преобразование: к левой и правой частям. В аналитических выкладках, надо четко понимать, какая операция в каком представлении поля и на каком полиноме выполняется.

gfmul[0x1C3](K, x) = M-1gfmul[0x11B](M·K, M·x);
sbox[0x1C3](x) = M-1sbox[0x11B](M·x);
sbox[0x11B](x) = M·sbox[0x1C3](M-1x);

Ключи (K) пересчитываются в представление поля 0x11B на этапе развертывания ключа. После подстановки формулы sbox[0x1C3] в качестве аргумента gfmul[0x1C3], произведение матриц M· M-1 = E дает единицу, сокращается:

gfmul[0x1C3](K, x) = M-1gfmul[0x11B](M·K, (M· M-1)sbox[0x11B](M·a));

Благодаря тому, что gfmul[0x1C3] выполняется в цикле, умножение вектора состояния на обратную и прямую матрицу можно вынести из цикла. В итоге в цикле аффинные преобразования не используются, матрицы изоморфного преобразования применяются только ко входным и выходным данным алгоритма зашифровывания, в то время как умножение производится на полиноме 0x11B.

В таблице представлены матрицы изоморфного аффинного преобразования из представления поля GF(2^8) 0x1C3 в 0x11B и обратно. В расчете можно использовать любую пару.

Таблица преобразований из представления поля 0x1C3 в 0x11B
M =0x5D0CE430CEE6BCD0 M-1 =0xC9248C8EB6BE7C4A
M =0x61D8CC543E9296A4 M-1 =0x359C60B0663A0EDA
M =0x2FA2EA44FA5AD66C M-1 =0x6332D8D6145ED06E
M =0x59A208A62EC29AF4 M-1 =0x61EC0A04B4F2D01C
M =0xED406082D258646E M-1 =0x89F844381A0602F0
M =0x5BD81880DC3CDA0A M-1 =0x494212C2C6360E08
M =0x0340F81A7C8C1EBA M-1 =0x87864834BAD4025C
M =0xC90C4A9E5604C632 M-1 =0x195A202216CC7C46

Идеальный S-Box

Глядя на алгоритм Кузнечик, видно его несовершенство.. Совершенный алгоритм -- тот, который сохраняя высокую степень нелинейности S-Box дает высокую производительность и высокую степень параллельности кода. Для этого нелинейное преобразование S-Box должно выражаться математическими операциями. В статьях предлагают S-Box полученные с использованием операции инверсии (возведения в степень) и аффинных преобразований, (англ. APA -- affine-power-affine).

s(x) = A2·Inv(A1·a(x)⊕C1) ⊕C2

В итоге формула "совершенного алгоритма" для следующего поколения ГОСТ выражается следующим циклом. При этом стандартизировать алгоритм можно с использованием любого из 30 нередуцируемых полиномов поля GF(2^8) и любых немыслимых аффинных преобразований, прежде всего в качестве основы для проектирования S-Box выбираются циркулянты - матрицы построенные на вращении полинома, которые дают обратимые преобразования. Возможно существует декомпозиция, которая представляет S-Box Кузнечик в совершенном виде.

// Предлагаемый алгоритм с использованием APA S-Box и матричного умножения.
uint8x16_t LS(uint8x16_t s) {
    s = _mm_gf2p8affine_epi64_epi8(s, A1, C1);
    s = _mm_gf2p8affineinv_epi64_epi8(s, A2, C2);
    uint8x16_t r = {0};
    for(i=0; i<N; i++) {
    	s = _mm_alignr_epi8(s,s,1);
        r^= _mm_gf2p8mul_epi8(s, LMR[i]);
    }
    return r;
}

Такая структура алгоритма позволяет получить производительность 1 такт на цикл алгоритма.

среда, 12 мая 2021 г.

Алгоритм блочного шифра "Кузнечик"

Занимался оптимизацией алгоритма шифрования Кузнечик [ГОСТ Р 34.12-2015]. Последние действия могу объяснить, а откуда получены промежуточные, забываю. Надо зафиксировать знание. Чтобы восстановить логику, пишу статью. Цель статьи -- показать, как можно оптимизировать алгоритмы в конечном поле.

Цикл алгоритма состоит из трех преобразований (LSX): наложение колюча(X), табличная подстановка(S) и некоторые линейные действия в конечном поле GF(2^8) с полиномом 0x1С3.

Преобразование L состоит из множества умножений и сложений в конечном поле. Операцию L можно исключить и сделать табличной. А наоборот, нельзя ли вычислить L честно и в тоже время быстро?

L состоит из 16 циклов R замешивания и умножения. В результате можно написать произведение матрицы коэффициентов (LM) 16х16 элементов на вектор из 16 элементов: LM*(a) -- линейная алгебра в поле GF. Если в нашем распоряжении есть векторная инструкция умножения в конечном поле, то задача может быть решена.

В архитектуре ARM Neon есть инструкция полиномиального векторного умножения (vpmull_p8), которая позволяет выполнить 8 умножений параллельно, результат - вектор 128 бит состоящий из 8 произведений выполненных без переносов(полиномиальных). Чтобы вернуть результат умножения в поле, надо выполнить операцию редуцирования. Операция матричного умножения в поле GF(2^8)

// Операция  матричного умножения в поле GF(2^8)
// редуцирование вынесено из цикла
#include <arm_neon.h>
poly16x8_t v0 = {0};
poly16x8_t v1 = {0};
int i;
for(i=0; i<16; i++) {
    // размножаем значение на все элементы
    poly8x8_t a8 = (poly8x8_t)vdup_n_u8(sbox[a[i]]);
    // загружаем столбцы матрицы коэффициентов
    poly8x16_t p8 = LMT[i];
    // умножаем младшую часть вектора poly8x8
    v0 ^= vmull_p8(a8, vget_low_p8 (p8));
    // умножаем старшую часть вектора poly8x8
    v1 ^= vmull_p8(a8, vget_high_p8(p8));
}

В результате вектор {v0,v1} содержит результат матричного умножения, 16 элементов по 16 бит в двух векторных регистрах, который далее следует редуцировать до 8 бит, чтобы вернуть в поле GF(2^8).

Редуцирование выполняется по методу Баретта. В алгоритме редуцирования используется обратное число - константа баретта (0x1B5) и сам полином (0x1С3).

Редуцирование по Баретту напоминает остаток от деления.
q = a/P = a*B
r = a - q*P

В нашем случае нужно заменить операции на соответствующие операции в поле GF

Алгоритм №__. Редуцирование по Баретту B=0x1B5, P=0x1C3.
На Входе: [a1:a0]
    [q1:q0] := a1 ⊙ B -- операция умножения
    [r1:r0] := [a1:a0] ⊕ q1 ⊙ P
На выходе: r0
// редуцирование вынесли из цикла
    poly8x8_t t0;
	poly16x8_t t;
	const poly8x8_t B5 = vdup_n_p8(0xB5);
	const poly8x8_t C3 = vdup_n_p8(0xC3);
    // сдвиг вправо элементов вектора 16 бит на 8 бит с заужением
    t0 = (poly8x8_t) vshrn_n_u16((uint16x8_t)v0, 8);
    t  = vmull_p8(t0, B5);// 
    t0^= (poly8x8_t) vshrn_n_u16((uint16x8_t)t, 8);
    v0^= vmull_p8(t0, C3);// v0 := v0 - t0*P

Операция редуцирования выполняется над каждым элементом вектора v0 и v1. Результат - вектор poly16x8_t. Операция выполняется над вектором v1 - старшая часть вектора 16х16. Финально необходимо преобразовать вектор обратно к разрядности poly8x16_t

Аналогичный алгоритм может быть построен на операции умножения без переносов на платформе Intel, с использованием инструкции PCLMUL

//Матричное умножение в поле GF
    for(i=0;i<16;i+=1){
        a16[0] = sbox[a[i]];
        poly64x2_t L = (poly64x2_t)UNPACKLBW128((int8x16_t)LMT[i], Z);
        poly64x2_t H = (poly64x2_t)UNPACKHBW128((int8x16_t)LMT[i], Z);
        v0 ^= CL_MUL128(a16, L, 0x00);
        v1 ^= CL_MUL128(a16, L, 0x10);
        v2 ^= CL_MUL128(a16, H, 0x00);
        v3 ^= CL_MUL128(a16, H, 0x10);
    }

Привожу табличный алгоритм, чтобы было с чем сравнивать

//Векторное воплощение операции LSX
uint8x16_t LSX(uint8x16_t a, uint8x16_t K)
{
    poly64x2_t r0={0};
    poly64x2_t r1={0};
    int i;
    poly64x2_t bb= (poly64x2_t)(a ^ K);
    uint64_t b0 = bb[0];
    uint64_t b1 = bb[1];
#pragma GCC unroll 8
    for(i=0;i<8;i++) {
        uint64_t aa = a[i];
        r0 ^= LS[i  ][(b0>>(i*8))&0xFF];
        r1 ^= LS[i+8][(b1>>(i*8))&0xFF];
    }
    return (uint8x16_t)(r0 ^ r1);
}

Результат практический: удалось получить производительность на Intel Core (10th Gen) до 20 тактов на байт при шифровании MGM. В то время, как производительность табличного метода - в три раза выше. Теоретически можно пытаться увеличить производительность алгоритма за счет использования векторных инструкций VPCLMUL с разрядностью 256 и 512 бит. На платформе Arm Neon ожидаю сравнимую производительность с табличным методом, поскольку в цикле используется меньше инструкций. Данных по производительности на ARM пока нет.

Подведем итог. Результат отрицательный по быстродействию. Но, как мне кажется, заслуживает внимания. Алгоритм можно использовать в тех случаях, когда нужно минимизировать таблицы -- в микроконтроллерах.

суббота, 30 ноября 2019 г.

Блочный шифр ГОСТ "Кузнечик" -- оптимизация

Речь идет об оптимизации алгоритма блочного шифра ГОСТ "Кузнечик". Я давно отложил задачу на сладкое, а тут время появилось ее доделать.
В основе шифра Кузнечик лежат две операции. 1) нелинейное биективное преобразование - табличная подстановка. 2) Что-то вполне линейное, в смысле алгебра линейная в конечном поле, на основе арифметики Галуа, с редуцированием по полиному 0x1С3.

Я увидел возможность преобразовать алгоритм, сделать его быстрее.
Итак структура алгоритма такова [ГОСТ 34.12-2015]:
E[K] = X[K10]LSX[K9]... LSX[K2]LSX[K1](a)
Я пропущу описание того, что сделано в первой серии. Я умею выражаться длинными векторами, для этого использую векторные расширения языка Си. Блочный шифр -- это преобразование вектора состоящего из 16 элементов разрядностью 8 бит. Для этого использую описание типа:

typedef uint8_t uint8x16_t __attribute__((__vector_size__(16)));

Алгоритм защифровывания выглядит так
uint8x16_t kuzn_encrypt(KuznCtx* ctx, const uint8x16_t a)
{
    uint8x16_t S = a ^ ctx->K[0];
    int i;
    for (i=0; i<9; i++){
        S = LS(S) ^ ctx->K[i+1];
    }
    return S;
}

Шифрование -- это вот такой алгоритм, внутри которого есть функция LS, всего девять циклов по развернутому ключу {K1,K2, ...K10}.
Зашифровывание -- это последовательное применение преобразований:
X[K1], S, L, X[K2], S, L, ... X[K9], S, L, X[K10]
Преобразование X[K](a) = K ⊕ a
Преобразование S -- это таблица подстановок -- "нелинейное биективное преобразование".

uint8x16_t LS(uint8x16_t a)
{
    int i;
    for(i=0; i< 16;i++) a[i] = sbox[a[i]]; // -- подстановка, 
    uint8x16_t v = R16(a); // -- то самое преобразование L
    return v;
}

Вот добрались до сути. КАК оптимизировать преобразование R16?
Преобразование R выполняется над векторами 16 элементов по 8 бит и содержит 16 циклов.
Кроме всяких пермутаций это преобразование содержит много операций умножения в конечном поле GF(2) с полиномом p(x) = x8 + x7 + x6 + x + 1, или Px = 0x1C3.
Путем долгих мучительных преобразований можно записать, что каждый элемент входного вектора зависит от каждого элемента выходного вектора. Всего 16 наборов констант для каждого элемента. vi = SUM(aj * LMTij) . LMT - это матрица коэффициентов, которую по ходу пришлось пересчитывать и транспонировать, чтобы преобразование стало выглядеть линейно.

uint8x16_t GMULv16(uint8x16_t L, uint8x16_t a);
uint8x16_t R16(uint8x16_t a)
{
    int i;
    uint8x16_t v = {0};
    for(i=0; i< 16;i++) {
        v ^= GMULv16(LMT[i], a[i]); // -- векторное умножение в поле Px = 0x1C3
    }
    return v;
}
Операйця GMULv16 выполняет умножение каждого элемента вектора с редуцированием по полиному 0x1C3.
На этом этапе я остановился в прошлый раз. Большим достижением казалость то, что алгоритм удалось сделать без ветвления и с ипользованием векторных инструкций. GMULv16 можно заменить на 16 таблиц по 256 значений по 16 байт каждое, 64кБайт. Но это не наш путь.

В цикле можно использовать умножение без переноса, а финальное редуцирование выполнять за пределами цикла.
vi = SUM((aj * LMTij) mod P(x)) =  SUM(aj * LMTij) mod P(x)

В результате получаем такой алгоритм:

uint8x16_t R16(uint8x16_t a)
{
    int i;
    uint16x16_t vh = {0};// -- 16 элементов разрядностью 16 бит. 256 бит.
    for(i=0; i< 16;i++) {
        vh ^= CL_MULv16(LMT[i], a[i]); // -- векторное умножение без переносов
    }
    uint8x16_t v = RR (vh, Px);// -- финальное редуцирование по полиному P(x),
    return v;
}
Размер таблицы - 16 элементов по 16 байт, 256 байт. В цикле получаем умножение 128 бит на 8 бит. Это умножение можно представить 4я инструкциями умножения полиномов в поле разрядностью 64 бит.

Может быть есть компромис между размером таблицы и безумным умножением?
Я так понимаю, что большие таблицы медленно обрабатываются, потому что в кеш память не влезают. Если мы делаем алгоритм для контроллера, то таблицы 65кБайт не лезут во флеш память, не лезут в память оперативную, сильно увеличивают объем кода. Обработка таблицы будет идти со скоростью работы памяти, а не со скоростью ядра процессора. Для встроенных приложений нужен компактный код. На нашей целевой архитектуре есть инструкция полиномиального умножения (ARM Cortex-A8+ NEON, ARMv8, Intel Core+AVX), но в контроллерах ARMv7e-m ничего такого нет, вынужденно используем таблицы. Нам понадобятся оба варианта алгоритма. Таблица не лезит в память, поэтому я сделал две таблицы, одна - это таблица умножения на числа 0,1,2,3...15, младшие биты числа. Вторая тоже таблица умножения на числа 0,16,32,64...256, - умножение на старшие биты числа.

uint8x16_t R16(uint8x16_t a)
{
    int i; 
    uint8x16_t v = {0}; 
    uint8x16_t a0 = a & 0xF,  a1 = (a >>4) & 0xF; 
    for(i=0; i< 16;i++) { 
        v ^= GMULv16_L[i][a0[i]] ^ GMULv16_H[i][a1[i]]; 
    }
    return v; 
}

В этом варианте умножения используются 32 таблицы по 16 значений по 16 байт, 8кБайт. Надо проверить, какой из двух вариантов быстрее: с подстановкой 8кБайт или с безумным умножением...
Проверил. Сильно зависит от процессора. На 3-м поколении Intel Core-i7 умножение без переносов работает крайне медленно, в пять раз медленнее чем табличный вариант. На 7-м поколении Core-i7 умножение работает в два раза медленнее чем табличный вариант. Разница есть. Табличный вариант позволяет получить скорость зашифровывания около 8 Мбайт/сек. Это все равно медленно.

[11.12.2019] Дальше я исследую возможность переноса алгоритма с умножением полиномов на векторные инструкции ARM NEON. У меня под рукой есть кросс-компилятор GNU ARM Embedded Toolchain, GCC. И есть под рукой плата BeagleBone, на которой могу запустить полученный код. Платформа содержит процессор ARM Cortex-A8 с архитектурой ARMv7-A и поддерживает инструкции NEON.
В системе команд NEON имеется инструкция полиномиального умножения VMULL.P8, которая оперирует с векторами poly8x8_t -- 8 элементов по 8бит. Инструкция производит результат - вектор 128 бит, 8 элементов по 16 бит. Подробнее см. [NEON™ Version: 1.0 Programmer’s Guide] на сайте ARM.

Мой код выглядит так:

#include <arm_neon.h>
uint8x16_t R16(uint8x16_t a)
{
    poly16x8_t v0 = {0};
    poly16x8_t v1 = {0};
    int i;
    for(i=0;i<16;i++){
        poly8x8_t  a8 = vdup_n_u8(sbox[a[i]]);// размножаем значение на все элементы
 poly8x16_t p8 = LMT[i];
        v0 ^= vmull_p8(a8, vget_low_p8 (p8));  // младшая часть вектора poly8x8
        v1 ^= vmull_p8(a8, vget_high_p8(p8));// старшая часть вектора poly8x8
    }
    /// редуцирование вынесли из цикла
    . . .
}
В коде использую псевдофункции, которые описаны в заголовке и в результате генерируют инструкции NEON. Исследую возможные комбинации ключей компилятора, чтобы задействовать инструкции NEON:
$ arm-eabi-gcc -print-multi-lib
Нахожу подходящую комбинацию ключей:
$ arm-eabi-gcc -march=armv7-a+fp -mcpu=cortex-a8 -mfpu=neon-vfpv4 \
  -mfloat-abi=hard -O3 -S -o - kuzn.c
Содержимое цикла преобразцется в набор инструкций

        vdup.8   d18, r3        -- размножить значение 8x8
        vld1.64 {d24-d25}, [r1:64]!  -- загрузить LMT 8x8x2
        vmull.p8 q13, d18, d24  -- умножить вектора 8x8 
        vmull.p8 q9,  d18, d25  -- умножить вектора 8x8
        veor     q8,  q8,  q13  -- исключающее ИЛИ 16x8
        veor     q11, q11, q9   -- исключающее ИЛИ 16x8
Ничего лишнего, одно умножение - одна инструкция процессора. Все инструкции используют вектора 8x16, умножение проивзодится над векторами 8x8, результат умножения имеет размерность 16x8. Не путать - умножение проиводится без переносов.
Можно ли считать этот результат хорошим. Не знаю. Объективно надо замерить скорость и сравнить результат. Код получился компактный, но не факт что инструкция умножения будет выполняться быстрее, чем обращение к памяти. Делаю 1Миллион циклов зашифровывания и замеряю время -- получаю результат = 2.5 секунды, на платформе beaglebone Texas Instrumens Sitara AM335x. Это получается скорость шифрования 6.4Мбайта/сек. Замеряю результат табличного варианта алгоритма зашифровывания = 4.2 секунды. Мы выиграли время и место. Дальше за счет разворачивания цила удалось повысить скорость зашифровывания при использовании умножения до 7.4Мбайт/сек. Это ХОРОШИЙ результат!