01

Skilj hemligheten från omslagsbilden

SnapSeal utgår från originalets byte, oavsett om de kommer från bilder, filer eller text. Kodeken krypterar dem tillsammans med metadata och bäddar sedan in kuvertet i en omslagsbild. Mottagaren behöver den skyddade filen och lösenordet, men ingen server eller ursprunglig omslagsbild.

Kryptografin skyddar innehållets sekretess; steganografin tillhandahåller bäraren. Båda teknikerna är etablerade. Utmaningen är att göra gränserna mellan dem tydliga. I det här exemplet är både omslagsbilden och testlösenordet avsiktligt offentliga.

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

Använd de två lägsta bitarna

Varje RGB-pixel har tre 8-bitarskanaler. I exemplet läser kodaren kanalerna från vänster till höger, hoppar över alfa, bevarar de sex högsta bitarna och ersätter de två lägsta. Fyra kanaler kan därmed bära en byte.

Flytta reglaget för att se de markerade bitarna ändras. Just denna skrivning av låga bitar ändrar en kanals värde med högst 3. Den valfria JPEG-bäraren och annan bearbetning av omslaget kan ändra bilden mer än så.

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

Räkna ut kapaciteten

Tre kanaler med två bitar vardera ger sex bitar per pixel. Ett omslag på 1024×1024 har plats för 786 432 byte före pixelpaketets 16 byte långa huvud och 786 416 byte efter det. Illustrationer reserverar pixlar och minskar kapaciteten.

En fast markör identifierar paketets format och en längduppgift anger var kuvertet slutar. Paketversioner och krypteringsversioner är separata: ett PNG2-paket kan bära ett kryptografiskt v3-kuvert.

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

Härled nyckeln och autentisera kuvertet

SnapSeals v3-profil använder Argon2id v1.3 för att härleda en nyckel på 32 byte från lösenordet i UTF-8 och ett slumpmässigt salt på 16 byte. Inställningarna är 64 MiB minne, tre genomgångar och ett spår. En läsare måste kontrollera att arbetsfaktorerna är de fasta värden som stöds innan nyckelhärledningen körs.

AES-256-GCM krypterar originalets byte och metadata. En tagg på 16 byte autentiserar kryptotexten och det offentliga huvudet på 48 byte. Om autentiseringen misslyckas får läsaren aldrig lämna ut klartext.

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

Meddelandet finns i pixlarna

Nyttolasten ligger i avkodade RGB-värden, inte i PNG-metadata. Två PNG-filer kan ha olika filinnehåll på bytenivå och ändå bevara samma meddelande i de låga bitarna, så länge de avkodade RGB-värdena är oförändrade. En skärmbild, pixelredigering eller storleksändring kan däremot förstöra datan medan bilden fortfarande går att känna igen.

Om källkomprimering inte uttryckligen har valts återställs originalets byte och metadata exakt. Filnamnet är krypterat, men utan utfyllnad avslöjar kuvertet fortfarande den ungefärliga storleken.

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

Antikomprimering: en andra väg genom JPEG

Antikomprimering är ett försök att återställa data ur JPEG-bilder som har kvar samma dimensioner. Samma krypterade kuvert finns då både som en förlustfri kopia i PNG-bildens låga RGB-bitar och, om funktionen valts, som en återställningskopia i bildens frekvenskoefficienter.

Det hindrar inte komprimering och lovar inte att datan överlever godtyckliga meddelandetjänster. Formatet blir inte heller omöjligt att upptäcka.

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

Från pixlar till frekvenser

JPEG använder diskret cosinustransform (DCT) för att omvandla luminansblock på 8×8 till frekvenskoefficienter, som sedan kvantiseras. SnapSeal använder utvalda koefficienter för att bära data på värden med avsiktliga mellanrum. Det ger läsaren en marginal för vissa ändringar som JPEG-kodningen orsakar.

Ett felkorrigerat transporthuvud anger hur återställningskanalen är upplagd. Det autentiserar inte den offentliga omslagsbilden, och läsaren behöver inte originalomslaget.

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

Lämna marginal mellan symbolerna

QIM, kvantiseringsindexmodulering, kopplar symboler till positioner med mellanrum. I modellen med en bit växlar multiplar av 24 mellan 0 och 1. För att lagra 1 flyttar kodaren 70 till 72. En ändring på +11 från 72 avkodas fortfarande som 1; +13 passerar gränsen och vänder biten. Flytta reglaget för att se var gränsen går.

Den verkliga kanalen lagrar en till tre bitar per vald koefficient med Gray-kodning. Python-exemplet med QIM illustrerar principen, men är ingen JPEG-avkodare.

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

Reparera först, autentisera sist

Reed–Solomon (255,223) lägger till 32 paritetsbyte till 223 databyte och kan rätta upp till 16 felaktiga byte per kodord. Interfoliering och block som sprids över bilden hjälper till att fördela skadorna i stället för att samla dem i några få kodord.

Läsaren reparerar först transportdatan, kontrollerar sedan dess CRC och autentiserar till sist kuvertet med AES-GCM. CRC kontrollerar transportens integritet; det är inte kryptografisk autentisering. En lyckad reparation ger inte rätt att lämna ut klartext om GCM-kontrollen misslyckas.

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

Testa den sammansatta bäraren

Båda kopiorna använder samma pixlar, så skrivning i de låga bitarna påverkar även DCT-koefficienterna. Därför körs testerna på den färdiga bilden med båda kopiorna. I lokala webbläsartester återvanns det exakta kuvertet efter JPEG-kodning med kvalitet 0,60 och efter upprepad kodning med 0,85→0,60. Högst 12 byte per kodord reparerades, under gränsen på 16. Resultaten är lokala och garanterar inte samma utfall med andra JPEG-kodare.

Automatiska försök görs bara upp till 280 000 krypterade byte, inklusive tillkommande formatdata. Redundans och starkare signaler kostar omslagskvalitet och kapacitet; kanalen komprimerar inte om det privata originalet. Om testerna misslyckas används förlustfri PNG eller ZIP i stället. Beskärning, storleksändring, rotation och andra kodare kan hindra återställning. Skicka därför helst den ursprungliga 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

Vad formatet avslöjar

Fasta markörer gör formatet upptäckbart. Omslagsbilden är offentlig och autentiseras inte som illustration; ändringar i dess höga bitar kan lämna det inbäddade meddelandet intakt. Felkorrigering och CRC hanterar transportskador, medan AES-GCM autentiserar det skyddade innehållet.

En angripare kan fortfarande gissa lösenord offline. Argon2id gör varje gissning dyrare men kan inte rädda ett svagt lösenord. Det finns ingen lösenordsåterställning, och detta är inte ett påstående om att en oberoende säkerhetsgranskning har gjorts.

08

Läs filen utan applikationen

Den oberoende läsaren för Python 3.12+ stöder ursprungliga PNG-filer, även sådana med en JPEG-återställningskopia, SnapSeal-ZIP-filer med en enda PNG inom de begränsningar som stöds samt råa v3-kuvert. JPEG-filer som konverterats från omslaget kräver TypeScript-kodeken.

Formatet som beskrivs här är v3-implementationen med öppen källkod. Äldre driftsatta läsare kan avvisa dess filer. Lösningen bygger på befintliga kryptografiska primitiver, ett format med tydliga gränser och en oberoende läsare. Installera de angivna beroendena i en virtuell miljö. Fullständig källkod och valfria nedladdningar följer efter kodexemplen i artikeln.

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