Noslēpums un tā nesējs
SnapSeal sāk ar attēlu, failu vai teksta oriģinālajiem baitiem. Kodeks tos šifrē kopā ar metadatiem un iegūto šifrēto konteineru ievieto vāka attēlā. Saņēmējam vajadzīgs aizsargātais fails un parole; sākotnējais vāka attēls vai serveris nav nepieciešams.
Kriptogrāfija aizsargā satura konfidencialitāti, bet steganogrāfija nodrošina nesēju. Abi paņēmieni ir sen zināmi; svarīgi ir skaidri nodalīt to lomas. Demonstrācijas ilustrācija un testa parole ir apzināti publiskotas.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"Divi zemākie biti
Katram RGB pikselim ir trīs 8 bitu kanāli. Šajā piemērā kodētājs lasa kanālus no kreisās uz labo, izlaiž alfa kanālu, saglabā sešus augstākos bitus un aizstāj divus zemākos. Tātad četros kanālos var ierakstīt vienu baitu.
Pārvietojiet slīdni, lai redzētu, kā mainās izceltie biti. Šis atsevišķais zemāko bitu ieraksts maina kanāla vērtību ne vairāk kā par 3; papildu JPEG nesējs un citi vizuāli pārveidojumi vāku var mainīt vairāk.
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) == 0xD3Cik baitu var ievietot?
Trīs kanāli ar diviem bitiem katrā dod sešus bitus pikselī. 1024×1024 vāka attēlā ir vieta 786 432 baitiem pirms 16 baitu pikseļu paketes galvenes un 786 416 baitiem pēc tās. Ilustrācijai rezervētie pikseļi ietilpību samazina.
Fiksēts marķieris norāda paketes izkārtojumu, bet garuma vērtība iezīmē konteinera beigas. Paketes un šifrēšanas versijas apzīmē atsevišķi: PNG2 pakete var saturēt v3 kriptogrāfisko konteineru.
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 |
Atslēgas atvasināšana un konteinera autentifikācija
SnapSeal v3 profilā Argon2id v1.3 no UTF-8 paroles un nejaušas 16 baitu sāls atvasina 32 baitu atslēgu. Tam izmanto 64 MiB atmiņas, trīs caurlaides un vienu paralēlo joslu. Pirms atslēgas atvasināšanas lasītājam jāpārbauda, vai norādītie darba parametri atbilst fiksētajām atbalstītajām vērtībām.
AES-256-GCM šifrē oriģinālos baitus un metadatus. Tā 16 baitu autentifikācijas tags aizsargā gan šifrtekstu, gan publisko 48 baitu galveni. Ja autentifikācija neizdodas, lasītājs nekādā gadījumā nedrīkst izdot atklāto tekstu.
[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)Ziņojums glabājas pikseļos
Dati atrodas dekodētajās RGB vērtībās, nevis PNG metadatos. Divu PNG failu baiti var atšķirties, bet, ja dekodētās RGB vērtības nav mainītas, zemākajos bitos ierakstītais ziņojums saglabājas. Savukārt ekrānuzņēmums, pikseļu rediģēšana vai izmēra maiņa var datus iznīcināt, kaut arī attēls joprojām ir atpazīstams.
Ja avota saspiešana nav īpaši izvēlēta, atkopšana precīzi atjauno sākotnējos baitus un metadatus. Faila nosaukums ir šifrēts, taču bez aizpildījuma konteiners joprojām atklāj aptuveno izmēru.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)Aizsardzība pret saspiešanu: otrs ceļš caur JPEG
Aizsardzība pret saspiešanu šeit nozīmē mēģinājumu atgūt datus no JPEG, kam saglabāti sākotnējie izmēri. Tas pats šifrētais konteiners tiek ierakstīts PNG zemākajos RGB bitos, un pēc izvēles tā atkopšanas kopiju var ievietot arī attēla frekvenču koeficientos. Šis paņēmiens neliedz attēlu saspiest un nepadara formātu neatklājamu; atkopšana nav garantēta.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → CRC → AES-GCM
No pikseļiem līdz frekvencēm
JPEG 8×8 spilgtuma blokiem piemēro diskrēto kosinusa transformāciju (DCT): tā pārvērš pikseļu vērtības frekvenču koeficientos, kurus pēc tam kvantē. SnapSeal izvēlētajiem koeficientiem piešķir apzināti atstatus novietotas vērtības. Šīs atstarpes palīdz izturēt daļu izmaiņu, ko rada JPEG kodēšana.
Transporta galvene ar kļūdu labošanu norāda atkopšanas datu izkārtojumu. Tā neautentificē publisko vāka ilustrāciju, un lasītājam sākotnējais vāka attēls nav vajadzīgs.
RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizerAtstarpe starp simboliem
QIM jeb kvantēšanas indeksa modulācija piesaista simbolus atstatus esošām vērtībām. Viena bita mācību modelī skaitļa 24 daudzkārtņi pārmaiņus apzīmē 0 un 1. Lai ierakstītu 1, kodētājs pārvieto 70 uz 72. Ja no 72 vērtība palielinās par 11, tā joprojām dekodējas kā 1; pieaugums par 13 šķērso robežu un maina bitu. Pārvietojiet slīdni, lai redzētu šo robežu.
Faktiskais kanāls katrā izvēlētajā koeficientā glabā no viena līdz trim bitiem, izmantojot Greja kodējumu. Python QIM fragments ilustrē principu; tas nav pilnīgs JPEG dekodētājs.
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 boundaryVispirms labošana, tad autentifikācija
Reed–Solomon (255,223) pievieno 32 paritātes baitus 223 datu baitiem un vienā kodvārdā spēj izlabot līdz 16 kļūdainiem baitiem. Datu savijums un pa attēlu izkliedēti bloki palīdz sadalīt bojājumus, lai tie nekoncentrētos dažos kodvārdos.
Lasītājs vispirms labo transporta datus, pārbauda to CRC un pēc tam autentificē konteineru ar AES-GCM. CRC pārbauda transporta datu integritāti, bet nav kriptogrāfiska autentifikācija. Ar veiksmīgu labošanu vien nepietiek: ja GCM pārbaude neizdodas, atklāto tekstu izdot nedrīkst.
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_bytesAbas kopijas jāpārbauda kopā
Abas kopijas atrodas vienos un tajos pašos pikseļos, tāpēc zemāko bitu ierakstīšana ietekmē arī DCT koeficientus. Pašpārbaudēs izmanto galīgo apvienoto nesēju. Atkopšanas iespēju norāda tikai tad, ja šifrēto konteineru izdodas atgūt precīzi vietējās pārlūka JPEG pārbaudēs ar kvalitāti 0.60 un pēc atkārtotas kodēšanas 0.85→0.60. Vienlaikus katrā kodvārdā drīkst būt laboti ne vairāk kā 12 baiti — mazāk par 16 baitu labošanas robežu. Tie ir vietējo pārbaužu rezultāti, nevis universāla JPEG garantija.
Automātiskos mēģinājumus neveic, ja šifrētais konteiners kopā ar papildu datiem pārsniedz 280 000 baitu. Redundance un spēcīgāki signāli samazina vāka kvalitāti un ietilpību; privātais oriģināls netiek atkārtoti saspiests. Ja pārbaudes neizdodas, izmanto bezzudumu PNG vai ZIP. Apgriešana, izmēra maiņa, pagriešana vai cits kodētājs var atkopšanu izjaukt, tāpēc visdrošāk sākotnējo PNG vai ZIP nosūtīt kā failu.
exact(PNG) ∧ exact(Q60) ∧ exact(Q85 → Q60)
max(corrected_bytes_per_codeword) ≤ 12 < 16
automatic: envelope_length ≤ 280_000Ko formāts atklāj
Fiksētie marķieri ļauj formātu atpazīt. Vāka ilustrācija ir publiska un netiek autentificēta kā ilustrācija; tās augstāko bitu maiņa var atstāt iegulto ziņojumu neskartu. Kļūdu labošana un CRC palīdz tikt galā ar transporta bojājumiem, savukārt AES-GCM autentificē aizsargāto saturu.
Uzbrucējs joprojām var minēt paroles bezsaistē. Argon2id sadārdzina katru mēģinājumu, bet vāju paroli neglābj. Paroles atgūšanas iespējas nav, un netiek apgalvots, ka veikts neatkarīgs drošības audits.
Neatkarīgs lasītājs
Formāts izmanto zināmus kriptogrāfijas primitīvus, skaidri nosaka savas robežas un piedāvā neatkarīgu lasītāju. Python 3.12+ lasītājs atbalsta sākotnējos PNG, tostarp PNG ar JPEG atkopšanas kopiju, ierobežota izmēra SnapSeal ZIP arhīvus ar vienu PNG un neapstrādātus v3 konteinerus. Pārveidotu JPEG failu lasīšanai nepieciešams TypeScript kodeks.
Šeit aprakstīta atvērtā pirmkoda v3 realizācija; vecāki jau izvietoti lasītāji tās veidotos failus var noraidīt. Norādītās atkarības instalējiet virtuālā vidē. Pilnais pirmkods un izvēles lejupielādes seko piemēriem.
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)

