Geheimnis und Deckbild
SnapSeal verarbeitet die ursprünglichen Bytes von Bildern, Dateien oder Text. Der Codec verschlüsselt sie samt Metadaten und bettet die entstandene Hülle in ein Deckbild ein. Zum Entschlüsseln brauchen Empfänger die geschützte Datei und das Passwort, aber weder einen Server noch das ursprüngliche Deckbild.
Die Kryptografie schützt die Vertraulichkeit; die Bildsteganografie stellt den Träger bereit. Beide Verfahren sind etabliert. Das Bildmaterial der öffentlichen Demo und das zugehörige Testpasswort sind bewusst veröffentlicht.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Die unteren zwei Bits nutzen
Ein RGB-Pixel besitzt drei Kanäle mit jeweils 8 Bit. In diesem Beispiel liest der Encoder die Kanäle von links nach rechts und überspringt Alpha. Er erhält jeweils die oberen sechs Bits und ersetzt die unteren zwei. Vier Kanäle tragen so ein Byte.
Bewege den Regler, um die Änderung der markierten Bits zu sehen. Dieser einzelne Schreibschritt verändert einen Kanalwert um höchstens 3. Der zusätzliche JPEG-Kanal und andere Gestaltungen können das Deckbild stärker verändern.
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) == 0xD3Wie viel Platz bleibt?
Drei Kanäle mit je zwei Bits ergeben sechs Bits pro Pixel. Ein Deckbild mit 1024×1024 Pixeln bietet vor dem 16 Byte großen Pixelpaket-Header Platz für 786.432 Bytes, danach noch für 786.416 Bytes. Bildgestaltung reserviert Pixel und verringert die Kapazität.
Eine feste Kennung bezeichnet das Paketlayout, eine Längenangabe das Ende der Hülle. Die Versionen von Pixelpaket und Verschlüsselung gehören zu unterschiedlichen Namensräumen: Ein PNG2-Paket kann eine kryptografische v3-Hülle tragen.
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 |
Schlüssel ableiten, Hülle authentifizieren
Das v3-Profil von SnapSeal verwendet Argon2id v1.3, um aus dem UTF-8-Passwort und einem zufälligen 16-Byte-Salt einen 32-Byte-Schlüssel abzuleiten. Dafür sind 64 MiB Speicher, drei Durchläufe und eine Lane vorgesehen. Ein Reader muss die fest unterstützten Arbeitsparameter prüfen, bevor er die Schlüsselableitung ausführt.
AES-256-GCM verschlüsselt die Originalbytes und Metadaten. Sein 16-Byte-Tag authentifiziert den Geheimtext und den öffentlichen 48-Byte-Header. Schlägt die Authentifizierung fehl, darf der Reader keinen Klartext ausgeben.
[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)Die Nachricht steckt in den Pixeln
Die Nutzlast liegt in den dekodierten RGB-Werten, nicht in PNG-Metadaten. Zwei PNG-Dateien können unterschiedliche Dateibytes haben und trotzdem dieselbe Nachricht in den unteren Bits bewahren, solange ihre dekodierten RGB-Werte unverändert sind. Ein Screenshot, eine Pixelbearbeitung oder eine Größenänderung kann die Daten dagegen zerstören, obwohl das Bild erkennbar bleibt.
Wurde keine Quellkompression ausdrücklich ausgewählt, stellt die Wiederherstellung die ursprünglichen Bytes und Metadaten exakt wieder her. Der Dateiname ist verschlüsselt; ohne Auffüllung lässt die Hülle jedoch die ungefähre Größe erkennen.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Anti-Kompression: ein zweiter Weg durch JPEG
Anti-Kompression bezeichnet hier einen Wiederherstellungsversuch für JPEGs, deren Abmessungen unverändert bleiben. Dieselbe verschlüsselte Hülle liegt verlustfrei in den unteren RGB-Bits des PNGs und kann zusätzlich als Wiederherstellungskopie in Frequenzkoeffizienten des Bildes stehen.
Das verhindert keine Kompression und verspricht auch nicht, dass die Daten beliebige Messenger überstehen. Das Format wird dadurch nicht unerkennbar.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
Von Pixeln zu Frequenzen
JPEG wandelt 8×8-Helligkeitsblöcke mit einer diskreten Kosinustransformation, kurz DCT, in Frequenzkoeffizienten um und quantisiert diese anschließend. SnapSeal nutzt ausgewählte Koeffizienten, um Daten auf bewusst voneinander getrennten Werten abzulegen. So bleibt Spielraum für Veränderungen durch die JPEG-Kodierung.
Ein fehlerkorrigierter Transport-Header beschreibt das Layout des Wiederherstellungskanals. Er authentifiziert nicht das öffentliche Deckbild; dessen Original wird zum Lesen auch nicht benötigt.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerAbstand zwischen den Symbolen
QIM steht für Quantization-Index Modulation und ordnet Symbolen Positionen mit Abstand zu. Im Ein-Bit-Lehrmodell stehen Vielfache von 24 abwechselnd für 0 und 1. Um eine 1 zu speichern, wird 70 auf 72 gesetzt. Eine Änderung um +11 gegenüber 72 ergibt beim Lesen noch eine 1; bei +13 ist die Grenze überschritten und das Bit kippt. Bewege den Regler, um diese Grenze zu sehen.
Der tatsächliche Kanal speichert mit Gray-Codierung ein bis drei Bits pro ausgewähltem Koeffizienten. Der Python-QIM-Code veranschaulicht das Prinzip, ist aber kein vollständiger 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 boundaryErst reparieren, dann authentifizieren
Reed–Solomon (255,223) ergänzt 223 Datenbytes um 32 Paritätsbytes und kann bis zu 16 falsche Bytes pro Codewort korrigieren. Interleaving und über das Bild verteilte Blöcke helfen, Schäden auf mehrere Codewörter zu verteilen, statt sie in wenigen zu konzentrieren.
Der Reader repariert zunächst den Transport, prüft dann die CRC und authentifiziert schließlich die Hülle mit AES-GCM. Die CRC prüft den Transport, ist aber keine kryptografische Authentifizierung. Auch nach erfolgreicher Reparatur darf bei fehlgeschlagener GCM-Prüfung kein Klartext ausgegeben werden.
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_bytesDen gemeinsamen Träger testen
Beide Kopien belegen dieselben Pixel. Das Schreiben der unteren Bits verändert daher auch die DCT-Koeffizienten. Die Selbsttests verwenden folglich den fertigen kombinierten Träger. Eine Wiederherstellung wird nur ausgewiesen, wenn die Hülle exakt zurückgewonnen wird: in lokalen Browser-JPEG-Tests bei Qualität 0,60 und nach einer wiederholten Kodierung mit 0,85→0,60. Dabei dürfen höchstens 12 Bytes pro Codewort repariert werden, weniger als die Korrekturgrenze von 16. Die lokalen Ergebnisse gelten nicht zwangsläufig für andere JPEG-Encoder.
Automatische Versuche enden bei 280.000 verschlüsselten Bytes einschließlich Overhead. Redundanz und stärkere Signale kosten Qualität und Kapazität des Deckbilds; das private Original wird dabei nicht neu komprimiert. Scheitern die Tests, wird ein verlustfreies PNG oder ZIP verwendet. Zuschneiden, Skalieren, Drehen oder ein anderer Encoder können die Wiederherstellung verhindern. Daher empfiehlt es sich, das ursprüngliche PNG oder ZIP als Datei zu senden.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Was das Format preisgibt
Feste Marker machen das Format erkennbar. Das Deckbild ist öffentlich und wird nicht als Bildmaterial authentifiziert; Änderungen an seinen oberen Bits können die eingebettete Nachricht unberührt lassen. Fehlerkorrektur und CRC behandeln Transportschäden, während AES-GCM den geschützten Inhalt authentifiziert.
Passwörter lassen sich weiterhin offline erraten. Argon2id verteuert jeden Versuch, kann ein schwaches Passwort aber nicht retten. Eine Passwortwiederherstellung gibt es nicht; ein unabhängiges Sicherheitsaudit wird nicht beansprucht.
Ohne Anwendung lesen
Das Format verbindet etablierte kryptografische Verfahren mit klaren Grenzen und einem unabhängigen Reader. Der Reader für Python 3.12+ unterstützt originale PNGs, auch mit JPEG-Wiederherstellungskopie, größenbegrenzte SnapSeal-ZIPs mit genau einem PNG sowie rohe v3-Hüllen. Für in JPEG umgewandelte Dateien ist der TypeScript-Codec nötig.
Beschrieben wird hier die Open-Source-Implementierung von v3. Ältere bereits eingesetzte Reader können ihre Dateien ablehnen. Installiere die angegebenen Abhängigkeiten in einer virtuellen Umgebung. Der vollständige Quellcode und optionale Downloads folgen auf die Beispiele.
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)

