Salaisuus ja sen kantaja
SnapSeal salaa kuvien, tiedostojen tai tekstin alkuperäiset tavut metatietoineen ja upottaa syntyvän salatun säiliön kansikuvaan. Suojatun tiedoston purkamiseen tarvitaan salasana, mutta ei palvelinta eikä alkuperäistä kansikuvaa.
Kryptografia suojaa sisällön luottamuksellisuuden; kuvasteganografia tarjoaa sille kantajan. Kumpikaan menetelmä ei ole uusi. Esimerkin kuvitus ja testisalasana on tarkoituksella julkaistu kaikkien nähtäviksi.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Kaksi alinta bittiä käyttöön
Jokaisessa RGB-pikselissä on kolme 8-bittistä kanavaa. Tässä esimerkissä kooderi lukee kanavat vasemmalta oikealle, ohittaa alfa-kanavan ja säilyttää kunkin kanavan kuusi ylintä bittiä. Kaksi alinta se korvaa viestin biteillä. Näin neljään kanavaan mahtuu yksi tavu.
Liikuta liukusäädintä ja seuraa korostettujen bittien muutoksia. Pelkkä alimpien bittien kirjoitus muuttaa kanavan arvoa enintään 3. Valinnainen JPEG-kantaja ja muu kuvan muotoilu voivat muuttaa kansikuvaa enemmänkin.
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) == 0xD3Kuinka paljon kuvaan mahtuu?
Kolme kanavaa ja kaksi bittiä kanavaa kohti antavat kuusi bittiä pikseliä kohden. Kooltaan 1024×1024 olevaan kansikuvaan mahtuu 786 432 tavua ennen 16-tavuista pikselipaketin otsaketta ja 786 416 tavua sen jälkeen. Kuvitus varaa pikseleitä ja pienentää käytettävissä olevaa tilaa.
Kiinteä tunniste kertoo paketin rakenteen, ja pituuskenttä ilmaisee salatun säiliön lopun. Paketin ja salauksen versionumerot kuuluvat eri nimiavaruuksiin: PNG2-paketti voi kuljettaa v3-salaussäiliön.
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 |
Avaimen johtaminen ja säiliön todennus
SnapSealin v3-profiilissa Argon2id v1.3 johtaa 32-tavuisen avaimen UTF-8-muotoisesta salasanasta ja satunnaisesta 16-tavuisesta suolasta. Laskenta käyttää 64 MiB muistia, kolme kierrosta ja yhtä kaistaa. Lukijan on tarkistettava, että kiinteät työmääräparametrit ovat tuettuja, ennen kuin se käynnistää avaimen johtamisen.
AES-256-GCM salaa alkuperäiset tavut ja metatiedot. Sen 16-tavuinen todennustunniste kattaa salatekstin ja julkisen 48-tavuisen otsakkeen. Jos todennus epäonnistuu, lukija ei saa koskaan luovuttaa selvätekstiä.
[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)Viesti kulkee pikseleissä
Hyötykuorma on puretuissa RGB-arvoissa, ei PNG-metatiedoissa. Kahden PNG-tiedoston tavut voivat erota toisistaan, mutta niissä on silti sama alimpien bittien viesti, jos puretut RGB-arvot ovat muuttumattomat. Kuvakaappaus, pikselien muokkaus tai koon muuttaminen taas voi tuhota datan, vaikka kuva pysyisi tunnistettavana.
Ellei lähteen pakkausta ole erikseen valittu, alkuperäiset tavut ja metatiedot palautuvat täsmälleen. Tiedostonimi on salattu, mutta ilman täytettä säiliö paljastaa sisällön likimääräisen koon.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Pakkauksen sieto: toinen reitti JPEG:n läpi
Pakkauksen sieto tarkoittaa yritystä palauttaa viesti JPEG-kuvasta, jonka mitat ovat pysyneet samoina. Samasta salatusta säiliöstä on häviötön kopio PNG:n alimmissa RGB-biteissä ja valinnaisesti palautuskopio kuvan taajuuskertoimissa.
Menetelmä ei estä pakkausta eikä takaa viestin säilymistä mielivaltaisissa viestipalveluissa. Se ei myöskään tee muodosta havaitsematonta.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
Pikseleistä taajuuskertoimiin
JPEG muuntaa 8×8-pikseliset kirkkauslohkot taajuuskertoimiksi diskreetillä kosinimuunnoksella eli DCT:llä ja kvantisoi ne. SnapSeal tallentaa dataa valittuihin kertoimiin sijoittamalla niiden arvot tarkoituksella riittävän etäälle toisistaan. Lukija saa näin hieman pelivaraa JPEG-koodauksen aiheuttamille muutoksille.
Virheenkorjattu siirto-otsake kertoo palautuskanavan rakenteen. Se ei todenna julkista kansikuvitusta, eikä lukija tarvitse alkuperäistä kansikuvaa.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerTilaa symbolien välissä
QIM eli kvantisointi-indeksimodulaatio sijoittaa symbolit erillisiin kohtiin arvoasteikolla. Yhden bitin opetusmallissa luvun 24 kerrannaiset vastaavat vuorotellen bittejä 0 ja 1. Bitin 1 tallentamiseksi kooderi siirtää arvon 70 arvoon 72. Jos arvoon 72 lisätään 11, tulos tulkitaan edelleen bitiksi 1; lisäys 13 ylittää rajan ja kääntää bitin. Liikuta liukusäädintä nähdäksesi rajan.
Varsinainen kanava tallentaa yhdestä kolmeen bittiä valittua kerrointa kohti ja käyttää Gray-koodausta. Pythonin QIM-koodikatkelma havainnollistaa periaatetta, mutta se ei ole JPEG-dekooderi.
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 boundaryEnsin korjaus, lopuksi todennus
Reed–Solomon (255,223) lisää 223 datatavuun 32 pariteettitavua ja voi korjata kussakin koodisanassa enintään 16 virheellistä tavua. Lomitus ja kuvan eri puolille hajautetut lohkot auttavat jakamaan vauriot sen sijaan, että ne keskittyisivät muutamaan koodisanaan.
Lukija korjaa ensin siirtodatan, tarkistaa sitten sen CRC-arvon ja todentaa lopuksi säiliön AES-GCM:llä. CRC tarkistaa siirtodatan eheyden, mutta ei ole kryptografinen todennus. Onnistunut korjaus ei oikeuta luovuttamaan selvätekstiä, jos GCM-tarkistus epäonnistuu.
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_bytesYhdistetty kantaja testiin
Molemmat kopiot käyttävät samoja pikseleitä, joten alimpien bittien kirjoittaminen häiritsee myös DCT-kertoimia. Siksi itsetestit tehdään lopullisella yhdistetyllä kantajalla. Palautusta luvataan vain, jos säiliö palautuu täsmälleen paikallisissa selaimen JPEG-testeissä laadulla 0.60 sekä peräkkäisessä 0.85→0.60-koodauksessa. Lisäksi yhdessä koodisanassa saa olla enintään 12 korjattua tavua, mikä jää alle 16 tavun korjausrajan. Laatuasetukset kuvaavat paikallisia testejä, eivät yleispäteviä JPEG-takuita.
Automaattisia yrityksiä tehdään enintään 280 000 salatulle tavulle, mukaan lukien rakenteen vaatimat lisätavut. Redundanssi ja voimakkaammat signaalit kuluttavat kansikuvan laatua ja kapasiteettia; ne eivät pakkaa yksityistä alkuperäistä uudelleen. Jos testit epäonnistuvat, toimituksessa palataan häviöttömään PNG- tai ZIP-tiedostoon. Rajaus, koon muuttaminen, kierto tai toinen kooderi voi estää palautuksen, joten luotettavin tapa on lähettää alkuperäinen PNG tai ZIP tiedostona.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Mitä muoto paljastaa?
Kiinteät tunnisteet tekevät muodosta havaittavan. Kansikuvitus on julkinen, eikä sen aitoutta kuvituksena todenneta; ylempien bittien muutokset voivat jättää upotetun viestin ennalleen. Virheenkorjaus ja CRC käsittelevät siirtovaurioita, kun taas AES-GCM todentaa suojatun sisällön.
Hyökkääjä voi edelleen arvailla salasanoja ilman verkkoyhteyttä. Argon2id tekee jokaisesta yrityksestä kalliimman, mutta ei pelasta heikkoa salasanaa. Salasanaa ei voi palauttaa, eikä tässä väitetä koodekille tehdyn riippumatonta turvallisuusauditointia.
Lukeminen ilman sovellusta
Ladattava Python 3.12+ -lukija tukee alkuperäisiä PNG-kuvia, myös JPEG-palautuskopion sisältäviä PNG-kuvia, kooltaan rajattuja yhden PNG:n SnapSeal-ZIP-tiedostoja sekä raakoja v3-säiliöitä. JPEG-muotoon muunnettujen tiedostojen lukemiseen tarvitaan TypeScript-koodekki.
Tässä kuvataan avoimen lähdekoodin v3-toteutusta; vanhemmat käytössä olevat lukijat voivat hylätä sen tuottamat tiedostot. Asenna ilmoitetut riippuvuudet virtuaaliympäristöön. Koko lähdekoodi ja valinnaiset lataukset ovat artikkelin koodiesimerkkien jälkeen.
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)

