01

Tajomstvo a jeho nosič

SnapSeal zašifruje pôvodné bajty obrázkov, súborov alebo textu spolu s ich metadátami a výslednú obálku vloží do krycieho obrázka. Príjemca potrebuje chránený súbor a heslo, nie však server ani pôvodný krycí obrázok.

Kryptografia chráni dôvernosť obsahu, steganografia mu poskytuje nosič. Obe techniky sú známe; zaujímavé je, ako tu spolu fungujú. Ilustrácia vo verejnej ukážke aj testovacie heslo sú zámerne zverejnené.

  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 najnižšie bity

Každý pixel RGB má tri 8-bitové kanály. V tomto príklade kodér číta kanály zľava doprava, alfa kanál preskakuje, horných šesť bitov zachováva a dva najnižšie nahrádza. Do štyroch kanálov tak uloží jeden bajt.

Posuňte jazdec a sledujte zmenu zvýraznených bitov. Samotný zápis do spodných bitov zmení hodnotu kanála najviac o 3. Voliteľný nosič pre obnovu z JPEG a ďalšie úpravy vzhľadu však môžu krycí obrázok zmeniť výraznejšie.

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

Koľko bajtov sa zmestí do obrázka

Dva bity v každom z troch kanálov dávajú šesť bitov na pixel. Krycí obrázok s rozmermi 1024×1024 má pred započítaním 16-bajtovej hlavičky pixelového paketu kapacitu 786 432 bajtov; po jej odpočítaní zostáva 786 416 bajtov. Ilustrácia si časť pixelov vyhradí, a tým kapacitu zníži.

Pevná značka určuje usporiadanie paketu a údaj o dĺžke označuje koniec obálky. Verzie paketu a šifrovania sú na sebe nezávislé: paket PNG2 môže niesť kryptografickú 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

Odvodenie kľúča a overenie obálky

Profil v3 používa Argon2id v1.3 na odvodenie 32-bajtového kľúča z hesla zakódovaného v UTF-8 a náhodnej 16-bajtovej soli. Vyžaduje 64 MiB pamäte, tri priechody a jednu výpočtovú vetvu. Čítačka musí pred spustením KDF overiť, že pevne stanovené pracovné parametre sú podporované.

AES-256-GCM šifruje pôvodné bajty aj metadáta. Jeho 16-bajtový autentizačný tag overuje šifrovaný obsah aj verejnú 48-bajtovú hlavičku. Ak autentizácia zlyhá, čítačka nikdy nesmie vydať otvorený 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

Správa je v pixeloch

Dáta sa nachádzajú v dekódovaných hodnotách RGB, nie v metadátach PNG. Dva súbory PNG môžu mať odlišné bajty, a predsa niesť tú istú správu v spodných bitoch, pokiaľ ich dekódované hodnoty RGB zostanú nezmenené. Snímka obrazovky, úprava pixelov či zmena rozmerov naopak môžu dáta zničiť, hoci obrázok zostane rozpoznateľný.

Ak nebola výslovne zvolená kompresia zdroja, pri obnove sa presne vrátia pôvodné bajty aj metadáta. Názov súboru je zašifrovaný, no bez doplnkovej výplne obálka stále prezrádza približnú veľkosť.

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

Antikompresia: druhá cesta cez JPEG

Antikompresia je pokus o obnovu z JPEG pri zachovaní rovnakých rozmerov obrázka, nie záruka úspechu. Tá istá šifrovaná obálka sa ukladá do spodných bitov RGB pôvodného PNG a voliteľne aj do frekvenčných koeficientov obrázka.

Tento postup nezabráni kompresii a formát zostáva zistiteľný.

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

Od pixelov k frekvenciám

JPEG používa diskrétnu kosínusovú transformáciu (DCT), ktorou prevádza bloky jasu s rozmermi 8×8 na frekvenčné koeficienty, a potom ich kvantuje. SnapSeal zapisuje dáta do vybraných koeficientov v zámerne rozostúpených hodnotách. Čítačka tak môže tolerovať niektoré zmeny spôsobené kódovaním JPEG.

Prenosová hlavička chránená opravou chýb určuje rozloženie obnovovacieho kanála. Neautentizuje však verejnú kryciu ilustráciu a čítačka pôvodný krycí obrázok nepotrebuje.

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

Rezerva medzi symbolmi

QIM, teda modulácia kvantizačného indexu, priraďuje symbolom rozostúpené pozície. V názornom jednobitovom modeli sa pri kroku 24 násobky striedavo čítajú ako 0 a 1. Pri ukladaní hodnoty 1 sa 70 zmení na 72. Zmena o +11 od hodnoty 72 sa ešte dekóduje ako 1; pri +13 sa prekročí hranica a bit sa preklopí. Posuňte jazdec a pozrite sa, kde hranica leží.

Skutočný kanál ukladá jeden až tri bity na vybraný koeficient pomocou Grayovho kódu. Ukážka QIM v Pythone vysvetľuje princíp, ale nedekóduje 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

Najprv oprava, potom autentizácia

Reed–Solomon (255,223) pridáva k 223 dátovým bajtom 32 paritných bajtov a dokáže opraviť najviac 16 chybných bajtov v jednom kódovom slove. Prekladanie dát a rozmiestnenie blokov po obrázku pomáhajú rozptýliť poškodenie, aby sa nesústredilo do niekoľkých kódových slov.

Čítačka najprv opraví prenosové dáta, potom skontroluje CRC a napokon autentizuje obálku pomocou AES-GCM. CRC overuje integritu prenosu, nie kryptografickú pravosť. Ani úspešná oprava neoprávňuje vydať otvorený text, ak kontrola GCM zlyhá.

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ča

Obe kópie využívajú tie isté pixely, takže zápis spodných bitov mení aj koeficienty DCT. Testy preto pracujú s výsledným obrázkom s oboma nosičmi. Obnova sa deklaruje iba vtedy, keď sa celá obálka presne obnoví pri lokálnom teste JPEG v prehliadači s kvalitou 0.60 aj pri opakovanom kódovaní 0.85→0.60, a to s opravou najviac 12 bajtov na kódové slovo. Je to pod limitom 16 opraviteľných bajtov. Výsledky týchto lokálnych testov nezaručujú úspech s každým kodérom JPEG.

Automatické pokusy sa zastavia pri 280 000 šifrovaných bajtoch vrátane réžie. Redundancia a silnejší signál uberajú z kvality a kapacity krycieho obrázka; súkromný originál sa pritom znovu nekomprimuje. Ak testy zlyhajú, použije sa bezstratové PNG alebo ZIP. Orezanie, zmena rozmerov, otočenie či iný kodér môžu obnovu zmariť. Najspoľahlivejšie je preto poslať pôvodné PNG alebo ZIP ako súbor.

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

Čo formát prezrádza

Pevné značky umožňujú formát rozpoznať. Krycia ilustrácia je verejná a ako ilustrácia nie je autentizovaná; zmeny jej horných bitov môžu vloženú správu ponechať neporušenú. Oprava chýb a CRC riešia poškodenie pri prenose, zatiaľ čo AES-GCM autentizuje chránený obsah.

Útočník môže heslá stále skúšať offline. Argon2id každý pokus predražuje, slabé heslo však nezachráni. Obnova zabudnutého hesla nie je možná a netvrdí sa ani to, že formát prešiel nezávislým bezpečnostným auditom.

08

Čítanie bez aplikácie

Nezávislá čítačka pre Python 3.12+ podporuje pôvodné súbory PNG vrátane tých s obnovovacou kópiou pre JPEG, archívy SnapSeal ZIP s jediným PNG a stanovenými limitmi aj surové obálky v3. Na súbory skonvertované do JPEG je potrebný kodek v TypeScripte.

Opísaná implementácia v3 má otvorený zdrojový kód; staršie nasadené čítačky môžu jej súbory odmietnuť. Uvedené závislosti nainštalujte vo virtuálnom prostredí. Úplný zdrojový kód a voliteľné súbory na stiahnutie nasledujú po ukážkach.

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