Самодельные шифры — зло. Зачем создавать собственный шифр, если есть… Кстати, а что есть? Есть известные шифры, которые пока ещё никто не взломал и есть не менее известные шифры, которые сначала считались надёжными, но потом в них нашли серьёзные уязвимости. А ещё у каждого (пока ещё) стойкого шифра есть как свои сторонники, так и свои противники. Хуже того, регулярно находятся чудаки, которые не разбираются в шифровании, но создают свои собственные шифры. И ладно бы молча пользовались ими сами, так они ещё их публикуют. А данная статья — лишь очередное подтверждение этого явления.
С чего всё начиналось?
Сейчас я пользуюсь дистрибутивом Linux с установленным по умолчанию gpg, есть интернет и есть ИИ, которого можно спросить: «Какой алгоритм шифрования выбрать?». А когда-то у меня была Windows XP с единственной известной мне шифрующей утилитой — встроенный архиватор zip с возможностью поставить на архив пароль. Поговаривали, что в те времена архиватор zip использовал довольно слабый алгоритм шифрования и никуда не годился.
Интернета у меня тогда не было (ИИ — тем более), зато был Turbo Pascal (позже Delphi) и было желание создать для себя шифр, который был бы безопаснее, чем zip с паролем. Подчеркну: для себя и в условиях острого дефицита необходимых знаний. И было интересно на собственном опыте разобраться с вопросом: как мог бы выглядеть шифр?
Вообще с шифрами я впервые столкнулся лет в 9, когда кто-то из старших товарищей нарисовал «пляшущих человечков» и спросил: «Что здесь написано?». Откуда же я знаю! Но показалась интересной мысль создать свой собственный тайный алфавит. Правда, уже через несколько лет я подобный «шифр» легко вскрыл, что для меня сразу закрыло тему «тайных алфавитов». Потому к моменту создания компьютерного шифра было понимание необходимости реализации других принципов.
Первые версии шифра
Если нельзя просто заменить один символ (или байт) другим, то что можно? Поначалу я решил строить шифр так: каждый шифруемый байт менять на результат некоторого вычисления над ним самим, его соседом и одним байтом ключа. И затем перемешать все байты в пределах блока. А для надёжности замены и перемешивания поместить в тело цикла и выполнять много раз. Сколько раз — я не знал, потому количество «циклов» сделал свободным параметром шифра.
Схему перемешивания байтов внутри блока я поначалу брал статической, но вскоре пришло понимание, что в неудачно подобранной статической схеме могу обнаружиться уязвимости. Без возможности самостоятельно найти эти уязвимости я решил пользоваться динамической схемой и вычислять её на основе ключа. Более того, поначалу я вообще реализовал три ключа: один для замен, второй для перемешивания, а байты третьего просто прибавлять к шифруемым.
Ещё одно интересное решение: менять ключи после каждого блока. Реализовано оно было в отдельной функции, которая изменяла все три ключа с зависимостью друг от друга. А алгоритм я для себя назвал ESCK (от Encrypting System with Changing Keys — Шифрующая Система с Меняющимися Ключами).
Шифр Вернама и дальнейшее развитие ESCK
А потом мне попались сведения о шифре Вернама, который оказал на меня сильное впечатление. Но как реализовать в блочном шифре те же принципы случайности и одноразовости? Разве что генерировать поток шифрующих байтов на основе ключа и самого блока. Тогда можно добиться некоторого приближения к случайности и одноразовости в контексте хотя бы каждой уникальной пары блока и ключа.
В принципе, первые версии ESCK что-то похожее и выполняли: для каждого шифруемого байта вычислить число на основе его соседа и ключа, прибавить это число к текущему байту и затем всех перемешать. Кто в следующий раз станет соседом, какое будет иметь значение, как повлияет на вычисляемое для прибавление число — заранее неизвестно. Всё упиралось лишь в качество реализации. Но как его добиться в алгоритме с тремя ключами, громоздкой функцией их изменения и побайтной обработкой?
В общем, со временем стало приходить понимание, что ключ должен быть один, а обработка блока эффективнее будет не по одному байту, а в виде 32- или даже 64-битных элементов. И также нужно перемешивать не элементы в блоке, а связи между ними и элементами ключа. Как? Например, на основе ключа и соседа по блоку вычислять специальную динамическую переменную, разбивать её на несколько адресов (индексов) и по ним из специальной таблицы брать числа непосредственно для шифрования. И так в каждом раунде для каждого элемента блока.
Если вычисляемая адресная переменная 64-битная, то её удобно разбивать на восемь 8-битных адресов/индексов, а таблицу чисел для шифрования взять из 256 элементов (по числу возможных значений каждого индекса). И да, эта таблица может сама быть ключом, но требовать обязательного преобразования для приведения к разнообразию элементов. Или эта таблица может содержать константы, а ключ будет применяться отдельно. Все эти подходы были реализованы в поздних версиях шифра.
Что получилось
Самое сложное в данной статье было сформулировать все принципы полученного в итоге шифра. Технически шифр реализован в виде трёх основных функций: шифрование блока, расшифровка блока, а также изменение ключа (похожа на шифрование блока, но шифрует сам ключ). Ниже приведена функция шифрования блока M=Block ключом K Cycles раз с участием опционального номера блока BlNum:
void ESCK7::EncryptBlock(uint64_t* M, const uint64_t* K, int Cycles, uint64_t BlNum) { // исключение в случае нулевого указателя if (M == nullptr || K == nullptr) throw std::invalid_argument("Нулевой указатель на блок или ключ"); // многоцикличное шифрование for (int C = 1; C <= Cycles; C++) { // основной цикл шифрования for (int i = 0; i < 2; i++) { // копирование текущего M uint64_t Mi = M[i]; // вычисление адресной переменной uint64_t Adr = ROL64(M[(i + 1) & 1] + BlNum, 37); Adr = ROL64(Adr + K[Adr & 0xff], 43); // последовательное преобразование Mi Mi += ROL64(~Adr + M[(i + 1) & 1], 15); Mi += ROL64(K[Adr & 0xff], 11); Adr >>= 8; Mi += ROL64(K[Adr & 0xff], 26); Adr >>= 8; Mi += ~ROL64(K[Adr & 0xff], 10); Adr >>= 8; Mi = ROL64(Mi, 39) ^ (~K[Adr & 0xff]); Adr >>= 8; Mi += ROL64(K[Adr & 0xff], 25); Adr >>= 8; Mi += ROL64(K[Adr & 0xff], 3); Adr >>= 8; Mi += ROL64(~K[Adr & 0xff], 29); Adr >>= 8; Mi = ROL64(Mi ^ K[Adr & 0xff], 54) + C; // возврат Mi в рабочий массив M[i] = Mi; } } }
Здесь видно, что каждый из двух 64-битных элементов блока шифруется с помощью взятых из массива ключа K (256 64-битных элементов) восьми чисел, которые с помощью операций XOR, NOT, сложения и сдвигов модифицируют текущий элемент блока. Числа из ключа берутся по адресам из переменной Adr (её младшие 8 бит с последующим сдвигом). Сама переменная Adr в каждом раунде/цикле вычисляется на основе соседа по блоку (который меняется после каждого раунда) и номера блока (который для каждого блока может быть уникальным).
Причём здесь шифр Вернама? В чуть более ранней версии ESCK все операции выполнялись вначале над отдельной переменной, которая затем прибавлялась к Mi. В некотором смысле вычисление той переменной напоминало генератор псевдослучайных чисел (ГПСЧ) и, значит, для каждого Mi в каждом раунде прибавляемое число было «случайным» и одноразовым (для каждой уникальной тройки ключа, блока и его номера). Получается некоторое приближение к шифру Вернама на основе конкретной реализации ГПСЧ. В текущей версии ESCK это не так очевидно: вместо вычисления псевдослучайного числа выполняются операции напрямую с Mi.
Функцию изменения ключа можно рассматривать и как шифрование ключа самим собой, и как его хеширование, поскольку все биты ключа-хеша в итоге будут зависеть от каждого бита ключа-аргумента. В этом смысле шифрование блока тоже можно рассматривать с позиций хеширования: для шифрования каждого элемента блока неявно вычисляется хеш соседа и ключа, затем с помощью этого хеша модифицируется текущий элемент блока.
Некоторые замечания
Во-первых, полная реализация текущей версии шифра на C++ доступна на GitFlic, а прежние версии (на Free Pascal) доступны в архиве. Как минимум код доступен для ознакомления. Реальное применение шифра или создание его модификаций тоже не запрещается, но при одном условии: если вы знаете, что делаете! В том смысле, что это любительский шифр и в нём могут быть уязвимости.
Во-вторых, глубокий профессиональный анализ шифра не проводился, но предварительно выполнялись некоторые тесты (результаты — чуть ниже). Возможные дополнительные оценки стойкости шифра и даже попытки взлома приветствуются.
В-третьих, идея о реализации шифрования с явным хешированием соседа по блоку и ключом легла в основу новой версии шифра, но работа над ней ещё не закончена.
Результаты предварительных тестов
Лавинный эффект (открытый текст → шифротекст)
Метод: генерация случайного ключа и случайного блока, блок копируется и в копии меняется случайный бит. Оба блока шифруются и определяется среднее количество изменившихся бит. Всего 10 000 испытаний, 5 циклов/раундов шифрования.
Результат: Среднее изменение бит шифротекста: 49.99% (σ=4.40%)
Лавинный эффект (ключ → шифротекст)
Метод: генерация случайного ключа и создание его копии. Изменение случайного бита копии и запуск функции изменения ключа (GFunc) с 2 раундами. Каждым ключом шифруется заполненный нулями блок и определяется среднее количество различающихся бит. Всего 10 000 испытаний, 5 циклов/раундов шифрования.
Результат: Среднее изменение бит шифротекста: 50.08% (σ=4.41%)
Корреляция блоков
Метод: Генерация 50 000 случайных ключей. Все блоки открытого текста нулевые. Каждый блок шифруется своим ключом, полученные шифротексты выстраиваются в последовательность. Вычисляется корреляция Пирсона между соседними блоками (побайтно) и автокорреляция при сдвигах от 1 до 16 байт. 5 циклов/раундов шифрования.
Результаты:
- Средняя корреляция между соседними блоками: 0.0015
- Автокорреляция при сдвигах 1–16 байт: не превышает 0.002
Дифференциальный анализ
Метод: генерация случайного ключа, создание массива случайных блоков P1, формирование P2 = P1 XOR 0x01 (побайтно). Соответствующие блоки из P1 и P2 шифруются одним ключом, результаты складываются по XOR. Подсчитывается частота каждого байта в полученном массиве разностей. 1 000 000 блоков, 5 циклов/раундов шифрования.
Результаты:
- Максимальная частота байта разности шифротекстов: 0.395%
- Отклонение от равномерного распределения: +1.1%
Линейный анализ (быстрая выборочная проверка)
Метод: выборочная проверка линейных аппроксимаций для фиксированных битовых позиций (0, 15, 31, 47, 63). Для каждого бита вычисляется вероятность его изменения в шифротексте при случайном открытом тексте и фиксированном ключе. Смещение (bias) определяется как |p - 0.5|. 5 циклов/раундов шифрования.
Результат: Максимальное смещение (bias): 0.00183 (≈2⁻⁹·¹)
Случайность
Метод: генерация случайного ключа, шифрование им массива из 20 000 нулевых блоков (с увеличением BlNum после шифрования каждого блока), выполнение тестов случайности NIST SP 800-22 для зашифрованного массива. Результаты для шифра и основанного на нём ГПСЧ приведены в отдельной статье.
Заключение
Это завершающая статья о данном шифре: в предыдущей я описывал что я сделал, а в данной статье я хотел объяснить как и почему я так сделал. И в качестве итогов ещё раз сформулирую реализованные в шифре основные принципы:
стремление к «случайности» и одноразовости шифрования для каждой уникальной пары ключа и шифруемого блока (+ необязательный номер блока);
техническая реализация «случайности» за счёт вычисляемых «на лету» индексов, упакованных в одну 64-битную переменную;
шифрование при участии чисел, взятых из специальной таблицы по вычисленным индексам;
таблица может быть секретной и являться ключом (с его приведением к разнообразию элементов) или содержать известные константы (с применением отдельного ключа);
простые операции шифрования: XOR, NOT, сложения, сдвиги;
неограниченное количество самодостаточных раундов (без раундовых ключей);
неочевидный взгляд на «случайность»: реализация хеширования или ГПСЧ на основе соседа по блоку и ключа;
модификации шифра допустимы (с осторожностью);
попытки взлома и сведения об уязвимостях приветствуются.
Дата последнего редактирования: 26.06.2026