01

Pisahkan rahsia daripada imej pembawa

SnapSeal menyulitkan bait asal imej, fail atau teks bersama metadatanya, kemudian menyisipkan sampul tersulit itu ke dalam piksel imej pembawa. Penerima memerlukan fail yang dilindungi dan kata laluan, tanpa memerlukan pelayan atau imej pembawa asal.

Kriptografi menjaga kerahsiaan kandungan; steganografi imej pula menyediakan pembawanya. Kedua-duanya bukan idea baharu. Ilustrasi dan kata laluan ujian dalam contoh ini sengaja diterbitkan untuk tatapan umum.

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

Gunakan dua bit terbawah

Setiap piksel RGB mempunyai tiga saluran 8 bit. Dalam contoh ini, pengekod membaca saluran dari kiri ke kanan, melangkau alfa, mengekalkan enam bit atas dan menggantikan dua bit terbawah. Maka, empat saluran boleh membawa satu bait.

Gerakkan peluncur untuk melihat bit yang diserlahkan berubah. Penulisan bit rendah ini sahaja mengubah nilai sesuatu saluran sebanyak maksimum 3; pembawa JPEG pilihan dan penggayaan lain boleh mengubah imej pembawa dengan lebih banyak.

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

Kira ruang yang tersedia

Tiga saluran dengan dua bit setiap satu memberikan enam bit bagi setiap piksel. Imej pembawa 1024×1024 mempunyai ruang untuk 786,432 bait sebelum pengepala paket piksel 16 bait, atau 786,416 bait selepasnya. Ilustrasi merizabkan sebahagian piksel dan mengurangkan kapasiti itu.

Penanda tetap mengenal pasti susun atur paket, manakala panjangnya menandakan hujung sampul. Versi paket dan versi penyulitan berada dalam ruang nama yang berbeza: paket PNG2 boleh membawa sampul kriptografi 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

Terbitkan kunci dan sahkan sampul

Profil v3 SnapSeal menggunakan Argon2id v1.3 untuk menerbitkan kunci 32 bait daripada kata laluan UTF-8 dan salt rawak 16 bait. Proses ini menggunakan memori 64 MiB, tiga laluan dan satu lorong. Pembaca mesti mengesahkan faktor kerja tetap yang disokong sebelum menjalankan KDF.

AES-256-GCM menyulitkan bait asal dan metadata. Tag 16 baitnya mengesahkan teks sifer serta pengepala awam 48 bait. Jika pengesahan gagal, pembaca sama sekali tidak boleh mengeluarkan teks biasa.

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

Piksel membawa mesej

Data terkandung dalam nilai RGB yang dinyahkod, bukan dalam metadata PNG. Dua fail PNG boleh mempunyai bait fail yang berbeza tetapi mengekalkan mesej bit rendah yang sama, selagi nilai RGB yang dinyahkod tidak berubah. Sebaliknya, tangkapan skrin, suntingan piksel atau perubahan saiz boleh memusnahkan data walaupun gambarnya masih mudah dikenali.

Melainkan pemampatan sumber dipilih secara jelas, pemulihan mengembalikan bait dan metadata asal dengan tepat. Nama fail disulitkan, tetapi tanpa padding, sampul masih mendedahkan anggaran saiznya.

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

Antimampatan: laluan kedua melalui JPEG

Antimampatan ialah laluan pemulihan secara usaha terbaik untuk JPEG yang mengekalkan dimensi asal. Sampul tersulit yang sama mempunyai salinan tanpa kehilangan dalam bit RGB rendah PNG dan, secara pilihan, salinan pemulihan dalam pekali frekuensi imej.

Ini tidak menghalang pemampatan atau menjamin data akan selamat melalui sebarang aplikasi pemesejan. Formatnya juga tetap boleh dikesan.

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

Daripada piksel kepada frekuensi

JPEG menggunakan DCT, atau transformasi kosinus diskret, untuk menukarkan blok luminans 8×8 kepada pekali frekuensi, kemudian mengkuantumkannya. SnapSeal menggunakan pekali terpilih untuk membawa data pada nilai yang sengaja dijarakkan, supaya pembaca dapat menampung sebahagian perubahan akibat pengekodan JPEG.

Pengepala pengangkutan yang dilengkapi pembetulan ralat mengenal pasti susun atur pemulihan. Pengepala itu tidak mengesahkan ilustrasi pembawa awam, dan pembaca tidak memerlukan imej pembawa asal.

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

Jarak antara simbol memberi ruang

QIM, atau modulasi indeks pengkuantuman, menetapkan simbol pada kedudukan yang berjarak. Dalam model pengajaran satu bit, gandaan 24 berselang-seli antara 0 dan 1. Untuk menyimpan 1, pengekod mengalihkan 70 kepada 72. Perubahan +11 daripada 72 masih dinyahkod sebagai 1; perubahan +13 melintasi sempadan dan membalikkan bit. Gerakkan peluncur untuk melihat di mana sempadan itu berada.

Saluran sebenar menyimpan satu hingga tiga bit bagi setiap pekali terpilih menggunakan pengekodan Gray. Cebisan Python QIM hanya menggambarkan prinsipnya, bukan penyahkod JPEG yang lengkap.

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

Baiki dahulu, sahkan kemudian

Reed–Solomon (255,223) menambah 32 bait pariti kepada 223 bait data dan boleh membetulkan sehingga 16 bait yang salah bagi setiap kata kod. Penyelangselian dan blok yang tersebar di seluruh imej membantu mengagihkan kerosakan, supaya tidak tertumpu pada segelintir kata kod.

Pembaca membaiki data pengangkutan, menyemak CRC, kemudian mengesahkan sampul dengan AES-GCM. CRC memeriksa integriti pengangkutan, bukan melakukan pengesahan kriptografi. Pembaikan yang berjaya tidak membenarkan teks biasa dikeluarkan jika semakan GCM gagal.

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

Uji pembawa yang telah digabungkan

Kedua-dua salinan menggunakan piksel yang sama, jadi penulisan bit rendah turut mengganggu pekali DCT. Oleh itu, ujian menggunakan imej akhir yang telah digabungkan. Dalam ujian pelayar setempat, sampul dipulihkan dengan tepat selepas pengekodan JPEG pada kualiti 0.60 dan pengekodan berulang 0.85→0.60. Maksimum 12 bait dibaiki bagi setiap kata kod, di bawah had pembetulan 16 bait. Ini ialah hasil ujian setempat, bukan jaminan untuk semua pengekod JPEG.

Percubaan automatik dihadkan kepada 280,000 bait tersulit, termasuk bait tambahan format. Lebihan data dan isyarat yang lebih kuat menggunakan kapasiti serta menjejaskan kualiti imej pembawa; fail asal yang dirahsiakan tidak dimampatkan semula. Jika ujian gagal, PNG atau ZIP tanpa kehilangan digunakan. Pemangkasan, perubahan saiz, putaran atau pengekod lain boleh menggagalkan pemulihan. Sebaiknya, hantarkan PNG atau ZIP asal sebagai fail.

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

Ketahui apa yang didedahkan format

Penanda tetap menjadikan format ini boleh dikesan. Ilustrasi pembawa bersifat awam dan tidak disahkan sebagai ilustrasi; perubahan pada bit atasnya mungkin tidak menjejaskan mesej yang disisipkan. Pembetulan ralat dan CRC menangani kerosakan semasa pengangkutan, manakala AES-GCM mengesahkan kandungan yang dilindungi.

Penyerang masih boleh meneka kata laluan di luar talian. Argon2id meningkatkan kos setiap tekaan, tetapi tidak dapat menyelamatkan kata laluan yang lemah. Tiada pemulihan kata laluan, dan tiada dakwaan bahawa keselamatannya telah diaudit secara bebas.

08

Baca fail tanpa aplikasi

Format ini menggunakan primitif kriptografi yang sedia ada, dengan struktur dan had yang jelas serta pembaca bebas. Pembaca Python 3.12+ menyokong PNG asal, termasuk PNG yang mempunyai salinan pemulihan JPEG, ZIP SnapSeal bersaiz terhad yang mengandungi satu PNG, serta sampul v3 mentah. Fail JPEG yang telah ditukar memerlukan kodek TypeScript.

Pelaksanaan yang dibincangkan di sini ialah v3 sumber terbuka; pembaca lama yang telah digunakan mungkin menolak hasilnya. Pasang kebergantungan yang disenaraikan dalam persekitaran maya. Kod sumber penuh dan muat turun pilihan menyusuli contoh.

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