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.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"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 Δ +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) == 0xD3Ile 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[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
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.
[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)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)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.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → 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_quantizerOdstę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.
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 boundaryNajpierw 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_bytesTest 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_000Co 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.
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.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)

