Как я делаю защищенное файловое хранилище — 4: Пришествие gocryptfs

Часть 1: Рождение идеи
Часть 2: Ограничения и возможности
Часть 3: PostgreSQL против MongoDB
Часть 4: Пришествие gocryptfs (вы здесь)
1. Новый Год
Ближе к зиме я переехал в новое жилье. Поскольку все мои вещи состояли из двух ноутбуков, стопки одежды и кофемолки, переезд занял ровно в одну поездку на такси.
Зимней одежды у меня не было, и я отправился в большой магазин. В процессе выбора решил спросить у какой-то покупательницы, какие обычно бывают зимы в Армении. Покупательница оказалась гостьей из Ливана и тоже не знала, какими обычно бывают зимы в Армении.
На всякий случай я купил длинный пуховик. Как выяснилось позже, можно было не покупать ничего — хватило бы ветровки и худи.
За первую зиму я видел снег буквально несколько раз. Обычно он выпадал под утро и к обеду от него уже не оставалось и следа.
Лишь однажды снег пролежал чуть дольше. Какие-то русские мальчик с девочкой успели слепить в парке снеговика и попросили меня сфотографировать их втроем. Медлить было нельзя — снеговик мог растаять в любой момент.
На следующий день от него осталась только морковка.
Наступил мой первый Новый Год в Армении. Для начала я не был уверен, по какому часовому поясу его правильнее встречать. Если по домашнему, то это будет московское время. Если по местному — ереванское. Если по рабочему — горное. Хотя последнее было бы уже совсем странно.
В итоге я вообще не смотрел на часы. Сходил за зеленым горошком, отварил яйца и занялся оливье. Когда оливье был готов, я открыл шампанское и загадал желание. У меня была гирлянда, запах мандаринов и созвон по зуму. Где-то на улице изредка хлопали фейерверки.
2. Там, где заканчивается база данных
Пришло время работать над той частью приложения, которая отвечает за хранение и шифрование файлов. По сути, она и должна быть сердцем всего приложения. Процесс обещал быть очень интересным (так и оказалось, только не в том смысле, в котором предполагал я).
Было понятно, что база данных не должна знать о содержимом файлов. Ей достаточно пути к файлу и его атрибутов. При этом файлы не должны знать о наличии базы данных. Они просто должны где-то находиться.
Оставалось решить, где и как будут храниться сами файлы. Тут я поступил довольно самонадеянно. Вместо того, чтобы заранее оценить пространство возможных решений и выбрать наиболее подходящее, я добавил в проект cryptography и стал писать собственный модуль шифрования. Предположение заключалось в том, что собственная система шифрования на уровне приложения, полностью адаптированая под его нужды, будет оптимальным решением. Казалось бы: что может пойти не так?
А не так пошло примерно всё. Собственный криптографический слой на уровне приложения открыл квестовую цепочку из двух серьезных проблем, на решение которых ушло довольно много времени. Каждый новый шаг тянул за собой новые исправления, новые проверки и бесконечное тестирование. Борьба с этими проблемами описана тут:
Как я решал задачу сортировки зашифрованных строк
Как я при помощи фрагментации решал одни проблемы и создавал другие
Если кратко, то выбранный подход был плох. Схема, в которой зашифрованные файлы хранились отдельно, а в открытой базе данных шифровались чувствительные поля, оказалась архитектурным тупиком. Не то, чтобы этот подход нельзя было использовать вовсе. Скорее, он был хрупок и без необходимости раскрывал часть метаданных.
Мне не нужен был собственный слой шифрования на уровне приложения. Мне нужен был слой шифрования уровнем ниже. И файлы, и база данных должны были находиться внутри этого общего слоя.
То есть нужна была файловая система с прозрачным шифрованием.
3. Все дороги ведут к FUSE
Пришлось вернуться на пару шагов назад. Требовался сначала слой шифрования, а уже потом — приложение и все остальное.
BitLocker и LUKS не подошли сразу. Еще в самом начале я решил, что установка приложения в контейнере не должна требовать каких-либо манипуляций на хосте. Ослаблять это требование я не собирался. Однако и BitLocker, и LUKS работают на уровне блочного устройства и шифруют целый том. А поскольку контейнер лишь использует уже смонтированную файловую систему хоста, то создать отдельный зашифрованный каталог для приложения таким способом нельзя.
Кроме того, приложение должно уметь самостоятельно монтировать файловую систему и управлять ее жизненным циклом. При этом файловая система должна выглядеть как обычный каталог. Стало понятно, что мой путь — FUSE (Filesystem in Userspace). Он как раз и позволяет реализовать файловую систему как обычное пользовательское приложение, не трогая модули ядра.
Если кратко, то в основе работы FUSE лежит простая идея. Вместо того, чтобы обращаться к файловой системе напрямую, ядро передает файловые операции пользовательскому процессу, который реализует собственную логику работы с данными. Получив запрос, процесс обрабатывает его, при необходимости обращаясь к своему хранилищу, и возвращает результат обратно.
Другими словами, FUSE позволяет представить практически любое хранилище данных в виде обычной файловой системы. А приложения работают с файлами через привычный интерфейс, не зная, как именно данные хранятся и обрабатываются внутри:
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Standard │ > │ FUSE │ > │ FUSE │
│ program │ < │ module │ < │ filesystem │
└────────────┘ └────────────┘ └────────────┘
Userspace Kernel space Userspace
На базе FUSE работают клиенты облачных хранилищ, системы резервного копирования, средства синхронизации, криптографические файловые системы и прочее.
Получается, что внутри контейнера с приложением должен создаваться зашифрованный FUSE-слой, который будет использоваться для хранения и базы данных, и файлов, а приложение должно им управлять.
При этом первоначальная схема не менялась: в двух внешних томах могли храниться ключи шифрования и зашифрованные данные. А расшифрованное представление данных оставалось бы только внутри контейнера и только на время, пока приложение работает с открытым хранилищем.
Предварительно выглядело хорошо.
4. Выбор файловой системы
Нужно было выбрать конкретную реализацию, которая добавит слой шифрования поверх FUSE. Текущие варианты: EncFS, CryFS, gocryptfs. Все они предоставляют прозрачное шифрование, но отличаются архитектурой, производительностью и подходом к хранению данных.
EncFS — один из первых проектов в этой области. К сожалению, сегодня он почти не развивается и считается устаревшим решением.
CryFS делает акцент на защите метаданных за счет фрагментации. Помимо шифрования содержимого самих файлов, он скрывает структуру каталогов и другую служебную информацию. Платой за это становятся более сложная организация хранения и снижение производительности.
gocryptfs предлагает баланс между безопасностью, производительностью и простотой использования. Он обеспечивает высокую производительность и надежность, но общая структура файловой системы остается видимой. То есть содержимое файлов, имена каталогов и имена файлов зашифрованы, но сам факт их существования не скрыт.
В качестве альтернативы можно было написать свой FUSE-слой шифрования на питоне. Он мог бы работать напрямую из кода приложения, минуя консольные вызовы. Но он точно проигрывал бы по производительности любому готовому решению в разы (а то и в десятки раз). В любом случае, идея интересная. Возможно позже есть смысл попробовать, чтобы сравнить.
В итоге, взвесив все за и против, я остановился на gocryptfs.
5. Рисуем архитектуру заново
Теперь можно было выкинуть почти все, что было сделано раньше, и начать работу заново. Я перерисовал первоначальную схему:
.
Container . External
internals . resources
.
┌───────────────────┐ . ┌───────────────────┐
│ Application │ . │ External │
│ core │───────│ interface │
│ (FastAPI) │ . │ (REST API) │
└───────────────────┘ . └───────────────────┘
│ .
┌───────────────────┐ . ┌───────────────────┐
│ gocryptfs │ . │ gocryptfs │
│ mountpoint │-------│ cipherdir │
│ (decrypted view) │ . │ (data volume) │
╞═══════════════════╡ . └───────────────────┘
│ gocryptfs │ .
│ FUSE layer │ .
│ (userspace) │ .
└───────────────────┘ .
│ .
┌───────────────────┐ . ┌───────────────────┐
│ Mount │ . │ gocryptfs │
│ supervisor │-------│ passphrase │
│ (watchdog) │ . │ (secrets volume) │
└───────────────────┘ . └───────────────────┘
.
Получилось лаконично. Приложение полностью живет внутри контейнера, наружу выставляется только API. При этом рабочие данные вынесены в два отдельных тома: один содержит зашифрованное хранилище с базой данных и файлами, второй — ключи шифрования. Контейнер с приложением можно полностью удалить и пересоздать, а тома подключить заново. Данные не потеряются и не поломаются.
При открытом хранилище начинает действовать система аутентификации, которая ограничивает права доступа в соответствии с ролями пользователей. То есть владелец хранилища с ключом и паролем — отдельно; пользователи со своими учетными данными — отдельно.
Ни ключ шифрования, ни пароль от него при работе не попадают на диск в открытом виде. Корректность того и другого проверяется попыткой расшифровки и монтирования. В момент открытия/закрытия хранилища расшифрованный ключ на короткое время оказывается в памяти процесса, после чего больше приложению не требуется.
Закрыть хранилище можно аналогичным образом — с помощью пароля. После проверки происходит отмонтирование, открытое отображение исчезает, а данные через API становятся недоступны.
Понятное дело, что при открытом хранилище исходные данные доступны внутри контейнера. То есть при наличии доступа к хосту можно зайти внутрь контейнера и найти точку монтирования. Но! Во-первых, так работает FUSE и в моей схеме это осмысленный архитектурный компромисс. А во-вторых, для этого нужен доступ к самому хосту. И все равно, несмотря на все эти оговорки, об этой особенности нужно будет помнить всегда.
Схема работы наконец-то сложилась в цельную картину. Но прежде чем двигаться дальше, мне хотелось понять, не упускаю ли я чего-то принципиального. Поэтому я нашел на LinkedIn одного из контрибьюторов gocryptfs и попросил его буквально в двух словах дать свою оценку. Глубоко вникать в мой проект он, конечно, не стал, но указал на две проблемы, с которыми сам столкнулся при работе с gocryptfs:
I believe the limitations were around the default number of file descriptors on Linux, which we had to increase (not gocryptfs fault, but caused silent failures), and another was lack of fACL support, had to stop using fACLs in encrypted folders.
Первая проблема касалась стандартного ограничения Linux на количество открытых файловых дескрипторов: при высокой нагрузке лимит приходилось увеличивать, причем его исчерпание приводило к неочевидным сбоям. Впрочем, я не сомневался, что реальные узкие места в производительности проявятся значительно раньше, чем приложение приблизится к этому пределу.
Вторая проблема заключалась в отсутствии поддержки файловых ACL. Получалось, что собственная система разграничения доступа в приложении должна естественным образом дополнить отсутствие fACL в gocryptfs.
Мне все нравилось. Можно было двигаться дальше.
6. Конец четвертой части
Дальше предстояло проверить работоспособность приложения при использовании FUSE-слоя и внести серьезные архитектурные изменения.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.