01

Skrivnost in nosilna slika

SnapSeal zašifrira izvirne bajte slik, datotek ali besedila skupaj z metapodatki in nastalo ovojnico vgradi v nosilno sliko. Prejemnik potrebuje zaščiteno datoteko in geslo, ne pa strežnika ali izvirne nosilne slike.

Kriptografija skrbi za zaupnost vsebine, steganografija pa zagotovi nosilec. Obe zamisli sta že znani; zanimivo je, kako tu delujeta skupaj. Ilustracija in preizkusno geslo v javni predstavitvi sta namenoma objavljena.

  1. original_bytes + metadata
  2. Argon2id → AES-GCM
  3. header + ciphertext + tag
  4. RGB → PNG
password = "Public SnapSeal article example"
GitHub · snapseal-codec
02

Spodnja bita kanalov RGB

Vsaka pika RGB ima tri 8-bitne kanale. V tem primeru kodirnik kanale bere od leve proti desni, kanal alfa preskoči, zgornjih šest bitov pa ohrani in zamenja spodnja dva. Štirje kanali tako prenesejo en bajt.

Premaknite drsnik in opazujte spreminjanje označenih bitov. Sam ta zapis spremeni vrednost kanala največ za 3; dodatni nosilec JPEG in druge oblikovne spremembe lahko nosilno sliko spremenijo bolj.

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 prostora je na voljo?

Trije kanali po dva bita dajo šest bitov na piko. Nosilna slika z merami 1024×1024 ima prostora za 786 432 bajtov pred 16-bajtno glavo paketa v pikah oziroma 786 416 bajtov po njej. Ilustracija zase rezervira del pik in s tem zmanjša zmogljivost.

Stalna oznaka določa razporeditev paketa, zapis dolžine pa pove, kje se ovojnica konča. Različice paketov in šifriranja so neodvisne: paket PNG2 lahko vsebuje kriptografsko ovojnico 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

Izpeljava ključa in overjanje ovojnice

Profil SnapSeal v3 uporablja Argon2id v1.3, ki iz gesla, zapisanega v UTF-8, in naključne 16-bajtne soli izpelje 32-bajtni ključ. Pri tem porabi 64 MiB pomnilnika, opravi tri prehode in uporablja eno stezo. Bralnik mora pred zagonom KDF preveriti, ali so parametri med podprtimi fiksnimi vrednostmi.

AES-256-GCM zašifrira izvirne bajte in metapodatke. Njegova 16-bajtna oznaka overi šifrirano vsebino in javno 48-bajtno glavo. Če overjanje ne uspe, bralnik nikoli ne sme razkriti čistopisa.

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

Sporočilo je v pikah

Podatki so zapisani v dekodiranih vrednostih RGB, ne v metapodatkih PNG. Dve datoteki PNG se lahko razlikujeta v bajtih, pa vendar ohranita isto sporočilo v spodnjih bitih, če so njune dekodirane vrednosti RGB enake. Posnetek zaslona, urejanje pik ali sprememba velikosti pa lahko podatke uniči, čeprav je slika še vedno prepoznavna.

Če stiskanje vira ni bilo izrecno izbrano, se izvirni bajti in metapodatki obnovijo natančno. Ime datoteke je šifrirano, brez dopolnjevanja ovojnice pa je iz njene velikosti še vedno mogoče sklepati na približno velikost vsebine.

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

Odpornost proti stiskanju: druga pot skozi JPEG

Odpornost proti stiskanju pomeni poskus obnove iz JPEG pri nespremenjenih merah slike, ne pa zagotovila za uspeh. Ista šifrirana ovojnica je zapisana v spodnjih bitih RGB izvirnega PNG in po izbiri še v frekvenčnih koeficientih slike.

Tak zapis ne prepreči stiskanja, format pa ostaja zaznaven.

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

Od pik do frekvenc

JPEG z diskretno kosinusno transformacijo (DCT) pretvori bloke svetilnosti velikosti 8×8 v frekvenčne koeficiente in jih nato kvantizira. SnapSeal podatke zapiše v izbrane koeficiente na namerno razmaknjene vrednosti, zato lahko bralnik prenese nekatere spremembe, ki nastanejo pri kodiranju JPEG.

Transportna glava, zaščitena s popravljanjem napak, določa razporeditev obnovitvenega zapisa. S tem ne overja javne nosilne ilustracije; bralnik izvirne nosilne slike ne potrebuje.

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

Razmik med simboli

QIM oziroma modulacija s kvantizacijskim indeksom simbolom dodeli razmaknjene položaje. V učnem modelu z enim bitom večkratniki 24 izmenično predstavljajo 0 in 1. Kodirnik za zapis vrednosti 1 premakne 70 na 72. Sprememba za +11 glede na 72 se še vedno dekodira kot 1, pri +13 pa je meja presežena in bit se obrne. Premaknite drsnik in poiščite to mejo.

Dejanski kanal v vsak izbrani koeficient zapiše od enega do treh bitov z Grayjevim kodiranjem. Pythonov odlomek QIM ponazarja načelo, vendar ne dekodira JPEG.

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

Najprej popravilo, nato overjanje

Reed–Solomon (255,223) doda 32 paritetnih bajtov k 223 podatkovnim bajtom in lahko popravi do 16 napačnih bajtov v vsaki kodni besedi. Prepletanje in bloki, razporejeni po sliki, pomagajo razpršiti poškodbe, da se ne nakopičijo v nekaj kodnih besedah.

Bralnik najprej popravi transportni zapis, preveri njegov CRC in nato ovojnico overi z AES-GCM. CRC preverja celovitost prenosa, ni pa kriptografsko overjanje. Tudi uspešno popravilo ne dovoljuje razkritja čistopisa, če preverjanje GCM ne uspe.

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

Preizkus končnega nosilca

Obe kopiji zasedata iste pike, zato zapis v spodnje bite vpliva tudi na koeficiente DCT. Preizkusi zato uporabljajo končno sliko z obema nosilcema. Obnova je ponujena le, če se ovojnica pri lokalnih preizkusih JPEG v brskalniku obnovi povsem natančno: pri kakovosti 0.60 in pri zaporednem kodiranju 0.85→0.60. Pri tem sme biti v posamezni kodni besedi popravljenih največ 12 bajtov, torej manj od meje 16. Rezultati teh lokalnih preizkusov ne zagotavljajo uspeha z vsakim kodirnikom JPEG.

Samodejni poskusi se ustavijo pri 280 000 šifriranih bajtih, vključno z dodatnimi podatki. Redundanca in močnejši signali zmanjšujejo kakovost in zmogljivost nosilne slike; zasebnega izvirnika pri tem ne stiskajo znova. Če preizkusi ne uspejo, se za prenos uporabi PNG ali ZIP brez izgub. Obrezovanje, sprememba velikosti, vrtenje ali drug kodirnik lahko obnovo onemogočijo, zato je najzanesljiveje izvirni PNG ali ZIP poslati kot datoteko.

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

Kaj format razkrije

Stalne oznake omogočajo zaznavo formata. Nosilna ilustracija je javna in ni overjena kot ilustracija; spremembe njenih zgornjih bitov lahko pustijo vgrajeno sporočilo nedotaknjeno. Popravljanje napak in CRC obravnavata poškodbe pri prenosu, AES-GCM pa overja zaščiteno vsebino.

Napadalec lahko gesla še vedno ugiba brez povezave. Argon2id posamezni poskus podraži, ne more pa rešiti šibkega gesla. Obnova gesla ni mogoča, opis pa tudi ne trdi, da je bila opravljena neodvisna varnostna revizija.

08

Branje brez aplikacije

Neodvisni bralnik za Python 3.12+ podpira izvirne datoteke PNG, tudi tiste z obnovitveno kopijo JPEG, arhive SnapSeal ZIP z enim samim PNG in določenimi omejitvami velikosti ter surove ovojnice v3. Za datoteke, pretvorjene v JPEG, je potreben kodek TypeScript.

Opisana je odprtokodna izvedba v3; starejši že nameščeni bralniki lahko njene datoteke zavrnejo. Navedene odvisnosti namestite v virtualno okolje. Celotna izvorna koda in izbirni prenosi sledijo primerom v besedilu.

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