01

Sekret i obraz nośny

SnapSeal bierze za punkt wyjścia oryginalne bajty obrazów, plików lub tekstu. Kodek szyfruje je wraz z metadanymi, a powstałą kopertę umieszcza w obrazie nośnym. Do odczytu potrzebne są zabezpieczony plik i hasło, ale nie oryginalny obraz nośny.

Kryptografia chroni poufność zawartości, a steganografia obrazu dostarcza jej nośnik. Obie opierają się na znanych ideach. Ilustracja i hasło testowe w publicznym przykładzie zostały celowo udostępnione wszystkim.

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

Dwa najmłodsze bity

Każdy piksel RGB ma trzy 8-bitowe kanały. W tym przykładzie koder odczytuje je od lewej do prawej, pomija kanał alfa, zachowuje sześć starszych bitów i zastępuje dwa najmłodsze. Cztery kanały wystarczają więc do zapisania jednego bajtu.

Przesuń suwak, aby zobaczyć zmianę wyróżnionych bitów. Sam ten zapis zmienia wartość kanału najwyżej o 3. Opcjonalny kanał odzyskiwania JPEG i inne modyfikacje wyglądu mogą zmienić obraz nośny bardziej.

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

Ile danych zmieści obraz?

Trzy kanały po dwa bity dają sześć bitów na piksel. Obraz nośny 1024×1024 mieści 786 432 bajty przed uwzględnieniem 16-bajtowego nagłówka pakietu pikselowego, a 786 416 bajtów po jego odliczeniu. Ilustracja rezerwuje część pikseli i zmniejsza pojemność.

Stały znacznik wskazuje układ pakietu, a pole długości wyznacza koniec koperty. Wersje pakietu i szyfrowania należą do różnych przestrzeni numeracji: pakiet PNG2 może zawierać kopertę kryptograficzną 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

Od hasła do uwierzytelnionej koperty

Profil v3 SnapSeal używa Argon2id v1.3 do wyprowadzenia 32-bajtowego klucza z hasła zakodowanego w UTF-8 i losowej 16-bajtowej soli. Algorytm korzysta z 64 MiB pamięci, wykonuje trzy przebiegi i używa jednej ścieżki. Przed uruchomieniem KDF czytnik musi sprawdzić, czy podane parametry odpowiadają stałym, obsługiwanym wartościom.

AES-256-GCM szyfruje oryginalne bajty i metadane. Jego 16-bajtowy znacznik uwierzytelnia szyfrogram oraz publiczny 48-bajtowy nagłówek. Jeśli uwierzytelnienie się nie powiedzie, czytnik nie może ujawnić tekstu jawnego.

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

Wiadomość jest w pikselach

Dane znajdują się w zdekodowanych wartościach RGB, a nie w metadanych PNG. Dwa pliki PNG mogą różnić się bajtami, lecz zachować tę samą wiadomość w najmłodszych bitach, o ile ich zdekodowane wartości RGB pozostaną bez zmian. Zrzut ekranu, edycja pikseli lub zmiana rozmiaru mogą natomiast zniszczyć dane, choć obraz nadal będzie rozpoznawalny.

Jeśli nie wybrano kompresji źródła, odzyskiwane są dokładnie oryginalne bajty i metadane. Nazwa pliku jest zaszyfrowana, ale bez dopełnienia koperta ujawnia przybliżony rozmiar zawartości.

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

Antykompresja: druga droga przez JPEG

Antykompresja to próba odzyskania danych z JPEG, który zachował pierwotne wymiary. Ta sama zaszyfrowana koperta ma bezstratną kopię w najmłodszych bitach RGB pliku PNG, a opcjonalnie także kopię odzyskiwania we współczynnikach częstotliwości obrazu.

Mechanizm nie zapobiega kompresji ani nie czyni formatu niewykrywalnym. Odzyskanie danych nie zawsze się udaje.

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

Od pikseli do częstotliwości

JPEG używa dyskretnej transformaty cosinusowej (DCT), by przekształcić bloki luminancji 8×8 we współczynniki częstotliwości, które następnie kwantyzuje. SnapSeal zapisuje dane w wybranych współczynnikach, ustawiając je na celowo oddalonych od siebie wartościach. Odstępy pozwalają czytnikowi tolerować część zmian wprowadzanych przez kodowanie JPEG.

Nagłówek transportowy chroniony korekcją błędów wskazuje układ danych odzyskiwania. Nie uwierzytelnia on publicznej ilustracji jako obrazu, a czytnik nie potrzebuje jej oryginału.

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

Odstęp między symbolami

QIM, czyli modulacja indeksu kwantyzacji, przypisuje symbole do oddalonych od siebie pozycji. W jednobitowym modelu wielokrotności 24 oznaczają na przemian 0 i 1. Aby zapisać 1, koder przesuwa 70 do 72. Zmiana o +11 względem 72 nadal daje w odczycie 1; przy +13 przekroczona zostaje granica i bit zmienia wartość. Przesuń suwak, by zobaczyć tę granicę.

W rzeczywistym kanale każdy wybrany współczynnik przenosi od jednego do trzech bitów z użyciem kodu Graya. Fragment QIM w Pythonie ilustruje zasadę działania, ale nie jest kompletnym dekoderem 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

Najpierw naprawa, potem uwierzytelnienie

Reed–Solomon (255,223) dodaje 32 bajty parzystości do 223 bajtów danych i pozwala poprawić do 16 błędnych bajtów w słowie kodowym. Przeplot i rozproszenie bloków w obrazie pomagają rozłożyć uszkodzenia, zamiast skupiać je w kilku słowach kodowych.

Czytnik naprawia dane transportowe, sprawdza ich CRC, a następnie uwierzytelnia kopertę za pomocą AES-GCM. CRC służy kontroli integralności transportu, nie uwierzytelnieniu kryptograficznemu. Nawet po udanej naprawie nie wolno ujawnić tekstu jawnego, jeśli kontrola GCM się nie powiedzie.

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

Test końcowego obrazu

Obie kopie zajmują te same piksele, więc zapis najmłodszych bitów zaburza również współczynniki DCT. Dlatego testowany jest końcowy obraz z oboma nośnikami. W lokalnych testach kodowania JPEG w przeglądarce udało się odzyskać dokładnie tę samą kopertę przy jakości 0.60 oraz po ponownym kodowaniu 0.85→0.60. W jednym słowie kodowym naprawiono najwyżej 12 bajtów, poniżej limitu korekcji wynoszącego 16. Są to wyniki lokalnych testów, a nie gwarancja dla wszystkich koderów JPEG.

Automatyczne próby kończą się przy 280 000 zaszyfrowanych bajtów, wliczając narzut. Nadmiarowość i silniejsze sygnały kosztują jakość obrazu nośnego oraz pojemność; nie powodują ponownej kompresji prywatnego oryginału. Jeśli testy zawiodą, pozostaje bezstratny PNG lub ZIP. Kadrowanie, zmiana rozmiaru, obrót czy inny koder mogą uniemożliwić odzyskanie. Najpewniejsze jest więc wysłanie oryginalnego PNG lub ZIP jako pliku.

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

Co ujawnia format

Stałe znaczniki pozwalają wykryć format. Ilustracja nośna jest publiczna i nie jest uwierzytelniana jako obraz; zmiany jej starszych bitów mogą pozostawić ukrytą wiadomość nienaruszoną. Korekcja błędów i CRC służą radzeniu sobie z uszkodzeniami transportu, natomiast AES-GCM uwierzytelnia chronioną zawartość.

Atakujący nadal może zgadywać hasła offline. Argon2id zwiększa koszt każdej próby, ale nie uratuje słabego hasła. Nie ma mechanizmu odzyskiwania hasła ani deklaracji niezależnego audytu bezpieczeństwa.

08

Odczyt bez aplikacji

Format korzysta z istniejących prymitywów kryptograficznych i jasno określa swoje ograniczenia. Niezależny czytnik w Pythonie 3.12+ obsługuje oryginalne pliki PNG, także te z kopią odzyskiwania JPEG, archiwa SnapSeal ZIP z jednym plikiem PNG i określonymi limitami oraz surowe koperty v3. Do odczytu plików przekonwertowanych na JPEG potrzebny jest kodek TypeScript.

Opisany format to implementacja v3 o otwartym kodzie źródłowym; starsze wdrożone czytniki mogą odrzucić utworzone przez nią pliki. Wskazane zależności należy zainstalować w środowisku wirtualnym. Pełny kod źródłowy i opcjonalne pliki do pobrania znajdują się za przykładami.

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