Делаем отказоустойчивое файловое хранилище поверх JPEG-файлов

от автора

TL;DR: Идея в том, чтобы использовать набор обычных JPEG-файлов в качестве распределённого носителя для зашифрованного контейнера. Данные разбиваются на избыточные фрагменты и распределяются между изображениями, благодаря чему контейнер может быть восстановлен даже после потери части JPEG-файлов. Мы не будем разбирать код, рассмотрим только общий принцип работы.

Откуда появилась идея

Большинство стеганографических систем работают по схеме “одно изображение — один файл”. Если изображение потеряно или повреждено, встроенные данные теряются вместе с ним. Возникла идея: распределить контейнер с данными между множеством JPEG-файлов и при этом сохранить возможность восстановления данных после потери части этих файлов.

Сразу отмечу, что это не стеганография в классическом понимании. Дополнительные данные в JPEG-файлы мы будем записывать после маркера EOI (End Of Image), где они легко обнаруживаются при простейшем анализе. Суть не в том, чтобы полностью скрыть наличие данных, а в том, чтобы использовать обычные JPEG-файлы как распределённый носитель для зашифрованного контейнера.

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

Почему JPEG

Во-первых, JPEG — самый распространённый пользовательский формат. Фотографии в этом формате естественным образом накапливаются на компьютерах и телефонах, пересылаются между людьми.

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

     JPEG      JPEG    Дополнительные     данные    EOI     данные      │         │       │...░░░░░░░░░░░░░▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒

На первый взгляд решение выглядит предельно простым: достаточно разделить зашифрованный контейнер на фрагменты и записать по одному фрагменту в конец каждого JPEG-файла.

Однако сразу возникает несколько вопросов. Как понять, какие JPEG-файлы относятся к одному контейнеру? Как определить порядок фрагментов при сборке? Где хранить ключи шифрования? Как сделать так, чтобы потеря любого отдельного JPEG-файла не делала контейнер невосстановимым? И как сделать весь JPEG-хвост неотличимым от случайного набора байтов?

По ту сторону EOI

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

Во-первых, для шифрования контейнера и метаданных не должен использоваться пользовательский пароль. Их следует шифровать случайно сгенерированным мастер-ключом, а пользовательским паролем — сам мастер-ключ.

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

В-третьих, каждый JPEG-файл должен содержать так же и собственный блок метаданных. Они позволят определить, какие JPEG-файлы относятся к одному контейнеру, какой фрагмент хранится в каждом файле и в каком порядке необходимо выполнять сборку.

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

Таким образом, при создании контейнера сначала запрашиваем пользовательский пароль и генерируем случайный мастер-ключ. Контейнер и его метаданные шифруем мастер-ключом, а сам мастер-ключ — пользовательским паролем. После маркера EOI последовательно записываем блок ключей шифрования, блок метаданных и фрагмент зашифрованного контейнера:

     JPEG      JPEG    Ключи         Метаданные    Фрагмент     данные    EOI     шифрования    фрагмента     контейнера      │         │       │             │             │...░░░░░░░░░░░░░▓░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░

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

Ключи шифрования

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

Если просто зашифровать мастер-ключ во всех JPEG-файлах одним и тем же паролем, блоки ключей окажутся одинаковыми. Поэтому используем AEAD-шифрование со случайными salt и nonce, сгенерированными отдельно для каждого JPEG-файла. Благодаря этому каждый блок ключей шифрования получает разное зашифрованное представление.

Будем использовать 32-байтный мастер-ключ. После AEAD-шифрования его размер увеличится до 48 байт за счёт 16-байтного тега аутентификации. Вместе с salt и nonce общий размер блока ключей составит 76 байт:

salt                  16 байтkey_nonce             12 байтencrypted_master_key  48 байт-----------------------------                      76 байт

Мастер-ключ даёт ещё одно преимущество: для смены пользовательского пароля достаточно заново зашифровать мастер-ключ новым паролем и обновить блок ключей шифрования в каждом JPEG-файле. Перешифровывать весь контейнер при этом не потребуется.

Метаданные

После восстановления мастер-ключа необходимо знать, какие JPEG-файлы относятся к контейнеру, какой фрагмент хранится в каждом файле и какой порог восстановления использовался при разбиении контейнера на фрагменты:

container_uuid        16 байт  - идентификатор контейнера (uuid)container_generation   4 байта - поколение контейнера (uint32)container_threshold    2 байта - порог восстановления для сборкиshard_index            2 байта - порядковый номер текущего фрагментаshard_total            2 байта - общее число фрагментов в контейнере------------------------------                      26 байт

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

Таким образом суммарный размер метаданных в открытом виде составляет всего 26 байт. Как и в случае с блоком ключей, используем AEAD-шифрование. Для каждого JPEG-файла генерируем собственный случайный nonce, благодаря чему даже одинаковые метаданные дают разное зашифрованное представление.

После шифрования к 26 байтам метаданных добавляется 16-байтный тег аутентификации, поэтому размер зашифрованных метаданных увеличивается до 42 байт. Вместе с nonce общий размер блока составит 54 байта:

metadata_nonce      12 байтencrypted_metadata  42 байта----------------------------                    54 байта

Главное — хвост!

Мы разобрали формат служебных данных. Осталось решить, как будут храниться сами пользовательские файлы. После этого структура JPEG-хвоста будет полностью определена.

Алгоритмы фрагментирования работают с единым потоком байтов, а не с набором отдельных файлов. Поэтому перед шифрованием пользовательские файлы (те, которые будут внутри контейнера) необходимо объединить в единый бинарный объект. При этом должна иметься возможность получить список хранимых файлов без извлечения их содержимого. Тут возможны несколько вариантов:

  1. Собственный формат бинарного объекта и файл манифеста. Даст полный контроль над структурой данных, но потребует ручной реализации.

  2. TAR-архив. Упаковывает файлы в единый бинарный объект, но для быстрого получения информации о содержимом потребуется иметь отдельный файл манифеста.

  3. ZIP-архив без сжатия. Упаковывает файлы в единый бинарный объект и одновременно хранит всю необходимую информацию о содержимом. Этот вариант и будем использовать.

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

         Ключи шифрования                Метаданные      Фрагмент контейнера░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓└─────────┘└───────┘└─────────────┘└───────┘└───────────┘└─────────────────┘  16 байт   12 байт     48 байт     12 байт   54 байта         N байт  random    random     encrypted    random    encrypted       encrypted  salt      nonce      master key   nonce     metadata        data shard

Фрагмент контейнера занимает всё оставшееся пространство до конца файла — его размер не фиксирован и зависит от размера зашифрованного контейнера, числа JPEG-файлов и выбранного порога восстановления.

Избыточность и восстановление

Дальше схема работы напоминает RAID-массив: фрагменты контейнера (вместе с ключами и метаданными) распределяются между несколькими независимыми JPEG-файлами с заданным порогом восстановления. Таким образом контейнер можно восстановить по любому набору JPEG-файлов текущего поколения, если количество валидных фрагментов не ниже порога:

                      Container                      Shards: 5┌──────────────────────────────────────────────────┐▒▒▒▒▒▒▓▓   ▒▒▒▒▒▒▓▓   ▒▒▒▒▒▒▓▓   ▒▒▒▒▒▒▓▓   ▒▒▒▒▒▒▓▓  JPEG       JPEG       JPEG       JPEG       JPEG└────────────────────────────┘   └─────────────────┘        Threshold: 3/3              Redundancy: 2

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

Для реализации на Python отлично подходит библиотека zfec, которая предоставляет механизм erasure coding — практическую реализацию алгоритма Рида—Соломона.

Инициализация контейнера

Итак, формат хранения полностью определён, переходим к инициализации контейнера. Для начала работы потребуется набор JPEG-файлов, пользовательский пароль и порог восстановления.

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

Затем генерируем идентификатор контейнера и мастер-ключ, создаём в памяти пустой ZIP-архив, шифруем его и разбиваем на фрагменты с помощью erasure coding.

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

Запись выполняем в два этапа. Сначала для всех JPEG-файлов создаем временные копии, в которые добавляем JPEG-хвосты. Каждый временный файл записываем и синхронизируем с диском. Только после успешной подготовки всех временных файлов поочерёдно заменяем ими исходные файлы с использованием POSIX-семантики (атомарное rename/replace). После этого выполняем синхронизацию каталога.

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

По тому же принципу работает удаление контейнера из carrier-файлов текущего восстановимого поколения: сначала подготавливаются временные версии всех JPEG-файлов без JPEG-хвостов, после чего они атомарно заменяют исходные носители.

Модификация содержимого

После создания контейнера работа с файлами внутри него сводится к обновлению внутреннего ZIP-архива. Все промежуточные операции выполняются в памяти — запись на диск выполняется только после формирования новой версии контейнера.

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

Каждая запись создаёт новое поколение контейнера (значение container_generation в метаданных увеличивается на единицу). Это позволит отличить актуальную версию контейнера от предыдущих в случае аварийного завершения операции и восстановить консистентность, если это возможно.

Неконсистентное состояние и восстановление избыточности

Представим, что контейнер распределён между четырьмя JPEG-файлами, а порог восстановления установлен в два фрагмента:

               Generation 1               Shards: 4┌───────────────────────────────────────┐░░░░░░▓▓   ░░░░░░▓▓   ░░░░░░▓▓   ░░░░░░▓▓  JPEG       JPEG       JPEG       JPEG└─────────────────┘  Threshold: 2/2

Предположим, что операция модификации была аварийно завершена после перезаписи второго фрагмента. Тогда после сбоя среди доступных JPEG-файлов будут находиться одновременно два поколения контейнера:

    Generation 1          Generation 2    Shards: 2             Shards: 2┌─────────────────┐   ┌─────────────────┐░░░░░░▓▓   ░░░░░░▓▓   ▒▒▒▒▒▒▓▓   ▒▒▒▒▒▒▓▓  JPEG       JPEG       JPEG       JPEG└─────────────────┘   └─────────────────┘  Threshold: 2/2        Threshold: 2/2

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

Рассмотрим менее удачный сценарий. Допустим, что операция модификации была так же аварийно завершена после перезаписи второго фрагмента, но при этом один из фрагментов второго поколения был утрачен по внешним причинам (например, JPEG-файл был случайно перезаписан в графическом редакторе):

    Generation 1          Generation 2    Shards: 2             Shards: 1┌─────────────────┐   ┌─────────────────┐░░░░░░▓▓   ░░░░░░▓▓   ▒▒▒▒▒▒▓▓   ▒▒▒▒▒▒▒▒  JPEG      JPEG        JPEG       JPEG└─────────────────┘   └─────────────────┘  Threshold: 2/2        Threshold: 1/2

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

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

Поэтому порог восстановления следует выбирать с запасом. Чем он ниже, тем больше потерь сможет пережить контейнер, но тем больше избыточных данных придётся хранить. Это всегда компромисс между отказоустойчивостью и объёмом избыточности (я обычно устанавливаю порог чуть ниже половины общего количества JPEG-файлов).

Проблемы и ограничения

Несмотря на работоспособность описанного подхода, он имеет ряд важных ограничений.

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

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

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

Конец

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

Реализацию я постарался сделать максимально простой. Из внешних зависимостей проект использует всего две библиотеки: cryptography и zfec. Желающие могут посмотреть код или попробовать проект самостоятельно:

GitHub: github.com/artabramov/jpegfs
PyPI: pypi.org/project/jpegfs

ссылка на оригинал статьи https://habr.com/ru/articles/1054284/