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

