Секрет и его обложка
SnapSeal шифрует исходные байты изображений, файлов или текста вместе с метаданными, а затем встраивает полученный контейнер в изображение-обложку. Чтобы прочитать защищённый файл, получателю нужны сам файл и пароль; исходная обложка или сервер не требуются.
Криптография обеспечивает конфиденциальность содержимого, а стеганография предоставляет носитель. Обе идеи давно известны; здесь важно разобраться, как они работают вместе. Иллюстрация и тестовый пароль в публичной демонстрации намеренно опубликованы.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Два младших бита
У пикселя RGB три 8-битных канала. В этом примере кодировщик читает каналы слева направо, пропускает альфа-канал, сохраняет шесть старших битов и заменяет два младших. Так четыре канала вмещают один байт.
Передвиньте ползунок, чтобы увидеть изменение выделенных битов. Сама по себе такая запись меняет значение канала максимум на 3; дополнительный JPEG-носитель и другие элементы оформления могут изменить обложку сильнее.
R₀10101011170 → 171 Δ +1G₀10110101181 → 181 Δ 0B₀0101110094 → 92 Δ -2R₁11001011203 → 203 Δ 0def embed_byte(channels: bytes, value: int) -> bytes:
if len(channels) != 4 or not 0 <= value <= 255:
raise ValueError("Expected four channels and one byte")
pairs = [(value >> shift) & 3 for shift in (6, 4, 2, 0)]
return bytes((c & 0xFC) | pair
for c, pair in zip(channels, pairs, strict=True))
def read_byte(channels: bytes) -> int:
value = 0
for channel in channels:
value = (value << 2) | (channel & 3)
return value
cover = bytes([170, 181, 94, 203])
stored = embed_byte(cover, 0xD3)
assert stored == bytes([171, 181, 92, 203])
assert read_byte(stored) == 0xD3Сколько байтов поместится
Два бита в каждом из трёх каналов дают шесть битов на пиксель. Обложка размером 1024×1024 вмещает 786 432 байта до учёта 16-байтового заголовка пиксельного пакета и 786 416 байтов после него. Оформление резервирует часть пикселей и уменьшает вместимость.
Фиксированный маркер обозначает структуру пакета, а поле длины — конец контейнера. Версии пакета и шифрования относятся к разным пространствам имён: пакет PNG2 может содержать криптографический контейнер v3.
C = floor(W × H × 3 × 2 / 8) − 16
C(1024, 1024) = 786,416 bytes[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
Ключ и проверка подлинности
В профиле SnapSeal v3 алгоритм Argon2id v1.3 выводит 32-байтовый ключ из пароля в UTF-8 и случайной 16-байтовой соли. Для этого выделяется 64 MiB памяти; выполняются три прохода с одной линией вычислений. Перед запуском KDF программа чтения должна проверить, что фиксированные параметры нагрузки поддерживаются.
AES-256-GCM шифрует исходные байты и метаданные. Его 16-байтовый тег позволяет проверить подлинность шифротекста и открытого 48-байтового заголовка. Если проверка не прошла, открытый текст нельзя выдавать ни при каких условиях.
[0:8] | SNAPSEAL |
|---|---|
[8:12] | version=3 · KDF=1 · Argon2=0x13 (v1.3) · lanes=1 |
[12:16] | memory_KiB=65536 |
[16:20] | passes=3 |
[20:36] | salt · 16 bytes |
[36:48] | nonce · 12 bytes |
from argon2.low_level import Type, hash_secret_raw
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def decrypt_v3(envelope: bytes, password: str) -> bytes:
if not 70 <= len(envelope) <= 50 * 1024 * 1024 + 4096:
raise ValueError("Invalid envelope size")
header = envelope[:48]
if header[:12] != b"SNAPSEAL\x03\x01\x13\x01":
raise ValueError("Expected the v3 Argon2id profile")
memory = int.from_bytes(header[12:16], "big")
passes = int.from_bytes(header[16:20], "big")
if (memory, passes) != (65536, 3):
raise ValueError("Unsupported work factors")
secret = password.encode("utf-8")
if not 1 <= len(password) <= 256 or len(secret) > 1024:
raise ValueError("Invalid password length")
key = hash_secret_raw(
secret, header[20:36], time_cost=passes,
memory_cost=memory, parallelism=1, hash_len=32,
type=Type.ID, version=19,
)
return AESGCM(key).decrypt(
header[36:48], envelope[48:], header,
)plaintext = uint32_be(metadata_length) + metadata_json + original_bytes
envelope = header + AESGCM(key).encrypt(nonce, plaintext, header)Сообщение хранится в пикселях
Данные записаны в декодированные значения RGB, а не в метаданные PNG. Два PNG-файла могут различаться на уровне байтов и при этом сохранять одно и то же сообщение в младших битах, если их декодированные значения RGB не изменились. Скриншот, редактирование пикселей или изменение размера, напротив, способны уничтожить данные, даже если картинка остаётся узнаваемой.
Если сжатие источника не было явно выбрано, исходные байты и метаданные восстанавливаются в точности. Имя файла зашифровано, но без дополнения до заданной длины контейнер выдаёт приблизительный размер содержимого.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Антисжатие: второй путь через JPEG
Антисжатие — это попытка восстановить данные после преобразования в JPEG с сохранением размеров изображения, без гарантии успеха. Один и тот же зашифрованный контейнер записывается в младшие биты RGB исходного PNG и, при включении этой возможности, дополнительно в частотные коэффициенты изображения.
Этот механизм не предотвращает сжатие и не делает формат необнаружимым.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
От пикселей к частотам
JPEG применяет дискретное косинусное преобразование (DCT) к блокам яркости 8×8, получая частотные коэффициенты, а затем квантует их. SnapSeal записывает данные в выбранные коэффициенты, размещая их значения с заданными интервалами. Это позволяет при чтении выдержать некоторые изменения, вызванные кодированием JPEG.
Транспортный заголовок с коррекцией ошибок указывает, как устроены данные для восстановления. Он не подтверждает подлинность публичной обложки; сама исходная обложка для чтения не нужна.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerРасстояние между символами
QIM, или модуляция индекса квантования, сопоставляет символам позиции, разнесённые с определённым шагом. В учебной однобитной модели кратные 24 поочерёдно обозначают 0 и 1. Чтобы записать 1, кодировщик меняет 70 на 72. При изменении 72 на +11 результат по-прежнему читается как 1, а при +13 пересекает границу и бит меняется. Передвиньте ползунок, чтобы увидеть эту границу.
В реальном канале каждый выбранный коэффициент хранит от одного до трёх битов с использованием кода Грея. Фрагмент QIM на Python лишь иллюстрирует принцип и не декодирует JPEG.
from math import floor
def put_bit(coefficient: float, bit: int, step: int = 24) -> int:
cell = floor((coefficient / step - bit) / 2 + 0.5)
return step * (2 * cell + bit)
def read_bit(coefficient: float, step: int = 24) -> int:
return floor(coefficient / step + 0.5) % 2
stored = put_bit(70, 1) # 72
assert read_bit(stored + 11) == 1 # 83: same cell
assert read_bit(stored + 13) == 0 # 85: crossed the boundaryСначала исправление, затем проверка
Reed–Solomon (255,223) добавляет 32 проверочных байта к 223 байтам данных и исправляет до 16 ошибочных байтов в каждом кодовом слове. Перемежение и размещение блоков в разных частях изображения помогают распределить повреждения, чтобы они не сосредоточились в нескольких словах.
Программа чтения сначала исправляет транспортные данные, затем проверяет CRC и только после этого проверяет подлинность контейнера с помощью AES-GCM. CRC служит для проверки транспорта, а не для криптографической аутентификации. Даже после успешного исправления открытый текст нельзя выдавать, если проверка GCM не прошла.
223 + 32 = 255 bytes
t = (255 − 223) / 2 = 16 byte errors
A₀ B₀ C₀ A₁ B₁ C₁ … → A, B, C
repair → CRC → AES-GCM → original_bytesПроверка общего носителя
Обе копии занимают одни и те же пиксели, поэтому запись младших битов тоже меняет коэффициенты DCT. Самопроверка работает с итоговым совмещённым носителем. О возможности восстановления сообщается только при точном восстановлении контейнера в локальных тестах браузерного JPEG: с качеством 0.60 и при повторном кодировании 0.85→0.60. При этом на кодовое слово должно приходиться не больше 12 исправленных байтов — меньше предела исправления в 16 байтов. Эти настройки относятся к локальным тестам и не гарантируют результат для любого JPEG.
Автоматические попытки ограничены 280 000 зашифрованных байтов, включая служебные данные. Избыточность и более сильный сигнал расходуют качество и вместимость обложки; исходное защищаемое содержимое этот канал повторно не сжимает. Если проверки не проходят, используется PNG или ZIP без потерь. Обрезка, изменение размера, поворот и другие кодировщики могут помешать восстановлению, поэтому надёжнее отправлять исходный PNG или ZIP именно как файл.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Что формат оставляет открытым
Фиксированные маркеры позволяют обнаружить формат. Иллюстрация обложки публична, и её подлинность как изображения не проверяется: изменение старших битов может оставить встроенное сообщение нетронутым. Коррекция ошибок и CRC помогают справиться с повреждениями при передаче, а AES-GCM проверяет подлинность защищённого содержимого.
Злоумышленник всё равно может перебирать пароли офлайн. Argon2id повышает стоимость каждой попытки, но не спасает слабый пароль. Восстановление пароля не предусмотрено; независимый аудит безопасности не заявляется.
Чтение без приложения
Независимая программа чтения на Python 3.12+ поддерживает исходные PNG, в том числе с копией для восстановления после JPEG, ZIP-архивы SnapSeal с одним PNG и ограничениями на размер, а также контейнеры v3 без внешней обёртки. Для файлов, преобразованных в JPEG, нужен кодек на TypeScript.
Здесь описана реализация v3 с открытым исходным кодом; старые развёрнутые программы чтения могут не принять её файлы. Установите указанные зависимости в виртуальном окружении. Полный исходный код и дополнительные материалы для скачивания приведены после примеров.
python -m pip install -r requirements.txtfrom getpass import getpass
from pathlib import Path
from decode_snapseal import decode_path
result = decode_path(
Path("protected.png"),
getpass("Password: "),
)
output = Path("restored")
output.mkdir(exist_ok=True)
with (output / result.filename).open("xb") as target:
target.write(result.data)

