Tajomstvo a jeho nosič
SnapSeal zašifruje pôvodné bajty obrázkov, súborov alebo textu spolu s ich metadátami a výslednú obálku vloží do krycieho obrázka. Príjemca potrebuje chránený súbor a heslo, nie však server ani pôvodný krycí obrázok.
Kryptografia chráni dôvernosť obsahu, steganografia mu poskytuje nosič. Obe techniky sú známe; zaujímavé je, ako tu spolu fungujú. Ilustrácia vo verejnej ukážke aj testovacie heslo sú zámerne zverejnené.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Dva najnižšie bity
Každý pixel RGB má tri 8-bitové kanály. V tomto príklade kodér číta kanály zľava doprava, alfa kanál preskakuje, horných šesť bitov zachováva a dva najnižšie nahrádza. Do štyroch kanálov tak uloží jeden bajt.
Posuňte jazdec a sledujte zmenu zvýraznených bitov. Samotný zápis do spodných bitov zmení hodnotu kanála najviac o 3. Voliteľný nosič pre obnovu z JPEG a ďalšie úpravy vzhľadu však môžu krycí obrázok zmeniť výraznejšie.
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) == 0xD3Koľko bajtov sa zmestí do obrázka
Dva bity v každom z troch kanálov dávajú šesť bitov na pixel. Krycí obrázok s rozmermi 1024×1024 má pred započítaním 16-bajtovej hlavičky pixelového paketu kapacitu 786 432 bajtov; po jej odpočítaní zostáva 786 416 bajtov. Ilustrácia si časť pixelov vyhradí, a tým kapacitu zníži.
Pevná značka určuje usporiadanie paketu a údaj o dĺžke označuje koniec obálky. Verzie paketu a šifrovania sú na sebe nezávislé: paket PNG2 môže niesť kryptografickú obálku 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 |
Odvodenie kľúča a overenie obálky
Profil v3 používa Argon2id v1.3 na odvodenie 32-bajtového kľúča z hesla zakódovaného v UTF-8 a náhodnej 16-bajtovej soli. Vyžaduje 64 MiB pamäte, tri priechody a jednu výpočtovú vetvu. Čítačka musí pred spustením KDF overiť, že pevne stanovené pracovné parametre sú podporované.
AES-256-GCM šifruje pôvodné bajty aj metadáta. Jeho 16-bajtový autentizačný tag overuje šifrovaný obsah aj verejnú 48-bajtovú hlavičku. Ak autentizácia zlyhá, čítačka nikdy nesmie vydať otvorený text.
[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)Správa je v pixeloch
Dáta sa nachádzajú v dekódovaných hodnotách RGB, nie v metadátach PNG. Dva súbory PNG môžu mať odlišné bajty, a predsa niesť tú istú správu v spodných bitoch, pokiaľ ich dekódované hodnoty RGB zostanú nezmenené. Snímka obrazovky, úprava pixelov či zmena rozmerov naopak môžu dáta zničiť, hoci obrázok zostane rozpoznateľný.
Ak nebola výslovne zvolená kompresia zdroja, pri obnove sa presne vrátia pôvodné bajty aj metadáta. Názov súboru je zašifrovaný, no bez doplnkovej výplne obálka stále prezrádza približnú veľkosť.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Antikompresia: druhá cesta cez JPEG
Antikompresia je pokus o obnovu z JPEG pri zachovaní rovnakých rozmerov obrázka, nie záruka úspechu. Tá istá šifrovaná obálka sa ukladá do spodných bitov RGB pôvodného PNG a voliteľne aj do frekvenčných koeficientov obrázka.
Tento postup nezabráni kompresii a formát zostáva zistiteľný.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
Od pixelov k frekvenciám
JPEG používa diskrétnu kosínusovú transformáciu (DCT), ktorou prevádza bloky jasu s rozmermi 8×8 na frekvenčné koeficienty, a potom ich kvantuje. SnapSeal zapisuje dáta do vybraných koeficientov v zámerne rozostúpených hodnotách. Čítačka tak môže tolerovať niektoré zmeny spôsobené kódovaním JPEG.
Prenosová hlavička chránená opravou chýb určuje rozloženie obnovovacieho kanála. Neautentizuje však verejnú kryciu ilustráciu a čítačka pôvodný krycí obrázok nepotrebuje.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerRezerva medzi symbolmi
QIM, teda modulácia kvantizačného indexu, priraďuje symbolom rozostúpené pozície. V názornom jednobitovom modeli sa pri kroku 24 násobky striedavo čítajú ako 0 a 1. Pri ukladaní hodnoty 1 sa 70 zmení na 72. Zmena o +11 od hodnoty 72 sa ešte dekóduje ako 1; pri +13 sa prekročí hranica a bit sa preklopí. Posuňte jazdec a pozrite sa, kde hranica leží.
Skutočný kanál ukladá jeden až tri bity na vybraný koeficient pomocou Grayovho kódu. Ukážka QIM v Pythone vysvetľuje princíp, ale nedekóduje JPEG.
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 boundaryNajprv oprava, potom autentizácia
Reed–Solomon (255,223) pridáva k 223 dátovým bajtom 32 paritných bajtov a dokáže opraviť najviac 16 chybných bajtov v jednom kódovom slove. Prekladanie dát a rozmiestnenie blokov po obrázku pomáhajú rozptýliť poškodenie, aby sa nesústredilo do niekoľkých kódových slov.
Čítačka najprv opraví prenosové dáta, potom skontroluje CRC a napokon autentizuje obálku pomocou AES-GCM. CRC overuje integritu prenosu, nie kryptografickú pravosť. Ani úspešná oprava neoprávňuje vydať otvorený text, ak kontrola GCM zlyhá.
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_bytesTest výsledného nosiča
Obe kópie využívajú tie isté pixely, takže zápis spodných bitov mení aj koeficienty DCT. Testy preto pracujú s výsledným obrázkom s oboma nosičmi. Obnova sa deklaruje iba vtedy, keď sa celá obálka presne obnoví pri lokálnom teste JPEG v prehliadači s kvalitou 0.60 aj pri opakovanom kódovaní 0.85→0.60, a to s opravou najviac 12 bajtov na kódové slovo. Je to pod limitom 16 opraviteľných bajtov. Výsledky týchto lokálnych testov nezaručujú úspech s každým kodérom JPEG.
Automatické pokusy sa zastavia pri 280 000 šifrovaných bajtoch vrátane réžie. Redundancia a silnejší signál uberajú z kvality a kapacity krycieho obrázka; súkromný originál sa pritom znovu nekomprimuje. Ak testy zlyhajú, použije sa bezstratové PNG alebo ZIP. Orezanie, zmena rozmerov, otočenie či iný kodér môžu obnovu zmariť. Najspoľahlivejšie je preto poslať pôvodné PNG alebo ZIP ako súbor.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Čo formát prezrádza
Pevné značky umožňujú formát rozpoznať. Krycia ilustrácia je verejná a ako ilustrácia nie je autentizovaná; zmeny jej horných bitov môžu vloženú správu ponechať neporušenú. Oprava chýb a CRC riešia poškodenie pri prenose, zatiaľ čo AES-GCM autentizuje chránený obsah.
Útočník môže heslá stále skúšať offline. Argon2id každý pokus predražuje, slabé heslo však nezachráni. Obnova zabudnutého hesla nie je možná a netvrdí sa ani to, že formát prešiel nezávislým bezpečnostným auditom.
Čítanie bez aplikácie
Nezávislá čítačka pre Python 3.12+ podporuje pôvodné súbory PNG vrátane tých s obnovovacou kópiou pre JPEG, archívy SnapSeal ZIP s jediným PNG a stanovenými limitmi aj surové obálky v3. Na súbory skonvertované do JPEG je potrebný kodek v TypeScripte.
Opísaná implementácia v3 má otvorený zdrojový kód; staršie nasadené čítačky môžu jej súbory odmietnuť. Uvedené závislosti nainštalujte vo virtuálnom prostredí. Úplný zdrojový kód a voliteľné súbory na stiahnutie nasledujú po ukážkach.
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)

