01

Saladus ja kandjapilt

SnapSeal krüptib piltide, failide või teksti algsed baidid koos metaandmetega ning peidab saadud ümbrise kandjapilti. Kaitstud faili avamiseks vajab saaja parooli, kuid mitte serverit ega algset kandjapilti.

Krüptograafia hoiab sisu konfidentsiaalsena, pildisteganograafia annab sellele kandja. Kumbki võte pole uus. Näites kasutatud illustratsioon ja testparool on teadlikult avaldatud.

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

Kaks alumist bitti

Igal RGB-pikslil on kolm 8-bitist kanalit. Selles näites loeb kodeerija kanaleid vasakult paremale, jätab alfakanali vahele, säilitab iga kanali 6 ülemist bitti ja asendab 2 alumist. Nii mahub 1 bait 4 kanalisse.

Liiguta liugurit, et näha esiletõstetud bittide muutumist. Ainuüksi see alumiste bittide kirjutamine muudab kanali väärtust kuni 3 võrra; valikuline JPEG-taastekanal ja muu kujundus võivad kandjapilti rohkem muuta.

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

Kui palju andmeid pildile mahub?

Kolm kanalit, igas 2 bitti, annavad piksli kohta 6 bitti. 1024×1024 kandjapilti mahub enne 16-baidist pikslipaketi päist 786 432 baiti; pärast päist jääb 786 416 baiti. Illustratsioon reserveerib osa piksleid ja vähendab mahtu.

Kindel marker määrab paketi paigutuse ning pikkus näitab, kus ümbris lõpeb. Paketi ja krüptimise versiooninumbrid kuuluvad eri nimeruumidesse: PNG2-pakett võib kanda v3 krüptograafilist ümbrist.

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

Võtme tuletamine ja ümbrise autentimine

SnapSeali v3-profiil kasutab Argon2id v1.3 algoritmi, et tuletada UTF-8-vormingus paroolist ja juhuslikust 16-baidisest soolast 32-baidine võti. Selleks kulub 64 MiB mälu, 3 läbimist ja 1 rada. Lugeja peab enne võtme tuletamist kontrollima, et tööparameetrid vastavad toetatud kindlatele väärtustele.

AES-256-GCM krüptib algsed baidid ja metaandmed. Selle 16-baidine autentimismärgend autendib nii krüptoteksti kui ka avaliku 48-baidise päise. Kui autentimine ebaõnnestub, ei tohi lugeja avateksti väljastada.

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

Sõnum on pikslites

Andmed asuvad dekodeeritud RGB-väärtustes, mitte PNG-metaandmetes. Kahe PNG-faili baidid võivad erineda, kuid kui nende dekodeeritud RGB-väärtused on samad, säilib ka alumistesse bittidesse kirjutatud sõnum. Kuvatõmmis, pikslite muutmine või suuruse muutmine võib seevastu andmed hävitada, kuigi pilt jääb äratuntavaks.

Kui lähteandmete pakkimist pole eraldi valitud, taastuvad algsed baidid ja metaandmed täpselt. Failinimi on krüptitud, kuid ilma täiteandmeteta reedab ümbris siiski ligikaudse suuruse.

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

Pakkimisvastane kaitse: teine tee läbi JPEG-i

Pakkimisvastane kaitse püüab andmeid taastada JPEG-ist, mille mõõtmed on jäänud samaks. See on parima võimaliku tulemuse poole püüdlev lahendus, mitte garantii. Samast krüptitud ümbrisest on kadudeta koopia PNG RGB-kanalite alumistes bittides ja soovi korral taastekoopia pildi sageduskordajates.

See ei takista pakkimist ega luba, et andmed peavad vastu suvalise sõnumirakenduse töötlusele. Samuti ei muuda see vormingut tuvastamatuks.

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

Pikslitest sageduskordajateni

JPEG teisendab 8×8 heleduseplokid diskreetse koosinusteisendusega ehk DCT-ga sageduskordajateks ja kvandib need. SnapSeal kasutab valitud kordajaid andmete kandmiseks, seades need üksteisest piisavalt kaugetele väärtustele. Nii jääb lugejale varu, et taluda mõningaid JPEG-kodeerimisel tekkivaid muutusi.

Veaparandusega transpordipäis määrab taastekanali paigutuse. See ei autendi avalikku kandjapildi illustratsiooni ning lugeja ei vaja algset kandjapilti.

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

Ruumi sümbolite vahel

QIM ehk kvantimisindeksi modulatsioon seob sümbolid üksteisest eemal paiknevate väärtustega. Ühe biti õpetlikus mudelis tähistavad 24 kordsed vaheldumisi 0 ja 1. Biti 1 talletamiseks muudab kodeerija väärtuse 70 väärtuseks 72. Kui sellele lisandub 11, loetakse endiselt 1; kui lisandub 13, ületatakse piir ja bitiks saab 0. Liiguta liugurit, et piiri näha.

Tegelik kanal talletab Gray koodi abil igas valitud kordajas 1 kuni 3 bitti. Pythoni QIM-koodinäide selgitab põhimõtet, kuid ei ole JPEG-dekooder.

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

Kõigepealt parandus, siis autentimine

Reed–Solomon (255,223) lisab 223 andmebaidile 32 paarsusbaiti ja suudab parandada kuni 16 vigast baiti igas koodsõnas. Põimimine ja pildil hajutatud plokid aitavad kahjustusi jaotada, et need ei koonduks üksikutesse koodsõnadesse.

Lugeja parandab esmalt transpordiandmed, kontrollib seejärel CRC-d ja autendib lõpuks ümbrise AES-GCM-iga. CRC kontrollib edastuse terviklust, mitte krüptograafilist autentsust. Ka edukas parandus ei luba avateksti väljastada, kui GCM-kontroll ebaõnnestub.

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

Mõlema kandja ühine proov

Mõlemad koopiad kasutavad samu piksleid, mistõttu alumiste bittide kirjutamine mõjutab ka DCT-kordajaid. Enesetestides kasutatakse seetõttu lõplikku ühendatud kandjapilti. Taastet lubatakse vaid siis, kui ümbris taastub täpselt nii brauseri kohalikes JPEG-testides kvaliteediga 0.60 kui ka korduva kodeerimise järel kvaliteetidega 0.85→0.60. Ühes koodsõnas tohib seejuures olla parandatud kuni 12 baiti, mis jääb alla 16-baidise paranduspiiri. Need sätted kirjeldavad kohalikke teste, mitte kõigile JPEG-idele kehtivat garantiid.

Automaatseid katseid tehakse kuni 280 000 krüptitud baidi korral, sealhulgas päised ja muu lisamaht. Liiasus ja tugevamad signaalid kulutavad kandjapildi kvaliteeti ja mahutavust; privaatset originaali see kanal uuesti ei paki. Kui testid ebaõnnestuvad, kasutatakse kadudeta PNG- või ZIP-faili. Kärpimine, suuruse muutmine, pööramine või mõni teine kodeerija võib taastamise nurjata. Kõige kindlam on saata algne PNG või ZIP failina.

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

Mida vorming välja annab

Kindlad markerid teevad vormingu tuvastatavaks. Kandjapildi illustratsioon on avalik ja seda ei autentita illustratsioonina; selle ülemiste bittide muutmine võib peidetud sõnumi puutumata jätta. Veaparandus ja CRC tegelevad edastuskahjustustega, AES-GCM aga autendib kaitstud sisu.

Ründaja saab paroole endiselt võrguühenduseta ära arvata. Argon2id muudab iga katse kulukamaks, kuid nõrka parooli see ei päästa. Parooli taastada ei saa ning siin ei väideta, et lahendus oleks läbinud sõltumatu turvaauditi.

08

Lugemine ilma rakenduseta

Allalaaditav Python 3.12+ lugeja toetab algseid PNG-faile, sealhulgas JPEG-taastekoopiaga PNG-sid, piiratud mahuga ühe PNG-ga SnapSeali ZIP-faile ja tooreid v3-ümbriseid. Teisendatud JPEG-failide lugemiseks on vaja TypeScripti koodekit.

Siin kirjeldatud v3-teostus on avatud lähtekoodiga; varem kasutusele võetud lugejad võivad selle väljundi tagasi lükata. Paigalda loetletud sõltuvused virtuaalkeskkonda. Täielik lähtekood ja valikulised allalaaditavad failid järgnevad tekstisisestele näidetele.

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