El secreto y la imagen que lo transporta
SnapSeal puede proteger imágenes, archivos o texto. Su códec cifra los bytes originales y sus metadatos, y después inserta el contenedor cifrado en una imagen de portada. Para descifrar el archivo protegido, el destinatario necesita la contraseña, pero no una red, un servidor ni la portada original.
La criptografía protege la confidencialidad; la esteganografía de imágenes aporta el soporte. Ninguna de las dos ideas es nueva. La demostración pública usa una ilustración y una contraseña de prueba publicadas deliberadamente.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Los dos bits menos significativos
Cada píxel RGB tiene tres canales de 8 bits. En este ejemplo, el codificador recorre los canales de izquierda a derecha, omite alfa, conserva los seis bits superiores y sustituye los dos inferiores. Así, cuatro canales pueden transportar un byte.
Mueve el deslizador para ver cómo cambian los bits resaltados. Esta escritura aislada modifica el valor de un canal en 3 como máximo; el canal JPEG opcional y otros ajustes de la imagen pueden alterarlo más.
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) == 0xD3Cuántos bytes caben
Tres canales con dos bits cada uno dan seis bits por píxel. Una portada de 1024×1024 tiene capacidad para 786.432 bytes antes de la cabecera de 16 bytes del paquete de píxeles, y para 786.416 bytes después. La ilustración reserva píxeles y reduce esa capacidad.
Un marcador fijo identifica la estructura del paquete y una longitud señala dónde termina el contenedor cifrado. Las versiones del paquete y del cifrado son independientes: un paquete PNG2 puede transportar un contenedor criptográfico 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 |
Derivar la clave y autenticar el contenido
El perfil v3 de SnapSeal usa Argon2id v1.3 para derivar una clave de 32 bytes a partir de la contraseña en UTF-8 y una sal aleatoria de 16 bytes. Emplea 64 MiB de memoria, tres pasadas y una vía. Antes de ejecutar la función de derivación, el lector debe comprobar que los factores de trabajo fijos son compatibles.
AES-256-GCM cifra los bytes originales y los metadatos. Su etiqueta de 16 bytes autentica tanto el texto cifrado como la cabecera pública de 48 bytes. Si falla la autenticación, el lector nunca debe entregar texto en claro.
[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)El mensaje está en los píxeles
Los datos están en los valores RGB decodificados, no en los metadatos PNG. Dos archivos PNG pueden tener bytes distintos y conservar el mismo mensaje en los bits bajos, siempre que sus valores RGB decodificados no cambien. Una captura de pantalla, una edición de píxeles o un cambio de tamaño pueden, en cambio, destruir los datos aunque la imagen siga siendo reconocible.
Salvo que se haya seleccionado explícitamente la compresión de la fuente, la recuperación restaura exactamente los bytes originales y sus metadatos. El nombre del archivo está cifrado, pero, si no se añade relleno, el contenedor revela su tamaño aproximado.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Anticompresión: una segunda vía a través de JPEG
La anticompresión intenta recuperar los datos de archivos JPEG que conservan las mismas dimensiones, aunque no siempre lo consigue. El mismo contenedor cifrado tiene una copia sin pérdidas en los bits bajos RGB del PNG y puede tener otra copia de recuperación en los coeficientes de frecuencia de la imagen.
Esto no impide la compresión ni garantiza que los datos sobrevivan a cualquier servicio de mensajería. Tampoco hace indetectable el formato.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
De los píxeles a las frecuencias
JPEG aplica la transformada discreta del coseno (DCT) a bloques de luminancia de 8×8. Así obtiene coeficientes de frecuencia que después cuantiza. SnapSeal utiliza algunos de esos coeficientes para transportar datos en valores deliberadamente separados. Esa distancia da al lector margen para tolerar ciertos cambios introducidos por la codificación JPEG.
Una cabecera de transporte con corrección de errores identifica la disposición del canal de recuperación. No autentica la ilustración pública de la portada, y el lector no necesita la portada original.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerDejar espacio entre símbolos
QIM, o modulación por índice de cuantización, asigna símbolos a posiciones espaciadas. En el modelo didáctico de un bit, los múltiplos de 24 alternan entre 0 y 1. Para guardar un 1, el codificador lleva 70 a 72. Un cambio de +11 desde 72 todavía se lee como 1; con +13 se cruza el límite y el bit cambia. Mueve el deslizador para ver dónde ocurre.
El canal real guarda de uno a tres bits por coeficiente seleccionado mediante código Gray. El fragmento de Python sobre QIM ilustra el principio; no es un decodificador JPEG completo.
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 boundaryPrimero reparar, después autenticar
Reed–Solomon (255,223) añade 32 bytes de paridad a 223 bytes de datos y puede corregir hasta 16 bytes erróneos por palabra de código. El entrelazado y la distribución de bloques por la imagen ayudan a repartir el daño en lugar de concentrarlo en unas pocas palabras.
El lector repara el transporte, comprueba su CRC y después autentica el contenedor con AES-GCM. El CRC valida el transporte, pero no proporciona autenticación criptográfica. Aunque la reparación tenga éxito, no se debe entregar texto en claro si falla la comprobación GCM.
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_bytesProbar el soporte combinado
Las dos copias ocupan los mismos píxeles, de modo que escribir los bits bajos también perturba los coeficientes DCT. Por eso las pruebas automáticas usan la imagen final combinada. Solo se anuncia la recuperación si se obtiene el contenedor exacto en pruebas locales de JPEG del navegador, tanto a calidad 0,60 como tras una codificación repetida 0,85→0,60, con un máximo de 12 bytes reparados por palabra de código, por debajo del límite de 16. Esos ajustes son pruebas locales, no garantías universales para JPEG.
Los intentos automáticos se detienen en 280.000 bytes cifrados, incluidos los datos adicionales. La redundancia y las señales más fuertes consumen calidad y capacidad de la portada; no recomprimen el original privado. Si fallan las pruebas, se recurre al PNG o ZIP sin pérdidas. Recortar, cambiar el tamaño, girar o usar otro codificador puede impedir la recuperación. La recomendación fiable es enviar el PNG o ZIP original como archivo.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Lo que el formato deja a la vista
Los marcadores fijos permiten detectar el formato. La portada es pública y su ilustración no se autentica como tal; modificar sus bits superiores puede dejar intacto el mensaje incrustado. La corrección de errores y el CRC tratan los daños del transporte, mientras que AES-GCM autentica el contenido protegido.
Un atacante aún puede probar contraseñas sin conexión. Argon2id encarece cada intento, pero no salva una contraseña débil. No hay recuperación de contraseñas ni se afirma que exista una auditoría de seguridad independiente.
Leer el archivo sin la aplicación
El lector descargable para Python 3.12+ admite PNG originales, incluidos los que llevan una copia de recuperación JPEG, ZIP SnapSeal sujetos a límites y con un solo PNG, y contenedores v3 en bruto. Los archivos convertidos a JPEG requieren el códec TypeScript.
El formato descrito aquí corresponde a la implementación v3 de código abierto; los lectores antiguos ya desplegados pueden rechazar sus archivos. Instala las dependencias indicadas en un entorno virtual. El código completo y las descargas opcionales aparecen después de los ejemplos.
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)

