Все мы знаем популярную задачу о рюкзаке. Все мы также знаем, что такое асимметричное шифрование и для чего оно используется. А если не знаете - то вот и повод узнать, потому что в этой статье мы попытаемся на основе задачи о рюкзаке написать свою систему шифрования с открытым ключом на C#. Основную логику криптосистемы спрячем в отдельный NuGet-пакет, который затем используем для написания собственного простого web-сервера (тоже на C#, конкретней на ASP.NET Core) с БД. Этот сервер будет представлять собой мессенджер, где пользователи смогут регистрироваться и посылать друг другу сообщения, а также проверять свою почту на наличие оных. Собственная система шифрования позволит нам обмениваться информацией между клиентами и сервером, а в случае перехвата противник не сможет её дешифровать (или, по крайней мере, сможет, но не сразу). Автор не гарантирует 100% адекватность кода или 100% применимость в жизни. Здесь вы найдёте лишь простой пример своей криптосистемы, основанной на известной задаче. Для тех, кто не знает, что такое криптосистемы с открытым ключом, эта статья - шанс потрогать их руками. Пристёгивайтесь, будет жарко.

Описание криптосистемы

Вступление

Кратко и простыми словами: криптосистема с открытым ключом - это как замóк или серия одинаковых замкóв, ключ к которым есть только у одного человека - производителя замкóв.

Симметричное шифрование.

Все мы привыкли к симметричному шифрованию (с зарытым ключом): есть две стороны, которые хотят пообщаться по открытому каналу связи, но так, чтобы никто не смог прочитать их сообщения друг другу. Эти две стороны в один прекрасный день встречаются и создают ключ K - набор данных, которым можно как шифровать сообщения, так и дешифровать; эти данные не раскрываются, их знают только стороны, обменивающиеся сообщениеями. Затем стороны расходятся. Предположим, что одна сторона A хочет передать сообщение "Hello, world!" стороне B. Для этого A шифрует своё сообщение используя общий ключ K и полученный набор буков или цифр (по научному говоря, - шифртекст) отправляет по каналу. Краказябра (шифртекст), которая летит в сторону B никому из всех остальных обитателей планеты не понятна. Сторона B получает шифртекст и при помощи того же ключа K его дешифрует, получая исходное сообщение. Всё просто. Примером может послужить самый популярный шифр Цезаря, который знает каждый школьник, кто ходил на информатику. В нём ключом является размер сдвига. Шифр Виженёра из той же оперы, но он чуть посложнее - в нём ключом является конечная последовательность сдвигов (или, проще говоря, слово).

Так вот, такой подход имеет ряд недостатков:

  1. Надо встретиться или иметь уже защищённый канал связи, чтобы обменяться ключами.

  2. Сколько пар пользователей хочет общаться - столько и ключей. Если задуматься, то можно написать формулу и самому убедиться, что зависимость получается квадратичной.

  3. В случае, когда ключами обладают 2 абонента, шанс того, что его кто-то сопрёт, вдвойне больше, чем если бы ключ был только у 1 абонента.

Ассиметричное (или шифрование с открытым ключом) не обладает этими недостатками.

Ассиметричное шифрование

Говоря формально: в случае ассиметричного шифрования абонент A, который хочет шифровать весь поток направленный в его сторону, создаёт 2 ключа: приватный и публичный. Сразу оговоримся, что шифрование с открытым ключом позволяет шифровать информацию только в сторону абонента A. В обратную сторону придётся вводить другие системы или уже другим абонентам заводить пары ключей. Приватный ключ A хранит у себя и никому не показывает. Публичный ключ A, напротив, раздаёт направо и налево, кому вздумается. Допустим, какой-то перец B решил отправить сообщение "Goodbye, world!" в сторону A, для этого B шифрует сообщение открытым ключом A и отправляет шифртекст. Ассиметричная криптосистема позволяет это сделать так, что никто кроме A не сможет прочитать зашифрованное сообщение. Почему? Потому что приватный ключ есть только у A. И потому что только приватным ключом можно это сообщение дешифровать. Как бы парадоксально это не звучало, но как только B зашифрует своё сообщение публичным ключом A, он сам не сможет ничего с этим сообщением сделать: ни понять, ни дешифровать.

Задача о рюкзаке

Перейдём к описанию алгоритма. Итак, пусть есть абонент #1 и все другие абоненты. Абонент #1 хочет позволить им отправлять ему зашифрованные сообщения. Для этого нам нужна пара ключей.

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

A=(a_0, a_1, ... a_n)\quad ,a_i>\sum_{j=0}^{i-1}{a_j}

Нам также понадобится два взаимно-простых числа - M и t. Первое должно быть больше суммы всех элементов, второе - меньше:

M>\sum{A},\quad t<\sum{A},\quad НОД(M, t)=1

Публичный ключ определим через последовательность A, умножив каждый элемент на t и взяв остаток от деления на M:

B=(t a_i \,(mod \, M))

Обратите внимание: A была монотонно возрастающей, а B - наш приватный ключ, не обладает этим свойством.

Повторю ещё раз.

Каждый элемент bi = t*ai (mod M). Знак * - это обычное умножение. Операция (mod M) - обычное взятие остатка. Ничего сложного. Если у вас есть A, то вы можете посчитать и последовательность B.

Вот и всё. Можете раздавать последовательность B каждому, кому хотите.

Тройка (A, M, t) является вашим приватным ключом, ничто из перечисленного никому отдавать нельзя.

Передача сообщения

Абонент #2 хочет передать абоненту #1 своё сообщение. Ему известен публичный ключ - последовательность B, в которой n элементов. А дальше я скажу очевидное: любую конечную информацию можно представить в виде конечной последовательности ноликов и единичек. Числа и строки не исключение. Не теряя общности будем считать, что надо передать последовательность нулей и единичек:

Message = 010100101_2

Здесь, например, сообщением является число 165. За раз мы можем передать последовательность состоящую из не более чем n элементов (потому что ключ длины n). Каждому биту исходного сообщения сопоставляем элемент из последовательности публичного ключа.

0

1

0

1

0

0

1

0

1

b0

b1

b2

b3

b4

b5

b6

b7

b8

Складываем те числа bj, у которых стоит бит 1, получаем шифртекст C.

C=\sum_{bit(b_j)=1}b_j

В примере выше C = b1 + b3 + b6 + b8. Этот шифртекст и передаётся по каналу.

Дешифровка

Пусть первый абонент получил шифртекст. Первый абонент имеет закрытые (приватные) ключи, которыми мы и воспользуемся, чтобы получить обратно сообщение.

Для этого для начала умножаем на обратный элемент к t по модулю M:

Ct^{-1}=\sum_{bit(a_i)=1}{a_i}

Нетрудно догадаться, что этой операцией мы получили сумму тех элементов из последовательности A, у которых в исходном сообщении Message установлен бит 1. Дальше мы решаем задачу о рюкзаке. Только вот есть один нюанс: последовательность A монотонно возрастает, а это значит, что алгоритм нахождения соответствующих ai будет намного проще.

Чуть конкретней: начинаем с самых больших ai и если число меньше чем текущая сумма, то вычитаем его из суммы, одновременно занося в память информацию о том, что мы нашли очередной бит, установленный в единицу. Так продолжаем до самого начала последовательности A. В итоге мы получили последовательность единиц и нулей, а это значит, восстановили сообщение Message.

Недостатки

Теперь вы знаете алгоритм шифрования на основе задачи о рюкзаке. Любой другой абонент #3, если захочет получить исходную последовательность нулей и единиц, должен будет решить задачу о рюкзаке, где C - сумма весов вещей в рюкзаке, а B - последовательность весов предметов. Долго, но просто. Но не так просто как для #1, у которого есть приватный ключ, и у которого задача сильно упрощается.

Недостатки у нашей модельной криптосистемы, конечно, есть (иначе бы ею пользовались). Например, если злоумышленник получит несколько последовательностей шифртекста и плейнтекста (исходного сообщения), то сможет взломать приватный ключ. Или, если последовательность B достаточно маленькая, то взломать отдельный шифртекст также становится проще.

Всегда помните правило: если хотите криптостойкость, никогда не делайте свою криптосистему! Потому что всё уже сделали за вас, и RSA или те же эллиптические кривые уже придуманы и реализованы, а их устойчивость доказана и исследована.

Но перейдём к реализации.

Реализация на языке C#

Описание классов

Напишем простой NuGet пакет, который выполняет генерацию пар ключей, их сохранение в виде файла, шифрование и дешифрование.

В папке, где будете хранить проект, создайте файл решения и добавьте шаблон библиотеки:

dotnet new sln
mkdir src
dotnet new classlib -o src/Backpack.Crypto
vim src/Backpack.Crypto/Backpack.Crypto.csproj  # Добавьте информацию о пакете.
dotnet sln add src/Backpack.Crypto/Backpack.Crypto.csproj

Для пользователей Visual Studio - можете воспользоваться IDE, выбрав нужный шаблон.

Напишем несколько классов:

KeyPair

static KeyPair Generate(int n=128)

Factory-method. Создаёт пару и сохраняет в себе. n - число элементов в последовательности.

PublicKey GetPublicKey()

Получение публичного ключа.

PrivateKey GetPrivateKey()

Получение приватного ключа.

KeyPair(PublicKey, PrivateKey)

KeyPair(string)

Создание пары ключей на основе уже имеющихся ключей. Конструктор. Выбрасывает ошибку, если ключи не соответствуют друг другу.

string ToString()

Экспорт в строку. Формат такой же, как в конструкторе.

PublicKey

PublicKey(string)

PublicKey(List<BigInteger>)

Публичный ключ. Конструктор. В строке получает последовательность B.

CipherText Encrypt(long)

CipherText Encrypt(string)

Возвращает зашифрованное число или строку в виде шифртекста или null если что-то пошло не так.

string ToString()

Экспорт в строку. Формат строки тот же, что приняли в конструкторе.

PrivateKey

PrivateKey(string)

PrivateKey(List<BigInteger>, BigInteger, BigInteger)

Приватный ключ. Конструктор. Создаёт приватный ключ A, читая его из строки или через параметры.

long? DecipherLong(CipherText)

string? DecipherString(CipherText)

Дешифровка шифртекста в число или строку. Возвращает null, если шифртекст не является числом или строкой. Кидает ошибку, если что-то пошло не так.

string ToString()

Экспорт в строку.

CipherText

CipherText(BigInteger)

CipherText(string)

Создание шифртекста. из числа или из строки.

BigInteger Get()

Получение представления шифртекста.

string ToString()

Экспорт в строку. Формат строки тот же, что приняли в конструкторе.

CipherText

Начать лучше с CipherText, так как он самый простой. Его представление в виде строки - это просто большое число, которое можно смело скормить BigInteger.Parse. В методе Get() просто возвращаем поле класса. Кстати, конструктор CipherText(BigInteger cipher) можно сделать internal, - он будет использоваться только другими классами пакета и не должен быть виден снаружи.

PrivateKey

Основная сложность заключается в том, что в строке, которая передаётся аргументом в конструктор и которая используется в экспорте, должна быть вся необходимая информация: последовательность чисел A, числа M и t. В качестве типа данных для хранения больших чисел опять же используем BigInteger, для последовательности дополнительно List над ним. В публичном конструкторе всё просто - копируем содержимое аргументов в поля объекта.

Что делать со строками?

Можно вбивать вручную и создавать строку, а в конструкторе её парсить. Такой подход подойдёт, если вы не хотите париться с зависимостями. Для cool programmer есть обход получше: воспользуемся простым пакетом Newtonsoft.Json. Для тех кто не знает - это удобная (и популярная) библа для работы с json:

# установка зависимости черех cli:
dotnet add package Newtonsoft.Json --version 13.0.2
# cat Backpack.Crypto.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    ...
  </PropertyGroup>

  <ItemGroup>
    ...
    <PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
  </ItemGroup>
</Project>

Добавили, перейдём к использованию. Приватные поля класса надо отметить атрибутом JsonProperty, чтобы json.net их увидел. Конструктор, в котором передаются все 3 аргумента можно пометить JsonConstructor, если вы, конечно, сделали его internal. Наконец внутри методов вызываем нужные функции:

using Newtonsoft.Json;
//...
  public PrivateKey(string json) {
    PrivateKey? me = JsonConvert.DeserializeObject<PrivateKey>(json);
    // копируем содержимое me...
  }
//...
  public override string ToString() {
    return JsonConvert.SerializeObject(this, Formatting.None);
  }

Проверка

Хотите проверить, что всё работает? Не вопрос: создадим новый консольный подпроект.

dotnet new console -o src/Sample.Console
dotnet sln add src/Sample.Console/Sample.Console.csproj

В качестве зависимости добавим наш (пока что недоделанный) пакет:

# cat Sample.Console.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    ...
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="../Backpack.Crypto/Backpack.Crypto.csproj" />
  </ItemGroup>
</Project>

В самом же консольном проекте создаёте метод Main со всеми вытекающими и импортируйте свою библиотеку: using Backpack.Crypto;. Создайте простой приватный ключ и выведете его содержимое.

// src/Sample.Console/test.cs
using System.Numerics;
using Backpack.Crypto;

public class Test {
    public static int Main(string[] args) {

        Console.WriteLine("Hello, World!");
        string s = "{\"a\":[12],\"M\":1277,\"t\":34}";
        PrivateKey key = new PrivateKey(s);
        Console.WriteLine(key.ToString());
        return 0;
    }
}

Запускать при помощи dotnet run -p src/Sample.Console/Sample.Console.csproj.

Остались DecipherLong & DecipherString. В обоих случаях нам понадобится найти обратный элемент к t по модулю M. Вынесем эту логику в отдельную статическую приватную функцию BigInteger ModuleReverse(BigInteger x, BigInteger M). Конечно, есть алгоритмы, решающую эту задачу эффективно. Просто перебрать тоже получится, но вряд ли это то, что нам нужно, учитывая какие числа мы сейчас будем генерировать. Можно вдохновиться здесь. Но cool programmer не станет писать велосипед за 5 минут, а будет искать решение в интернете 15 минут.

Добавляем зависимость

Добавляем зависимость (странно, но у этого пакета нету публичного API reference):

dotnet add package BouncyCastle --version 1.8.9
# cat Backpack.Crypto.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    ...
  </PropertyGroup>
  <ItemGroup>
    ...
    <PackageReference Include="BouncyCastle" Version="1.8.9" />
  </ItemGroup>
</Project>

Импортируем BigInteger из этого пакета. Чтобы не случилось конфликта, задаём import alias. Конвертированием в строку и обратно обычный BigInteger можно превратить в BigInteger этого пакета, что нам и надо, ведь у BigInteger пакета есть волшебный ModInverse:

using BouncyCastleBigInteger = Org.BouncyCastle.Math.BigInteger;
//...
private static BigInteger ModuleReverse(BigInteger x, BigInteger M) {
  BouncyCastleBigInteger xBouncyCastle = new BouncyCastleBigInteger(x.ToString());
  return BigInteger.Parse(xBouncyCastle.ModInverse(new BouncyCastleBigInteger(M.ToString())).ToString());
}

Здесь мы находим обратный к x по модулю M. Для этого x конвертируем в формат BouncyCastleBigInteger, для чего получаем строковое представление x и передаём в конструктор. Дальше надо вызвать метод ModInverse, который принимает число формата BouncyCastleBigInteger. Для этого создадим аргумент через конструктор, передав строковое представление числа M. После вызова метода мы получили число в формате BouncyCastleBigInteger - наш ответ. Приводим его к строке и из строкового формата получаем стандартный BigInteger при помощи статического BigInteger.Parse.

Проверка

Проверить скорость вашего решения можно, если на время сделать функцию ModuleReverse публичной, а в скрипте Sample.Console написать такие строки:

public static int Main(string[] args) {
  Console.WriteLine(PrivateKey.ModuleReverse(BigInteger.Parse("30000000000000000000000000001"), BigInteger.Parse("90000000000000000000000000000000000000000000000000001")));
  return 0;
}

Методом перебора вы будете ждать больше получаса. Через зависимость, которую мы прописали ранее, вы получите решение меньше чем за 5 секунд:

45004499550044995500449955001499850014998500149985002

Процесс дешифровки был показан выше в описании алгоритмов. В случае с DecipherLong надо:

  1. Проверить, что кол-во элементов в последовательности A больше или равна 64. Иначе тип long нельзя было шифровать.

  2. Найти сумму искомых элементов A умножением шифртекста на обратный к t по модулю M.

  3. При желании перебрать все элементы в A у которых индекс больше или равен 64 и если хоть один из них меньше или равен суммы из предыдущего пункта, то мы получили в аргументе не long, а шифрованную строку.

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

На самом деле, для этой задачи можно написать метод, который и будет перебирать элементы последовательности A, вызывая лямбду, переданную в аргументе, для каждого установленного в 1 бита. В этом случае выглядеть метод DecipherString будет совсем коротко:

public string? DecipherString(CipherText c) {
  BigInteger sum = c.Get() * ModuleReverse(this.t, this.M) % this.M;
  char[] chars = new char[this.a.Count * BITS_IN_CHAR];
  // Первые 2 аргумента: начиная с какой позиции и сумма.
  if (GetSetBits(this.a.Count - 1, sum, i => {
          int charIndex = i / BITS_IN_CHAR;
          int charBit = i % BITS_IN_CHAR;
          chars[charIndex] = XorBitInChar(chars[charIndex], BITS_IN_CHAR - 1 - charBit);
      }))
  {
    // Возвращает true если сумма обнулилась, когда дошли до i==0.
    return new string(chars);
  }
  throw new FormatException("Incorrect cipher!");
}
GetSetBits
private bool GetSetBits(int startIndex, BigInteger sum, Action<int> trigger)
{
  for (int i = startIndex; i >= 0; i--)
  {
    if (this.a[i] <= sum)
    {
      sum -= this.a[i];
      trigger(i);
    }
  }
  return sum == BigInteger.Zero;
}

В вызове метода в лямбде меняется бит BITS_IN_CHAR - 1 - charBit потому что i - индекс в последовательности, которая движется слева направо (a[0], a[1], ...), в char младшие биты расположены справа. В коде выше charBit - индекс бита если считать, что младшие биты слева. Чтобы XorBitInChar (иначе говоря, это просто вычисление выражения (char)((byte)c ^ (1 << bit))) поменял нужный бит, передаём ему индекс бита в стандартном порядке, а не в том, в котором мы декодируем последовательность.

PublicKey

В конструкторе internal PublicKey(List<BigInteger> b) сохраняем значение в приватную переменную. Как сохранять в строку и извлекать из строки мы уже умеем - см. выше. Кстати, можете сразу кинуть ошибку, если DeserializeObject вернул null:

public PublicKey(string json) {
  PublicKey me = JsonConvert.DeserializeObject<PublicKey>(json) 
                 ?? throw new FormatException("Json convertion returned null!");
  this.b = me.b;
}

В CipherText Encrypt(long value) также проверяем, что кол-во элементов в последовательности B больше или равно 64. Создаём локальную переменную sum, инициализируем нулём. Проходимся по последовательности, одновременно проверяя биты value. Для каждого бита прибавляем элемент последовательности к sum, в конце возвращаем новый объект CipherText, передав ему в конструктор эту сумму. Всё просто.

Так же, как и в случае с PrivateKey, где мы создали XorBitInChar, тут бы я добавил приватную GetBitInChar. Основной цикл public CipherText Encrypt(string value) тогда выглядел бы так:

for (int i = 0; i < value.Length * BITS_IN_CHAR; i++)
{
  int charIndex = i / BITS_IN_CHAR;
  int charBit = i % BITS_IN_CHAR;
  if (GetBitInChar(value[charIndex], BITS_IN_CHAR - 1 - charBit))
  {
      sum += this.b[i];
  }
}

Для получения char-а в конкретной позиции используем операцию [].

KeyPair

Сразу создаём приватные поля:

    [JsonProperty]
    private PublicKey PublicKey { get; }
    [JsonProperty]
    private PrivateKey PrivateKey { get; }

Нам надо создать пару ключей в public static KeyPair Generate(int n=128). Это довольно просто. Создадим A, потом по сумме всех элементов подберём M и t и останется только посчитать B. Первый элемент A можно положить 1, каждый последующий должен быть больше суммы предыдущих. Для этого сумму предыдущих достаточно увеличить на какое-то случайное значение, скажем, от 50000 до 1000000.

Нахождение взаимно-простых чисел

Можно по очереди увеличивать M и уменьшать t до тех пор, пока каждое не станет простым. Инициализировать их надо суммой всех элементов A. Можно написать руками проверку на простоту, нахождение НОД и прочее. Но если вы уже импортировали библиотеку BouncyCastle, то всё немного проще:

BouncyCastleBigInteger t = new BouncyCastleBigInteger((sum - MAX_ADDITIVE).ToString()).NextProbablePrime();
BouncyCastleBigInteger M = new BouncyCastleBigInteger((sum + MAX_ADDITIVE).ToString()).NextProbablePrime();
while (t.Gcd(M) != BouncyCastleBigInteger.One) {
  t = t.Subtract(new BouncyCastleBigInteger(MAX_ADDITIVE.ToString()));
  if (t.CompareTo(BouncyCastleBigInteger.Two) < 0) {
    throw new ApplicationException("Can not calculate 2 primary numbers!");
  }
  t = t.NextProbablePrime();
  M = M.Add(new BouncyCastleBigInteger(MAX_ADDITIVE.ToString())).NextProbablePrime();
}

MAX_ADDITIVE - сдвиг для каждого шага. Достаточно большое число.

Вспоминаем определение последовательности B: умножить каждый элемент из последовательности A на t и взять по модулю M.

Проверка

Напишем простой тест, чтобы проверить, что всё работает.

public static int Main(string[] args)
{
  KeyPair gen = KeyPair.Generate(500);
  CipherText text = gen.PublicKey.Encrypt("eye boy cool hat you know!");
  Console.WriteLine(text);
  Console.WriteLine(gen.PrivateKey.DecipherString(text));
  return 0;
}

Заметили, что шифртекст получается просто огромным? Недаром мы везде использовали BigInteger.

Ошибки, которые я встретил, и которые пришлось дебажить:

1) Кол-во битов в одном char не 8, как мы привыкли, а 16.

2) Метод SetBit у BouncyCastleBigInteger возвращает новый BouncyCastleBigInteger, поэтому надо делать так: value = value.SetBit(Constants.BITS_IN_LONG - 1 - i);

3) Не забывайте взять по модулю шифртекст, когда умножаете его на обратный к t:

BigInteger sum = (c.Get() * ModuleReverse(this.t, this.M)) % this.M;


Заключение

Статья получилась довольно обширной, поэтому здесь не всё, что было запланированно. В следующих мы поговорим о том, как нашу либу экспортировать в NuGet пакет, возможно, настроим ci/cd в gitlab, добавим тестов, потом напишем сервер на C#, потом поднимем его в k8s, потом напишем клиента на Android. Короче, планов много!

Результат (код) могу дать как zip-пакет или скинуть ссыль по личному запросу.

Спасибо за внимание.

Комментарии (4)


  1. saipr
    20.12.2022 23:23
    +2

    Всегда помните правило: если хотите криптостойкость, никогда не делайте свою криптосистему!

    Криптосистемы делали и будут делать, а вот криптоалгоритмы для этих систем, тут вы правы, следует использовать проверенные временем, будь то RSA или эллептические кривые, включая, кстати и ГОСТ.
    В качестве криптосистем можно привести тот же CSP от Микросовт, openssl, gnutls, NSS (network security services) и т.п.


    1. LuggerFormas
      21.12.2022 13:46

      ЭллИптические, хоспаде.

      Где ГОСТ на асимметру? Можно посмотреть?


  1. s_f1
    21.12.2022 07:38
    +1

    Потому что всё уже сделали за вас, и RSA или те же эллептические кривые уже придуманы и реализованы, а их устойчивость доказана и исследована.
    Не знаю, что подразумевается под устойчивостью, но для RSA не доказана сложность получения закрытого ключа.


  1. MentalBlood
    21.12.2022 10:59
    +2

    Кол-во битов в одном char не 4, как мы привыкли

    Это где ж такие char водятся, 4-битные? 8-битные видел, 16-битные можно понять...