Atskirkime paslaptį nuo nešiklio
SnapSeal pradeda nuo originalių vaizdų, failų ar teksto baitų. Kodekas juos užšifruoja kartu su metaduomenimis, o gautą šifruotą paketą įrašo į nešiklio vaizdą. Gavėjui reikia apsaugoto failo ir slaptažodžio; pradinio nešiklio ar serverio nereikia.
Kriptografija saugo turinio konfidencialumą, o steganografija suteikia nešiklį. Abu principai seniai žinomi; svarbu aiškiai atskirti jų vaidmenis. Demonstracinė iliustracija ir bandomasis slaptažodis sąmoningai paskelbti viešai.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Du žemiausieji bitai
Kiekvieną RGB pikselį sudaro trys 8 bitų kanalai. Šiame pavyzdyje koduotuvas kanalus skaito iš kairės į dešinę, alfa kanalą praleidžia, šešis aukštesniuosius bitus išsaugo, o du žemiausiuosius pakeičia. Taip vienam baitui įrašyti pakanka keturių kanalų.
Pajudinkite slankiklį ir stebėkite, kaip keičiasi pažymėti bitai. Vien šis žemųjų bitų įrašymas kanalo reikšmę pakeičia ne daugiau kaip 3; papildomas JPEG atkūrimo kanalas ar kiti vaizdo apdorojimo veiksmai gali ją pakeisti labiau.
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) == 0xD3Kiek baitų telpa į vaizdą?
Trys kanalai po du bitus suteikia šešis bitus vienam pikseliui. 1024×1024 nešiklyje telpa 786 432 baitai prieš 16 baitų pikselių paketo antraštę ir 786 416 baitų po jos. Iliustracijai rezervuoti pikseliai šią talpą sumažina.
Fiksuota žyma nurodo paketo struktūrą, o ilgis – kur baigiasi šifruotas paketas. Pikselių paketo ir šifravimo versijos žymimos atskirai: PNG2 paketas gali nešti v3 kriptografinį paketą.
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 |
Rakto išvedimas ir paketo autentifikavimas
SnapSeal v3 profilyje Argon2id v1.3 iš UTF-8 slaptažodžio ir atsitiktinės 16 baitų druskos išveda 32 baitų raktą. Tam naudojama 64 MiB atminties, trys perėjimai ir viena gija. Prieš paleisdamas KDF, skaitytuvas privalo patikrinti, ar nurodyti fiksuoti sąnaudų parametrai yra palaikomi.
AES-256-GCM užšifruoja originalo baitus ir metaduomenis. Jo 16 baitų autentifikavimo žyma saugo ir šifruotą turinį, ir viešą 48 baitų antraštę. Jei autentifikavimas nepavyksta, skaitytuvas jokiu būdu neturi pateikti atvirojo teksto.
[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)Žinutė gyvena pikseliuose
Duomenys įrašomi į dekoduotas RGB reikšmes, o ne į PNG metaduomenis. Todėl dviejų PNG failų baitai gali skirtis, bet jų žemųjų bitų žinutė išliks ta pati, jei dekoduotos RGB reikšmės nepasikeitė. Ekrano kopija, pikselių redagavimas ar dydžio keitimas, priešingai, gali sunaikinti duomenis, nors pats paveikslas liks atpažįstamas.
Jei nebuvo aiškiai pasirinktas šaltinio glaudinimas, atkuriami tikslūs originalo baitai ir metaduomenys. Failo vardas užšifruotas, tačiau be papildomo užpildymo šifruotas paketas išduoda apytikrį turinio dydį.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Atsparumas glaudinimui: antras kelias per JPEG
Atsparumas glaudinimui čia reiškia bandymą atkurti duomenis iš tokių pačių matmenų JPEG. Tas pats šifruotas paketas įrašomas į PNG žemuosius RGB bitus, o pasirinktinai – ir į vaizdo dažnių koeficientus kaip atkūrimo kopija. Šis būdas nei sustabdo glaudinimą, nei padaro formatą neaptinkamą; atkūrimas nėra garantuotas.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
Nuo pikselių prie dažnių
JPEG 8×8 šviesumo blokams taiko diskrečiąją kosinuso transformaciją (DCT): ji paverčia pikselių reikšmes dažnių koeficientais, kurie tada kvantuojami. SnapSeal parinktus koeficientus nustato į sąmoningai atskirtas reikšmes. Tarpai tarp jų padeda atlaikyti kai kuriuos JPEG kodavimo pokyčius.
Transporto antraštė su klaidų taisymu nurodo atkūrimo kanalo išdėstymą. Ji neautentifikuoja viešos nešiklio iliustracijos, o pačiam atkūrimui pradinio nešiklio nereikia.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerTarpai tarp simbolių
QIM, arba kvantavimo indekso moduliacija, susieja simbolius su atskirtomis reikšmėmis. Mokomajame vieno bito modelyje 24 kartotiniai pakaitomis žymi 0 ir 1. Norėdamas įrašyti 1, koduotuvas pakeičia 70 į 72. Pridėjus prie 72 dar 11, vis dar iškoduojamas 1; pridėjus 13, peržengiama riba ir bitas apsiverčia. Pajudinkite slankiklį, kad pamatytumėte tą ribą.
Tikrasis kanalas kiekviename pasirinktame koeficiente saugo nuo vieno iki trijų bitų, naudodamas Grėjaus kodą. Python QIM kodo ištrauka tik iliustruoja principą – tai nėra visas JPEG dekoderis.
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 boundaryPirma taisymas, tada autentifikavimas
Reed–Solomon (255,223) prie 223 duomenų baitų prideda 32 kontrolinius baitus ir gali pataisyti iki 16 klaidingų baitų viename kodiniame žodyje. Duomenų permaišymas ir po vaizdą išsklaidyti blokai padeda žalą paskirstyti, užuot sutelkus ją keliuose kodiniuose žodžiuose.
Skaitytuvas pirmiausia pataiso perduotus duomenis, tada patikrina CRC ir galiausiai autentifikuoja šifruotą paketą su AES-GCM. CRC tikrina transporto duomenų vientisumą, bet nėra kriptografinis autentifikavimas. Net ir sėkmingai pataisius duomenis, nepavykusi GCM patikra neleidžia pateikti atvirojo teksto.
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_bytesAbiejų kopijų bandymas kartu
Abi kopijos užima tuos pačius pikselius, todėl žemųjų bitų įrašymas pakeičia ir DCT koeficientus. Dėl to savitikrai naudojamas galutinis jungtinis nešiklis. Atkūrimo galimybė skelbiama tik tada, kai vietiniuose bandymuose tiksliai atkuriamas visas šifruotas paketas: po naršyklės JPEG kodavimo 0.60 kokybės nuostata ir po pakartotinio kodavimo 0.85→0.60. Be to, viename kodiniame žodyje turi būti pataisyta ne daugiau kaip 12 baitų – mažiau už 16 baitų taisymo ribą. Tai vietinių bandymų rezultatai, o ne universali JPEG garantija.
Automatiniai bandymai nevykdomi, kai šifruotas paketas su papildomais duomenimis viršija 280 000 baitų. Pertekliniai duomenys ir stipresni signalai mažina nešiklio kokybę bei talpą, bet privataus originalo iš naujo neglaudina. Jei bandymai nepavyksta, naudojamas nenuostolingas PNG arba ZIP perdavimas. Apkirpimas, dydžio keitimas, pasukimas ar kitas koduotuvas gali sutrukdyti atkurti duomenis, todėl patikimiausia pradinį PNG arba ZIP siųsti kaip failą.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Ką formatas atskleidžia
Fiksuotos žymos leidžia formatą aptikti. Nešiklio iliustracija yra vieša ir kaip iliustracija neautentifikuojama; jos aukštesniųjų bitų pakeitimai gali palikti įterptą žinutę nepaliestą. Klaidų taisymas ir CRC padeda tvarkytis su perdavimo pažeidimais, o AES-GCM autentifikuoja apsaugotą turinį.
Užpuolikas vis tiek gali spėlioti slaptažodžius neprisijungęs. Argon2id padidina kiekvieno spėjimo kainą, tačiau silpno slaptažodžio neišgelbės. Slaptažodžio atkurti negalima; nepriklausomas saugumo auditas čia taip pat neteigiamas.
Nepriklausomas skaitytuvas
Formatas remiasi žinomais kriptografijos primityvais, aiškiai nurodo ribas ir turi nepriklausomą skaitytuvą. Python 3.12+ skaitytuvas palaiko originalius PNG, įskaitant PNG su JPEG atkūrimo kopija, riboto dydžio SnapSeal ZIP archyvus su vienu PNG ir neapdorotus v3 šifruotus paketus. Į JPEG konvertuotiems failams reikia TypeScript kodeko.
Čia aprašyta atvirojo kodo v3 realizacija; senesni jau naudojami skaitytuvai jos sukurtų failų gali nepriimti. Nurodytas priklausomybes įdiekite virtualioje aplinkoje. Visas pradinis kodas ir pasirenkami atsisiuntimai pateikiami po pavyzdžių.
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)

