Как хранить зашифрованный файл в репозитории Git или другой системы контроля версий? Если изменение одной строчки содержимого будет (из-за шифрования) менять весь файл целиком или значительную его часть - это портит весь подход системы контроля версий - хранение изменений "фрагментами". Значит хорошо бы шифровать "куски" - например, строки - по-отдельности, независимо.

Здесь описывается простенький рецепт для этого подхода - в духе "соль + пароль и от них взять хэш в качестве ключа". Большая же часть текста имеет несколько "дидактический" характер - обсуждение мотивации для такой задачи, границ применимости - а также напоминания и пояснения базовых соображений по криптографии - поэтому заранее приносим извинения тем читателям для кого эта информация тривиальна.

Как вообще хранят конфиденциальную информацию?

Нередко кроме кода, который может быть достаточно публичным (например, проект видно всем сотрудникам компании - или он вообще опенсорсный) нужно хранить какие-то секреты.

В простейшем случае, например, ключи-пароли, требующиеся для деплоя в разные энвы.

С этим всё понятно - их можно или запомнить (и хранить только в голове - хотя это несовместимо с CI) - или использовать какое-то отдельное хранилище - начиная с тайного google-документа или стикера на мониторе, до специальных хранилищ секретов типа vault - так что можно гранулировано настраивать доступ и т.п.

Другой случай, если нужно хранить какие-то достаточно объёмные данные (от десятков килобайт), которые, быть может, не составляют великой тайны, но и светить их кому попало не следует.

Можно в качестве аналогии вспомнить типичное разделение "уровней секретности" в госучреждениях и т.п.: есть гриф "Секретно", есть "Совершенно Секретно", есть "Особой Важности" - но в то же время есть и "Для Служебного Пользования". Этот последний как раз и означает "не то чтобы секретно, но разбрасывать не следует".

Например это данные "приближенные к реальным" (или даже с прода и взятые) используемые в тестах. Может быть транзакции продаж (допустим, за неделю). А может быть описание игрового мира (допустим, одна из зон большой карты).

Тут возникает дилемма о нескольких гранях:

  • уровень секретности сравнимый с хранением приватных ключей кажется неразумным

  • но чтобы кто-попало нос совал - тоже не нужно :)

  • да ещё файл вероятно будет многократно обновляться и нужно хранить ревизии.

Хороший вариант - хранить такой файл в отдельном репозитории с особым доступом. Но отдельная репа - это отдельная репа. Это может быть неудобно по многим причинам - одна из них, необходимость синхронизировать ревизии и т.п. В каких-то ситуациях (обычно, корпоративных) и получение / настройка доступов к отдельному репозиторию могут быть затруднены.

Вот в качестве альтернативы - подумаем о том, как удобнее хранить наш "секретный файл" в общей репе. Но напоследок напомним важное правило.

Золотое Правило Криптографии

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

Сложность криптозащиты выбирается так, чтобы трудозатраты на взлом были больше чем ценность защищённой информации.

Этим мы и руководствуемся когда решаем, подходит ли нам тот или иной способ ширования с такими или сякими предосторожностями. В частности в параграфе выше - туманное требование "чтобы кто попало не совал нос" имеет смысл как-то оценить в деньгах.

Условно: если мы понимаем что наш "секретный файл" содержит персональные данные 1000 пользователей и потенциально каждый из них может обидеться и подать иск на 1000 долларов - то кто-то может решить потратить "человекомесяц" (условно, несколько тысяч долларов) чтобы попробовать его взломать. В этом случае наш рецепт не рекомендуется к использованию (по крайней мере без дополнительных оценок).

Если же это небольшая зона онлайн игры, да еще и более-менее исхоженная многими пользователями, "новичковая" - то уровень защиты выглядит адекватно.

Базовые Воспоминания о Методах Шифрования

Ещё в школе мы вероятно сталкиваемся с задачами на "одноподстановочный" шифр - каждая буква заменяется какой-то другой буквой или символом - но этот "мэппинг" фиксированный. Рассказы о взломе таких шифров (Золотой Жук, Пляшущие Человечки) - общеизвестны. Из реальных примеров - Мария Стюарт, которой за подобную наивность отрубили голову.

Мрачные подробности можно прочесть в статье wiki "Заговор Баббингтона"
Мрачные подробности можно прочесть в статье wiki "Заговор Баббингтона"

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

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

Следующий уровень - использовать какую-то непериодическую последовательность (или по крайней мере с довольно длинным периодом). Очевидно эту последовательность надо как-то генерировать с помощью более короткого ключа.

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

Более надёжно (абсолютно надёжно) - использовать в качестве ключа полноценную случайную последовательность. Всевозможные истории, особенно военные, в которых упоминаются "шифровальные блокноты" - обычно относятся к этому способу. Фактически, мы используем ключ заведомо длиннее чем шифруемый текст - и используем только один раз. Очевидных недостатков тут примерно два:

  • обе стороны должны иметь под рукой этот самый ключ (набор ключей если придётся шифровать больше одного сообщения) - например в виде того самого блокнота

  • эти ключи надо уметь надёжно генерировать (используя какой-то "тру-рэндом")

Известный случай взлома такой "абсолютной" защиты связан с тем что ключ был переиспользован (то ли страницы блокнотов продублировали то ли еще что) - эта история известна под названием Venona Project.

Зачем мы всё это вспоминаем?

Описанный далее подход (как и многие способы шифрования) использует эти же принципы:

  • для шифрования используется "псевдо-случайная" последовательность (каким-то образом сгенерённая по ключу)

  • последовательность конечная (в духе шифра Виженера) но её "безопасную" длину можно подобрать исходя из характера шифруемых данных

  • нужно позаботиться чтобы разные строки шифровались разными последовательностями (несмотря на использование одного ключа)

Рецепт Построчного Шифрования

  1. Каждая строка шифруется с помощью "длинного ключа", более-менее уникального для каждой из строк. Например операцией побайтового XOR, так что и расшифровка происходит аналогично.

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

  3. Пароль, очевидно, для всех строк будет одинаковый. Чтобы сгенерировать из него различающиеся "длинные ключи" мы будем добавлять к паролю "соль" - несколько рандомных символов или байт.

  4. "Длинный ключ" генерируем из пары "соль+пароль" с помощью подходящей хэш-функции (соль ведь именно для хэш-функции и применяется).

  5. Соль записывается вместе с зашифрованной строкой (больше её взять в общем-то неоткуда).

Запишем это в виде коротенького скрипта на Питоне:

# encrypts standard input to standard output
# pass-phrase in the first command-line argument

import sys
import random
import hashlib

random.seed()

pass_phrase = sys.argv[1]

for line in sys.stdin:
    salt = str(random.randint(1000000, 9999999))
    sys.stdout.write(salt + ' ')
    key = hashlib.sha512((salt + pass_phrase).encode('utf-8')).digest()
    data = line.rstrip('\n').encode('utf-8')
    for i in range(len(data)):
        b = data[i] ^ key[i % len(key)]
        sys.stdout.write(f"{b:02x}")
    sys.stdout.write('\n')

Внимание: эта упрощённая реализация - для наглядности, а не для реального использования. Мы обсудим полезные модификации которые сделают её надежнее.

Передача "пароля" в качестве аргумента командной строки, вывод в 16-ричном виде и т.п. - эти нюансы тоже больше для наглядности, в "живой" реализации возможно лучше использовать getpass и base64 например. Сюда же отнесем использование random для получения соли. В реальной работе стоит предпочесть использование секьюрного ГСЧ.

Попробуем в работе:

$ python3 crypter.pl bla
And I think to myself: What a wonderful World!
2901441 6c4fc41c7a6e85cae4354b7a1b7bef8992f2759426a96d415aeb1037865ae24a77a01baf60f5590f2899060c55fc
О а как Ушаков лил во кашу Какао!
9584538 2b9b51ce8f124389bdbef9931ad8047662de986d782b2d78f079df3b6e213e62fade8c851c87c1547e9b836195db89adc8e3e2b04145c57ceb97
i'm a test
2284302 b6a06226933a394ff043

Пожалуй, визуально проверить мы можем только что печатается рандомная "соль" и после неё в шестнадцатеричном виде зашифрованная строчка. В качестве критерия истины нужен код для расшифровки. Хотя выше сказано что расшифровка аналогична шифрованию, этап с получением соли (на этот раз из строки а не рандомной) делает этот код немножко отличающимся:

# decrypts standard input to standard output
# pass-phrase in the first command-line argument

import sys
import hashlib

pass_phrase = sys.argv[1]

for line in sys.stdin:
    salt, cipher = line.rstrip('\n').split(' ')
    key = hashlib.sha512((salt + pass_phrase).encode('utf-8')).digest()
    data = bytes.fromhex(cipher)
    res = b''
    for i in range(len(data)):
        res += (data[i] ^ key[i % len(key)]).to_bytes(1)
    print(res.decode('utf-8'))

Предоставляем читателям проверить работоспособность (смело жалуйтесь на ошибки-опечатки и предллагайте возможные улучшения).

Заключение

Ещё одно "золотое правило криптографии" гласит: не изобретайте шифрование самостоятельно!

В общем-то это резонно - получив такую "самодельную" шифровалку мы можем задаться вопросом, а насколько она надёжна. Уместно хотя бы прикинуть какие способы докопаться до содержимого возможны в нашем подходе.

  1. Определить пароль, имея зашифрованные строки и соль к ним - непосредственно "ревертнуть" хэш-функцию затруднительно т.к. это одностороннее преобразование; тем не менее если пароль слишком короткий, то его возможно подобрать брут-форсом или по словарю. Похоже на задачу взлома таблицы с хэшами паролей в БД - но дело осложняется тем что у нас нет непосредственно результата работы самой хэш-функции - а только XOR этого результата с неизвестным сообщением. Тем не менее можно нечаянно упростить жизнь взломщику если сообщения имеют какие-нибудь особенности формата - например большинство из них содержат точку в конце, или это вообще json-строчки.

  2. Другой "вектор атаки" - строки с совпадающей солью - если удастся их найти, то очевидно они и зашифрованы одинаковым ключом. Применив XOR на них мы исключим этот самый ключ, получив два XOR-ированных нешифрованных сообщения. Дальнейший частотный/лексикографический анализ будет тем проще чем больше таких сообщений и чем они длиннее - в целом этот подход изучен и описан довольно хорошо.

  3. В случае если удастся расшифровать пару сообщений способом из пункта 2, окажется известен и сгенерированный (для данной соли) ключ - что упростит задачу описанную в пункте 1.

  4. Другая возможность аналогичной атаки - строки которые оказались длиннее чем ключ. В нашем варианте он попросту переиспользуется - и соответственно применимы все те же соображения что в пункте 3.

Какие меры можно предпринять для предотвращения этих, довольно явных, проблем?

  • по пункту 1 - использовать не слишком тривиальный пароль, использовать медленные хэш-функции (в духе bcrypt);

  • по пункту 2 - использовать заведомо длинную соль (вроде uuid) либо соль на основе таймстемпа (всё же позаботившись чтобы она не оказалась одинаковой для нескольких строк подряд);

  • по пункту 4 - очевидно, когда ключ шифрования "заканчивается" желательно не переиспользовать его (по принципу Виженера) а сформировать новый на его основе - например, повторно применив хэш-функцию.

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