Показаны сообщения с ярлыком ГОСТ. Показать все сообщения
Показаны сообщения с ярлыком ГОСТ. Показать все сообщения

вторник, 20 сентября 2022 г.

Крипто-Рубль. Изоморфные преобразования при вычислении Хеш функции Stribog

Для расчета хеш функции ГОСТ Р 34.11-2012 "Stribog" выполняется табличная подстановка в цикле - это общепринятая практика.

Если вернуться к определению алгоритма, его описывают последовательностью матричных операций S, P, L, X:
S - подстановка Sbox для каждого элемента 8x8 байт.
P - транспонирование матрицы 8х8 байт
L - линейное преобразование.
X - исключающее ИЛИ.

Линейное преобразование может быть представлено операцией умножения матрицы коэффициентов 8x8 на матрицу состояния 8x8 в поле GF(2^8) с полиномом 0x171.

LM = {
0x71, 0x05, 0x09, 0xB9, 0x61, 0xA2, 0x27, 0x0E,
0x04, 0x88, 0x5B, 0xB2, 0xE4, 0x36, 0x5F, 0x65,
0x5F, 0xCB, 0xAD, 0x0F, 0xBA, 0x2C, 0x04, 0xA5,
0xE5, 0x01, 0x54, 0xBA, 0x0F, 0x11, 0x2A, 0x76,
0xD4, 0x81, 0x1C, 0xFA, 0x39, 0x5E, 0x15, 0x24,
0x05, 0x71, 0x5E, 0x66, 0x17, 0x1C, 0xD0, 0x02,
0x2D, 0xF1, 0xE7, 0x28, 0x55, 0xA0, 0x4C, 0x9A,
0x0E, 0x02, 0xF6, 0x8A, 0x15, 0x9D, 0x39, 0x71,
Пояснение, как оно соотносится с с матрицей A. Колонка 1 матрицы M - это элемент A[0] = 0x8e20faa72ba0b470,
для которого выполнен разворот порядка следования бит.
8E=>71 20=>04 FA=>5F
Колонка 2 матрицы M - это элемент A[8],
. . .
Колонка 8 матрицы M - это элемент A[56],

Я независимо вывел эту таблицу, из представленнй таблицы подстановки. О возможности подобного представления читал в статье автора [1]. Продемонструю, как выполнить декомпозицию таблиц. Берем два числа: первоя строка, вторая и третья колонка:
A[1] = 0x47107ddd9b505a38
A[2] = 0xad08b0e0c3282d1c
Выделяем по маске 7 бит в каждом байте (A[1] & ~0x0101010101010101) >> 1)^A[2] видим числа 0x8E008E8E8E000000. В каждом байте стоит полином, по которуму производится редуцирование 0x171, с отраженным порядком бит будет 0x8E. Перед нами просто таблица умножения в поле GF(2^8)/[0x171] с отраженным порядком следования бит в байте.

Линейное преобразование может быть представлено таблично.
Cвойство L(a+b) = L(a) + L(b), как и свойство L(a*b) = L(a)*L(b) - действует и для таблицы.

Мы рассматриваем возможность реализации без таблицы, с использованием афинных преобазований и операци умножения в поле GF(2^8). Операция умножения может быть выполнена с использованием векторных инструкций умножения без переносов (Intel), полиномиального умножения (Arm Neon). Афинные преобразования могут выполняться с использованием векторных инструкций без обращения к таблицам.

Оптимизация под векторные инсрукции Arm Neon. 16 байт составляют вектор, используя вектора можно в два действия эмулировать работу операции умножения на константу. Для эмуляции используется свойство линейности преобразования.

Оптимизация под GF-NI:

Парадокс в том, что отечественные алгоритмы Stribog и Kuznyechik можно считать с использованием американской криптографии. Американская криптография использует умножение в поле GF(2^8) по полиному 0x11B. Отечетсвенная криптография использует умножение с редуцированием по полиному 0x171 в алгоритме Stribog, 0x1C3 в алгоритме Kuznyechik. Можно использовать афинные изоморфные преобразования, чтобы отобразить все матрицы и коэффициенты для использования умножения в поле 0x11b.

Матрицы изоморфного преобразования мы научились находить[2]. Для преобразования 0x171=> 0x11B можно использовать матрицы

матрицы изоморфного преобразования 171 => 11B "Stribog"
171=>11B 02 0E M =49A24EE22C984CF0 M-1 =FB44BAF0CC5A0A1C
171=>11B 02 17 M =C30E560C5240D828 M-1 =6D0A141C3A9C2046
171=>11B 02 52 M =B1A27CD0765CD234 M-1 =F5481A5CBE24D86E
171=>11B 02 54 M =29903A0892D47ABC M-1 =E5126608F2EC44F0
171=>11B 02 A0 M =C154448014AEDCC6 M-1 =1B8C164A06F81208
171=>11B 02 B4 M =1590FECC3E8E8CE6 M-1 =9960C6DA5E32485C
171=>11B 02 F6 M =090EFAA096722E1A M-1 =6FD8B46E36428C4A
171=>11B 02 FD M =A7541EDA2602426A M-1 =CB20B646D48660DA
Изоморфное представление матрицы LM в поле 0x11B:
unsigned char LM_11b = {
	0xF6,0x55,0x74,0xE4,0x56,0x3E,0xC1,0x2F,
	0x54,0xDF,0x17,0x9E,0xA9,0x60,0x43,0x02,
	0x43,0x1D,0x10,0x2E,0xEB,0xBB,0x54,0x65,
	0xA8,0x01,0x39,0xEB,0x2E,0xA1,0xE1,0xAD,
	0x93,0xAB,0x81,0x26,0x4E,0x42,0xF5,0xCE,
	0x55,0xF6,0x42,0x0D,0xFB,0x81,0xC7,0x0E,
	0xBA,0x5C,0xA6,0xEF,0x38,0x30,0xEC,0x71,
	0x2F,0x0E,0x07,0xD1,0xF5,0x2A,0x4E,0xF6,

Необходимо таже преобразовать таблицу Sbox в изоморфное представление Sbox_iso = MR·S·RM-1, где MR и RM-1 - матрицы прямого и обратного изоморфного преобразования из поля образованного полиномом 0x171 в поле 0x11b. R - отражение порядка следования бит.


void SPLX_11b(const uint8_t* s, uint8_t* r)
{
	int i,j,k;
	for (i=0; i<8; i++)
	for (j=0; j<8; j++) {
		uint8_t v= 0;
		for (k=0; k<8; k++) 
			v ^= gf2p8_mul(LM_11b[j*8+k], Sbox_iso[s[k*8+i]]);
		r[i*8+j] ^= v;
	}
}

Полученный алгоритм выполняет операции S,P,L,X. В цике выполняется операция умножения в поле GF(2^8)/0x11B. Оптимизация под ARM Neon может использовать полиномиальное умножение с последующим редуцированием.

Я озаглавил сообщение "Крипто-рубль..". Все алгоритмы добычи крипто-валют основаны на вычислении хеш-функций, этот алгоритм удалось выполнить исключительно в 8-битных числах. Это открывает возможность векторизовать алгоритм для параллельного вычисления до 64 хешей. Другая оптимизация - изоморфное преобразование в композитное поле позволяет получить эффективную реализацию алгоритма аппаратно в ASIC или на FPGA.


[1] Algebraic Aspects of the Russian Hash Standard GOST R 34.11-2012, O. Kazymyrov, V. Kazymyrova

среда, 1 сентября 2021 г.

Цифровая подпись ГОСТ -- быстрое редуцирование по модулю простого числа

Настал момент поделиться ноу-хау, как ускорять криптографию ГОСТ. Вернее одна маленькая деталь, но самая важная. Как быстро выполнять редуцирование - модуль простого числа. Как вообще строится цифровая подпись. Есть математика на бублике, в поле целых чисел по модулю простого числа. На этом строится и RSA и ECDSA и ГОСТ. Можно сказать модуль числа - это самая критическая операция - остаток от деления.

Любая операция на бублике: это умножение большого числа (256 или 512 бит) или сложение большого числа сопровождается последующем редуцированием(взятием по модулю).
Простые числа в криптографии часто выбирваются исходя из простоты восприятия человеком и с возможностью оптимизации вычислений, например в ГОСТ представлены простые числа вида P = 2^(n-1)+p0 и P = 2^n - p0 где n=256 или n=512 бит.

Все операции в поле выполняются по модулю. Например, умножение по модулю - это умножение и модуль числа.

// Умножение с редуцированием по модулю простого числа.
void bn_mulm(bn* r, bn* a, bn* b)
{
	int cy = bn_mul(r, a, b);
	bn_mod(r, cy, Prime);
}

Вычисление модуля можно выполнять оптом, т.е. сначала выполнить все операции умножения и сложения, а потом выпонить редуцирование результата по модулю. Но это может быть накладно с точки зрения числа операций умножения и сложения. Т.е. если на входе число 512 бит, то умножение таких чисел даст 1024 бита. И последующие операции нужно будет выполнять с разрядностью 1024 бита. По этой причине все алгоритмы стрим по принципу: умножение - редуцирование. Редуцирование надо выполнять после каждого умножения больших чисел.

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


Что в таком случае редуцирование?



Представим результат операции - как перенос (сx- число, которое вылезло за пределы 512 бит) и число X, которое осталось в пределах 512 бит {cx, x}. При этом число x имеет разрядность 512 бита, а перенос - 32 или 64 бита, зависит от архитектуры процессора, ориентируемся на 64 бита.

Наша задача сводится к тому, чтобы вычесть из числа {cx,x} простое число несколько раз, чтобы перенос стал нулевым. При этом остаток помещается в 512 бит, но может требовать дальнейших операций по редуцированию. Нам важно только одно - результат редуцирования помещается в заданные 512 бит. Операцию можно выполнить в два этапа.

Алгоритм 1. Быстрое редуцирование в поле P = 2^N - p0
1. {cy,x} : = {cx, x} - (2^512-p0)*cx;
В результате cy может быть равен нулю или единице.
2. if cy>0 then {cy,x} : = {cy, x} - P;

Чтобы это работало на P надо наложить некоторые ограничения p0 < 2^(n-m), где m - разрядность переноса.

Алгоритм можно упростить:

Алгоритм 1.1. Быстрое редуцирование P = 2^N - p0
1. {cy,x} : = x + p0*cx;
2. if cy>0 then x : = x + p0;

Быстрое редуцирование не является взятием по модулю. Редуцирование возвращает число в заданную разрядность 2^n. Операция взятия модуля выполняется всего один раз на множество операций, только при сравнении чисел.

Теперь рассмотрим второй случай, когда простое число представленно суммой P=2^(N-1) + p0, где N -- разрядность поля, 256 или 512.

Алгоритм 2. Быстрое редуцирование P = 2^(N-1) + p0
1. {cy,x} : = {cx, x} - 2*(2^511+p0)*cx = x - 2*p0;
В результате cy может быть равен нулю или (-1). 
2. if cy<0 then {cy,x} : = {cy, x} + P;

При значении cy == (-1) x находится в интервале [-2*p0; -1], в старшем бите единица (1).

Полагаю равносильно заменить P на втором шаге на 2P. Cуть операции редуцирования не меняется.

Алгоритм 2.1. Быстрое редуцирование P = 2^(N-1) + p0
1. {cy,x} : = {cx, x} - 2*(2^511+p0)*cx = x - 2*p0*cx;
В результате cy может быть равен нулю или (-1).
2. if cy<0 then {cy,x} : = {cy, x} + 2*P = x + 2*p0;

Отдельно рассматриваем редуцирование при сложении и вычитании

Алгоритм 3. Сложение с редуцированием
1. {cy, x} := a+b
В результате cy принимает значения 0 или 1.
2. if (cy>0) {cy,x} := {cy,x} - (2^N - p0) = x + p0
Может понадобится третий шаг, очень мало вероятно:
3. if (cy>0) {cy,x} := {cy,x} - (2^N - p0) = x + p0

Для примера рассмотрим операцию вычисления умножения двух больших чисел разрядностью N. Зачем? Просто так, добавить больше нечего.

// Алгоритм 4. Умножение с накоплением (со сложением)
uint64_t bn_mla_512(uint64_t* r, uint64_t *a, uint64_t d)
{
    unsigned __int128 ac = 0;
    int i;
    for (i=0;i<8; i++) {
        ac +=  (unsigned __int128)d*a[i] + (unsigned __int128)r[i];
        r[i] = (uint64_t)ac;
        ac = ac>>64;
    }
    return (uint64_t)ac;
}

Операция "Умножение с накоплением" используется в реализации Алгоритма 1.1, быстрого редуцирования.

// Алгоритм 5. Умножение с вычитанием
 int64_t bn_mls_512(uint64_t* r, uint64_t *a, uint64_t d)
{
    __int128 ac = 0;
    int i;
    for (i=0; i<8; i++) {
        ac += (unsigned __int128)r[i] - (unsigned __int128)a[i]*d;
        r[i] = (uint64_t)ac;
        ac = ac>>64;
    }
    return (int64_t)ac;
}

Операция "Умножение с вычитанием" используется в реализации Алгоритма 2.1, быстрого редуцирования.

Эффективность описания операции (Alg.5) сомнительна, в рабочем проекте я использую ассемблер. Данное описание мне понадобилось для отладки под GCC 10 на платформе Intel x86_64.

пятница, 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 пока нет.

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

четверг, 19 декабря 2019 г.

MGM -- (Multilinear Galois Mode) векторная реализация

Вышли новые рекомендации к Российской национальной криптографии, режим шифрования MGM, который обещают использовать как основной и пожалуй единственный, если не считать рекомендации к протоколу CRISP. Режим MGM использует полиномиальное умножение в поле Галуа, по полиному x128 + x7 + x2 + x1 + 1

Опишу порядок создания реализации алгоритма MGM. До начала работы надо иметь работающую реализацию алгоритма зашифровывания "Кузнечик" и тестовые вектора от MGM. Тестовые вектора нашел в draft стандарте IETF [Multilinear Galois Mode (MGM)]

Нужны глубокие познания в области умножения полиномов. Мы хотим использовать векторные инструкции полиномиального умножения, умножения без переносов.
На архитектуре Intel обещают операцию pclmul -- умножение без переноса двух чисел 64 бит, результат. Эта инструкция присутствует практически во всех новых процессорах Intel. И ожидаем появления векторной инструкции pclmul, которая позволяет производить одновременно 4 умножения 64бит на векторе 512 бит (AVX512+VPCLMULQDQ). Эта инструкция будет доступна на серверах Ice Lake в следующем году (2020). Но, алгоритмы на ее основе можно отладить уже сегодня с использованием эмулятора платформы Intel SDE. Я скачал SDE и убедился, что на нем можно эмулировать конкретные семейства процессоров Intel. Я использую компилятор GCC 9.2 под MSYS64, который хорошо осведомлен о новых процессорных инструкциях Intel и поддерживает целевую платформу Ice Lake.

Я не думаю, что векторные инструкции сильно ускорят процесс, потому что Intel обещает только один или два блока AVX512, в то время как каждое ядро способно выполнять четыре инструкции одновременно. Есть подозрение, что четыре инструкции 128 бит могут выполняться с такой же скоростью, что и одна инструкция AVX512 -- это пессимистический взгляд на вещи. Я не знаю, как устроен процессор на самом деле, но есть подозрение, что блоки AVX умеют объединяться в два блока AVX2 или в один блок AVX512. Возможно что в новых процессорах AVX2 -- неделимый блок, два блока AVX2 умеют объединяться в один блок AVX512. Если бы я разрабатывал процессоры Intel, я бы так и сделал, для экономии ресурсов. Почитал сообщения на форуме Intel, подтверждают, что быстродействие векторных инструкций AVX512 в два раза ниже чем быстродействие AVX2 на процессорах Xeon Scalable. В спецификации на процессор указывается число блоков AVX512. Подтверждают, что один блок AVX512 имеет такую же производительность на такт, как две инструкции AVX2. Но тактовая частота блоков AVX512 снижается от загрузки, она чуть ниже чем у ядра. Поэтому производительность получается даже ниже, чем на  инструкциях AVX2. Исходя из этих утверждений я хочу разработать алгоритм под векторные инструкции AVX2+VPCLMULQDQ, два умножения на инструкцию.


Начнем разработку с умножения без переносов.

Для начала решаем еще более простую задачу.
Пишем алгоритм умножения без переносов 64 бит на 64бит. с последующим редуцированием 128 бит в 64 бит. Редуцирование выполняется по полиному x64 + x4 + x3 + x1 + 1


uint64_t gfmul64(uint64_t a, uint64_t b)
{
 poly64x2_t r = CL_MUL128((poly64x2_t){a}, (poly64x2_t){b}, 0x00);
 uint64_t x1 = r[1];
 uint64_t x0 = r[0];
 x1 = x1 ^ x1>>63 ^ x1>>61 ^ x1>>60;
 return x0 ^ x1 ^ x1<<1 ^ x1<<3 ^ x1<<4;
}

Второй вариант алгоритма пишем с редуцированием на операциях умножения без переносов.

uint64_t gfmul64(uint64_t a, uint64_t b)
{
 poly64x2_t r = CL_MUL128((poly64x2_t){a}, (poly64x2_t){b}, 0x00);
// Редукция Барретт'а:
 poly64x2_t t = CL_MUL128(r, (poly64x2_t){0x1BULL}, 0x01) ^ r;
 r ^= CL_MUL128(t, (poly64x2_t){0x1BULL}, 0x01);
 return r[0];
}

В этом варианте я использую две константы 0x1B для редуцирования по методу Барретта. Константы высчитывал честно по алгоритму, который представлен в предыдущем сообщении. На самом деле первый вариант умножения gfmul64 тоже основан на редуцировании по методу Барретта только умножение разложено на операции сдвига и сложения в поле.
Проверяю лаконичность высказываний компилятора:

        vpclmulqdq  $0,  %xmm1, %xmm0, %xmm1
        vpclmulqdq  $17, %xmm2, %xmm1, %xmm0
        vpxor       %xmm1, %xmm0, %xmm0
        vpclmulqdq  $17, %xmm2, %xmm0, %xmm0
        vpxor       %xmm1, %xmm0, %xmm0

Я боюсь ошибиться. Поэтому сделал еще и третий вариант побитовое умножение.

uint64_t gfmul64_2(uint64_t a, uint64_t b)
{
    uint64_t r = 0;
    int i;
    for (i=0; i< 64; i++){
        if (b & (1ULL<<i)){
            r ^= a;
        }
        a = (a<<1) ^ (((int64_t)a>>63) & 0x1BULL);
    }
    return r;
}

Для отладки мы сравниваем результаты умножения по двум-трем реализациям. Идея, которую мы хотим заложить в алгоритм MGM: редуцирование может выполняется один раз в конце расчета тега аутентичности, в цикле мы оперируем только умножением без переносов, с отложенной операцией редуцирования.

Из популярной литературы я знаю алгоритм редуцирования на основе сдвигов [Carry-Less Multiplication and Its Usage for Computing the GCM Mode, алгоритм 4].

Теперь опишем умножение без переносов 128 бит на 128 бит. с последующим редуцированием 256 бит в 128 бит. Редуцирование выполняется по полиному x128 + x7 + x2 + x1 + 1

Начнем с референсной реализации, образцовой, на которую ссылается Intel, Sun OpenSolaris, кто с кого списывал:


/// (Copyright 2009 Sun Microsystems, Inc.)
void gfmul(__m128i x_in_m, __m128i y_m, uint64_t *res)
{
   uint64_t R = { 0xe100000000000000ULL };
   struct aes_block z = { 0, 0 };
   struct aes_block v;
   uint64_t x;
   int i, j;
   uint64_t *x_in=(uint64_t*)&x_in_m;
   uint64_t *y=(uint64_t*)&y_m;
   v.a=y[1];
   v.b=y[0];
   for (j = 1; j>=0; j--) {
      x = x_in[j];
      for (i = 0; i < 64; i++, x <<= 1) {
         if (x & 0x8000000000000000ULL) {
            z.a ^= v.a;
            z.b ^= v.b;
         }
         if (v.b & 1ULL) {
             v.b = (v.a << 63)|(v.b >> 1);
             v.a = (v.a >> 1) ^ R;
         } else {
             v.b = (v.a << 63)|(v.b >> 1);
             v.a =  v.a >> 1;
         }
      }
   }
   res[0] = z.b;
   res[1] = z.a;
}
Этот алгоритм так себе описан. Он медленный, 128 циклов. Вроде люди пытались описать с использованием векторных типов и псевдо-функций векторных инструкций (wmmintrin), но что-то пошло не так. Я даже знаю, что пошло не так, компилятор Sun не имел никаких векторных расширений и это был черти какой год, GCC тогда тоже не умел их использовать, а Intel умел только через явное использование этих самых псевдо-функций. Пробую описать тоже самое, теми средствами, которые понятны современному компилятору GCC 9.+.
Надо заметить, что в GCM используется обратный порядок следования бит. А нам нужен прямой порядок.
Алгоритм 1 полиномиальное умножение 128x128 бит
Шаг 1: рассчитываем умножение без переноса для двух чисел: 
  A = [A1:A0]; B= [B1:B0];
Покомпонентный результат умножения будет:
  A0•B0 = [C1:C0], A1•B1 = [D1:D0], A0•B1 = [E1:E0], A1•B0 = [F1:F0]
Шаг 2: составляем результат 256-бит:
 [A1:A0]•[B1:B0] = [D1:F1⊕E1⊕D0:F0⊕E0⊕C1:C0]
На шаге 2 можно применить способ умножения Карацубы
Алгоритм 2 полиномиальное умножение 128x128 бит методом Карацубы
Шаг 1: Покомпонентный результат умножения будет:
  A0•B0 = [C1:C0], A1•B1 = [D1:D0], (A0⊕A1)•(B0⊕B1) = [E1:E0]
Шаг 2: составляем результат 256-бит:
 [A1:A0]•[B1:B0] = [D1:D0⊕D1⊕E1⊕C1:D0⊕E0⊕C0⊕C1:C0]
Если считать, что инструкция умножения без переносов выполняется за такт, за то же время, что и операция Исключающее ИЛИ, то экономии не будет. Это можно проверить.
Описываем умножение по Алгоритму 1:
/// Алгоритм 1 полиномиального умножения 128x128
poly64x4_t CL_MUL256(poly64x2_t p, poly64x2_t v)
{
    poly64x2_t q  = CL_MUL128(p, v, 0x10) 
                  ^ CL_MUL128(p, v, 0x01);// Карацуба отдыхает
    poly64x2_t r0 = CL_MUL128(p, v, 0x00) ^ SLL128(q, 64);
    poly64x2_t r1 = CL_MUL128(p, v, 0x11) ^ SRL128(q, 64);
    return (poly64x4_t){r0[0], r0[1], r1[0], r1[1]};
}
Тут я использую встроенные псевдо-функции, которые призваны генерировать векторные инструкции.
/// SRL128 - сдвиг числа 128бит вправо на указанное число бит
static inline poly64x2_t SRL128(v2du a, const int bits) {
    return (poly64x2_t)__builtin_ia32_psrldqi128((v2di)a, bits);
}
/// SRL128 - сдвиг числа 128бит влево на указанное число бит
static inline poly64x2_t SLL128(poly64x2_t a, const int bits) {
    return (poly64x2_t)__builtin_ia32_pslldqi128((v2di)a, bits);
}
/// CL_MUL128 - полиномиальное умножение 64х64 бит
static inline poly64x2_t CL_MUL128(poly64x2_t a, poly64x2_t b, const int c) {
    return (poly64x2_t)__builtin_ia32_pclmulqdq128 ((v2di)a,(v2di)b,c);
}
Из сатьи взял алгоритм со сдвигами для редуцирования по полиному x128 + x7 + x2 + x + 1
Алгоритм 4 редуцирование по полиному x128
Обозначим входные данные [X3:X2:X1:X0] с разбивкой числа 256 бит по 64 бит.
Шаг 1: Сдвигаем X3 на 63, 62 и 57-бит вправо.
  A = X3>>63, B = X3>>62, С = X3>>57
Шаг 2: D = X2 ⊕ A ⊕ B ⊕ C
Шаг 3: Выполняем свдиги над числам 128 бит
 [E1:E0] = [X3:D]<<7
 [F1:F0] = [X3:D]<<2
 [G1:G0] = [X3:D]<<1
Шаг 4: Сложение (XOR) над числми [E1:E0], [F1:F0], [G1:G0] и [X3:D]. 
 [H1:H0] = [X3 ⊕ E1 ⊕ F1 ⊕ G1 : D ⊕ E0 ⊕ F0 ⊕ G0]
Шаг 5: Возвращаем результат [X1 ⊕ H1: X0 ⊕ H0].
Пояснения к алгоритму 4. Мы можем сделать тоже самое с использованием умножения. Константа Барретта при использовании редуцирования будет равна самому полиному, сдвиги {0,1,2,7} соответствуют умножению на число 0x87, хвост от полинома (x7 + x2 + x + 1).
Алгоритм 5 редуцирование по полиному x128
Обозначим входные данные [X3:X2:X1:X0] с разбивкой числа 256 бит на 64 бит.
Шаг 1: Умножаем старшую часть числа X на константу Барретта (0x87).
  [A3:A2:A1:A0] = [X3:X2]•0x87
Шаг 2: B = A ⊕ X
Шаг 3: Умножаем старшую часть числа B на полином (0x87)
  [C3:C2:C1:C0] = [B3:B2]•0x87
Шаг 4: D = C ⊕ X
Шаг 5: Возвращаем результат [D1:D0].
Благодаря равенству констант алгоритм можно упростить, сократив число умножений с четырех до трех.
Алгоритм 6 редуцирование по полиному x128
Обозначим входные данные [X3:X2:X1:X0] с разбивкой числа 256 бит на 64 бит.
Шаг 1: Умножаем старшую часть числа X на константу Барретта (0x87).
  [0:A2:A1:A0] = [X3:X2]•0x87
Шаг 2: Умножаем старшую часть числа B на полином (0x87)
  [0: 0: 0:C0] = [0 :A2]•0x87
Шаг 3: D = C ⊕ A ⊕ X
Шаг 4: Возвращаем результат [D1:D0].
Совместим алгоритм №6 с алгоритмом №1 умножения полиномов.
Алгоритм 7 редуцирование по полиному x128 + 0x87
Шаг 1:
  X2•0x87 = [A1:A0], X3•0x87 = [B1:B0]
Шаг 2:
  B1•0x87 = [C1:C0]
Шаг 3: Возвращаем [B0⊕A1⊕С1⊕X1:A0⊕C0⊕X0]
Можно заметить, что заменой умножения на сдвиги можно получить алгоритм №4.
// Алгоритм №7: Редуцирование по полиному x128 + 0x87
poly64x2_t gf128_reduction(poly64x2_t r0, poly64x2_t r1)
{
 const poly64x2_t Px ={0x87};
 poly64x2_t b  = CL_MUL128(r1,Px, 0x01);
 poly64x2_t a  = CL_MUL128(r1,Px, 0x00);
 poly64x2_t c  = CL_MUL128( b,Px, 0x01);
 return r0 ^ a ^ c ^ SLL128(b,64);
}
Проверяем результат компиляции:
// $ gcc -O3 -march=native -o - -S test.c
        vpclmulqdq      $1, %xmm2, %xmm0, %xmm3
        vpclmulqdq      $0, %xmm2, %xmm0, %xmm0
        vpclmulqdq      $1, %xmm2, %xmm3, %xmm2
        vpxor   %xmm0, %xmm2, %xmm2
        vpslldq $8, %xmm3, %xmm3
        vpxor   %xmm3, %xmm1, %xmm3
        vpxor   %xmm3, %xmm2, %xmm0
-- ничего лишнего.
Проверяем тестовые вектора, взял из статьи.
Умножение в конечном поле GF(2128), P(x) = x128 + x7 + x2 + x + 1
a = 0x7b5b54657374566563746f725d53475d
b = 0x48692853686179295b477565726f6e5d
gfmul128 (a, b) 
  = 0x040229a09a5ed12e7e4e10da323506d2
Результат. Для алгоритма MGM разработал эффективную реализацию умножения в конечном поле GF(264) и GF(2128). Умножение состоит из двух частей - умножение без переноса и редуцирование. В цикле достаточно выполнять только умножение без переноса, а этап редуцирования можно вынести из цикла.
Несколько комментариев к статье
Операция PCLMUL -- тормозная. Она долго грузится, но исполняется за такт. На разных платформах она выполняется по-разному. Хотелось бы сюда вставить табличку, из которой видно насколько эффективно использовать эту инструкцию. Быстродействие складывается из двух параметров: Задержка и Исполнение. Инструкции могут долго загружаться, но выполняться одна за другой. Например, для новых серверов я ожидаю быстродействие 6 тактов задержка на исполнение и 1 такт на исполнение. Это значит, что группа инструкций PCLMUL не связанных между собой могут грузиться 6 тактов, и исполняться по 1-ому такту на команду. В некоторых случаях процессор выполняет инструкции в несколько потоков, тога пиковая производительность получается выше. Следить за тем чтобы команды эффективно грузились и переставлять порядок исполнения - это дело компилятора и процессора. НО зачастую уменьшив взаимосвязь между параметрами алгоритма можно добиться большей производительности.

Для понимания, когда стиот применять инструкции, а когда это совсем не эффективно, привожу таблицу по производительности операции PCLMUL на разных платформах. Таблицу нашел в итнернетах, источник - [Agner Fog. Lists of instruction latencies, throughputs..] Таблицы таймингов (задержки и производительность инструкций) для некторых моделей процессоров Intel представлены в
[#]Intel(c) Intel® 64 and IA-32 Architectures Optimization Reference Manual, приложение С.3 [Appendix C.3, “Latency and Throughput”].

Процессор      Задержка+обработка 
-- AMD -- 
Bulldozer     12 7
Piledriver    12 7
Steamroller   11 7
Excavator      5 5
Ryzen          4 2
Jaguar         3 1
-- Intel --
Silvermont    10 10 -- ужасно медленно!!
Nehalem       12 8
Ivy Bridge    14 8
Goldmont       6 4
Haswell        7 2
KnightsLanding 6 2
Skylake        7 1
SkylakeX       7 1
Broadwell      5 1
Можно утверждать, что в современных процессорах инструкция выполняется достаточно быстро, чтобы вытеснить другие решения. Все познается в сравнении, другие решения могут сроится на таблицах, операциях PXOR и PSHUFB. Например, на платформе Skylake операция PSHUFB выполняется за 1+1 такт, операция PXOR за 1+0.5 такта.
[22.12.2019]
Нашел возможность дальнейшей оптимизации Алгоритма редуцирования (№7), сократил число умножений до двух.
Алгоритм №8 редуцирование по полиному x128 + 0x87
Шаг 1:
  X3•0x87 = [B1:B0]
Шаг 2:
  (B1⊕X2)•0x87 = [D1:D0]
Шаг 3: Возвращаем [B0⊕D1⊕X1:D0⊕X0]
// Алгоритм №8: Редуцирование по полиному x128 + 0x87
poly64x2_t gf128_reduction(poly64x2_t r0, poly64x2_t r1)
{
 const poly64x2_t Px ={0x87};
 poly64x2_t b = CL_MUL128(r1,Px, 0x01) ^ SLL128(r1,64);
 poly64x2_t d = CL_MUL128(b, Px, 0x01) ^ SLL128(b, 64);
 return r0 ^ d;
}
Возвращаясь к режиму GCM, приведу мой вариант редуцирования для отраженного порядка следования бит.
// Алгоритм №9: Редуцирование по полиному x128 + 0x87
poly64x2_t gf128r_reduction(poly64x2_t r0, poly64x2_t r1)
{
 const poly64x2_t Px ={0x87};
 poly64x2_t b = CL_MUL128(r1,Px, 0x01) ^ SLL128(r1,64);
 poly64x2_t d = CL_MUL128(b, Px, 0x01) ^ SLL128(b, 64);
 return r0 ^ d;
}
Алгоритм №9 я сам вывел. Однако, до меня в примерно в такой форме его использовали в OpenSSL () -- этот факт обнаружился уже позже. Алгоритм в OpenSSL сложно распознать, поскольку он дан уже в ассемблерном коде. Я его распознал там по сигнатуре, схожая последовательность инструкций PCLMUL+PALIGNER, как в моем случае.
[23.12.2019]
Сравнил быстродействие варианта умножения методом Карацубы по Алгоритму №2 (три инструкции PCLMUL) и Алгоритма №1. Существенного выиграша не обнаружил. Думаю следует использовать вариант без Карацубы, по Алгоритму №1, на новых процессорах этот вариант будет быстрее.

суббота, 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Мбайт/сек. Это ХОРОШИЙ результат!

среда, 6 ноября 2019 г.

CRISP 1.0 -- Протокол защищенного обмена для индустриальных систем


Протокол не важен. Важно что это первый нормальный набор векторов для Magma (ГОСТ 34.12-2015)в режиме генерации имитовставки (IMIT) и в режим гаммирования (CTR) для данных не выровненных. Сами режимы описаны в документе (ГОСТ 34.13-2015).

Протокол защищенного обмена выпущен ТС26 (Технический комитет по стандартизации российской криптографии) в виде методических рекомендаций.
Основная проблема в реализации режимов блочного шифрования - Это, не глядя в чужой код, получить точное соответствие с векторами. Сразу никогда не получается.
Где-то это мои проблемы - читать не умею. Где-то проблемы разработчика - описано плохо. Но чаще всего - это неточности в описании алгоритма, который надо как-то отладить, и ошибок в реализации может быть больше одной на пути рабочей версии.
Почему путь тернист. Берем сам алгоритм блочного шифра, "Магма" (ГОСТ 34.12-2015): описан, есть примеры - тестовые вектора. Взял алгоритм, сделал реализацию, но - не работает. Нигде в стандарте не сказано, в каком представлении даны числа в тестовых векторах. Порядок следования байт может быть Little-Endian или Network (Big-Endian). С ключами шифрования - тоже самое, они могут быть в нормальном порядке, задом наперед по 64 бита, задом наперед/совсем задом наперед - все число от старшего бита к младшему вывернуто. В процессе отладки я подбираю, как правильно записать, в какой последовательности, входные и выходные данные, чтобы числа сошлись. Сходятся.
Потом беру описание режима блочного шифра (ГОСТ 34.13-2015). В тестовых векторах та же путаница не ясно, как должны сходится эти вектора с изменение порядка следования байт или без изменения. Примеры почему то даны только для случая выровненных данных. В случае с усечением или без выравнивания на 64 бита, алгоритм остается не отлаженным.


 Теоретически мы можем предположить, что алгоритм работает так:
(подготовка ключа- надо решить как его правильно выкрутить, чтобы из сетевого представления получить числовое) -- (подготовка данных из последовательного представления в числовое) -- (подать на вход ключ и данные) -- (результат выкрутить в "сетевое" представление). При этом существуют некоторые неточности изложения, в результате которых такие же неоднозначные преобразования могут содержаться внутри самого алгоритма. Парадокс в том, что при этом вектора сходятся, потому что дают те же числа только в ином порядке.

Например, смотрим определения, чем отличается "бинарное представление", "байтовое представление" и числовое. Как разгадать этот ребус. По-русски ведь написано.
x||y -- конкатенация двоичных строк x и y ... при которой левая часть - слева, а правая - справа.
Потом пишут 1||0 - вот это чего за число. или это 0x02? Или это число 0x01? Оказывается это число 0x80.Оказывается оно таким после серии проб и ошибок.
binary() -- это представление символьной строки в виде байтовой строки. -- отпад.
byte() -- это представление числа в виде байтовой строки, при котором соответствующая итоговой байтовой строке двоичная строка .... -- вынос мозга.
вот у меня есть число 0xBAD как его представить в виде байтовой строки: "0B AD" или "AD 0B". Отсюда и берутся такие неоднозначности. Числа можно представить в "сетевом" порядке или в порядке свойственном аппаратуре LE|BE.
На самом деле, если четко договориться по терминам, что такое "двоичная строка", что такое "байтовая строка", что такое "число", что такое "символьная строка", как одно в другое переводится, то проблем нет. Авторы пытаются прикрыть неточность выражения мыслей обилием математических выражений типа Zn||...||Z2||Z1||Z0 - такие выражения никак не облегчаются понимание вопроса по выравниванию данных. Биты которые однозначно слева в "байтовом представлении" могут быть как слева так и справа. Но после прочтения не всегда это ясно.

Вот например каким количеством вариантов можно описать действие
IV = LSB_32(byte(SeqNum, 6)) -- по сути это означает что мы должны взять младшие 32 бита от байтового представления числа, при генерации байтового представления нужно взять 6 байт от числа, т.е 48 бит. Пишу варианты:
uint64_t SeqNum;
uint32_t IV = htonll(SeqNum); (1)
uint32_t IV = htonll(SeqNum<<16); (2)
uint32_t IV = (SeqNum); (3)
uint32_t IV = htonll(SeqNum)>>32; (3)
...
Или вот другая формула SN = 0^5||MSB_35(byte(SeqNum,6))
Если под функцией byte() автор понимает преставление байт из числового представления в сетевое, то это запишется так:
uint64_t SN = (swap64(SeqNum<<16)) & ((1ULL<<35)-1)); (3)
uint64_t SN = ((SeqNum>>(48-35)) & ((1ULL<<35)-1)); (4)
-- я реально перебираю все эти варианты, ориентируясь всего на один признак, результат равен или нет тестовому вектору. После подбора я нахожу, что автор имел ввиду:
SN = старшие_биты(0^5)||число(MSB_35(число(SeqNum,6)). -- сшивание битовых строк/чисел.
При укладке данных SN в "байтовую строку" происходит преобразование "в сетевом" порядке следования байт.
Строка = ...|| byte(SN,5) ||... -- число SN (40бит, 5 байт) разворачивается в порядке Big-Endian, в сетевом представлении. Откуда это следует? -- Я догадался.
Можно вчитываться в документацию, пытаться понять, все ли точно выражено в описании или все таки бинарное представление итоговой байтовой строки от числового представления сдвинутого на пять бит делается как-то иначе. А если в изложении несколько таких неточностей, или я не так понял и в моей реализации неточность или ошибка.
Может автор прав, есть однозначная запись битовых строк, операция MSB применима к битовым строкам, операция byte() преобразует число в битовую строку. Проверяем определения... Я понял, все дело в том, что битовые строки почему-то неявным образом переводятся в байтовые с выравниванием по старшему биту.
При использовании блочного шифра, я почему-то упускаю: надо данные из сетевого представления переводить в числовое перед использованием функции шифрования или надо результат функции шифрования переводить из локального в сетевое представление перед использованием. Такие вот проблемы. 

Когда результат достигнут - все просто. Такие задачи я использую исключительно для тренировки. Перед началом нового проекта, я берусь реализовать какой-нибудь новый для себя криптографический алгоритм не глядя в чужие реализации, по документации. В этот раз неделю потратил.