01

Separate the secret from the cover

SnapSeal can protect images, files or text. Its codec encrypts the original bytes and their metadata, then embeds the resulting envelope in a cover image. To decode the protected file, the recipient needs its password, but no network, server or original cover.

Cryptography keeps the contents confidential; image steganography provides the carrier. Neither idea is new. The public demo uses artwork and a test password that are deliberately published.

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

Use the bottom two bits

Each RGB pixel has three 8-bit channels. In this example, the encoder reads channels from left to right, skips alpha, preserves the upper six bits and replaces the lowest two. Four channels can therefore carry one byte.

Move the slider to see the highlighted bits change. This isolated low-bit write changes a channel’s value by at most 3; the optional JPEG carrier and other styling can change the cover by more.

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

Count the available bytes

Three channels at two bits each give six bits per pixel. A 1024×1024 cover has room for 786,432 bytes before the 16-byte pixel-packet header, leaving 786,416 bytes after it. Artwork reserves pixels and reduces that capacity.

A fixed marker identifies the packet layout, and a length marks the end of the envelope. Packet and encryption versions belong to different namespaces: a PNG2 packet can carry a 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

Derive the key and authenticate the envelope

SnapSeal’s v3 profile uses Argon2id v1.3 to derive a 32-byte key from the UTF-8 password and a random 16-byte salt. It uses 64 MiB of memory, three passes and one lane. A reader must validate the fixed supported work factors before running the KDF.

AES-256-GCM encrypts the original bytes and metadata. Its 16-byte tag authenticates the ciphertext and the public 48-byte header. If authentication fails, the reader must never release 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

The pixels carry the message

The payload is in decoded RGB values, not PNG metadata. Two PNG files can have different file bytes yet preserve the same low-bit message if their decoded RGB values are unchanged. A screenshot, pixel edit or resize can instead destroy the data while leaving the picture recognizable.

Unless source compression was explicitly selected, recovery restores the original bytes and metadata exactly. The filename is encrypted, but without padding the envelope still reveals the approximate size.

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

Anti-compression: a second route through JPEG

Anti-compression is a best-effort recovery path for JPEGs that retain the same dimensions. The same encrypted envelope has a lossless copy in the PNG’s low RGB bits and, optionally, a recovery copy in the image’s frequency coefficients.

This does not prevent compression or promise survival through arbitrary messengers. It also does not make the format undetectable.

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

From pixels to frequencies

JPEG applies the discrete cosine transform (DCT) to 8×8 luminance blocks, producing frequency coefficients that it then quantizes. SnapSeal uses selected coefficients to carry data at deliberately spaced values, giving the reader room to tolerate some changes caused by JPEG encoding.

An error-corrected transport header identifies the recovery layout. It does not authenticate the public cover artwork, and the reader does not need the original cover.

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

Leave room between symbols

QIM, or quantization-index modulation, assigns symbols to spaced positions. In the one-bit teaching model, multiples of 24 alternate between 0 and 1. To store 1, the encoder moves 70 to 72. A change of +11 from 72 still decodes as 1; +13 crosses the boundary and flips the bit. Move the slider to see where that happens.

The actual channel stores one to three bits per selected coefficient using Gray coding. The Python QIM snippet illustrates the principle; it is not a complete 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

Repair first, authenticate last

Reed–Solomon (255,223) adds 32 parity bytes to 223 data bytes and can correct up to 16 wrong bytes per codeword. Interleaving and blocks spread across the image help distribute damage rather than concentrating it in a few codewords.

The reader repairs the transport, checks its CRC, then authenticates the envelope with AES-GCM. CRC checks transport integrity; it is not cryptographic authentication. A successful repair is not permission to release plaintext if the GCM check fails.

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

Test the combined carrier

Both copies occupy the same pixels, so low-bit writes also perturb the DCT coefficients. Self-tests therefore use the final combined carrier. Recovery is advertised only when the exact envelope is recovered in local browser JPEG tests at quality 0.60 and through a repeated 0.85→0.60 encode, with at most 12 repaired bytes per codeword—below the 16-byte correction limit. Those settings are local tests, not universal JPEG guarantees.

Automatic attempts stop at 280,000 encrypted bytes, including overhead. Redundancy and stronger signals spend cover quality and capacity; they do not recompress the private original. If the tests fail, delivery falls back to lossless PNG or ZIP. Cropping, resizing, rotation or a different encoder can defeat recovery, so the reliable recommendation is to send the original PNG or ZIP as a 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

Know what the format exposes

Fixed markers make the format detectable. The cover artwork is public and is not authenticated as artwork; changes to its upper bits may leave the embedded message intact. Error correction and CRC address transport damage, while AES-GCM authenticates the protected content.

An attacker can still guess passwords offline. Argon2id makes each guess more expensive, but cannot rescue a weak password. There is no password recovery, and this is not a claim of an independent security audit.

08

Read it without the application

The downloadable Python 3.12+ reader supports original PNGs, including those with a JPEG recovery copy, bounded single-PNG SnapSeal ZIPs, and raw v3 envelopes. Converted JPEG files require the TypeScript codec.

The format described here is the open-source v3 implementation; older deployed readers may reject its output. Install the listed dependencies in a virtual environment. Full source and optional downloads follow the examples.

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