01

Pisahkan rahasia dari gambar pembawa

SnapSeal memulai proses dengan byte asli gambar, file, atau teks. Codec-nya mengenkripsi byte tersebut beserta metadata, lalu menyisipkan envelope hasilnya ke dalam gambar sampul. Penerima memerlukan file terproteksi dan kata sandi untuk memulihkannya, tanpa memerlukan sampul asli.

Kriptografi menjaga kerahasiaan isi, sedangkan steganografi gambar menyediakan media pembawanya. Keduanya bukan teknik baru. Ilustrasi dan kata sandi uji pada demo memang sengaja dipublikasikan.

  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 memiliki tiga kanal 8-bit. Dalam contoh ini, encoder membaca kanal dari kiri ke kanan, melewati alfa, mempertahankan enam bit teratas, dan mengganti dua bit terbawah. Dengan demikian, empat kanal dapat membawa satu byte.

Geser penggeser untuk melihat perubahan bit yang disorot. Penulisan bit rendah ini saja mengubah nilai sebuah kanal paling banyak 3; pembawa JPEG opsional dan penataan gambar lainnya dapat mengubah sampul lebih jauh.

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

Hitung kapasitas yang tersedia

Tiga kanal dengan dua bit masing-masing menyediakan enam bit per piksel. Sampul berukuran 1024×1024 memiliki kapasitas 786.432 byte sebelum header paket piksel 16 byte, atau 786.416 byte setelahnya. Ilustrasi mencadangkan sebagian piksel sehingga kapasitasnya berkurang.

Penanda tetap mengenali tata letak paket, sedangkan informasi panjang menandai akhir envelope. Versi paket dan versi enkripsi berada dalam ruang penomoran yang berbeda: paket PNG2 dapat membawa envelope 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

Turunkan kunci dan autentikasi envelope

Profil v3 SnapSeal menggunakan Argon2id v1.3 untuk menurunkan kunci 32 byte dari kata sandi UTF-8 dan salt acak 16 byte. Prosesnya memakai memori 64 MiB, tiga lintasan, dan satu jalur. Sebelum menjalankan KDF, pembaca harus memvalidasi bahwa faktor kerja tetap yang diminta memang didukung.

AES-256-GCM mengenkripsi byte asli dan metadata. Tag 16 byte mengautentikasi ciphertext serta header publik 48 byte. Jika autentikasi gagal, pembaca tidak boleh mengeluarkan plaintext.

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

Pesan berada di piksel

Payload tersimpan dalam nilai RGB yang sudah didekode, bukan dalam metadata PNG. Dua file PNG dapat memiliki byte file yang berbeda, tetapi tetap membawa pesan bit rendah yang sama jika nilai RGB hasil dekodenya tidak berubah. Sebaliknya, tangkapan layar, penyuntingan piksel, atau perubahan ukuran dapat merusak data meski gambarnya masih tampak serupa.

Kecuali kompresi sumber dipilih secara eksplisit, byte asli dan metadatanya dipulihkan secara persis. Nama file dienkripsi, tetapi tanpa padding, envelope masih memperlihatkan perkiraan ukuran isi.

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

Antikompresi: jalur pemulihan lewat JPEG

Antikompresi adalah jalur pemulihan semaksimal mungkin untuk JPEG yang dimensinya tetap sama. Envelope terenkripsi yang sama memiliki salinan tanpa kehilangan data pada bit rendah RGB di PNG dan, secara opsional, salinan pemulihan pada koefisien frekuensi gambar.

Cara ini tidak mencegah kompresi dan tidak menjamin data bertahan di sembarang aplikasi pengirim pesan. Formatnya pun tetap dapat dikenali.

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

Dari piksel ke frekuensi

JPEG menggunakan discrete cosine transform (DCT), atau transformasi kosinus diskret, untuk mengubah blok luminans 8×8 menjadi koefisien frekuensi, lalu menguantisasinya. SnapSeal menggunakan koefisien terpilih untuk membawa data pada nilai-nilai yang sengaja diberi jarak. Jarak ini memberi ruang bagi pembaca untuk menoleransi sebagian perubahan akibat pengodean JPEG.

Header transport yang dilengkapi koreksi kesalahan menunjukkan tata letak pemulihan. Header itu tidak mengautentikasi ilustrasi sampul publik, dan pembaca tidak memerlukan sampul asli.

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

Beri jarak antarsimbol

QIM, atau quantization-index modulation, menempatkan simbol pada posisi-posisi yang berjarak. Dalam model pengajaran satu bit, kelipatan 24 bergantian mewakili 0 dan 1. Untuk menyimpan 1, encoder mengubah 70 menjadi 72. Perubahan +11 dari 72 masih didekode sebagai 1; +13 melewati batas dan membalik bit. Geser penggeser untuk melihat batas tersebut.

Kanal sebenarnya menyimpan satu hingga tiga bit per koefisien terpilih dengan pengodean Gray. Cuplikan QIM dalam Python hanya menggambarkan prinsipnya, bukan decoder JPEG 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

Perbaiki lebih dahulu, autentikasi terakhir

Reed–Solomon (255,223) menambahkan 32 byte paritas pada 223 byte data dan dapat memperbaiki hingga 16 byte yang salah per kata kode. Interleaving dan blok yang tersebar di seluruh gambar membantu menyebarkan kerusakan agar tidak menumpuk pada sedikit kata kode.

Pembaca memperbaiki data transport, memeriksa CRC, lalu mengautentikasi envelope dengan AES-GCM. CRC memvalidasi transport, bukan melakukan autentikasi kriptografis. Perbaikan yang berhasil tidak membenarkan pelepasan plaintext jika pemeriksaan 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 setelah kedua salinan digabung

Kedua salinan menempati piksel yang sama, sehingga penulisan bit rendah juga mengganggu koefisien DCT. Karena itu, pengujian mandiri memakai pembawa gabungan yang sudah final. Pemulihan hanya dinyatakan tersedia jika envelope pulih secara persis dalam pengujian JPEG lokal di peramban pada kualitas 0,60 dan pada pengodean berulang 0,85→0,60, dengan paling banyak 12 byte diperbaiki per kata kode—di bawah batas koreksi 16 byte. Pengaturan tersebut adalah pengujian lokal, bukan jaminan untuk semua JPEG.

Percobaan otomatis berhenti pada 280.000 byte terenkripsi, termasuk overhead. Redundansi dan sinyal yang lebih kuat mengorbankan kualitas serta kapasitas sampul; keduanya tidak mengompresi ulang file asli yang bersifat privat. Jika pengujian gagal, pengiriman kembali menggunakan PNG atau ZIP tanpa kehilangan data. Pemotongan, perubahan ukuran, rotasi, atau encoder lain dapat menggagalkan pemulihan. Pilihan yang andal adalah mengirim PNG atau ZIP asli sebagai file.

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 terungkap

Penanda tetap membuat format ini dapat dikenali. Ilustrasi sampul bersifat publik dan tidak diautentikasi sebagai ilustrasi; perubahan pada bit atasnya bisa saja tidak merusak pesan yang disisipkan. Koreksi kesalahan dan CRC menangani kerusakan transport, sedangkan AES-GCM mengautentikasi isi yang dilindungi.

Penyerang tetap dapat menebak kata sandi secara offline. Argon2id menaikkan biaya setiap tebakan, tetapi tidak dapat menyelamatkan kata sandi yang lemah. Tidak ada pemulihan kata sandi, dan ini bukan klaim bahwa keamanan format telah diaudit secara independen.

08

Baca tanpa aplikasi

Format ini memakai teknik kriptografi yang sudah ada, dengan batas yang dijelaskan secara terbuka dan pembaca independen. Pembaca Python 3.12+ mendukung PNG asli, termasuk PNG dengan salinan pemulihan JPEG, ZIP SnapSeal yang dibatasi dan berisi satu PNG, serta envelope v3 mentah. File JPEG hasil konversi memerlukan codec TypeScript.

Artikel ini membahas implementasi v3 sumber terbuka; pembaca lama yang sudah terpasang mungkin menolak file yang dihasilkannya. Instal dependensi yang tercantum dalam lingkungan virtual. Kode sumber lengkap dan unduhan opsional tersedia setelah contoh-contoh dalam artikel.

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