Исходные данные
Дано:- конвейер CI/CD, реализованный, к примеру, в GitLab. Для корректной работы ему требуются, как это очень часто бывает, некие секреты - API-токены, пары логи/пароль, приватные SSH-ключи - да всё, о чём только можно подумать;
- работает этот сборочный конвейер, как это тоже часто бывает, на базе контейнеров. Соответственно, чем меньше по размеру образы - тем лучше, чем меньше в них всякой всячины - тем лучше.
- занимает минимум места;
- умеет расшифровывать секреты, зашифрованные ansible-vault;
- не требует никаких внешних зависимостей;
- умеет читать ключ из файла.
На всякий случай сразу напоминаю, что по действующему законодательству разработка средств криптографической защиты информации в РФ - лицензируемая деятельность. Иначе говоря, без наличия лицензии вы не можете просто так взять и продавать получившееся решение.
По поводу допустимости полных текстов расшифровщиков в статьях вроде этой - надеюсь, что компетентные в этом вопросе читатели смогут внести свои уточнения в комментариях.
Начнём сначала
Итак, предположим, что у нас на Linux-хосте с CentOS 7 уже установлен Ansible, к примеру, версии 2.9 для Python версии 3.6. Установлен, конечно же, с помощью virtualenv в каталог "/opt/ansible". Дальше для целей удовлетворения чистого научного любопытства возьмём какой-нибудь YaML-файл, и зашифруем его с помощью утилиты ansible-vault:ansible-vault encrypt vaulted.yml --vault-password-file=.password
Этот вызов, как можно догадаться, зашифрует файл vaulted.yml с помощью пароля, который хранится в файле .password.
Итак, что получается после зашифровывания файла с помощью утилиты ansible-vault? На первый взгляд - белиберда какая-то, поэтому спрячу её под спойлер:
Содержимое файла vaulted.yml
Ну а как именно эта белиберда работает "под капотом" - давайте разбираться.
Открываем файл /opt/ansible/lib/python3.6/site-packages/ansible/parsing/vault/__init__.py, и в коде метода encrypt класса VaultLib видим следующий вызов:
VaultLib.encrypt
То есть результирующее содержимое нашего файла будет создано в результате вызова метода encrypt некоторого класса. Какого именно - в общем-то, невелика загадка, ниже по файлу есть всего один класс с именем VaultAES256.
Смотрим в его метод encrypt:
VaultAES256.encrypt
То есть перво-наперво генерируется "соль" длиной 32 байта. Затем из побайтного представления пароля и "соли" вызовом _gen_key_initctr генерируется пара ключей (b_key1, b_key2) и вектор инициализации (b_iv).
Генерация ключей
Что же происходит в _gen_key_initctr?_gen_key_initctr:
Если по сути, то внутри этого метода вызов _create_key_cryptography на основе пароля, "соли", длины ключа и длины вектора инициализации генерирует некий производный ключ (строка 10 приведённого фрагмента). Далее этот производный ключ разбивается на части, и получаются те самые b_key1, b_key2 и b_iv.
Следуем по кроличьей норе дальше. Что внутри _create_key_cryptography?
_create_key_cryptography:
Ничего особенного. Если оставить в стороне всю мишуру, то в итоге вызывается функция библиотеки OpenSSL под названием PBKDF2HMAC с нужными параметрами. Можете, кстати, самолично в этом убедиться, открыв файл /opt/ansible/lib/python3.6/site-packages/cryptography/hazmat/backends/openssl/backend.py.
Кстати, длина производного ключа, как видите, специально выбирается таким образом, чтобы хватило и на b_key1, и на b_key2, и на b_iv.
Собственно шифрование
Движемся дальше. Здесь нас встречает вызов _encrypt_cryptography с параметрами в виде открытого текста, обоих ключей и вектора инициализации:_encrypt_cryptography
В принципе, тут нет ничего особенного: шифр инициализируется из вектора b_iv, затем первым ключом b_key1 шифруется исходный текст, а результат этого шифрования хэшируется с помощью второго ключа b_key2.
Полученные в итоге байты подписи и шифртекста преобразуются в строки своих шестнадцатеричных представлений через hexlify. (см. строка 14 фрагмента выше)
Окончательное оформление файла
Возвращаемся к строкам 16-20 фрагмента VaultAES256.encrypt: три строки, содержащие "соль", подпись и шифртекст, склеиваются вместе, после чего снова преобразуются в шестнадцатеричное представление (комментарий прямо подсказывает, что это - для обратной совместимости).Дальше дописывается заголовок (помните, тот самый - $ANSIBLE_VAULT;1.1;AES256), ну и, в общем-то, всё.
Обратный процесс
После того, как мы разобрались в прямом процессе, реализовать обратный будет не слишком сложно - по крайней мере, если выбрать правильный инструмент.Понятно, что Python нам не подходит, иначе можно было и огород не городить: ansible-vault одинаково хорошо работает в обе стороны. С другой стороны, никто не мешает на базе библиотек Ansible написать что-либо своё - в качестве разминки перед "подходом к снаряду" я так и сделал, и о результате напишу отдельную статью.
Тем не менее, для написания предмета статьи я воспользовался FreePascal. Ввиду того, что языковой холивар темой статьи не является, буду краток: выбрал этот язык, во-первых, потому что могу, а во-вторых - потому что получаемый бинарник удовлетворяет заданным требованиям.
Итак, нам понадобятся: FreePascal версии 3.0.4 (эта версия в виде готовых пакетов - самая свежая, нормально устанавливающаяся в CentOS 7), и библиотека DCPCrypt версии 2.1 (на GitHub). Интересно, что прямо вместе с компилятором (fpc) и обширным набором библиотек в rpm-пакете поставляется консольная среда разработки fp.
К сожалению, "искаропки" модули этой библиотеки не собираются компилятором fpc - в них нужны минимальные правки. С другой стороны, я предполагаю, что без этих правок предмет статьи перестаёт относиться к лицензируемым видам деятельности и начинает представлять чисто академический интерес - именно поэтому выкладываю статью без них.
Часть кода, относящуюся к генерированию производного ключа (реализацию той самой функции PBKDF2), я нашёл в интернете, и поместил в отдельный модуль под названием "kdf".
Вот этот модуль собственной персоной:
kdf.pas
Из бросающегося в глаза - обратите внимание, что в Pascal и его потомках отсутствует классическое разделение на заголовочные файлы и файлы собственно с кодом, в этом смысле модульная организация роднит его с Python, и отличает от C.
Однако от питонячьего модуля паскалевский отличается ещё и тем, что "снаружи" доступны только те функции/переменные, которые объявлены в секции interface. То есть по умолчанию внутри модуля ты можешь хоть "на ушах стоять" - снаружи никто не сможет вызвать твои внутренние API. Так устроен язык, а хорошо это или плохо - вопрос вкуса, поэтому оценки оставим в стороне (питонистам передают привет функции/методы, начинающиеся на "_" и "__").
Заголовочная часть
Код, как обычно, под спойлером.Заголовочная часть ("шапка", header)
Далее нам понадобится пара функций - hexlify и unhexlify (набросаны, конечно, "на скорую руку"). Они являются аналогами соответствующих функций Python - вторая возвращает строку из шестнадцатеричных представлений байтов входного аргумента, а первая - наоборот, переводит строку шестнадцатеричных кодов обратно в байты.
hexlify/unhexlify
Назначение функций showbanner(), showlicense() и showhelp() очевидно из названий, поэтому я просто приведу их без комментариев.
showbanner() / showlicense() / showhelp()
Дальше объявляем переменные и константы, которые будут использоваться в коде. Привожу их здесь только для полноты текста, потому что комментировать тут особо нечего.
Переменные и константы
Код
Ну, почти код - всё ещё вспомогательная функция, которая в рантайме готовит массив записей для разбора параметров командной строки. Почему она здесь - потому что работает с переменными, объявленными в секции vars выше.preparecliparams()
А вот теперь точно код самой утилиты:
Весь остальной код
Дальше будет странная таблица, но, кажется, это - самый удобный способ рассказа об исходном коде.
Стр. | Назначение | |
2-13 | разбор параметров командной строки с отображением нужных сообщений; | |
14-34 | проверка наличия пароля в параметрах, при отсутствии - попытка прочесть пароль из файла, при невозможности - останавливаем работу; | |
35-44 | попытка прочесть зашифрованный файл, указанный в параметрах; | Небольшой чит: по умолчанию имя файла (переменная secretfile) равно пустой строке; в этом случае вызов Assign(F, secretfile) в строке 36 свяжет переменную F с stdin |
45-50 | проверка наличия в файле того самого заголовка $ANSIBLE_VAULT;1.1;AES256; | |
51-57 | читаем всё содержимое зашифрованного файла и закрываем его; | |
58-63 | разбираем файл на части: "соль", дайджест, шифртекст - всё отдельно; при этом все три части нужно будет ещё раз прогнать через unhexlify (помните примечание в VaultAES256.encrypt?) | |
64-73 | вычисление производного ключевого материала; разбиение его на части; расчёт дайджеста; проверка зашифрованного файла на корректность дайждеста; | |
74-83 | подготовка буфера для расшифрованного текста; расшифровка; затирание ключей в памяти случайными данными; вывод расшифрованного содержимого в поток stdout |
Собственно, теперь всё на месте, осталось собрать:
[root@ansible devault]# time fpc devault.pas -Fudcpcrypt_2.1:dcpcrypt_2.1/Ciphers:dcpcrypt_2.1/Hashes -MOBJFPC
Здесь имейте в виду, что форматирование Хабра играет злую шутку - никакого разрыва строки после первого минуса нет.
Вывод компилятора
Вроде неплохо: 3,8 тысячи строк кода собраны до исполняемого файла за 0.6 сек. На выходе - статически связанный бинарник, которому для работы от системы требуется только ядро. Ну то есть для запуска достаточно просто скопировать этот бинарник в файловую систему - и всё. Кстати, я забыл указать его размер: 875К. Никаких зависимостей, компиляций по несколько минут и т.д.
Ах да, чуть не забыл самое интересное! Запускаем, предварительно сложив пароль в файл ".password":
[root@ansible devault]# ./devault -w .password -f vaulted.yml
---
collections:
- name: community.general
scm: git
src: https://github.com/ansible-collections/community.general.git
version: 1.0.0
Вот такой нехитрый YaML я использовал в самом начале статьи для создания зашифрованного файла.
Источник статьи: https://habr.com/ru/post/554148/