01

ምስጢሩና ተሸካሚው

SnapSeal ከምስሎች፣ ከፋይሎች ወይም ከጽሑፍ የመጀመሪያ ባይቶች ይነሳል። ባይቶቹንና ሜታዳታቸውን አመስጥሮ የተመሰጠረውን ጥቅል በሽፋን ምስል ፒክስሎች ውስጥ ያስቀምጣል። ተቀባዩ የተጠበቀውን ፋይልና የይለፍ ቃሉን ይፈልጋል፤ የመጀመሪያው ሽፋን ወይም አገልጋይ አያስፈልገውም።

ምስጠራ ይዘቱን በሚስጥር ይጠብቃል፤ የምስል ስቴጋኖግራፊ ደግሞ መሸከሚያውን ይሰጣል። ሁለቱም የታወቁ ሐሳቦች ናቸው፤ የአሠራሩ ፈተና ድንበራቸውን በግልጽ ማስቀመጥ ነው። በዚህ ምሳሌ የሚጠቀሙት ስዕልና የሙከራ የይለፍ ቃል ሆን ተብለው ይፋ የተደረጉ ናቸው።

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

የታችኛዎቹን ሁለት ቢቶች መጠቀም

እያንዳንዱ የRGB ፒክስል ሦስት ባለ 8-ቢት ቻናሎች አሉት። በዚህ ምሳሌ ኢንኮደሩ ቻናሎቹን ከግራ ወደ ቀኝ ያነባል፣ alphaን ይዘላል፤ የላይኛዎቹን ስድስት ቢቶች ሳይነካ የታችኛዎቹን ሁለት ይተካል። ስለዚህ አራት ቻናሎች አንድ ባይት ይሸከማሉ።

የተደመቁት ቢቶች ሲቀየሩ ለማየት ተንሸራታቹን ያንቀሳቅሱ። ይህ የዝቅተኛ ቢቶች ጽሕፈት ብቻውን የቻናል እሴትን ቢበዛ በ3 ይቀይራል፤ አማራጩ የJPEG ተሸካሚና ሌሎች የማስዋቢያ ለውጦች ግን ሽፋኑን ከዚያ በላይ ሊቀይሩ ይችላሉ።

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

ስንት ባይት ይገባል?

በሦስቱ ቻናሎች እያንዳንዳቸው ሁለት ቢቶችን ሲሸከሙ፣ አንድ ፒክስል ስድስት ቢቶች ይይዛል። 1024×1024 የሆነ ሽፋን ከ16-ባይት የፒክስል ፓኬት ራስጌ በፊት 786,432 ባይቶች ይይዛል፤ ራስጌው ከተቀነሰ በኋላ 786,416 ባይቶች ይቀራሉ። ስዕል ለራሱ ፒክስሎችን ስለሚይዝ የሚገኘውን አቅም ይቀንሳል።

ቋሚ ምልክት የፓኬቱን አቀማመጥ ይለያል፤ የርዝመት መስክ ደግሞ የተመሰጠረው ጥቅል የሚያበቃበትን ቦታ ያመለክታል። የፓኬትና የምስጠራ ስሪቶች የተለያዩ መለያዎች ናቸው፤ የPNG2 ፓኬት የv3 የምስጠራ ጥቅል ሊሸከም ይችላል።

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

ቁልፉን ማውጣት፣ ጥቅሉን ማረጋገጥ

የSnapSeal v3 መገለጫ Argon2id v1.3ን ተጠቅሞ ከUTF-8 የይለፍ ቃል ባይቶችና ከዘፈቀደ 16-ባይት salt 32-ባይት ቁልፍ ያወጣል። ለዚህም 64 MiB ማህደረ ትውስታ፣ 3 ዙሮችና 1 lane ይጠቀማል። አንባቢው KDFን ከማስኬዱ በፊት የተገለጹት የሥራ መለኪያዎች ከተደገፉት እሴቶች ጋር መጣጣማቸውን ማረጋገጥ አለበት።

AES-256-GCM የመጀመሪያዎቹን ባይቶችና ሜታዳታቸውን ያመሰጥራል። ባለ 16-ባይት tag የተመሰጠረውን ውሂብና ይፋዊውን 48-ባይት ራስጌ ያረጋግጣል። ማረጋገጫው ካልተሳካ፣ አንባቢው የተፈታውን ውሂብ ፈጽሞ መልቀቅ የለበትም።

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

መልዕክቱ በፒክስሎች ውስጥ ነው

ውሂቡ የሚቀመጠው በተፈቱ የRGB እሴቶች ውስጥ ነው፣ በPNG ሜታዳታ ውስጥ አይደለም። ሁለት የPNG ፋይሎች ባይቶቻቸው ቢለያዩም፣ የተፈቱት የRGB እሴቶቻቸው ሳይቀየሩ ከቀሩ ተመሳሳይ የዝቅተኛ ቢት መልዕክት ይይዛሉ። ስክሪንሾት ማንሳት፣ ፒክስሎችን ማርትዕ ወይም መጠን መቀየር ግን ምስሉ የሚታወቅ ሆኖ ሳለ ውሂቡን ሊያጠፋ ይችላል።

የምንጩን መጭመቅ በግልጽ ካልተመረጠ፣ የመጀመሪያዎቹ ባይቶችና ሜታዳታቸው በትክክል ይመለሳሉ። የፋይሉ ስም የተመሰጠረ ነው፤ ጥቅሉ መጠኑን ለመደበቅ ተጨማሪ ባይቶች ስለሌሉት ግን ግምታዊውን የይዘት መጠን ያሳያል።

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

መጭመቅን መቋቋም፦ በJPEG በኩል ሁለተኛ መንገድ

«መጭመቅን መቋቋም» የተባለው አሠራር የስፋትና የቁመት መጠኑ ያልተቀየረ JPEG ውስጥ ያለውን ውሂብ ለመመለስ የሚደረግ ሙከራ ነው፤ ስኬቱ የተረጋገጠ አይደለም። ተመሳሳዩ የተመሰጠረ ጥቅል በPNG ዝቅተኛ የRGB ቢቶች ውስጥ ቅጂ አለው፤ እንደ አማራጭም በምስሉ የድግግሞሽ ኮኤፊሺየንቶች ውስጥ የመልሶ ማግኛ ቅጂ ሊኖረው ይችላል። ይህ መጭመቅን አያግድም፤ ቅርጸቱንም ሊለይ የማይችል አያደርገውም።

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

ከፒክስሎች ወደ ድግግሞሽ ኮኤፊሺየንቶች

JPEG የ8×8 ብሩህነት ብሎኮችን DCT (discrete cosine transform) በሚባል ለውጥ ወደ ድግግሞሽ ኮኤፊሺየንቶች ይቀይራል፤ ከዚያም እሴቶቻቸውን በደረጃዎች ያጠጋጋል። SnapSeal በተመረጡ ኮኤፊሺየንቶች ላይ ውሂብን እርስ በርሳቸው ክፍተት ባላቸው እሴቶች ያስቀምጣል። ይህም በJPEG ኢንኮዲንግ የሚመጡ አንዳንድ ለውጦችን አንባቢው እንዲቋቋም ህዳግ ይሰጠዋል።

የስህተት እርማት ያለው የማስተላለፊያ ራስጌ የመልሶ ማግኛውን አቀማመጥ ይለያል። ይህ ይፋዊውን የሽፋን ስዕል እንደ ስዕል አያረጋግጥም፤ አንባቢውም የመጀመሪያውን ሽፋን አይፈልግም።

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

በምልክቶች መካከል ህዳግ ማስቀመጥ

QIM፣ ማለትም quantization-index modulation፣ ምልክቶችን ክፍተት ባላቸው ቦታዎች ያዛምዳል። በ1-ቢት የማስተማሪያ ሞዴሉ የ24 ብዜቶች በተራ 0 እና 1ን ይወክላሉ። 1 ለማስቀመጥ 70 ወደ 72 ይንቀሳቀሳል። ከ72 ላይ +11 ቢጨመር አሁንም 1 ተብሎ ይነበባል፤ +13 ግን ድንበሩን አልፎ ቢቱን ይገለብጣል። ይህ ድንበር የት እንዳለ ለማየት ተንሸራታቹን ያንቀሳቅሱ።

ትክክለኛው ቻናል Gray codingን ተጠቅሞ በእያንዳንዱ የተመረጠ ኮኤፊሺየንት ከ1 እስከ 3 ቢቶችን ያስቀምጣል። የPython QIM ኮድ ቁራጭ መርሑን ለማሳየት ነው፤ የJPEG ዲኮደር አይደለም።

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

መጀመሪያ እርማት፣ በመጨረሻ ማረጋገጥ

Reed–Solomon (255,223) ለ223 የውሂብ ባይቶች 32 የparity ባይቶችን ይጨምራል፤ በእያንዳንዱ የኮድ ቃል እስከ 16 የተሳሳቱ ባይቶችን ማስተካከል ይችላል። ባይቶችን በተቀላቀለ ቅደም ተከተል ማስቀመጥና ብሎኮችን በምስሉ ላይ መበተን ጉዳት በጥቂት የኮድ ቃላት ላይ ብቻ እንዳይከማች ይረዳሉ።

አንባቢው መጀመሪያ የተላለፈውን ውሂብ ያስተካክላል፣ ከዚያ CRCን ይፈትሻል፣ በመጨረሻም ጥቅሉን በAES-GCM ያረጋግጣል። CRC የማስተላለፊያ ውሂቡን ለመፈተሽ ነው፤ የምስጠራ ማረጋገጫ አይደለም። እርማቱ ቢሳካም የGCM ፍተሻ ካልተሳካ የተፈታው ውሂብ መለቀቅ የለበትም።

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

የተዋሃደውን ተሸካሚ መፈተሽ

ሁለቱም ቅጂዎች በተመሳሳይ ፒክስሎች ውስጥ ስለሚኖሩ፣ ዝቅተኛ ቢቶችን መጻፍ የDCT ኮኤፊሺየንቶችንም ይቀይራል። ስለዚህ የራስ ፍተሻዎቹ ሁለቱንም ቅጂዎች የያዘውን የመጨረሻ ተሸካሚ ይጠቀማሉ። መልሶ ማግኘቱ እንደሚቻል የሚገለጸው፣ በአካባቢው የአሳሽ JPEG ፍተሻ በጥራት 0.60 እና በተከታታይ 0.85→0.60 ኢንኮዲንግ ውስጥ ጥቅሉ በትክክል ሲመለስ ብቻ ነው። በእያንዳንዱ የኮድ ቃልም ቢበዛ 12 ባይቶች መስተካከል አለባቸው፤ ይህ ከ16-ባይት የእርማት ገደብ ያነሰ ነው። እነዚህ የጥራት እሴቶች የአካባቢ ፍተሻዎች ናቸው፣ ለሁሉም JPEG ዋስትና አይሰጡም።

ራስ-ሰር ሙከራዎች ተጨማሪ የጥቅል ውሂብን ጨምሮ በ280,000 የተመሰጠሩ ባይቶች ገደብ ይቆማሉ። ተጨማሪ ቅጂ መጨመርና ጠንካራ ምልክቶችን መጠቀም የሽፋኑን ጥራትና አቅም ይቀንሳሉ፤ የግል መጀመሪያውን ፋይል ግን እንደገና አይጨምቁትም። ፍተሻዎቹ ካልተሳኩ፣ አሠራሩ ያለ መረጃ መጥፋት ወደሚነበብ PNG ወይም ZIP ይመለሳል። መከርከም፣ መጠን መቀየር፣ ማዞር ወይም ሌላ ኢንኮደር መጠቀም መልሶ ማግኘትን ሊያስቀር ይችላል። ስለዚህ አስተማማኙ ምክር የመጀመሪያውን PNG ወይም ZIP እንደ ፋይል መላክ ነው።

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

ቅርጸቱ የሚያጋልጠው

ቋሚ ምልክቶቹ ቅርጸቱን ለይቶ ማወቅ ያስችላሉ። የሽፋኑ ስዕል ይፋዊ ነው፤ እንደ ስዕልም አይረጋገጥም። የላይኛዎቹ ቢቶች ቢቀየሩ እንኳ የተቀመጠው መልዕክት ሳይበላሽ ሊቀር ይችላል። የስህተት እርማትና CRC የማስተላለፊያ ጉዳትን ለመቋቋምና ለመለየት ይረዳሉ፤ AES-GCM ደግሞ የተጠበቀውን ይዘት ያረጋግጣል።

አጥቂ አሁንም የይለፍ ቃላትን ከመስመር ውጭ መገመት ይችላል። Argon2id የእያንዳንዱን ግምት ወጪ ያሳድጋል፣ ደካማ የይለፍ ቃልን ግን ማዳን አይችልም። የይለፍ ቃልን መልሶ ማግኛ የለም፤ ገለልተኛ የደህንነት ኦዲት ተደርጓል የሚል መግለጫም የለም።

08

ያለ መተግበሪያው ማንበብ

የዚህ አሠራር ጠቀሜታ የታወቁ የምስጠራ አልጎርይዝሞችን፣ በግልጽ የተገለጹ ገደቦችንና ገለልተኛ አንባቢን በአንድ ቅርጸት ማቀናጀቱ ነው። የPython 3.12+ አንባቢው የመጀመሪያ PNG ፋይሎችን፣ የJPEG መልሶ ማግኛ ቅጂ ያላቸውን PNGዎች ጨምሮ፣ ይደግፋል። እንዲሁም በተወሰነ ገደብ ውስጥ አንድ PNG የያዙ SnapSeal ZIP ፋይሎችንና ያልታሸጉ v3 ጥቅሎችን ያነባል። ወደ JPEG የተቀየሩ ፋይሎች ግን የTypeScript ኮዴክን ይፈልጋሉ።

እዚህ የተገለጸው ክፍት ምንጭ የሆነው የv3 ትግበራ ነው፤ ቀድመው የተሰማሩ የቆዩ አንባቢዎች ፋይሎቹን ላይቀበሉ ይችላሉ። የተዘረዘሩትን ጥገኛ ጥቅሎች በምናባዊ አካባቢ ይጫኑ። ሙሉ ምንጭ ኮዱና አማራጭ ማውረጃዎች ከምሳሌዎቹ በኋላ ይገኛሉ።

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