Secretul și imaginea care îl poartă
SnapSeal pornește de la octeții originali ai imaginilor, fișierelor sau textului. Codecul criptează acești octeți și metadatele lor, apoi încorporează anvelopa rezultată într-o imagine de copertă. Pentru decodare, destinatarul are nevoie de fișierul protejat și de parolă, dar nu și de coperta originală.
Criptografia asigură confidențialitatea conținutului, iar steganografia în imagini oferă suportul de transport. Ambele se bazează pe idei cunoscute. În demonstrația publică, atât ilustrația, cât și parola de test sunt publicate intenționat.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Cei doi biți inferiori
Fiecare pixel RGB are trei canale de câte 8 biți. În acest exemplu, codificatorul citește canalele de la stânga la dreapta, omite canalul alfa, păstrează cei șase biți superiori și îi înlocuiește pe cei doi inferiori. Astfel, patru canale pot transporta un octet.
Mutați glisorul pentru a vedea cum se schimbă biții evidențiați. Luată separat, această scriere modifică valoarea unui canal cu cel mult 3; copia suplimentară pentru recuperare JPEG și alte modificări grafice pot schimba coperta mai mult.
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) == 0xD3Câți octeți încap în imagine
Trei canale cu câte doi biți oferă șase biți pe pixel. O copertă de 1024×1024 are o capacitate de 786.432 de octeți înaintea antetului de pachet de 16 octeți, respectiv 786.416 după acesta. Elementele grafice rezervă pixeli și reduc capacitatea disponibilă.
Un marcaj fix identifică structura pachetului, iar un câmp de lungime indică unde se termină anvelopa. Versiunile pachetului și cele ale criptării sunt independente: un pachet PNG2 poate transporta o anvelopă criptografică 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 |
Derivarea cheii și autentificarea anvelopei
Profilul v3 al SnapSeal folosește Argon2id v1.3 pentru a deriva o cheie de 32 de octeți din parola codificată UTF-8 și o sare aleatorie de 16 octeți. Parametrii sunt 64 MiB de memorie, trei treceri și un singur fir de execuție. Înainte de a rula funcția de derivare a cheii, cititorul trebuie să verifice că factorii de cost corespund valorilor fixe acceptate.
AES-256-GCM criptează octeții originali și metadatele. Eticheta sa de 16 octeți autentifică atât textul cifrat, cât și antetul public de 48 de octeți. Dacă autentificarea eșuează, cititorul nu trebuie să elibereze niciodată textul clar.
[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)Mesajul se află în pixeli
Datele sunt în valorile RGB decodificate, nu în metadatele PNG. Două fișiere PNG pot avea octeți diferiți și totuși să păstreze același mesaj din biții inferiori, dacă valorile RGB decodificate rămân neschimbate. În schimb, o captură de ecran, o editare a pixelilor sau o redimensionare poate distruge datele, chiar dacă imaginea rămâne recognoscibilă.
Dacă nu s-a ales explicit comprimarea sursei, recuperarea restaurează exact octeții originali și metadatele. Numele fișierului este criptat, dar, în lipsa completării cu octeți de umplere, anvelopa dezvăluie în continuare dimensiunea aproximativă.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Anticompresia: o a doua cale de recuperare prin JPEG
Anticompresia încearcă să recupereze datele dintr-un JPEG care păstrează aceleași dimensiuni, fără a garanta succesul. Aceeași anvelopă criptată are o copie fără pierderi în biții inferiori RGB ai PNG-ului și, opțional, o copie de recuperare în coeficienții de frecvență ai imaginii.
Mecanismul nu împiedică comprimarea și nu face formatul nedetectabil.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
De la pixeli la frecvențe
JPEG folosește transformata discretă a cosinusului (DCT) pentru a transforma blocurile de luminanță de 8×8 în coeficienți de frecvență, pe care apoi îi cuantizează. SnapSeal stochează date în coeficienți selectați, fixându-i la valori distanțate intenționat. Astfel, cititorul are o marjă pentru unele schimbări produse de codificarea JPEG.
Un antet de transport protejat prin corectarea erorilor identifică structura copiei de recuperare. El nu autentifică ilustrația publică a copertei, iar cititorul nu are nevoie de coperta originală.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerDistanța dintre simboluri
QIM, sau modulația indicelui de cuantizare, atribuie simbolurilor poziții distanțate. În modelul didactic cu un singur bit, multiplii lui 24 alternează între 0 și 1. Pentru a stoca 1, codificatorul mută 70 la 72. O modificare de +11 pornind de la 72 se decodează tot ca 1; la +13 se trece granița, iar bitul se inversează. Mutați glisorul pentru a vedea unde apare această graniță.
Canalul real stochează între unu și trei biți în fiecare coeficient selectat, folosind codificarea Gray. Fragmentul QIM în Python ilustrează principiul; nu este un decodor JPEG complet.
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Întâi repararea, apoi autentificarea
Reed–Solomon (255,223) adaugă 32 de octeți de paritate la 223 de octeți de date și poate corecta până la 16 octeți greșiți pe cuvânt de cod. Întrețeserea și răspândirea blocurilor în imagine ajută la distribuirea deteriorării, în loc să o concentreze în câteva cuvinte de cod.
Cititorul repară datele de transport, le verifică CRC-ul, apoi autentifică anvelopa cu AES-GCM. CRC verifică integritatea transportului, dar nu este o autentificare criptografică. Chiar dacă repararea reușește, textul clar nu poate fi eliberat dacă verificarea GCM eșuează.
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_bytesTestarea suportului combinat
Cele două copii ocupă aceiași pixeli, astfel că scrierea biților inferiori perturbă și coeficienții DCT. De aceea, autotestele folosesc imaginea finală, cu ambele copii încorporate. În testele locale de codificare JPEG din browser, anvelopa a fost recuperată exact la calitatea 0.60 și după o codificare repetată 0.85→0.60. Au fost reparați cel mult 12 octeți pe cuvânt de cod, sub limita de corecție de 16. Sunt rezultate locale, nu garanții pentru toate codificatoarele JPEG.
Încercările automate se opresc la 280.000 de octeți criptați, inclusiv datele suplimentare ale formatului. Redundanța și semnalele mai puternice consumă din calitatea și capacitatea copertei; canalul nu recomprimă originalul privat. Dacă testele eșuează, se revine la livrarea fără pierderi, ca PNG sau ZIP. Decuparea, redimensionarea, rotirea ori folosirea altui codificator pot împiedica recuperarea. Cel mai sigur este să trimiteți PNG-ul sau ZIP-ul original ca fișier.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Ce dezvăluie formatul
Marcajele fixe fac formatul detectabil. Ilustrația copertei este publică și nu este autentificată ca imagine; modificarea biților ei superiori poate lăsa mesajul încorporat intact. Corectarea erorilor și CRC se ocupă de deteriorarea la transport, în timp ce AES-GCM autentifică conținutul protejat.
Un atacator poate încerca în continuare să ghicească parola offline. Argon2id mărește costul fiecărei încercări, dar nu poate salva o parolă slabă. Parola nu poate fi recuperată, iar descrierea formatului nu reprezintă o afirmație că acesta a trecut printr-un audit independent de securitate.
Citirea fără aplicație
Formatul folosește primitive criptografice existente și își precizează limitele. Un cititor independent pentru Python 3.12+ acceptă PNG-uri originale, inclusiv pe cele care conțin o copie de recuperare JPEG, arhive ZIP SnapSeal cu un singur PNG și dimensiuni limitate, precum și anvelope v3 brute. Pentru fișierele convertite în JPEG este necesar codecul TypeScript.
Formatul descris aici este implementarea v3 cu sursă deschisă; cititoarele mai vechi aflate deja în uz pot respinge fișierele produse de ea. Instalați dependențele indicate într-un mediu virtual. Codul sursă complet și descărcările opționale urmează după exemple.
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)

