Секрет і його носій
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 застосовує до блоків яскравості 8×8 дискретне косинусне перетворення (DCT), а потім квантує отримані коефіцієнти. 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, зокрема PNG із копією для відновлення після JPEG, SnapSeal ZIP з одним 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)

