01

A titok és a hordozó különválasztása

A SnapSeal képek, fájlok vagy szövegek eredeti bájtjaiból indul ki. Titkosítja ezeket és a metaadataikat, majd a kapott csomagot egy fedőképbe ágyazza. A visszafejtéshez a címzettnek a védett fájlra és a jelszóra van szüksége; az eredeti fedőkép nem kell.

A bizalmasságot a kriptográfia adja, a képi szteganográfia pedig a hordozóról gondoskodik. Egyik eljárás sem új. A bemutató fedőképe és tesztjelszava szándékosan nyilvános.

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

Az alsó két bit felhasználása

Egy RGB-pixel három, egyenként 8 bites csatornából áll. A példában a kódoló balról jobbra olvassa a csatornákat, az alfacsatornát kihagyja, a felső hat bitet megtartja, az alsó kettőt pedig lecseréli. Négy csatornában így egy bájt fér el.

Mozgasd a csúszkát, és figyeld a kiemelt biteket. Ez az önmagában vizsgált írás csatornánként legfeljebb 3-mal módosítja az értéket; az opcionális JPEG-hordozó és a kép egyéb alakítása ennél nagyobb változást is okozhat.

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

Mennyi adat fér el?

Három csatorna két-két bitje pixelenként hat bitet ad. Egy 1024×1024-es fedőképben ez 786 432 bájt, amiből a 16 bájtos pixelcsomag-fejléc után 786 416 marad. A grafika pixeleket foglal le, ezért csökkenti a kapacitást.

A csomag elrendezését rögzített jelölő azonosítja, a csomagban megadott hossz pedig kijelöli a titkosított csomag végét. A pixelcsomag és a titkosítás verziószámai egymástól függetlenek: egy PNG2 csomag v3 titkosított csomagot is hordozhat.

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

Kulcsszármaztatás és hitelesítés

A SnapSeal v3 profilja az Argon2id v1.3 segítségével 32 bájtos kulcsot származtat az UTF-8 kódolású jelszóból és egy véletlen, 16 bájtos sóból. Ehhez 64 MiB memóriát, három menetet és egy sávot használ. Az olvasónak még a kulcsszármaztatás előtt ellenőriznie kell, hogy a rögzített munkaparaméterek a támogatott értékek-e.

Az AES-256-GCM az eredeti bájtokat és a metaadatokat titkosítja. A 16 bájtos hitelesítő címke a titkosított adatokat és a nyilvános, 48 bájtos fejlécet is védi. Sikertelen hitelesítés után az olvasó semmiképpen sem adhat ki nyílt szöveget.

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

Az üzenet a pixelekben van

A beágyazott adat a dekódolt RGB-értékekben található, nem a PNG metaadataiban. Két PNG fájl bájtjai eltérhetnek egymástól úgy is, hogy azonos dekódolt RGB-értékeik ugyanazt az alsó bitekbe írt üzenetet őrzik. Egy képernyőkép, pixelmódosítás vagy átméretezés viszont akkor is tönkreteheti az adatokat, ha a kép felismerhető marad.

Ha a felhasználó nem választott kifejezetten forrástömörítést, az eredeti bájtok és metaadatok pontosan helyreállnak. A fájlnév titkosítva van, de kitöltés nélkül a csomag hozzávetőleges mérete továbbra is látszik.

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

Tömörítés ellen: második út a JPEG-en át

A tömörítés elleni védelem azonos méretű JPEG esetén megkísérli az adatok helyreállítását, de nem garantálja azt. Ugyanannak a titkosított csomagnak van egy veszteségmentes másolata a PNG alsó RGB-bitjeiben, és opcionálisan egy helyreállítási másolata a kép frekvencia-együtthatóiban.

Ez nem akadályozza meg a tömörítést, és nem ígér túlélést tetszőleges üzenetküldőkön keresztül. A formátumot sem teszi felismerhetetlenné.

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

A pixelektől a frekvenciákig

A JPEG a 8×8-as fényességblokkokat diszkrét koszinusztranszformációval (DCT) frekvencia-együtthatókká alakítja, majd kvantálja őket. A SnapSeal kiválasztott együtthatókat egymástól szándékosan elkülönített értékekre állít: így az olvasó bizonyos, a JPEG-kódolásból eredő változásokat még el tud viselni.

A helyreállítási elrendezést hibajavítással védett átviteli fejléc azonosítja. Ez nem hitelesíti a nyilvános fedőképet, és az olvasónak az eredeti fedőképre sincs szüksége.

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

Távolság a szimbólumok között

A QIM, azaz kvantálásiindex-moduláció egymástól elválasztott pozíciókhoz rendeli a szimbólumokat. Az egybites szemléltető modellben a 24 többszörösei felváltva jelentenek 0-t és 1-et. Az 1 tárolásához a kódoló a 70-et 72-re állítja. A 72-höz képesti +11 változás után az eredmény még 1-ként olvasható; +13-nál viszont átlépi a határt, és a bit megfordul. Mozgasd a csúszkát, hogy lásd ezt a határt.

A tényleges csatorna kiválasztott együtthatónként egy-három bitet tárol Gray-kódolással. A Python QIM-kódrészlet az elvet szemlélteti, nem teljes JPEG-dekóder.

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

Előbb javítás, végül hitelesítés

A Reed–Solomon (255,223) 223 adatbájthoz 32 paritásbájtot ad, és kódszavanként legfeljebb 16 hibás bájtot képes javítani. Az átlapolás és a képen szétszórt blokkok segítenek elosztani a sérülést, hogy az ne néhány kódszóban összpontosuljon.

Az olvasó először javítja az átvitt adatot, ellenőrzi a CRC-t, majd AES-GCM-mel hitelesíti a titkosított csomagot. A CRC az átvitel ellenőrzésére szolgál, nem kriptográfiai hitelesítésre. A sikeres javítás nem jogosít fel nyílt szöveg kiadására, ha a GCM-ellenőrzés sikertelen.

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

A kész hordozó próbája

A két másolat ugyanazokat a pixeleket használja, ezért az alsó bitek írása a DCT-együtthatókat is megzavarja. Az öntesztek emiatt a végleges, mindkét másolatot tartalmazó hordozót vizsgálják. A rendszer csak akkor jelzi, hogy a helyreállítás rendelkezésre áll, ha a helyi böngészős JPEG-tesztekben a teljes titkosított csomag pontosan visszanyerhető: 0,60 minőségen és ismételt, 0,85→0,60 kódolás után is. Emellett kódszavanként legfeljebb 12 bájt javítása engedett, ami a 16 bájtos javítási határ alatt van. Ezek helyi tesztbeállítások, nem általános JPEG-garanciák.

Az automatikus próbálkozások határa 280 000 titkosított bájt, a többletadatokkal együtt. A redundancia és az erősebb jelek a fedőkép minőségéből és kapacitásából vesznek el; a privát eredetit ez a csatorna nem tömöríti újra. Sikertelen teszt esetén a rendszer veszteségmentes PNG-re vagy ZIP-re tér vissza. Vágás, átméretezés, forgatás vagy más kódoló meghiúsíthatja a helyreállítást, ezért a megbízható megoldás az eredeti PNG vagy ZIP fájlként való elküldése.

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

Mit árul el a formátum?

A rögzített jelölők alapján a formátum felismerhető. A fedőkép nyilvános, és képként nincs hitelesítve: felső bitjeinek módosítása akár érintetlenül is hagyhatja a beágyazott üzenetet. A hibajavítás és a CRC az átviteli sérülésekkel foglalkozik, a védett tartalmat az AES-GCM hitelesíti.

A jelszavak offline is próbálgathatók. Az Argon2id minden próbát költségesebbé tesz, de egy gyenge jelszót nem ment meg. Jelszó-helyreállítás nincs, és a leírás nem állítja, hogy független biztonsági audit történt.

08

Olvasás az alkalmazás nélkül

A formátum ismert kriptográfiai építőelemekre, egyértelműen megadott korlátokra és egy független olvasóra támaszkodik. A Python 3.12+ rendszeren futó olvasó eredeti PNG-ket olvas, köztük JPEG-helyreállítási másolatot is tartalmazó PNG-ket, továbbá korlátozott méretű, egyetlen PNG-t tartalmazó SnapSeal ZIP-eket és nyers v3 titkosított csomagokat. Átalakított JPEG-fájlokhoz a TypeScript-kodek szükséges.

A cikk a nyílt forráskódú v3-implementációt mutatja be; a régebben kiadott olvasók elutasíthatják a fájljait. A megadott függőségeket virtuális környezetbe telepítsd. A teljes forráskód és az opcionális letöltések a példák után következnek.

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