01

Séparer le secret de son image de couverture

SnapSeal part des octets originaux d’images, de fichiers ou de texte. Il les chiffre avec leurs métadonnées, puis insère l’enveloppe obtenue dans une image de couverture. Pour décoder le fichier protégé, le destinataire a besoin du mot de passe, mais ni d’un serveur ni de la couverture d’origine.

La cryptographie protège la confidentialité du contenu ; la stéganographie d’image fournit son support. Ces deux idées sont bien établies. L’illustration et le mot de passe de test de la démonstration sont volontairement publics.

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

Inscrire les données dans les deux bits de poids faible

Chaque pixel RGB comporte trois canaux de 8 bits. Dans cet exemple, l’encodeur parcourt les canaux de gauche à droite, ignore l’alpha, conserve les six bits de poids fort et remplace les deux bits de poids faible. Quatre canaux suffisent ainsi à transporter un octet.

Déplacez le curseur pour voir changer les bits surlignés. Cette seule écriture modifie la valeur d’un canal de 3 au maximum ; le support JPEG facultatif et les autres traitements visuels peuvent modifier davantage la couverture.

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

Compter les octets disponibles

À raison de deux bits sur chacun des trois canaux, un pixel transporte six bits. Une couverture de 1024×1024 offre donc 786 432 octets avant l’en-tête de paquet de 16 octets, soit 786 416 octets après celui-ci. L’illustration réserve des pixels et réduit cette capacité.

Un marqueur fixe indique la structure du paquet, tandis qu’une longueur délimite l’enveloppe. Les versions du paquet et celles du chiffrement sont distinctes : un paquet PNG2 peut transporter une enveloppe cryptographique v3.

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

Dériver la clé et authentifier l’enveloppe

Le profil v3 de SnapSeal utilise Argon2id v1.3 pour dériver une clé de 32 octets à partir du mot de passe encodé en UTF-8 et d’un sel aléatoire de 16 octets. Il emploie 64 MiB de mémoire, trois passes et une voie. Avant de lancer la dérivation, le lecteur doit vérifier que les paramètres de coût correspondent aux valeurs fixes prises en charge.

AES-256-GCM chiffre les octets originaux et leurs métadonnées. Son tag de 16 octets authentifie le texte chiffré ainsi que l’en-tête public de 48 octets. Si l’authentification échoue, le lecteur ne doit jamais livrer de texte en clair.

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

Le message est dans les pixels

Les données sont inscrites dans les valeurs RGB décodées, pas dans les métadonnées PNG. Deux fichiers PNG peuvent avoir des octets différents et préserver le même message dans leurs bits faibles, à condition que leurs valeurs RGB décodées restent inchangées. À l’inverse, une capture d’écran, une retouche des pixels ou un redimensionnement peut détruire les données tout en laissant l’image reconnaissable.

Sauf si la compression de la source a été explicitement choisie, la récupération restitue exactement les octets et les métadonnées d’origine. Le nom du fichier est chiffré, mais en l’absence de remplissage, l’enveloppe révèle encore sa taille approximative.

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

Anticompression : une seconde voie par JPEG

L’anticompression est une tentative de récupération, sans garantie, après conversion en JPEG à dimensions inchangées. La même enveloppe chiffrée dispose d’une copie sans perte dans les bits faibles RGB du PNG et, éventuellement, d’une copie de récupération dans les coefficients de fréquence de l’image.

Ce mécanisme n’empêche pas la compression et ne promet pas de résister à toutes les messageries. Il ne rend pas non plus le format indétectable.

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

Des pixels aux fréquences

JPEG applique une transformée en cosinus discrète, ou DCT, à des blocs de luminance de 8×8 pour obtenir des coefficients de fréquence, puis les quantifie. SnapSeal utilise certains de ces coefficients pour porter des données sur des valeurs délibérément espacées. Cet espacement donne au lecteur une marge face à certaines modifications dues à l’encodage JPEG.

Un en-tête de transport protégé par correction d’erreurs indique l’organisation du canal de récupération. Le lecteur n’a pas besoin de la couverture d’origine.

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

Espacer les symboles pour tolérer les écarts

La QIM, ou modulation par indice de quantification, associe les symboles à des positions espacées. Dans le modèle pédagogique à un bit, les multiples de 24 représentent alternativement 0 et 1. Pour inscrire 1, l’encodeur fait passer 70 à 72. Une variation de +11 à partir de 72 est encore décodée comme 1 ; à +13, on franchit la frontière et le bit bascule. Déplacez le curseur pour voir où elle se situe.

Le canal réel stocke un à trois bits par coefficient sélectionné, avec un codage Gray. L’extrait Python consacré à la QIM illustre ce principe : ce n’est pas un décodeur JPEG complet.

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

Réparer d’abord, authentifier ensuite

Reed–Solomon (255,223) ajoute 32 octets de parité à 223 octets de données et peut corriger jusqu’à 16 octets erronés par mot de code. L’entrelacement et la dispersion des blocs dans l’image contribuent à répartir les dégâts plutôt qu’à les concentrer sur quelques mots de code.

Le lecteur répare le transport, vérifie son CRC, puis authentifie l’enveloppe avec AES-GCM. Le CRC contrôle l’intégrité du transport ; ce n’est pas une authentification cryptographique. Même après une réparation réussie, aucun texte en clair ne doit être livré si la vérification GCM échoue.

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

Tester le support final

Les deux copies occupent les mêmes pixels : l’écriture des bits faibles perturbe donc aussi les coefficients DCT. Les autotests portent sur l’image finale combinée. Ils vérifient que l’enveloppe est retrouvée à l’identique après une conversion JPEG locale dans le navigateur, à la qualité 0.60, puis après deux encodages successifs à 0.85 et 0.60. Ils exigent aussi au plus 12 octets réparés par mot de code, sous la limite de correction de 16 octets. Ces essais locaux ne garantissent pas le résultat avec tous les encodeurs JPEG.

Les tentatives automatiques s’arrêtent à 280 000 octets chiffrés, frais de format compris. La redondance et des signaux plus forts consomment de la capacité et dégradent la qualité de la couverture ; ils ne recompressent pas l’original privé. Si les tests échouent, la livraison revient au PNG ou au ZIP sans perte. Recadrage, redimensionnement, rotation ou changement d’encodeur peuvent empêcher la récupération : mieux vaut envoyer le PNG ou le ZIP original en tant que fichier.

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

Ce que le format laisse voir

Les marqueurs fixes rendent le format détectable. L’illustration de couverture est publique et n’est pas authentifiée en tant qu’image : une modification de ses bits de poids fort peut laisser le message intégré intact. La correction d’erreurs et le CRC traitent les dommages de transport ; AES-GCM authentifie le contenu protégé.

Un attaquant peut toujours essayer des mots de passe hors ligne. Argon2id augmente le coût de chaque essai, mais ne sauve pas un mot de passe faible. Il n’existe pas de récupération du mot de passe, et aucun audit de sécurité indépendant n’est revendiqué.

08

Lire le fichier sans l’application

Le format s’appuie sur des primitives existantes, des limites explicites et un lecteur indépendant. Ce lecteur Python 3.12+ prend en charge les PNG originaux, y compris ceux qui comportent une copie de récupération JPEG, les ZIP SnapSeal soumis à des limites et contenant un seul PNG, ainsi que les enveloppes v3 brutes. Les fichiers convertis en JPEG nécessitent le codec TypeScript.

Le format présenté ici est l’implémentation open source v3 ; les anciens lecteurs déployés peuvent refuser les fichiers qu’elle produit. Installez les dépendances indiquées dans un environnement virtuel. Le code complet et les téléchargements facultatifs suivent les exemples.

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