01

Tajna i naslovna slika nisu isto

SnapSeal šifrira izvorne bajtove slika, datoteka ili teksta zajedno s njihovim metapodacima, a nastalu omotnicu ugrađuje u piksele naslovne slike. Primatelju su za čitanje zaštićenog sadržaja potrebni datoteka i lozinka, ali ne i poslužitelj ili izvorna naslovna slika.

Kriptografija čuva povjerljivost sadržaja, a steganografija slike osigurava nosač. Obje su zamisli poznate; inženjerski je zadatak jasno odrediti granice među njima. Ilustracija i testna lozinka u primjeru namjerno su objavljene.

  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ža bita

Svaki RGB piksel ima tri 8-bitna kanala. U ovom primjeru koder čita kanale slijeva nadesno, preskače alfa-kanal, čuva šest viših bitova i zamjenjuje dva najniža. Četiri kanala tako mogu prenijeti jedan bajt.

Pomaknite klizač i pratite promjenu označenih bitova. Sam taj zapis mijenja vrijednost kanala najviše za 3; dodatni JPEG nosač i druge stilske izmjene mogu je promijeniti i više.

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

Koliko bajtova stane u sliku?

Tri kanala s po dva bita daju šest bitova po pikselu. Slika od 1024×1024 piksela ima mjesta za 786.432 bajta prije 16-bajtnog zaglavlja paketa u pikselima, odnosno 786.416 bajtova nakon njega. Ilustracija rezervira dio piksela i time smanjuje kapacitet.

Fiksna oznaka određuje raspored paketa, a podatak o duljini označuje kraj omotnice. Verzije paketa i šifriranja pripadaju odvojenim sustavima označavanja: paket PNG2 može nositi kriptografsku omotnicu 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

Izvođenje ključa i autentifikacija omotnice

Profil v3 koristi Argon2id v1.3 za izvođenje 32-bajtnog ključa iz lozinke kodirane u UTF-8 i slučajne soli od 16 bajtova. Koristi 64 MiB memorije, tri prolaza i jednu stazu. Čitač prije pokretanja KDF-a mora provjeriti odgovaraju li radni parametri fiksnim podržanim vrijednostima.

AES-256-GCM šifrira izvorne bajtove i metapodatke. Njegova 16-bajtna oznaka autentificira šifrat i javno 48-bajtno zaglavlje. Ako autentifikacija ne uspije, čitač nikada ne smije predati otvoreni tekst.

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

Poruka je u pikselima

Podaci se nalaze u dekodiranim RGB vrijednostima, a ne u PNG metapodacima. Dvije PNG datoteke mogu se razlikovati po bajtovima, a ipak sačuvati istu poruku u najnižim bitovima ako su im dekodirane RGB vrijednosti jednake. Snimanje zaslona, uređivanje piksela ili promjena veličine mogu pak uništiti podatke, iako slika ostane prepoznatljiva.

Ako kompresija izvora nije izričito odabrana, oporavak točno vraća izvorne bajtove i metapodatke. Naziv datoteke je šifriran, ali bez dopune omotnica i dalje otkriva približnu veličinu sadržaja.

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

Oporavak iz JPEG-a: drugi put do iste omotnice

Zaštita od kompresije ovdje znači pokušaj oporavka iz JPEG-a koji je zadržao iste dimenzije, bez jamstva uspjeha. Ista šifrirana omotnica ima kopiju bez gubitaka u najnižim RGB bitovima PNG-a te, po izboru, kopiju za oporavak u frekvencijskim koeficijentima slike.

Time se ne sprječava kompresija niti se jamči prolazak kroz proizvoljne aplikacije za razmjenu poruka. Format se također može otkriti.

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

Od piksela do frekvencija

JPEG diskretnom kosinusnom transformacijom (DCT) pretvara blokove svjetline veličine 8×8 u frekvencijske koeficijente, a zatim ih kvantizira. SnapSeal zapisuje podatke u odabrane koeficijente na namjerno razmaknutim vrijednostima, ostavljajući prostora za neke promjene nastale pri JPEG kodiranju.

Transportno zaglavlje zaštićeno ispravljanjem pogrešaka određuje raspored kopije za oporavak. Ono ne autentificira javnu ilustraciju, a čitaču za oporavak nije potrebna izvorna naslovna slika.

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

Razmak između simbola

QIM, modulacija kvantizacijskim indeksom, pridružuje simbole razmaknutim položajima. U modelu koji prikazuje zapis jednog bita višekratnici broja 24 naizmjence predstavljaju 0 i 1. Za zapis vrijednosti 1 koder pomiče 70 na 72. Promjena od +11 u odnosu na 72 i dalje se čita kao 1; +13 prelazi granicu i okreće bit. Pomaknite klizač da vidite gdje se to događa.

Stvarni kanal po odabranom koeficijentu zapisuje jedan do tri bita uz Grayev kod. Primjer QIM-a u Pythonu prikazuje načelo rada; nije JPEG dekoder.

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

Najprije popravak, zatim autentifikacija

Reed–Solomon (255,223) dodaje 32 paritetna bajta na 223 podatkovna bajta te može ispraviti do 16 pogrešnih bajtova po kodnoj riječi. Ispreplitanje i blokovi raspoređeni po slici pomažu razdijeliti oštećenja, umjesto da se ona nakupe u nekoliko kodnih riječi.

Čitač najprije popravlja podatke za prijenos, provjerava njihov CRC, a zatim autentificira omotnicu s AES-GCM-om. CRC služi provjeri prijenosa, a ne kriptografskoj autentifikaciji. Uspješan popravak nije razlog za predaju otvorenog teksta ako provjera GCM-a ne uspije.

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

Testiranje konačne slike

Obje kopije zauzimaju iste piksele pa zapis najnižih bitova mijenja i DCT koeficijente. Zato se samotestiranje provodi na konačnoj slici s oba nosača. Oporavak se nudi samo ako se u lokalnim testovima u pregledniku dobije točno ista omotnica: pri JPEG kvaliteti 0,60 i nakon uzastopnog kodiranja 0,85→0,60, uz najviše 12 popravljenih bajtova po kodnoj riječi. To je manje od granice ispravljanja od 16 bajtova. Te su postavke dio lokalnog testa, a ne univerzalno jamstvo za JPEG.

Automatski pokušaji zaustavljaju se na 280.000 šifriranih bajtova, uključujući dodatne podatke. Redundancija i jači signali troše kvalitetu i kapacitet naslovne slike; taj kanal ne komprimira ponovno privatni izvornik. Ako testovi ne uspiju, isporuka se vraća na PNG ili ZIP bez gubitaka. Obrezivanje, promjena veličine, rotacija ili drugi koder mogu spriječiti oporavak, pa je pouzdana preporuka poslati izvorni PNG ili ZIP kao datoteku.

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

Što format otkriva

Fiksne oznake omogućuju otkrivanje formata. Naslovna ilustracija je javna i ne autentificira se kao ilustracija; promjene njezinih viših bitova mogu ostaviti ugrađenu poruku netaknutom. Ispravljanje pogrešaka i CRC bave se oštećenjima pri prijenosu, dok AES-GCM autentificira zaštićeni sadržaj.

Napadač i dalje može pokušavati pogoditi lozinku izvan mreže. Argon2id poskupljuje svaki pokušaj, ali ne može spasiti slabu lozinku. Oporavak lozinke nije moguć, a ovdje se ne tvrdi da je provedena neovisna sigurnosna revizija.

08

Čitanje bez aplikacije

Samostalan format, jasno navedena ograničenja i neovisni čitač omogućuju pristup sadržaju bez izvorne aplikacije. Čitač za Python 3.12+ podržava izvorne PNG-ove, uključujući one s JPEG kopijom za oporavak, SnapSeal ZIP-ove s jednim PNG-om i ograničenom veličinom te sirove omotnice v3. Za datoteke pretvorene u JPEG potreban je TypeScript kodek.

Ovdje je opisana implementacija formata v3 otvorenog koda; stariji čitači koji su već u uporabi mogu odbiti njezine datoteke. Navedene ovisnosti instalirajte u virtualnom okruženju. Potpuni izvorni kod i neobavezna preuzimanja slijede nakon primjera u članku.

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