01

Тајна и слика носилац

SnapSeal полази од изворних бајтова слика, датотека или текста. Шифрује их заједно с метаподацима, а затим добијени омотач уписује у слику носилац. Примаоцу су потребни заштићена датотека и лозинка, али не и сервер или изворна слика носилац.

Криптографија чува поверљивост садржаја, док стеганографија обезбеђује начин преноса. Обе идеје су добро познате; изазов је јасно разграничити њихове улоге. Илустрација и лозинка за тестирање у овом примеру намерно су јавне.

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

Два најнижа бита

Сваки RGB пиксел има три 8-битна канала. У овом примеру кодер пролази кроз канале слева надесно, прескаче алфа-канал, чува шест виших битова и замењује два најнижа. Четири канала тако могу да понесу један бајт.

Померајте клизач да видите како се истакнути битови мењају. Сам овај упис у ниске битове мења вредност канала највише за 3; додатни JPEG носилац и друге измене изгледа могу да промене слику и више од тога.

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

Колико бајтова стаје у слику

Три канала са по два бита дају шест битова по пикселу. Слика носилац величине 1024×1024 има места за 786.432 бајта пре заглавља пикселског пакета од 16 бајтова, односно за 786.416 бајтова после њега. Илустрације заузимају део пиксела и тиме смањују расположиви капацитет.

Фиксна ознака одређује распоред пакета, а записана дужина показује где се омотач завршава. Верзије пакета и шифровања означавају се независно: PNG2 пакет може да носи криптографски омотач 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

Извођење кључа и аутентификација омотача

Профил v3 користи Argon2id v1.3 да од лозинке кодиране у UTF-8 и насумичне соли од 16 бајтова изведе кључ од 32 бајта. Поступак користи 64 MiB меморије, три пролаза и једну паралелну путању. Пре покретања KDF-а читач мора да провери да ли параметри одговарају фиксним подржаним вредностима.

AES-256-GCM шифрује изворне бајтове и метаподатке. Аутентификациона ознака од 16 бајтова штити и шифровани садржај и јавно заглавље од 48 бајтова. Ако аутентификација не успе, читач ни у ком случају не сме да испоручи отворени текст.

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

Порука је у пикселима

Подаци се налазе у декодираним RGB вредностима, а не у PNG метаподацима. Две PNG датотеке могу да имају различите бајтове, а да носе исту поруку у ниским битовима, ако су им декодиране RGB вредности непромењене. Снимак екрана, измена пиксела или промена величине, напротив, могу да униште податке иако слика остаје препознатљива.

Ако компресија извора није изричито изабрана, при опоравку се изворни бајтови и метаподаци враћају тачно. Назив датотеке је шифрован, али без допуне омотач и даље открива приближну величину.

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

Опоравак из JPEG-а: други пут до података

Заштита од компресије овде значи покушај опоравка из JPEG-а који је задржао исте димензије, без гаранције успеха. Исти шифровани омотач има копију без губитака у ниским RGB битовима PNG-а, а по потреби и копију за опоравак у фреквенцијским коефицијентима слике.

То не спречава компресију и не обећава да ће подаци преживети произвољне апликације за размену порука. Не чини ни сам формат неоткривљивим.

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

Од пиксела до фреквенција

JPEG примењује дискретну косинусну трансформацију (DCT) на блокове осветљености величине 8×8, добијене коефицијенте затим квантизује. SnapSeal користи одабране коефицијенте за пренос података, постављајући их на намерно размакнуте вредности. Тако остаје простора да читач поднесе неке измене настале при JPEG кодирању.

Транспортно заглавље са исправљањем грешака описује распоред података за опоравак. Оно не аутентификује јавну илустрацију носиоца, а читачу изворна слика носилац није потребна.

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

Размак између симбола

QIM, односно модулација индексом квантизације, придружује симболима размакнуте положаје. У поједностављеном моделу за један бит, умношци броја 24 наизменично представљају 0 и 1. Да би уписао 1, кодер помера 70 на 72. Промена од +11 у односу на 72 и даље се чита као 1; на +13 прелази се граница и бит се мења. Померите клизач да видите где је та граница.

Стварни канал користи један до три бита по одабраном коефицијенту и Грејев код. Python исечак за QIM само илуструје начело рада; није 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

Прво исправка, па аутентификација

Reed–Solomon (255,223) додаје 32 паритетна бајта на 223 бајта података и може да исправи до 16 погрешних бајтова по кодној речи. Преплитање и распоређивање блокова по слици помажу да се оштећења рашире, уместо да се усредсреде на неколико кодних речи.

Читач најпре исправља транспортне податке, затим проверава CRC и на крају аутентификује омотач помоћу AES-GCM-а. CRC служи за проверу преноса, не за криптографску аутентификацију. Чак и ако је исправка успела, отворени текст не сме да се испоручи ако GCM провера не прође.

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

Тест коначне слике носиоца

Обе копије користе исте пикселе, па упис у ниске битове утиче и на DCT коефицијенте. Зато се провере изводе на коначној слици са обе копије. У локалним тестовима тачан омотач је опорављен након JPEG кодирања у прегледачу при квалитету 0,60 и након узастопног кодирања 0,85→0,60, уз највише 12 исправљених бајтова по кодној речи. То је испод границе исправке од 16 бајтова. Ови тестови не гарантују исти исход са другим JPEG кодерима.

Аутоматски покушаји ограничени су на 280.000 шифрованих бајтова, укључујући додатне податке. Редунданса и јачи сигнали троше квалитет и капацитет слике носиоца; не компресују поново приватни оригинал. Ако тестови не успеју, користи се PNG или ZIP без губитака. Исецање, промена величине, ротација или други кодер могу да осујете опоравак, па је најбоље послати изворни PNG или ZIP као датотеку.

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

Шта формат открива

Фиксне ознаке омогућавају да се формат препозна. Илустрација носиоца је јавна и не аутентификује се као илустрација; промене њених виших битова могу да оставе уграђену поруку нетакнутом. Исправљање грешака и CRC баве се оштећењима у преносу, док AES-GCM аутентификује заштићени садржај.

Нападач и даље може да погађа лозинке без приступа мрежи. Argon2id поскупљује сваки покушај, али не може да спасе слабу лозинку. Не постоји опоравак лозинке, нити се овде тврди да је спроведена независна безбедносна ревизија.

08

Читање без апликације

Независни читач за Python 3.12+ подржава изворне PNG датотеке, укључујући оне са JPEG копијом за опоравак, SnapSeal ZIP архиве са једним PNG-ом у оквиру подржаних ограничења и сирове омотаче v3. За датотеке претворене у JPEG потребан је TypeScript кодек.

Описани формат је v3 имплементација отвореног кода; старији већ објављени читачи могу да одбију њене датотеке. Формат користи постојеће криптографске примитиве, уз јасно наведена ограничења и независни читач. Наведене зависности инсталирајте у виртуелном окружењу. Комплетан изворни код и додатна преузимања следе после примера у тексту.

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