Как хранить зашифрованный файл в репозитории Git или другой системы контроля версий? Если изменение одной строчки содержимого будет (из-за шифрования) менять весь файл целиком или значительную его часть - это портит весь подход системы контроля версий - хранение изменений "фрагментами". Значит хорошо бы шифровать "куски" - например, строки - по-отдельности, независимо.
Здесь описывается простенький рецепт для этого подхода - в духе "соль + пароль и от них взять хэш в качестве ключа". Большая же часть текста имеет несколько "дидактический" характер - обсуждение мотивации для такой задачи, границ применимости - а также напоминания и пояснения базовых соображений по криптографии - поэтому заранее приносим извинения тем читателям для кого эта информация тривиальна.
Как вообще хранят конфиденциальную информацию?
Нередко кроме кода, который может быть достаточно публичным (например, проект видно всем сотрудникам компании - или он вообще опенсорсный) нужно хранить какие-то секреты.
В простейшем случае, например, ключи-пароли, требующиеся для деплоя в разные энвы.
С этим всё понятно - их можно или запомнить (и хранить только в голове - хотя это несовместимо с CI) - или использовать какое-то отдельное хранилище - начиная с тайного google-документа или стикера на мониторе, до специальных хранилищ секретов типа vault - так что можно гранулировано настраивать доступ и т.п.
Другой случай, если нужно хранить какие-то достаточно объёмные данные (от десятков килобайт), которые, быть может, не составляют великой тайны, но и светить их кому попало не следует.
Можно в качестве аналогии вспомнить типичное разделение "уровней секретности" в госучреждениях и т.п.: есть гриф "Секретно", есть "Совершенно Секретно", есть "Особой Важности" - но в то же время есть и "Для Служебного Пользования". Этот последний как раз и означает "не то чтобы секретно, но разбрасывать не следует".
Например это данные "приближенные к реальным" (или даже с прода и взятые) используемые в тестах. Может быть транзакции продаж (допустим, за неделю). А может быть описание игрового мира (допустим, одна из зон большой карты).
Тут возникает дилемма о нескольких гранях:
уровень секретности сравнимый с хранением приватных ключей кажется неразумным
но чтобы кто-попало нос совал - тоже не нужно :)
да ещё файл вероятно будет многократно обновляться и нужно хранить ревизии.
Хороший вариант - хранить такой файл в отдельном репозитории с особым доступом. Но отдельная репа - это отдельная репа. Это может быть неудобно по многим причинам - одна из них, необходимость синхронизировать ревизии и т.п. В каких-то ситуациях (обычно, корпоративных) и получение / настройка доступов к отдельному репозиторию могут быть затруднены.
Вот в качестве альтернативы - подумаем о том, как удобнее хранить наш "секретный файл" в общей репе. Но напоследок напомним важное правило.
Золотое Правило Криптографии
Ну ладно, наверняка "Золотых Правил" можно нагуглить с десяток, поэтому рассматривайте название с долей юмора. Но не само правило!
Сложность криптозащиты выбирается так, чтобы трудозатраты на взлом были больше чем ценность защищённой информации.
Этим мы и руководствуемся когда решаем, подходит ли нам тот или иной способ ширования с такими или сякими предосторожностями. В частности в параграфе выше - туманное требование "чтобы кто попало не совал нос" имеет смысл как-то оценить в деньгах.
Условно: если мы понимаем что наш "секретный файл" содержит персональные данные 1000 пользователей и потенциально каждый из них может обидеться и подать иск на 1000 долларов - то кто-то может решить потратить "человекомесяц" (условно, несколько тысяч долларов) чтобы попробовать его взломать. В этом случае наш рецепт не рекомендуется к использованию (по крайней мере без дополнительных оценок).
Если же это небольшая зона онлайн игры, да еще и более-менее исхоженная многими пользователями, "новичковая" - то уровень защиты выглядит адекватно.
Базовые Воспоминания о Методах Шифрования
Ещё в школе мы вероятно сталкиваемся с задачами на "одноподстановочный" шифр - каждая буква заменяется какой-то другой буквой или символом - но этот "мэппинг" фиксированный. Рассказы о взломе таких шифров (Золотой Жук, Пляшущие Человечки) - общеизвестны. Из реальных примеров - Мария Стюарт, которой за подобную наивность отрубили голову.

Повышение секретности достигается тем, что используются разные "таблицы подстановки" для каждой следующей буквы. Среди простейших идей - Шифр Виженера, в котором используется несколько "шифров цезаря" с разным сдвигом - и эти сдвиги запоминаются, например, кодовым словом.
Конечно, при достаточно длинном тексте, и эта защита легко взламывается аналогичным подходом - нужно только попробовать несколько разных длинн ключа и применить частотный анализ.
Следующий уровень - использовать какую-то непериодическую последовательность (или по крайней мере с довольно длинным периодом). Очевидно эту последовательность надо как-то генерировать с помощью более короткого ключа.
Например, ключ может быть использован для инициализации псевдо-случайной последовательности с какими-то известными параметрами.
Более надёжно (абсолютно надёжно) - использовать в качестве ключа полноценную случайную последовательность. Всевозможные истории, особенно военные, в которых упоминаются "шифровальные блокноты" - обычно относятся к этому способу. Фактически, мы используем ключ заведомо длиннее чем шифруемый текст - и используем только один раз. Очевидных недостатков тут примерно два:
обе стороны должны иметь под рукой этот самый ключ (набор ключей если придётся шифровать больше одного сообщения) - например в виде того самого блокнота
эти ключи надо уметь надёжно генерировать (используя какой-то "тру-рэндом")
Известный случай взлома такой "абсолютной" защиты связан с тем что ключ был переиспользован (то ли страницы блокнотов продублировали то ли еще что) - эта история известна под названием Venona Project.
Зачем мы всё это вспоминаем?
Описанный далее подход (как и многие способы шифрования) использует эти же принципы:
для шифрования используется "псевдо-случайная" последовательность (каким-то образом сгенерённая по ключу)
последовательность конечная (в духе шифра Виженера) но её "безопасную" длину можно подобрать исходя из характера шифруемых данных
нужно позаботиться чтобы разные строки шифровались разными последовательностями (несмотря на использование одного ключа)
Рецепт Построчного Шифрования
Каждая строка шифруется с помощью "длинного ключа", более-менее уникального для каждой из строк. Например операцией побайтового XOR, так что и расшифровка происходит аналогично.
"Длинный ключ" для каждой строки должен генерироваться с помощью некоего пароля - сравнительно короткой фразы, которую легко запомнить, продиктовать - а не хранить в отдельном хранилище секретов.
Пароль, очевидно, для всех строк будет одинаковый. Чтобы сгенерировать из него различающиеся "длинные ключи" мы будем добавлять к паролю "соль" - несколько рандомных символов или байт.
"Длинный ключ" генерируем из пары "соль+пароль" с помощью подходящей хэш-функции (соль ведь именно для хэш-функции и применяется).
Соль записывается вместе с зашифрованной строкой (больше её взять в общем-то неоткуда).
Запишем это в виде коротенького скрипта на Питоне:
# 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'))
Предоставляем читателям проверить работоспособность (смело жалуйтесь на ошибки-опечатки и предллагайте возможные улучшения).
Заключение
Ещё одно "золотое правило криптографии" гласит: не изобретайте шифрование самостоятельно!
В общем-то это резонно - получив такую "самодельную" шифровалку мы можем задаться вопросом, а насколько она надёжна. Уместно хотя бы прикинуть какие способы докопаться до содержимого возможны в нашем подходе.
Определить пароль, имея зашифрованные строки и соль к ним - непосредственно "ревертнуть" хэш-функцию затруднительно т.к. это одностороннее преобразование; тем не менее если пароль слишком короткий, то его возможно подобрать брут-форсом или по словарю. Похоже на задачу взлома таблицы с хэшами паролей в БД - но дело осложняется тем что у нас нет непосредственно результата работы самой хэш-функции - а только XOR этого результата с неизвестным сообщением. Тем не менее можно нечаянно упростить жизнь взломщику если сообщения имеют какие-нибудь особенности формата - например большинство из них содержат точку в конце, или это вообще
json-строчки.Другой "вектор атаки" - строки с совпадающей солью - если удастся их найти, то очевидно они и зашифрованы одинаковым ключом. Применив XOR на них мы исключим этот самый ключ, получив два XOR-ированных нешифрованных сообщения. Дальнейший частотный/лексикографический анализ будет тем проще чем больше таких сообщений и чем они длиннее - в целом этот подход изучен и описан довольно хорошо.
В случае если удастся расшифровать пару сообщений способом из пункта 2, окажется известен и сгенерированный (для данной соли) ключ - что упростит задачу описанную в пункте 1.
Другая возможность аналогичной атаки - строки которые оказались длиннее чем ключ. В нашем варианте он попросту переиспользуется - и соответственно применимы все те же соображения что в пункте 3.
Какие меры можно предпринять для предотвращения этих, довольно явных, проблем?
по пункту 1 - использовать не слишком тривиальный пароль, использовать медленные хэш-функции (в духе bcrypt);
по пункту 2 - использовать заведомо длинную соль (вроде uuid) либо соль на основе таймстемпа (всё же позаботившись чтобы она не оказалась одинаковой для нескольких строк подряд);
по пункту 4 - очевидно, когда ключ шифрования "заканчивается" желательно не переиспользовать его (по принципу Виженера) а сформировать новый на его основе - например, повторно применив хэш-функцию.