01

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.

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

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 Δ +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

Cik 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
SNAPSEALPNG2
[0:12]SNAPSEALPNG212 bytes
[12:16]uint32_be(envelope_length)4 bytes
[16:…]envelopeenvelope_length
pixel-packet.tsstyled-packet.ts
04

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.

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

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)
PNG · W3C
06

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.

  1. envelope → RS(255,223)
  2. interleave → spread blocks
  3. QIM → luma DCT 8×8
  4. repair → 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_quantizer

Atstarpe 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.

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

Vispirms 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_bytes

Abas 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_000
jpeg-channel.tspng-container.tsJPEG · ITU-T T.81QIM · Chen & Wornell (2001)
07

Ko 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.

08

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.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