Привет! Сегодня мы продолжаем реализовывать шифрование. В этой статье мы рассмотрим алгоритм шифра "Магма", который был разработан и использовался в СССР.
Реализация шифра "Магма" на Rust будет использовать собственную библиотеку, которую мы начали писать в предыдущей статье по режимам блочного шифрования.
Шифр "Магма" имеет несколько идентификаторов:
ГОСТ 28147-89 использовался в СССР и это была первая публикация шифра.
ГОСТ Р 34.12-2015 использовался с 2015 года. Под этим идентификатором скрывалось два шифра: "Магма" и "Кузнечик".
ГОСТ 34.12-2018 используется с 2018 года и представляет уже межгосударственный стандарт.
Под каждым скрывается почти один и тот же шифр, разница заключается только в используемых константах — узлах замены и направлении чтения данных.
Характеристики и подробности
Шифр "Магма" — криптосистема, созданная на основе итерационной схемы сети Фейстеля.
Один из методов построения блочных шифров — сеть Фейстеля. Она состоит из ячеек, каждая из которых получает данные и ключ на входе и выдает измененные данные и ключ на выходе. Все ячейки одинаковы, и сеть можно рассматривать как многократно повторяющуюся структуру.
Ключ выбирается в зависимости от алгоритма и меняется при переходе от одной ячейки к другой. При шифровании и расшифровывании используются одни и те же операции, но порядок ключей отличается.
В "Магме" используется:
Длина блока — 64 бита (8 байт).
Длина ключа — 256 бит (32 байта).
Количество итераций — 32 итерации.
Соответственно, сеть Фейстеля шифра "Магма" состоит из 32 ячеек, каждой на вход которой подаются данные (8 байт) и итерационный ключ (4 байта).
Алгоритм
Для того, чтобы реализовать шифр "Магма" понадобятся следующие функции и преобразования:
Формирование итерационных ключей.
Преобразование t.
Преобразование g.
Функция шифрования.
Функция расшифровывания.
Сам по себе алгоритм, как и его отдельные преобразования, довольно прост.
Зададим сразу несколько необходимых констант:
/// Длина блока: 64 бита
const BLOCK_SIZE: usize = 8;
/// Длина ключа: 256 бит
const KEY_SIZE: usize = 32;
/// Количество раундов шифрования: 32
const ROUNDS: usize = 32;
И создадим структуру шифра:
/// # Шифр "Магма"
///
/// - Длина блока: 64 бита
/// - Длина ключа: 256 бит
/// - Количество раундов шифрования: 32
#[derive(Debug, Clone)]
pub struct Magma {
round_keys: [u32; ROUNDS],
matrix_pi: [[u8; 16]; 8],
}
Нам необходимо будет создать и хранить итерационные ключи. Кроме того шифр "Магма" может использовать разные узлы замены, поэтому можно добавить функционал хранения, чтобы не хардкодить какой-то один.
1. Формирование итерационных ключей
Начнем с того, что мы уже знаем:
Длина ключа — 256 бит (32 байта).
Количество итераций — 32 итерации.
И дополним новой информацией:
Длина итерационного ключа — 32 бита (4 байта).
Получается, что из 32 байт ключа шифрования нам нужно получить 128 байт итерационных ключей, то есть в 4 раза больше.
Алгоритм формирования итерационных ключей максимально прост:
Первые 24 ключа мы получаем просто разделяя ключ шифрования на части по 4 байта — получится 8 частей (ключей). Эти 8 частей мы просто повторим 3 раза и получим 24 ключа.
Последние 8 ключей получаются почти таким же образом. Мы должны разделить ключ шифрования на 8 частей, а потом взять их в обратном порядке, то есть первым ключом будет часть, под порядковым номером 8, вторым — 7 и так далее.
Таким образом формируются 32 итерационных ключа.
// Первые 24 раундовых ключа
// с порядком следования 0, 1, 2, 3, 4, 5, 6, 7
let mut round_keys = [0u32; ROUNDS];
for i in 0..3 {
for (j, chunk) in key.chunks_exact(4).enumerate() {
let key = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
round_keys[j + 8 * i] = key;
}
}
// Последние 8 раундовых ключа
// с порядком следования 7, 6, 5, 4, 3, 2, 1, 0
for (j, chunk) in key.chunks_exact(4).rev().enumerate() {
let key = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
round_keys[j + 24] = key;
}
2. Преобразование t
Сложнее этого преобразования уже не будет, хотя само по себе оно довольно простое. Преобразование t принимает и возвращает 32-битную последовательность.
Это нелинейное преобразование разделяет 32-битную последовательность на части по 4 бита, далее по таблице \pi производит подмену и собирает обратно в 32-битную последовательность.
Начнем с разбиения 32-битной последовательности на части по 4 бита.
Для удобства восприятия я перевел значения в десятичное представление.
Код
/// # Таблица подстановок RFC 7836
pub const ID_TC26_GOST_28147_PARAM_Z: [[u8; 16]; 8] = [
[0x0c, 0x04, 0x06, 0x02, 0x0a, 0x05, 0x0b, 0x09, 0x0e, 0x08, 0x0d, 0x07, 0x00, 0x03, 0x0f, 0x01],
[0x06, 0x08, 0x02, 0x03, 0x09, 0x0a, 0x05, 0x0c, 0x01, 0x0e, 0x04, 0x07, 0x0b, 0x0d, 0x00, 0x0f],
[0x0b, 0x03, 0x05, 0x08, 0x02, 0x0f, 0x0a, 0x0d, 0x0e, 0x01, 0x07, 0x04, 0x0c, 0x09, 0x06, 0x00],
[0x0c, 0x08, 0x02, 0x01, 0x0d, 0x04, 0x0f, 0x06, 0x07, 0x00, 0x0a, 0x05, 0x03, 0x0e, 0x09, 0x0b],
[0x07, 0x0f, 0x05, 0x0a, 0x08, 0x01, 0x06, 0x0d, 0x00, 0x09, 0x03, 0x0e, 0x0b, 0x04, 0x02, 0x0c],
[0x05, 0x0d, 0x0f, 0x06, 0x09, 0x02, 0x0c, 0x0a, 0x0b, 0x07, 0x08, 0x01, 0x04, 0x03, 0x0e, 0x00],
[0x08, 0x0e, 0x02, 0x05, 0x06, 0x09, 0x01, 0x0c, 0x0f, 0x04, 0x0b, 0x00, 0x0d, 0x0a, 0x03, 0x07],
[0x01, 0x07, 0x0e, 0x0d, 0x00, 0x05, 0x08, 0x03, 0x04, 0x0f, 0x0a, 0x06, 0x09, 0x0c, 0x0b, 0x02],
];
Далее нам понадобится матрица Pi. С помощью нее мы будем совершать подмену. Разделяя 32-битную последовательность на части по 4 бита мы получим 8 частей. Для каждой из частей мы будем использовать свою строку.
Интересно, что узлы замены могут варьироваться. В тексте стандарта ГОСТ 28147-89 указывается, что поставка заполнения узлов замены производится в установленном порядке, то есть разработчиком алгоритма.
Например, на Википедии содержатся 6 узлов замены: 4 для CryptoPro, 1 из RFC 7836 и 1 использовавшийся на территории Украины.
Поскольку значения 4-х битные, то и за пределы 16 значений в строке мы точно не выйдем.
Подмену мы будем совершать начиная с конца последовательности, то есть для первой строки мы используем младшие 4 бита последовательности.
Примерно таким образом это должно выглядеть.
После подмены, необходимо обратно собрать 32-битную последовательность не забывая о порядке.
Таким образом мы определили преобразование t.
fn transform_t(&self, value: u32) -> u32 {
let mut result: u32 = 0;
for (i, row) in self.matrix_pi.iter().enumerate() {
let part = (value >> (4 * i)) & 0x0f;
let substituted = row[part as usize] as u32;
result |= substituted << (4 * i);
}
result
}
Здесь я использую обычные сдвиги и маску 0x0f
, чтобы выбрать только 4 бита. Далее использую полученное значение как индекс для получения элемента в строке и на месте дополняю результирующее значение.
3. Преобразование g
Преобразование g работает с двумя 32-битными числами a
и k
, а в качестве результата возвращает только одно 32-битное число.
Это преобразование еще проще и состоит оно всего из 3-х действий:
Необходимо сложить числа
a
иk
, отбрасывая переполнение (сложение по модулю 2 в степени 32).Результат сложения необходимо передать преобразованию t.
Результат преобразования t необходимо циклически сдвинуть влево на 11 позиций.
fn transform_g(&self, a: u32, k: u32) -> u32 {
let temp = a.wrapping_add(k);
let substituted = self.transform_t(temp);
substituted.rotate_left(11)
}
На этом вспомогательные функции закончились, теперь можно переходить к шифрованию.
4. Функция шифрования
Для шифрования и расшифровывания, очевидно, используются одни и те же действия, но первым рассмотрим шифрование.
Вначале поступающий блок данных мы разделяем ровно пополам.
let mut a0 = u32::from_be_bytes([block[0], block[1], block[2], block[3]]);
let mut a1 = u32::from_be_bytes([block[4], block[5], block[6], block[7]]);
Мы будем чередовать работу над каждой частью. Это как раз будут ячейки сети Фейстеля. Мы будем каждый раз подавать данные (2 части по 4 байта) и итерационный ключ, а всего таких ячеек будет 32.
for i in 0..32 {
let temp = a1;
a1 = a0 ^ self.transform_g(a1, self.round_keys[i]);
a0 = temp;
}
Каждый раз мы будем передавать вторую часть данных на вход следующей ячейке. В этой же мы просто выполним операцию XOR
, а в качестве операндов передадим первую часть данных и результат преобразования g, операндами которого в свою очередь является вторая часть данных и итерационный ключ.
let mut result = Vec::with_capacity(BLOCK_SIZE);
result.extend_from_slice(&a1.to_be_bytes());
result.extend_from_slice(&a0.to_be_bytes());
В конце шифрования собираем блок обратно, помещая вперед вторую часть. Вот так просто устроено шифрование блока данных.
5. Функция расшифровывания
let mut a0 = u32::from_be_bytes([block[0], block[1], block[2], block[3]]);
let mut a1 = u32::from_be_bytes([block[4], block[5], block[6], block[7]]);
Как и в случае шифрования, разбиваем данные на 2 части.
for i in (0..32).rev() {
let temp = a1;
a1 = a0 ^ self.transform_g(a1, self.round_keys[i]);
a0 = temp;
}
Проводим абсолютно такие же операции с единственным отличием в применяемых итерационных ключах — используем их в обратном порядке.
let mut result = Vec::with_capacity(BLOCK_SIZE);
result.extend_from_slice(&a1.to_be_bytes());
result.extend_from_slice(&a0.to_be_bytes());
И снова собираем блок данных, не забывая вперед подать вторую часть.
Проверка и эталонные значения
Проверка осуществлялась двумя способами:
С помощью эталонных значений из RFC 8891.
С помощью CyberChef.
Эталонные значения
В документе RFC 8891 заботливо приведены эталонные значения для всех используемых преобразований и функций.
Таким образом мы можем убедиться в том, что хотя бы один блок данных мы можем зашифровать верно.
В этой статье я не стану приводить данные значения, вы можете найти их либо в моем репозитории — там уже готовый тестовый код, либо в документе RFC 8891.
CyberChef
В конце таких материалов всегда хотелось бы использовать чужую публичную реализацию для сверки собственного решения на произвольных данных. Меня удивило, что по запросу онлайн шифрования с помощью "Магмы" ничего не было найдено.
Каким-то образом я обнаружил, что CyberChef содержит реализацию как "Магмы", так и "Кузнечика", вот только с одной особенностью — в CyberChef не работает заполнение (padding).
Сверяя результаты я заметил, что все всегда сходится кроме последнего блока. Недолгими поисками я обнаружил, что CyberChef не дополняет данные согласно алгоритму PKCS5/7 (в целом одно и то же, за исключением неважных деталей).
Принцип работы PKCS5/7
Для незнающих или забывших напомню, что это очень простой алгоритм, который заполняет пустоту блока байтами, значения которых как раз равны числу недостающих байтов.
Пример: если у нас блок 8 байт, а недостает 3 байта, то заполнение будет выглядеть как:
[0x03, 0x03, 0x03]
.
Очень просто. Кроме того, если заполнение не требуется, то оно все равно будет производиться как если бы нам требовалось заполнить полностью пустой блок. Это требование PKCS5/7.
Пример: если у нас блок 8 байт, а размер данных кратен размеру блока, то заполнение будет выглядеть как:
[0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08]
.
В этом CyberChef подвел. Возможно я что-то упускаю, хотя перепроверил все несколько раз.
Тем не менее CyberChef можно пользоваться, произведя дополнение вручную. В таком случае все режимы шифрования: ECB, CBC, CFB, OFB, CTR отрабатывают верно.
Заключение проверки
running 7 tests
test tests::cipher::magma::decrypt_test::test_decryption ... ok
test tests::cipher::magma::encrypt_test::test_encryption ... ok
test tests::cipher::magma::keys_test::test_invalid_key_lenght - should panic ... ok
test tests::cipher::magma::keys_test::test_invalid_zeroed_key - should panic ... ok
test tests::cipher::magma::keys_test::test_key_generation ... ok
test tests::cipher::magma::transform_g_test::test_transform_g ... ok
test tests::cipher::magma::transform_t_test::test_transform_t ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 16 filtered out; finished in 0.00s
Все проверки использовали небольшое количество данных, считанные байты, хотя корректнее было бы сравнить зашифровав большой объект, например, видео.
Тем не менее — фундаментальные функции работают верно, составные функции, такие как шифрование и расшифровывание работают верно, а еще более высокоуровные функции, такие как шифрование в определенном режиме, также работают верно.
Заключение
digit4lsh4d0w
Автор
В комментариях часто можно встретить высказывания о том, что создание собственной криптографии недопустимо. Создаётся впечатление, что авторы комментариев опираются исключительно на заголовок и первый абзац.
Безусловно, использование собственной криптографии на реальных рабочих серверах, даже в личных целях, категорически запрещено. Сотни нюансов не могут обеспечить безопасность ваших данных.
Однако характер данной заметки указывает на то, что это лишь скромная попытка реализации, предназначенная для тех, кто не хочет тратить много времени на изучение основ алгоритма. В моих заметках рассматриваются именно алгоритмы, а не промышленное использование или проблемы криптографии.
Весь цикл материалов вылился из разрозненных, плохо оформленных и скудных статей по алгоритму SHA. Поэтому я подготовил свои материалы так, как самому это хотелось бы воспринимать.
Буду рад услышать обоснованную критику моего материала и моей позиции в комментариях, как здесь, так и в Telegram.
Весь код сохранен в репозитории GitVerse.
Напоследок про Rust
Иногда можно услышать вопрос: "А почему вы пишете на Rust? Разве Python не был бы более понятным?"
И да, и нет. Для меня интереснее использовать новый, низкоуровневый и концептуально сложный язык, такой как Rust. Мне нравится его концепция и реализация, но у меня нет задач, которые требовали бы его использования. Поэтому я пытаюсь найти баланс между обучением и созданием заметок для других.
Кроме того, Rust позволяет явно указать, что и куда перемещается. Это может быть полезно. Python, напротив, создаёт абстракции, что мне не всегда удобно.
Для тех, кто не хочет изучать Rust или не хочет воспринимать мой код как псевдокод (что, в целом, не составляет труда), я могу порекомендовать использовать LLM, такие как GigaCode. Эти инструменты без ошибок переведут код на любой удобный язык программирования. Часть информации по реализации некоторых алгоритмов я получил именно таким образом, например, конвертируя код с Java.
Источники
Комментарии (8)
Gromilo
14.12.2024 15:06Безусловно, использование собственной криптографии на реальных рабочих серверах, даже в личных целях, категорически запрещено. Сотни нюансов не могут обеспечить безопасность ваших данных.
А собственно, говоря, почему?
Я понимаю почему нельзя придумывать свои алгоритмы или протоколы. Но в чём может быть проблема реализации?
digit4lsh4d0w Автор
14.12.2024 15:06Свои алгоритмы, протоколы и криптографию придумывать можно, никто не запрещает, но всегда будет вероятность ошибиться.
Говоря о том, что именно криптографию свою использовать нельзя, я имею в виду уровень угрозы возможной ошибки.
Что будет, если мой алгоритм сортировки содержит ошибку? Просто будет неверный порядок элементов. Это легко выявляется и легко исправляется. Это все относится к теории информации.
А что будет, если я ошибусь в криптографии? Например, если что-то зашифрованное не будет расшифровываться? Это можно будет легко обнаружить, но непонятно как исправить. Еще пример, допустим все, что нужно, расшифровывается, но сам процесс шифрования содержит математическую ошибку. Тогда и выявить, и исправить ошибку будет сложно. Это относится как к теории информации, так и к теории криптографии.
К моей предыдущей статье оставили несколько комментариев по поводу атаки оракула с заполнением. Совершенно неочевидная ошибка, которая реально была выявлена в распространенном криптографическом пакете.
Поэтому когда вы что-то реализуете, необходимо учитывать какой уровень угрозы несут потенциальные ошибки.
Balling
Что-то я не хочу, чтобы мои карты Мир работали через код на Rust на стороне банков и ПС. https://ruscrypto.ru/resource/archive/rc2017/files/02_smyshlyaev.pdf
digit4lsh4d0w Автор
Насколько я знаю, в подобных отраслях используются аппаратные криптографические средства. Тогда задача Rust, C или CPP сводится к тому, чтобы доставлять и получать из определенных регистров байты.
На самом деле не вижу никакой разницы будет здесь работать Rust или CPP.
В чем вы видите проблему для себя ?
Gromilo
Почему? На какой слайд смотреть?