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

