01

Tajemství a jeho nosič

SnapSeal vychází z původních bajtů obrázků, souborů nebo textu. Zašifruje je spolu s metadaty a vzniklou obálku vloží do pixelů krycího obrázku. K přečtení stačí chráněný soubor a heslo; původní krycí obrázek ani server nejsou potřeba.

Kryptografie chrání důvěrnost obsahu, zatímco obrazová steganografie poskytuje nosič. Oba principy jsou dobře známé. Ukázka záměrně používá veřejný obrázek i zveřejněné testovací heslo.

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

Dva nejnižší bity

Každý pixel RGB má tři osmibitové kanály. V tomto příkladu kodér prochází kanály zleva doprava, alfa kanál vynechává, šest horních bitů zachová a dva nejnižší nahradí. Do čtyř kanálů se tak vejde jeden bajt.

Posuňte jezdec a sledujte změny zvýrazněných bitů. Samotný zápis do nejnižších bitů změní hodnotu kanálu nejvýše o 3. Volitelný nosič pro obnovu z JPEG i další úpravy vzhledu mohou krycí obrázek změnit výrazněji.

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

Kolik bajtů se vejde do obrázku

Tři kanály po dvou bitech dávají šest bitů na pixel. Do obrázku o rozměrech 1024×1024 se tedy před započtením 16bajtové hlavičky pixelového paketu vejde 786 432 bajtů; po jejím odečtení zbývá 786 416 bajtů. Grafické prvky si část pixelů vyhradí a kapacitu dále snižují.

Pevná značka určuje uspořádání paketu a údaj o délce vyznačuje konec obálky. Verze paketu a verze šifrování jsou na sobě nezávislé: paket PNG2 může nést kryptografickou obálku 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

Odvození klíče a ověření obálky

Profil SnapSeal v3 odvozuje 32bajtový klíč algoritmem Argon2id v1.3 z hesla v kódování UTF-8 a náhodné 16bajtové soli. Používá 64 MiB paměti, tři průchody a jednu paralelní větev. Čtečka musí před spuštěním KDF ověřit, že parametry odpovídají pevným podporovaným hodnotám.

AES-256-GCM šifruje původní bajty a metadata. Jeho 16bajtový autentizační tag ověřuje šifrovaný text i veřejnou 48bajtovou hlavičku. Pokud autentizace selže, čtečka nikdy nesmí vydat otevřený text.

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

Zprávu nesou pixely

Data jsou uložená v dekódovaných hodnotách RGB, nikoli v metadatech PNG. Dva soubory PNG se mohou lišit svými bajty, a přesto nést stejnou zprávu v nejnižších bitech, pokud jsou jejich dekódované hodnoty RGB shodné. Snímek obrazovky, úprava pixelů nebo změna velikosti naopak mohou data zničit, přestože obrázek zůstane rozpoznatelný.

Pokud uživatel výslovně nezvolil kompresi zdroje, obnova vrátí původní bajty i metadata přesně. Název souboru je zašifrovaný, ale bez výplně prozrazuje velikost obálky přibližnou velikost obsahu.

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

Proti kompresi: druhá cesta přes JPEG

Ochrana proti kompresi zde znamená pokus o obnovu z JPEG, který zachoval původní rozměry. Táž zašifrovaná obálka má kopii v nejnižších bitech RGB v PNG a volitelně také obnovovací kopii ve frekvenčních koeficientech obrázku. Postup kompresi nezabrání ani neučiní formát nezjistitelným.

  1. envelope → RS(255,223)
  2. interleave → spread blocks
  3. QIM → luma DCT 8×8
  4. repair → CRC → AES-GCM

Od pixelů k frekvencím

JPEG používá diskrétní kosinovou transformaci neboli DCT: bloky jasu o rozměrech 8×8 převádí na frekvenční koeficienty, které pak kvantuje. SnapSeal ukládá data do vybraných koeficientů na hodnoty s promyšlenými rozestupy. Čtečka tak může tolerovat určité změny způsobené kódováním do JPEG.

Přenosová hlavička chráněná samoopravným kódem určuje rozložení obnovovacího kanálu. Neautentizuje však veřejný krycí obrázek jako výtvarné dílo a čtečka nepotřebuje jeho původní podobu.

RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizer

Odstup mezi symboly

QIM neboli modulace kvantizačního indexu přiřazuje symbolům vzájemně oddělené pozice. V názorném jednobitovém modelu představují násobky 24 střídavě hodnoty 0 a 1. Pro uložení 1 kodér posune hodnotu 70 na 72. Změna o +11 od hodnoty 72 se ještě přečte jako 1; +13 už překročí hranici a bit převrátí. Posuňte jezdec a podívejte se, kde hranice leží.

Skutečný kanál ukládá do každého vybraného koeficientu jeden až tři bity pomocí Grayova kódu. Ukázka QIM v Pythonu vysvětluje princip, není to úplný dekodér 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

Nejdřív oprava, potom autentizace

Reed–Solomon (255,223) přidává k 223 datovým bajtům 32 paritních bajtů a v jednom kódovém slově dokáže opravit až 16 chybných bajtů. Prokládání a rozložení bloků po obrázku pomáhá poškození rozptýlit, aby se nesoustředilo do několika slov.

Čtečka nejprve opraví přenosová data, zkontroluje jejich CRC a teprve pak autentizuje obálku pomocí AES-GCM. CRC ověřuje přenos, nejde o kryptografickou autentizaci. Ani úspěšná oprava neopravňuje k vydání otevřeného textu, pokud kontrola GCM selže.

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

Test výsledného nosiče

Obě kopie využívají stejné pixely, takže zápis do nejnižších bitů ovlivňuje i koeficienty DCT. Automatické testy proto pracují až s výsledným kombinovaným obrázkem. Možnost obnovy se uvádí pouze tehdy, když se podaří přesně obnovit obálku v místních testech JPEG v prohlížeči: při kvalitě 0.60 i při opakovaném kódování 0.85→0.60, nejvýše s 12 opravenými bajty na kódové slovo. To je pod hranicí 16 bajtů; místní testy však nejsou zárukou pro všechny kodéry JPEG.

Při více než 280 000 šifrovaných bajtech včetně režie se automatická obnova nezkouší. Redundance a silnější signály ubírají kvalitu a kapacitu krycího obrázku; soukromý originál tento kanál znovu nekomprimuje. Pokud testy selžou, použije se bezeztrátové předání v PNG nebo ZIP. Ořez, změna velikosti, otočení či jiný kodér mohou obnovu znemožnit. Nejlepší je proto poslat původní PNG nebo ZIP jako soubor.

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

Co formát prozrazuje

Pevné značky umožňují formát rozpoznat. Krycí obrázek je veřejný a jeho výtvarná podoba se neautentizuje; změny horních bitů mohou ponechat vloženou zprávu nedotčenou. Oprava chyb a CRC řeší poškození při přenosu, zatímco AES-GCM autentizuje chráněný obsah.

Útočník může hesla zkoušet offline. Argon2id jednotlivé pokusy prodražuje, slabé heslo však nezachrání. Zapomenuté heslo nelze obnovit a autoři formátu netvrdí, že prošel nezávislým bezpečnostním auditem.

08

Čtení bez aplikace

Nezávislá čtečka pro Python 3.12+ podporuje původní PNG včetně těch s kopií pro obnovu z JPEG, SnapSeal ZIPy s jediným PNG a omezenou velikostí i samotné obálky v3. Pro soubory převedené do JPEG je potřeba kodek v TypeScriptu.

Přínosem je spojení zavedených kryptografických postupů, formátu s jasnými limity a nezávislé čtečky. Popsaná verze je open-source implementace v3; starší nasazené čtečky mohou její soubory odmítnout. Uvedené závislosti instalujte ve virtuálním prostředí. Úplný zdrojový kód a volitelné soubory ke stažení následují po příkladech.

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