01

Skill hemmeligheten fra bildet som bærer den

SnapSeal tar utgangspunkt i de opprinnelige bytene fra bilder, filer eller tekst. Kodeken krypterer bytene og metadataene, og legger deretter den krypterte konvolutten inn i et bærerbilde. Mottakeren trenger den beskyttede filen og passordet, men ikke det opprinnelige bærerbildet.

Kryptografien holder innholdet fortrolig; bildesteganografien sørger for en bærer. Begge bygger på etablerte ideer. I det offentlige eksemplet er både illustrasjonen og testpassordet bevisst gjort tilgjengelige.

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

Bruk de to laveste bitene

Hver RGB-piksel har tre kanaler på 8 biter. I dette eksemplet leser koderen kanalene fra venstre mot høyre, hopper over alfa og beholder de seks øverste bitene. De to nederste erstattes med data. Dermed kan fire kanaler bære én byte.

Flytt glidebryteren for å se de markerte bitene endre seg. Denne isolerte skrivingen i de lave bitene endrer en kanalverdi med høyst 3. Den valgfrie JPEG-kanalen og annen utforming kan endre bærerbildet mer.

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

Regn ut kapasiteten

Tre kanaler med to biter hver gir seks biter per piksel. Et bærerbilde på 1024×1024 har plass til 786 432 byte før pikselpakkens hode på 16 byte, og 786 416 byte etterpå. Illustrasjoner legger beslag på piksler og reduserer kapasiteten.

En fast markør angir pakkeformatet, og et lengdefelt viser hvor konvolutten slutter. Pakkeversjoner og krypteringsversjoner følger ulike versjonsserier: En PNG2-pakke kan inneholde en v3-kryptokonvolutt.

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

Avled nøkkelen og autentiser konvolutten

SnapSeals v3-profil bruker Argon2id v1.3 til å avlede en nøkkel på 32 byte fra et UTF-8-kodet passord og et tilfeldig salt på 16 byte. Avledningen bruker 64 MiB minne, tre gjennomløp og én bane. Leseren må kontrollere at de faste arbeidsparametrene er støttet før den kjører nøkkelavledningen.

AES-256-GCM krypterer de opprinnelige bytene og metadataene. En autentiseringstagg på 16 byte beskytter både chifferteksten og det offentlige hodet på 48 byte. Hvis autentiseringen feiler, må leseren aldri gi ut klartekst.

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

Det er pikslene som bærer meldingen

Nyttelasten ligger i de dekodede RGB-verdiene, ikke i PNG-metadata. To PNG-filer kan ha ulike filbyte og likevel bevare samme melding i de lave bitene, så lenge de dekodede RGB-verdiene er uendret. Et skjermbilde, en pikselredigering eller en størrelsesendring kan derimot ødelegge dataene selv om bildet fortsatt er gjenkjennelig.

Med mindre kildekomprimering er valgt uttrykkelig, gjenopprettes de opprinnelige bytene og metadataene nøyaktig. Filnavnet er kryptert, men uten utfylling avslører konvolutten fortsatt den omtrentlige størrelsen.

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

Antikomprimering: en ekstra vei gjennom JPEG

Antikomprimering er et forsøk på gjenoppretting etter JPEG-komprimering, forutsatt at bildedimensjonene er de samme. Den samme krypterte konvolutten finnes i en tapsfri kopi i PNG-bildets lave RGB-biter og kan i tillegg få en gjenopprettingskopi i bildets frekvenskoeffisienter.

Dette hindrer ikke komprimering og gir ingen garanti for at dataene overlever alle meldingstjenester. Formatet blir heller ikke uoppdagelig.

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

Fra piksler til frekvenser

JPEG bruker diskret cosinustransformasjon (DCT) til å omforme lysstyrkeblokker på 8×8 piksler til frekvenskoeffisienter, som deretter kvantiseres. SnapSeal skriver data i utvalgte koeffisienter ved å sette dem til verdier med bevisst avstand. Det gir leseren en margin for noen av endringene som JPEG-kodingen kan påføre dem.

Et feilrettet transporthode angir hvordan gjenopprettingsdataene er lagt ut. Det autentiserer ikke den offentlige illustrasjonen, og leseren trenger ikke det opprinnelige bærerbildet.

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

La det være avstand mellom symbolene

QIM, eller kvantiseringsindeksmodulasjon, knytter symboler til posisjoner med mellomrom. I modellen som viser prinsippet med én bit, representerer annethvert multiplum av 24 henholdsvis 0 og 1. For å lagre 1 flytter koderen 70 til 72. En endring på +11 fra 72 dekodes fortsatt som 1, mens +13 krysser grensen og snur biten. Flytt glidebryteren for å se hvor grensen går.

Den faktiske kanalen lagrer én til tre biter per utvalgte koeffisient med Gray-koding. QIM-eksemplet i Python illustrerer prinsippet; det er ikke en fullstendig JPEG-dekoder.

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

Reparer først, autentiser til slutt

Reed–Solomon (255,223) legger 32 paritetsbyte til 223 databyte og kan rette opptil 16 feilaktige byte per kodeord. Interleaving og blokker som er spredt over bildet, bidrar til å fordele skaden i stedet for å samle den i noen få kodeord.

Leseren reparerer først transportdataene, kontrollerer så CRC-verdien og autentiserer til slutt konvolutten med AES-GCM. CRC kontrollerer transportintegritet, men er ikke kryptografisk autentisering. Selv en vellykket reparasjon gir ikke grunnlag for å utlevere klartekst hvis GCM-kontrollen feiler.

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

Test den samlede bæreren

De to kopiene bruker de samme pikslene, så skriving i de lave bitene forstyrrer også DCT-koeffisientene. Derfor testes det ferdige bildet med begge kopiene. I lokale nettlesertester ble den nøyaktige konvolutten hentet ut etter JPEG-koding ved kvalitet 0.60 og etter gjentatt koding 0.85→0.60. Høyst 12 byte per kodeord måtte repareres, under grensen på 16. Dette er lokale tester, ikke garantier for alle JPEG-kodere.

Automatiske forsøk stopper ved 280 000 krypterte byte, inkludert tillegg. Redundans og sterkere signaler koster bildekvalitet og kapasitet i bærerbildet; de komprimerer ikke den private originalen på nytt. Hvis testene feiler, brukes tapsfri PNG eller ZIP i stedet. Beskjæring, størrelsesendring, rotasjon og andre kodere kan hindre gjenoppretting. Send derfor helst den opprinnelige PNG- eller ZIP-filen som fil.

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

Hva formatet avslører

Faste markører gjør formatet mulig å oppdage. Illustrasjonen er offentlig og autentiseres ikke som illustrasjon; endringer i de øvre bitene kan la den innebygde meldingen være intakt. Feilretting og CRC håndterer transportskader, mens AES-GCM autentiserer det beskyttede innholdet.

En angriper kan fortsatt gjette passord uten nettilgang. Argon2id gjør hvert forsøk dyrere, men redder ikke et svakt passord. Det finnes ingen passordgjenoppretting, og det hevdes ikke at løsningen har gjennomgått en uavhengig sikkerhetsrevisjon.

08

Les filen uten applikasjonen

Formatet bygger på etablerte kryptografiske byggesteiner og gjør grensene tydelige. En uavhengig leser for Python 3.12+ støtter opprinnelige PNG-filer, også når de inneholder en JPEG-gjenopprettingskopi, størrelsesbegrensede SnapSeal-ZIP-filer med én PNG og rå v3-konvolutter. For konverterte JPEG-filer trengs TypeScript-kodeken.

Formatet som beskrives her, er v3-implementeringen med åpen kildekode. Eldre lesere som allerede er i bruk, kan avvise filene den lager. Installer de oppgitte avhengighetene i et virtuelt miljø. Full kildekode og valgfrie nedlastinger følger etter eksemplene.

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