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.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"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 Δ +1G₀10110101181 → 181 Δ 0B₀0101110094 → 92 Δ -2R₁11001011203 → 203 Δ 0def 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) == 0xD3Koliko 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[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
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.
[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)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)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.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → 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_quantizerRazmak 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.
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 boundaryNajprije 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_bytesTestiranje 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Š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.
Č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.txtfrom 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)

