01

Тайната и изображението носител

SnapSeal започва с оригиналните байтове на изображения, файлове или текст. Шифрова ги заедно с метаданните им, след което вгражда получената обвивка в пикселите на изображение носител. Получателят се нуждае от защитения файл и паролата, но не и от оригиналния носител.

Криптографията пази съдържанието поверително, а стеганографията осигурява носителя. И двете идеи са познати; инженерната задача е границите между тях да са ясни. Илюстрацията и тестовата парола в публичната демонстрация са умишлено публикувани.

  1. original_bytes + metadata
  2. Argon2id → AES-GCM
  3. header + ciphertext + tag
  4. RGB → PNG
password = "Public SnapSeal article example"
GitHub · snapseal-codec
02

Двата младши бита

Всеки RGB пиксел има три 8-битови канала. В този пример кодиращата програма обхожда каналите отляво надясно, пропуска алфа канала, запазва шестте старши бита и заменя двата младши. Така четири канала могат да пренесат един байт.

Преместете плъзгача, за да видите как се променят маркираните битове. Само това записване в младшите битове изменя стойността на канал най-много с 3; допълнителният JPEG носител и други промени във външния вид могат да изменят изображението повече.

R₀10101011170 → 171 Δ +1
G₀10110101181 → 181 Δ 0
B₀0101110094 → 92 Δ -2
R₁11001011203 → 203 Δ 0
def 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
03

Колко байта се побират

Три канала с по два бита дават шест бита на пиксел. Изображение носител с размер 1024×1024 побира 786 432 байта преди 16-байтовия хедър на пикселния пакет и 786 416 байта след него. Илюстрацията заема част от пикселите и намалява този капацитет.

Фиксиран маркер обозначава структурата на пакета, а поле за дължина показва къде свършва обвивката. Версиите на пакета и на шифроването са независими: пакет PNG2 може да носи криптографска обвивка v3.

C = floor(W × H × 3 × 2 / 8) − 16
C(1024, 1024) = 786,416 bytes
SNAPSEALPNG2
[0:12]SNAPSEALPNG212 bytes
[12:16]uint32_be(envelope_length)4 bytes
[16:…]envelopeenvelope_length
pixel-packet.tsstyled-packet.ts
04

Ключът и удостоверяването на обвивката

Профилът v3 на SnapSeal използва Argon2id v1.3, за да изведе 32-байтов ключ от паролата в UTF-8 и случайна 16-байтова сол. Зададени са 64 MiB памет, три преминавания и една изчислителна нишка. Четецът трябва да провери дали фиксираните параметри са сред поддържаните, преди да изпълни KDF.

AES-256-GCM шифрова оригиналните байтове и метаданните. Неговият 16-байтов таг удостоверява шифротекста и публичния 48-байтов хедър. При неуспешно удостоверяване четецът никога не бива да предоставя открития текст.

SNAPSEAL v3 · 48 bytes
[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)
RFC 9106 · Argon2AESGCM · cryptography
05

Съобщението е в пикселите

Данните се намират в декодираните RGB стойности, а не в PNG метаданните. Два PNG файла могат да се различават като байтове и въпреки това да запазват едно и също съобщение в младшите битове, ако декодираните им RGB стойности са непроменени. Екранна снимка, редакция на пиксели или преоразмеряване обаче могат да унищожат данните, макар картината да остане разпознаваема.

Ако компресиране на източника не е избрано изрично, оригиналните байтове и метаданни се възстановяват точно. Името на файла е шифровано, но без допълване обвивката издава приблизителния размер.

PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)
PNG · W3C
06

Защита при компресия: втори път през JPEG

Защитата при компресия е опит за възстановяване от JPEG със същите размери, без гаранция за успех. Една и съща шифрована обвивка има копие в младшите RGB битове на PNG и, по избор, копие за възстановяване в честотните коефициенти на изображението. Това не предотвратява компресирането и не прави формата неоткриваем.

  1. envelope → RS(255,223)
  2. interleave → spread blocks
  3. QIM → luma DCT 8×8
  4. repair → 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. Промяна с +11 спрямо 72 все още се декодира като 1; при +13 се преминава границата и битът се обръща. Преместете плъзгача, за да видите къде става това.

Действителният канал записва от един до три бита във всеки избран коефициент чрез код на Грей. Фрагментът с QIM на Python илюстрира принципа; той не е пълен JPEG декодер.

c′ = 72 + 0 = 72 b′ = ⌊c′ / 24 + ½⌋ mod 2 = 1 = b
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
jpeg-channel.tspng-container.tsJPEG · ITU-T T.81QIM · Chen & Wornell (2001)
07

Какво издава форматът

Фиксираните маркери правят формата откриваем. Илюстрацията носител е публична и не се удостоверява като изображение; промени в старшите ѝ битове могат да оставят вграденото съобщение непокътнато. Корекцията на грешки и CRC се занимават с повреди при пренос, докато AES-GCM удостоверява защитеното съдържание.

Нападател все още може да отгатва пароли офлайн. Argon2id оскъпява всеки опит, но не може да спаси слаба парола. Няма възстановяване на паролата и не се твърди, че е правен независим одит на сигурността.

08

Четене без приложението

Форматът използва утвърдени алгоритми, определя ясно границите си и предлага независим четец. Четецът за Python 3.12+ поддържа оригинални PNG файлове, включително такива с JPEG копие за възстановяване, SnapSeal ZIP архиви с един PNG в определени граници и сурови обвивки v3. За файлове, преобразувани в JPEG, е нужен кодекът на TypeScript.

Описаната тук реализация е v3 с отворен код; по-стари вече внедрени четци може да отхвърлят файловете ѝ. Инсталирайте посочените зависимости във виртуална среда. Пълният код и допълнителните файлове за изтегляне следват примерите.

python -m pip install -r requirements.txt
from 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)
GitHub · decode_snapseal.pyFORMAT.md
↓ decode_snapseal.py↓ requirements.txt