01

Adskil hemmeligheden fra billedet

SnapSeal tager udgangspunkt i de oprindelige bytes fra billeder, filer eller tekst. Codec'en krypterer dem sammen med deres metadata og indlejrer den krypterede beholder i et dækkende billede. Modtageren skal bruge den beskyttede fil og adgangskoden, men ikke det oprindelige dækkende billede eller en server.

Kryptografi beskytter indholdets fortrolighed; billedsteganografi giver det en bærer. Begge teknikker er velkendte. Demoens billedmateriale og testadgangskode er bevidst gjort offentlige.

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

Brug de nederste to bit

Hver RGB-pixel har tre kanaler på 8 bit. I dette eksempel gennemgår koderen kanalerne fra venstre mod højre, springer alfakanalen over, bevarer de øverste seks bit og erstatter de nederste to. Fire kanaler kan dermed bære én byte.

Flyt skyderen for at se de markerede bit ændre sig. Denne isolerede skrivning i de lave bit ændrer en kanalværdi med højst 3. Den valgfrie JPEG-bærer og anden billedbehandling kan ændre det dækkende billede mere.

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

Hvor mange bytes er der plads til?

To bit i hver af tre kanaler giver seks bit pr. pixel. Et dækkende billede på 1024×1024 har plads til 786.432 bytes før pixelpakkens header på 16 bytes og 786.416 bytes efter den. Billedudsmykning reserverer pixels og reducerer kapaciteten.

En fast markør identificerer pakkens struktur, og en længde angiver, hvor beholderen slutter. Pakkeversioner og krypteringsversioner er separate nummersystemer: En PNG2-pakke kan indeholde en v3-kryptobeholder.

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

Afled nøglen, og autentificer beholderen

SnapSeals v3-profil bruger Argon2id v1.3 til at aflede en nøgle på 32 bytes fra den UTF-8-kodede adgangskode og et tilfældigt salt på 16 bytes. Profilen bruger 64 MiB hukommelse, tre gennemløb og én bane. En læser skal kontrollere de faste, understøttede arbejdsparametre, før den kører nøgleafledningen.

AES-256-GCM krypterer originalens bytes og metadata. Et tag på 16 bytes autentificerer både chifferteksten og den offentlige header på 48 bytes. Hvis autentificeringen fejler, må læseren aldrig frigive 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

Beskeden ligger i pixels

Dataene ligger i de afkodede RGB-værdier, ikke i PNG-metadata. To PNG-filer kan have forskellige filbytes og alligevel bevare den samme besked i de lave bit, hvis deres afkodede RGB-værdier er uændrede. Et skærmbillede, en pixelredigering eller en skalering kan derimod ødelægge dataene, selv om billedet stadig er genkendeligt.

Medmindre kildekomprimering udtrykkeligt er valgt, gendannes originalens bytes og metadata præcist. Filnavnet er krypteret, men uden udfyldning afslører beholderen stadig den omtrentlige størrelse.

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

Antikomprimering: en ekstra vej gennem JPEG

Antikomprimering er et forsøg på at gendanne data fra JPEG-filer med uændrede dimensioner, ikke en garanti. Den samme krypterede beholder findes som en tabsfri kopi i PNG-billedets lave RGB-bit og eventuelt som en gendannelseskopi i billedets frekvenskoefficienter.

Det forhindrer ikke komprimering og lover ikke, at data overlever vilkårlige beskedtjenester. Det gør heller ikke formatet umuligt at opdage.

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

Fra pixels til frekvenser

JPEG omdanner blokke af lysstyrkeværdier på 8×8 med en diskret cosinustransformation, DCT, til frekvenskoefficienter og kvantiserer dem derefter. SnapSeal bruger udvalgte koefficienter til at bære data på værdier med bevidst afstand. Det giver læseren et vist spillerum til at tåle ændringer fra JPEG-kodningen.

En fejlkorrigeret transportheader identificerer gendannelseslayoutet. Den autentificerer ikke det offentlige dækkende billedmateriale, og læseren behøver ikke det oprindelige dækkende billede.

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

Giv symbolerne plads

QIM, quantization-index modulation, knytter symboler til positioner med afstand imellem. I undervisningsmodellen med én bit skifter multipla af 24 mellem 0 og 1. For at gemme 1 flytter koderen 70 til 72. En ændring på +11 fra 72 afkodes stadig som 1; +13 krydser grænsen og vender bitten. Flyt skyderen for at se, hvor grænsen går.

Den faktiske kanal gemmer en til tre bit pr. udvalgt koefficient med Gray-kodning. Python-eksemplet med QIM illustrerer princippet; det er ikke en komplet 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, autentificer til sidst

Reed–Solomon (255,223) tilføjer 32 paritetsbytes til 223 databytes og kan rette op til 16 forkerte bytes pr. kodeord. Interleaving og blokke spredt over billedet hjælper med at fordele skaderne, så de ikke samles i få kodeord.

Læseren reparerer først transportdataene, kontrollerer derefter CRC og autentificerer til sidst beholderen med AES-GCM. CRC kontrollerer transportens integritet; det er ikke kryptografisk autentificering. En vellykket reparation giver ikke lov til at frigive klartekst, hvis GCM-kontrollen fejler.

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ærer

Begge kopier bruger de samme pixels, så skrivning i de lave bit påvirker også DCT-koefficienterne. Derfor køres selvtestene på det færdige billede med begge kopier. Gendannelse angives kun som mulig, hvis den præcise beholder genskabes i lokale browserbaserede JPEG-test ved kvalitet 0,60 og efter gentagen kodning med 0,85→0,60. Højst 12 bytes må være repareret pr. kodeord, altså under grænsen på 16. Resultaterne er lokale og gælder ikke nødvendigvis for andre JPEG-kodere.

Automatiske forsøg stopper ved 280.000 krypterede bytes inklusive overhead. Redundans og stærkere signaler koster kvalitet og kapacitet i det dækkende billede; de genkomprimerer ikke den private original. Hvis testene fejler, bruges tabsfri PNG eller ZIP. Beskæring, skalering, rotation og andre kodere kan forhindre gendannelse. Send derfor den oprindelige PNG eller ZIP 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

Hvad formatet afslører

Faste markører gør formatet genkendeligt. Det dækkende billedmateriale er offentligt og autentificeres ikke som illustration; ændringer i de øvre bit kan lade den indlejrede besked være intakt. Fejlkorrektion og CRC håndterer transportskader, mens AES-GCM autentificerer det beskyttede indhold.

En angriber kan stadig gætte adgangskoder offline. Argon2id gør hvert forsøg dyrere, men kan ikke redde en svag adgangskode. Der er ingen gendannelse af adgangskoden, og der fremsættes ingen påstand om en uafhængig sikkerhedsrevision.

08

Læs filen uden applikationen

Formatet bygger på etablerede kryptografiske byggesten, tydelige grænser og en uafhængig læser. Python 3.12+-læseren understøtter originale PNG-filer, også med en JPEG-gendannelseskopi, SnapSeal-ZIP-filer med én PNG og begrænset størrelse samt rå v3-beholdere. Konverterede JPEG-filer kræver TypeScript-codec'en.

Her beskrives open source-implementeringen af v3; ældre læsere i drift kan afvise dens filer. Installer de angivne afhængigheder i et virtuelt miljø. Den fulde kildekode og valgfrie downloads følger efter kodeeksemplerne.

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