El secret i la imatge que el transporta
SnapSeal parteix dels bytes originals d’imatges, fitxers o text. Els xifra juntament amb les metadades i insereix el sobre resultant en els píxels d’una imatge de coberta. Per llegir el contingut calen el fitxer protegit i la contrasenya, però no la coberta original ni un servidor.
La criptografia protegeix la confidencialitat; l’esteganografia d’imatges proporciona el suport per transportar les dades. Són dues tècniques consolidades. La il·lustració de l’exemple i la contrasenya de prova són deliberadament públiques.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Els dos bits de menys pes
Cada píxel RGB té tres canals de 8 bits. En aquest exemple, el codificador recorre els canals d’esquerra a dreta, omet l’alfa, conserva els sis bits superiors i substitueix els dos inferiors. Així, quatre canals poden transportar un byte.
Mou el control lliscant per veure com canvien els bits ressaltats. Aquesta escriptura aïllada modifica el valor d’un canal com a màxim en 3; el canal JPEG opcional i altres tractaments visuals poden alterar la coberta més enllà d’aquest límit.
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) == 0xD3Quants bytes hi caben?
Tres canals amb dos bits cadascun donen sis bits per píxel. Una coberta de 1024×1024 té capacitat per a 786.432 bytes abans de la capçalera de 16 bytes del paquet de píxels; després en queden 786.416. La il·lustració reserva píxels i redueix aquesta capacitat.
Un marcador fix identifica l’estructura del paquet, i un camp de longitud indica on acaba el sobre. Les versions del paquet i les del xifrat són independents: un paquet PNG2 pot contenir un sobre criptogràfic 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 clau i autenticar el sobre
El perfil v3 de SnapSeal fa servir Argon2id v1.3 per derivar una clau de 32 bytes a partir de la contrasenya en UTF-8 i una sal aleatòria de 16 bytes. Hi dedica 64 MiB de memòria, tres passades i una via. Abans d’executar la funció de derivació, el lector ha de validar que els factors de treball siguin els fixos admesos.
AES-256-GCM xifra els bytes originals i les metadades. L’etiqueta de 16 bytes autentica tant el text xifrat com la capçalera pública de 48 bytes. Si l’autenticació falla, el lector no ha de lliurar mai text en clar.
[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 missatge és als píxels
Les dades són als valors RGB descodificats, no a les metadades del PNG. Dos fitxers PNG poden tenir bytes diferents i conservar el mateix missatge als bits baixos, sempre que els seus valors RGB descodificats siguin idèntics. En canvi, una captura de pantalla, una edició dels píxels o un canvi de mida poden destruir les dades encara que la imatge continuï sent recognoscible.
Si no s’ha seleccionat explícitament la compressió de l’origen, la recuperació restitueix exactament els bytes i les metadades originals. El nom del fitxer està xifrat, però, sense farciment, el sobre deixa entreveure la mida aproximada.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Anticompressió: una segona via a través de JPEG
Aquí, anticompressió vol dir un intent de recuperar les dades d’un JPEG que conservi les mateixes dimensions. Un mateix sobre xifrat té una còpia als bits baixos RGB del PNG i, opcionalment, una còpia de recuperació als coeficients de freqüència de la imatge. El procediment no impedeix la compressió ni fa indetectable el format.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
Dels píxels a les freqüències
JPEG aplica la transformada discreta del cosinus, o DCT, a blocs de luminància de 8×8 per obtenir coeficients de freqüència, que després quantitza. SnapSeal fa servir alguns d’aquests coeficients per transportar dades en valors separats expressament, de manera que el lector pugui tolerar alguns dels canvis causats per la codificació JPEG.
Una capçalera de transport amb correcció d’errors identifica la disposició del canal de recuperació. No autentica la il·lustració pública de coberta, i el lector no necessita la coberta original.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerDeixar marge entre els símbols
QIM, o modulació per índex de quantització, assigna els símbols a posicions separades. En el model didàctic d’un bit, els múltiples de 24 representen alternativament 0 i 1. Per desar un 1, el codificador mou 70 a 72. Un canvi de +11 respecte de 72 encara es descodifica com a 1; amb +13 se supera el límit i el bit canvia. Mou el control lliscant per veure on és aquest límit.
El canal real desa d’un a tres bits per coeficient seleccionat mitjançant codi Gray. El fragment de QIM en Python il·lustra el principi, però no és un descodificador JPEG complet.
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 boundaryReparar primer, autenticar després
Reed–Solomon (255,223) afegeix 32 bytes de paritat a 223 bytes de dades i pot corregir fins a 16 bytes erronis per paraula de codi. L’entrellaçament i la dispersió dels blocs per la imatge ajuden a repartir els danys, en lloc de concentrar-los en unes poques paraules de codi.
El lector repara el transport, en comprova el CRC i després autentica el sobre amb AES-GCM. El CRC valida el transport, però no és una autenticació criptogràfica. Que la reparació tingui èxit no permet lliurar text en clar si falla la comprovació 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_bytesProvar la imatge final amb les dues còpies
Les dues còpies ocupen els mateixos píxels, de manera que escriure els bits baixos també altera els coeficients DCT. Per això les proves automàtiques fan servir la imatge final combinada. Només s’anuncia la recuperació si s’obté exactament el mateix sobre en proves locals de JPEG del navegador a qualitat 0.60 i després d’una codificació successiva 0.85→0.60, amb un màxim de 12 bytes reparats per paraula de codi, per sota del límit de 16. Són proves locals, no garanties universals per a tots els codificadors JPEG.
No es fan intents automàtics si se superen els 280.000 bytes xifrats, inclosa la sobrecàrrega. La redundància i els senyals més forts consumeixen qualitat i capacitat de la coberta; aquest canal no recomprimeix l’original privat. Si les proves fallen, es recorre al lliurament sense pèrdues en PNG o ZIP. Un retall, un canvi de mida, una rotació o un altre codificador poden impedir la recuperació; és preferible enviar el PNG o ZIP original com a fitxer.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Què deixa veure el format
Els marcadors fixos fan detectable el format. La il·lustració de coberta és pública i no s’autentica com a obra gràfica: canvis als seus bits superiors poden deixar intacte el missatge inserit. La correcció d’errors i el CRC tracten els danys del transport, mentre que AES-256-GCM autentica el contingut protegit.
Un atacant encara pot provar contrasenyes fora de línia. Argon2id encareix cada intent, però no pot salvar una contrasenya feble. No hi ha recuperació de contrasenya ni s’afirma que s’hagi fet una auditoria de seguretat independent.
Llegir el fitxer sense l’aplicació
El lector independent per a Python 3.12+ admet PNG originals, inclosos els que tenen una còpia de recuperació JPEG; ZIP SnapSeal d’un sol PNG subjectes a límits; i sobres v3 en brut. Per llegir fitxers convertits a JPEG cal el còdec TypeScript.
La proposta combina primitives consolidades, un format amb límits explícits i un lector independent. La versió descrita és la implementació v3 de codi obert; els lectors antics ja desplegats poden rebutjar-ne els fitxers. Instal·la les dependències indicades en un entorn virtual. El codi complet i les descàrregues opcionals segueixen els exemples.
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)

