Separare il segreto dall’immagine di copertura
SnapSeal parte dai byte originali di immagini, file o testi. Il codec li cifra insieme ai metadati, poi inserisce l’involucro risultante in un’immagine di copertura. Per recuperare il contenuto servono il file protetto e la password, ma non la copertura originale.
La crittografia protegge la riservatezza; la steganografia delle immagini fornisce il supporto. Nessuna delle due idee è nuova. L’illustrazione della demo e la relativa password di prova sono volutamente pubbliche.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Usare i due bit meno significativi
Ogni pixel RGB ha tre canali a 8 bit. In questo esempio la codifica percorre i canali da sinistra a destra, salta l’eventuale canale alfa, conserva i sei bit superiori e sostituisce i due inferiori. Bastano quindi quattro canali per contenere un byte.
Sposta il cursore per vedere cambiare i bit evidenziati. Questa sola scrittura nei bit inferiori modifica il valore di un canale al massimo di 3; il supporto JPEG opzionale e altre modifiche grafiche possono alterare maggiormente la copertura.
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) == 0xD3Calcolare lo spazio disponibile
Tre canali con due bit ciascuno danno sei bit per pixel. Una copertura da 1024×1024 pixel offre 786.432 byte prima dell’intestazione di 16 byte del pacchetto nei pixel: ne restano 786.416 dopo averla sottratta. L’illustrazione riserva una parte dei pixel e riduce la capacità.
Un marcatore fisso identifica la struttura del pacchetto, mentre un campo di lunghezza delimita l’involucro. Le versioni del pacchetto e quelle della cifratura appartengono a spazi distinti: un pacchetto PNG2 può contenere un involucro crittografico 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 |
Derivare la chiave e autenticare l’involucro
Il profilo v3 di SnapSeal usa Argon2id v1.3 per derivare una chiave di 32 byte dalla password codificata in UTF-8 e da un salt casuale di 16 byte. I parametri prevedono 64 MiB di memoria, tre passaggi e una corsia. Prima di eseguire la derivazione, un lettore deve verificare che i parametri di costo siano quelli fissi supportati.
AES-256-GCM cifra i byte originali e i metadati. Il suo tag di 16 byte autentica il testo cifrato e l’intestazione pubblica di 48 byte. Se l’autenticazione fallisce, il lettore non deve mai rilasciare il testo in chiaro.
[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)Sono i pixel a trasportare il messaggio
I dati si trovano nei valori RGB decodificati, non nei metadati PNG. Due file PNG possono avere byte diversi e conservare lo stesso messaggio nei bit inferiori, purché i valori RGB decodificati rimangano identici. Uno screenshot, una modifica dei pixel o un ridimensionamento possono invece distruggere i dati lasciando l’immagine riconoscibile.
A meno che non sia stata esplicitamente selezionata la compressione della sorgente, il recupero restituisce esattamente i byte originali e i metadati. Il nome del file è cifrato, ma senza padding l’involucro ne rivela comunque la dimensione approssimativa.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Anticompressione: una seconda via attraverso JPEG
L’anticompressione è un tentativo di recupero, senza garanzia di successo, per i JPEG che mantengono le stesse dimensioni. Lo stesso involucro cifrato ha una copia senza perdita nei bit inferiori RGB del PNG e, facoltativamente, una copia di recupero nei coefficienti di frequenza dell’immagine.
Questo meccanismo non impedisce la compressione, non promette di sopravvivere al passaggio attraverso qualunque app di messaggistica e non rende il formato impossibile da individuare.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
Dai pixel alle frequenze
JPEG usa la trasformata discreta del coseno (DCT) per convertire blocchi di luminanza 8×8 in coefficienti di frequenza, che poi quantizza. SnapSeal usa alcuni di questi coefficienti per trasportare dati, assegnando loro valori opportunamente distanziati. Il lettore ha così un margine per tollerare alcune variazioni introdotte dalla codifica JPEG.
Un’intestazione di trasporto protetta dalla correzione degli errori identifica la disposizione dei dati di recupero. Non autentica l’illustrazione pubblica della copertura e il lettore non ha bisogno della copertura originale.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerLasciare spazio tra i simboli
La QIM, o modulazione dell’indice di quantizzazione, associa i simboli a posizioni distanziate. Nel modello didattico a un bit, i multipli di 24 rappresentano alternativamente 0 e 1. Per memorizzare 1, la codifica porta 70 a 72. Una variazione di +11 rispetto a 72 viene ancora decodificata come 1; con +13 si oltrepassa la soglia e il bit cambia. Sposta il cursore per vedere dove avviene il passaggio.
Il canale effettivo memorizza da uno a tre bit per coefficiente selezionato, usando il codice Gray. Il frammento Python sulla QIM illustra il principio, ma non è un decoder 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 boundaryPrima riparare, poi autenticare
Reed–Solomon (255,223) aggiunge 32 byte di parità a 223 byte di dati e corregge fino a 16 byte errati per parola di codice. L’interleaving e la distribuzione dei blocchi nell’immagine aiutano a spargere i danni, evitando che si concentrino in poche parole di codice.
Il lettore ripara il trasporto, ne verifica il CRC e infine autentica l’involucro con AES-GCM. Il CRC controlla l’integrità del trasporto, ma non è un’autenticazione crittografica. Anche dopo una riparazione riuscita, il testo in chiaro non va rilasciato se la verifica GCM fallisce.
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_bytesProvare il supporto completo
Le due copie occupano gli stessi pixel: scrivere nei bit inferiori altera quindi anche i coefficienti DCT. Per questo gli autotest usano il supporto finale, con entrambe le copie. Il recupero viene dichiarato disponibile solo se l’involucro è recuperato esattamente nei test JPEG locali del browser, sia a qualità 0,60 sia dopo una codifica ripetuta 0,85→0,60, con al massimo 12 byte riparati per parola di codice, sotto il limite di correzione di 16. Sono prove locali, non garanzie universali sui JPEG.
I tentativi automatici si fermano a 280.000 byte cifrati, compreso il sovraccarico. Ridondanza e segnali più forti consumano qualità della copertura e capacità; non ricomprimono l’originale privato. Se i test falliscono, si ripiega sulla consegna senza perdita in PNG o ZIP. Ritaglio, ridimensionamento, rotazione o un codificatore diverso possono impedire il recupero: la scelta affidabile resta inviare il PNG o lo ZIP originale come file.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Che cosa espone il formato
I marcatori fissi rendono il formato rilevabile. L’illustrazione di copertura è pubblica e non è autenticata in quanto tale; modificarne i bit superiori può lasciare intatto il messaggio incorporato. Correzione degli errori e CRC affrontano i danni di trasporto, mentre AES-GCM autentica il contenuto protetto.
Un attaccante può comunque tentare di indovinare la password offline. Argon2id rende ogni tentativo più costoso, ma non può salvare una password debole. Non è previsto il recupero della password e non viene dichiarato alcun audit di sicurezza indipendente.
Leggere il file senza l’applicazione
Il formato si basa su tecniche crittografiche esistenti, limiti dichiarati e un lettore indipendente. Il lettore per Python 3.12+ supporta i PNG originali, compresi quelli con una copia di recupero JPEG, gli ZIP SnapSeal soggetti a limiti e contenenti un solo PNG, e gli involucri v3 grezzi. Per i file convertiti in JPEG serve invece il codec TypeScript.
Qui è descritta l’implementazione open source v3: i lettori meno recenti già distribuiti potrebbero rifiutare i file che produce. Installa le dipendenze indicate in un ambiente virtuale. Il codice completo e i download facoltativi seguono gli esempi nel testo.
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)

