01

Het geheim en de drager

SnapSeal versleutelt de oorspronkelijke bytes en metadata van afbeeldingen, bestanden of tekst en verwerkt de resulterende envelop in de pixels van een dragerafbeelding. De ontvanger heeft het beveiligde bestand en het wachtwoord nodig, maar geen server of oorspronkelijke drager.

Cryptografie houdt de inhoud vertrouwelijk; beeldsteganografie zorgt voor de drager. Beide technieken bestaan al langer. De illustratie en het testwachtwoord in dit voorbeeld zijn bewust openbaar gemaakt.

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

De onderste twee bits

Een RGB-pixel heeft drie kanalen van elk 8 bits. In dit voorbeeld leest de encoder de kanalen van links naar rechts en slaat hij het alfakanaal over. De bovenste zes bits blijven staan; de onderste twee worden vervangen. Zo kunnen vier kanalen samen één byte dragen.

Verplaats de schuifregelaar om de gemarkeerde bits te zien veranderen. Alleen deze schrijfstap verandert de waarde van een kanaal met maximaal 3. De optionele JPEG-herstelkopie en andere vormgeving kunnen de drager sterker veranderen.

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

Hoeveel past erin?

Drie kanalen met elk twee bits leveren zes bits per pixel op. Een drager van 1024×1024 biedt daarmee plaats aan 786.432 bytes vóór de pixelpakketheader van 16 bytes, en aan 786.416 bytes daarna. Illustraties reserveren pixels en verkleinen de beschikbare capaciteit.

Een vaste markering geeft de pakketindeling aan; een lengteveld bepaalt waar de envelop eindigt. Pakketversies en encryptieversies staan los van elkaar: een PNG2-pakket kan een cryptografische v3-envelop bevatten.

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

Van wachtwoord naar geauthenticeerde envelop

Het v3-profiel van SnapSeal leidt met Argon2id v1.3 een sleutel van 32 bytes af uit het UTF-8-wachtwoord en een willekeurige salt van 16 bytes. Dat kost 64 MiB geheugen en gebruikt drie passes en één lane. Een lezer moet vóór het uitvoeren van de KDF controleren of de vaste werkfactoren worden ondersteund.

AES-256-GCM versleutelt de oorspronkelijke bytes en metadata. De tag van 16 bytes authenticeert de versleutelde inhoud én de openbare header van 48 bytes. Mislukt de authenticatie, dan mag de lezer nooit onversleutelde inhoud vrijgeven.

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

Het bericht zit in de pixels

De gegevens zitten in gedecodeerde RGB-waarden, niet in PNG-metadata. Twee PNG-bestanden kunnen verschillende bestandsbytes hebben en toch hetzelfde bericht in hun lage bits bewaren, zolang de gedecodeerde RGB-waarden onveranderd zijn. Een schermafbeelding, pixelbewerking of schaalverandering kan de gegevens daarentegen vernietigen terwijl de afbeelding herkenbaar blijft.

Tenzij expliciet voor broncompressie is gekozen, worden de oorspronkelijke bytes en metadata exact hersteld. De bestandsnaam is versleuteld, maar zonder opvulling verraadt de envelop nog wel de grootte bij benadering.

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

Anticompressie: een tweede route via JPEG

Anticompressie is een poging om gegevens te herstellen uit een JPEG met dezelfde afmetingen. Dezelfde versleutelde envelop heeft een verliesvrije kopie in de lage RGB-bits van de PNG en kan daarnaast een herstelkopie krijgen in de frequentiecoëfficiënten van de afbeelding.

Dit voorkomt geen compressie en biedt geen garantie dat de gegevens alle berichtendiensten overleven. Het maakt het formaat ook niet ondetecteerbaar.

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

Van pixels naar frequenties

JPEG gebruikt de DCT, of discrete cosinustransformatie, om helderheidsblokken van 8×8 om te zetten in frequentiecoëfficiënten en kwantiseert die vervolgens. SnapSeal gebruikt geselecteerde coëfficiënten om gegevens op bewust gespreide waarden te plaatsen. Daardoor krijgt de lezer enige ruimte om veranderingen door JPEG-codering op te vangen.

Een transportheader met foutcorrectie geeft de indeling van het herstelkanaal aan. Die header authenticeert de openbare illustratie niet; de lezer heeft de oorspronkelijke drager ook niet nodig.

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

Ruimte tussen symbolen

QIM, of quantization-index modulation, koppelt symbolen aan gespreide posities. In het lesmodel met één bit staan veelvouden van 24 afwisselend voor 0 en 1. Om een 1 op te slaan, verschuift de encoder 70 naar 72. Een verandering van +11 vanaf 72 levert nog steeds een 1 op; bij +13 wordt de grens overschreden en slaat de bit om. Verplaats de schuifregelaar om die grens te zien.

Het werkelijke kanaal slaat met Gray-codering één tot drie bits per geselecteerde coëfficiënt op. Het Python-fragment over QIM illustreert het principe; het is geen volledige JPEG-decoder.

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

Eerst herstellen, dan authenticeren

Reed–Solomon (255,223) voegt 32 pariteitsbytes toe aan 223 databytes en kan per codewoord maximaal 16 foutieve bytes corrigeren. Interleaving en blokken die over de afbeelding zijn verspreid, helpen voorkomen dat schade zich in enkele codewoorden ophoopt.

De lezer herstelt eerst het transport, controleert daarna de CRC en authenticeert vervolgens de envelop met AES-GCM. De CRC controleert het transport, maar is geen cryptografische authenticatie. Ook na geslaagd herstel mag bij een mislukte GCM-controle geen onversleutelde inhoud worden vrijgegeven.

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

De gecombineerde drager testen

Beide kopieën gebruiken dezelfde pixels. Het schrijven van de lage bits beïnvloedt dus ook de DCT-coëfficiënten. Daarom wordt de uiteindelijke gecombineerde afbeelding getest. In lokale browser-JPEG-tests kwam de envelop exact terug na codering op kwaliteit 0.60 en na herhaalde codering van 0.85→0.60. Daarbij werden maximaal 12 bytes per codewoord gerepareerd, onder de correctiegrens van 16. Dit zijn lokale testresultaten, geen garanties voor alle JPEG-encoders.

Automatische pogingen zijn beperkt tot 280.000 versleutelde bytes, inclusief overhead. Redundantie en sterkere signalen kosten kwaliteit en capaciteit van de drager; het privéorigineel wordt niet opnieuw gecomprimeerd. Mislukken de tests, dan wordt verliesvrije PNG of ZIP gebruikt. Bijsnijden, schalen, draaien of een andere encoder kan herstel verhinderen. Verstuur daarom bij voorkeur de oorspronkelijke PNG of ZIP als bestand.

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

Wat het formaat blootgeeft

Vaste markeringen maken het formaat detecteerbaar. De illustratie van de drager is openbaar en wordt niet als illustratie geauthenticeerd; wijzigingen aan de bovenste bits kunnen het ingebedde bericht intact laten. Foutcorrectie en CRC behandelen transportschade, terwijl AES-GCM de beveiligde inhoud authenticeert.

Een aanvaller kan nog steeds offline wachtwoorden raden. Argon2id maakt elke poging duurder, maar kan een zwak wachtwoord niet redden. Er is geen wachtwoordherstel en er wordt geen onafhankelijke beveiligingsaudit geclaimd.

08

Lezen zonder de applicatie

Het formaat gebruikt bestaande cryptografische bouwstenen, met een expliciete indeling en duidelijke grenzen. Het heeft ook een onafhankelijke lezer. Die lezer voor Python 3.12+ ondersteunt oorspronkelijke PNG’s, ook als die een JPEG-herstelkopie bevatten, SnapSeal-ZIP’s met begrensde omvang en één PNG, en ruwe v3-enveloppen. Voor geconverteerde JPEG-bestanden is de TypeScript-codec nodig.

De hier beschreven implementatie is de opensourceversie v3; oudere lezers die al in gebruik zijn, kunnen de uitvoer weigeren. Installeer de opgegeven afhankelijkheden in een virtuele omgeving. De volledige broncode en optionele downloads volgen na de voorbeelden.

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