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.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"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 Δ +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) == 0xD3Hvor 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[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
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.
[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)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)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.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → 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_quantizerGiv 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.
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 boundaryReparer 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_bytesTest 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_000Hvad 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.
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.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)

