01

Ihiwalay ang lihim sa larawang nagdadala nito

Kinukuha ng SnapSeal ang mga orihinal na byte ng mga larawan, file o teksto. Ini-encrypt nito ang mga byte at metadata, saka inilalagay ang nabuong envelope sa isang cover image. Para ma-decode ang protektadong file, kailangan ng tatanggap ang password, pero hindi ang server o orihinal na cover.

Cryptography ang nagpapanatiling kumpidensiyal sa nilalaman; image steganography naman ang nagbibigay ng tagapagdala. Parehong kilalang ideya ang mga ito. Sadyang pampubliko ang artwork at test password sa demo.

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

Gamitin ang dalawang pinakamababang bit

Bawat RGB pixel ay may tatlong 8-bit channel. Sa halimbawang ito, binabasa ng encoder ang mga channel mula kaliwa pakanan, nilalaktawan ang alpha, pinananatili ang anim na pinakamataas na bit at pinapalitan ang dalawang pinakamababa. Kaya nakapagdala ng isang byte ang apat na channel.

Igalaw ang slider para makita ang pagbabago sa mga naka-highlight na bit. Sa pagsulat na ito sa mabababang bit lamang, hanggang 3 ang maaaring maging pagbabago sa halaga ng isang channel. Maaaring mas malaki ang pagbabago dahil sa opsyonal na JPEG carrier o iba pang pag-istilo ng cover.

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

Bilangin ang magagamit na espasyo

Sa tatlong channel na may tig-dalawang bit, may anim na bit bawat pixel. Ang 1024×1024 na cover ay may kapasidad na 786,432 byte bago ang 16-byte pixel-packet header, o 786,416 byte pagkatapos nito. May mga pixel ding inilalaan para sa artwork, kaya nababawasan ang kapasidad.

Isang nakapirming marker ang tumutukoy sa layout ng packet, at isang length field ang nagsasaad kung saan nagtatapos ang envelope. Magkahiwalay ang mga namespace ng bersiyon ng packet at ng encryption: maaaring maglaman ang PNG2 packet ng v3 cryptographic envelope.

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

Buuin ang key at patunayan ang envelope

Sa v3 profile ng SnapSeal, gumagamit ang Argon2id v1.3 ng UTF-8 password at random na 16-byte salt upang bumuo ng 32-byte key. Nakatakda ito sa 64 MiB na memory, tatlong pass at isang lane. Dapat munang tiyakin ng reader na suportado ang mga nakapirming work factor bago patakbuhin ang KDF.

Ini-encrypt ng AES-256-GCM ang orihinal na mga byte at metadata. Pinatutunayan ng 16-byte tag nito ang ciphertext at ang pampublikong 48-byte header. Kapag nabigo ang authentication, hindi dapat maglabas ang reader ng 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

Nasa mga pixel ang mensahe

Nasa decoded RGB values ang payload, hindi sa PNG metadata. Maaaring magkaiba ang mga byte ng dalawang PNG file ngunit pareho pa rin ang mensahe sa mabababang bit kung hindi nagbago ang decoded RGB values. Sa kabilang banda, maaaring sirain ng screenshot, pag-edit ng pixel o pag-resize ang data kahit nakikilala pa ang larawan.

Maliban kung tahasang pinili ang source compression, eksaktong naibabalik ang orihinal na mga byte at metadata. Naka-encrypt ang filename, pero kung walang padding, makikita pa rin ang tinatayang laki mula sa envelope.

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

Anti-compression: pangalawang daan sa JPEG

Ang anti-compression ay pagtatangkang mabawi ang data mula sa JPEG na nanatili sa parehong sukat. Ang iisang naka-encrypt na envelope ay may lossless na kopya sa mabababang RGB bit ng PNG at, kung pinili, recovery copy sa mga frequency coefficient ng larawan.

Hindi nito pinipigilan ang compression o ginagarantiyang makakaligtas ang data sa anumang messenger. Hindi rin nito ginagawang hindi matukoy ang format.

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

Mula pixel tungo sa frequency

Ginagamit ng JPEG ang discrete cosine transform (DCT) upang gawing frequency coefficient ang mga 8×8 luminance block, saka kino-quantize ang mga ito. Gumagamit ang SnapSeal ng mga piling coefficient para magdala ng data sa mga halagang sadyang may pagitan. Nagbibigay ito ng puwang para sa ilang pagbabagong dulot ng JPEG encoding.

Tinutukoy ng transport header na may error correction ang layout ng recovery. Hindi kailangan ng reader ang orihinal na cover.

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

Maglaan ng pagitan sa mga simbolo

Iniuugnay ng QIM, o quantization-index modulation, ang mga simbolo sa magkakahiwalay na posisyon. Sa one-bit na modelong panturo, salit-salitang 0 at 1 ang mga multiple ng 24. Para mag-imbak ng 1, inililipat ng encoder ang 70 sa 72. Kung madagdagan ng 11 ang 72, mababasa pa rin ito bilang 1; sa dagdag na 13, tatawid ito sa hangganan at magbabago ang bit. Igalaw ang slider para makita ang hangganang iyon.

Sa aktuwal na channel, isa hanggang tatlong bit ang iniimbak sa bawat piling coefficient gamit ang Gray coding. Paliwanag lamang ng prinsipyo ang Python QIM snippet; hindi ito kumpletong JPEG decoder.

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

Ayusin muna, saka patunayan

Nagdaragdag ang Reed–Solomon (255,223) ng 32 parity byte sa 223 data byte at kayang magtama ng hanggang 16 maling byte bawat codeword. Tumutulong ang interleaving at mga block na nakakalat sa larawan upang maipamahagi ang pinsala sa halip na maipon ito sa ilang codeword.

Inaayos muna ng reader ang transport, sinusuri ang CRC, saka pinatutunayan ang envelope gamit ang AES-GCM. Para sa integridad ng transport ang CRC; hindi ito cryptographic authentication. Kahit matagumpay ang pag-aayos, hindi dapat ilabas ang plaintext kung pumalya ang GCM check.

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

Subukan ang pinagsamang carrier

Nasa iisang mga pixel ang dalawang kopya, kaya naaapektuhan din ng pagsulat sa mabababang bit ang mga DCT coefficient. Dahil dito, ang pinal na pinagsamang carrier ang ginagamit sa self-test. Sa mga lokal na browser JPEG test, dapat eksaktong mabawi ang envelope sa quality na 0.60 at pagkatapos ng paulit-ulit na pag-encode na 0.85→0.60. Hindi rin dapat lumampas sa 12 ang inayos na byte bawat codeword, mas mababa sa limitasyong 16 byte. Mga lokal na pagsubok ang mga ito, hindi pangkalahatang garantiya para sa lahat ng JPEG encoder.

Humihinto ang mga awtomatikong pagtatangka sa 280,000 naka-encrypt na byte, kasama ang overhead. Kapalit ng redundancy at mas malakas na signal ang kalidad at kapasidad ng cover; hindi muling kino-compress ng channel na ito ang pribadong orihinal. Kapag pumalya ang mga test, lossless na PNG o ZIP ang ginagamit sa paghahatid. Maaaring hindi gumana ang recovery matapos ang pag-crop, pag-resize, pag-rotate o paggamit ng ibang encoder. Ipadala ang orihinal na PNG o ZIP bilang 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

Alamin kung ano ang nakalantad

Natutukoy ang format dahil sa mga nakapirming marker. Pampubliko ang cover artwork at hindi ito pinatutunayan bilang artwork; maaaring manatiling buo ang naka-embed na mensahe kahit baguhin ang matataas na bit nito. Error correction at CRC ang tumutugon sa pinsala sa transport, samantalang AES-GCM ang nagpapatunay sa protektadong nilalaman.

Maaari pa ring manghula ng password ang isang attacker offline. Pinapataas ng Argon2id ang gastos sa bawat hula, pero hindi nito maililigtas ang mahinang password. Walang password recovery, at walang inaangking independent security audit.

08

Basahin ang file nang wala ang application

Gumagamit ang format ng mga kilalang cryptographic primitive, may tahasang mga limitasyon at maaaring basahin ng isang independent reader. Sinusuportahan ng Python 3.12+ reader ang mga orihinal na PNG, kabilang ang may JPEG recovery copy, mga single-PNG SnapSeal ZIP na may takdang limitasyon, at raw v3 envelope. Kailangan ang TypeScript codec para sa mga file na na-convert sa JPEG.

Ang inilalarawan dito ay ang open-source v3 implementation; maaaring tanggihan ng mga lumang deployed reader ang output nito. I-install sa virtual environment ang mga nakalistang dependency. Kasunod ng mga halimbawa ang buong source at mga opsyonal na download.

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