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-বিটের 3টি চ্যানেল থাকে। এই উদাহরণে এনকোডার বাঁ থেকে ডানে চ্যানেল পড়ে, আলফা বাদ দেয়, ওপরের 6টি বিট অক্ষত রেখে নিচের 2টি বদলায়। ফলে 4টি চ্যানেলে 1 বাইট রাখা যায়।

স্লাইডার সরিয়ে দেখুন, চিহ্নিত বিটগুলো কীভাবে বদলায়। শুধু নিচের বিট লেখার এই ধাপে কোনো চ্যানেলের মান সর্বোচ্চ 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

কত বাইট ধরে

3টি চ্যানেলে 2 বিট করে মোট 6 বিট থাকে প্রতি পিক্সেলে। তাই 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-বাইট সল্ট থেকে 32-বাইট কী তৈরি করে। এতে 64 MiB মেমোরি, 3 পাস ও 1 লেন ব্যবহৃত হয়। KDF চালানোর আগে রিডারকে নির্ধারিত ও সমর্থিত কাজের মাত্রা যাচাই করতে হবে।

AES-256-GCM মূল বাইট ও মেটাডেটা এনক্রিপ্ট করে। এর 16-বাইট ট্যাগ সাইফারটেক্সটের পাশাপাশি প্রকাশ্য 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 প্রয়োগ করে ফ্রিকোয়েন্সি সহগ পায়, তারপর সেগুলো কোয়ান্টাইজ করে। SnapSeal নির্বাচিত সহগে ইচ্ছাকৃত ব্যবধানযুক্ত মান ব্যবহার করে তথ্য রাখে। ফলে JPEG এনকোডিংয়ে কিছু পরিবর্তন হলেও রিডার সঠিক মান চিনতে পারে।

ত্রুটি-সংশোধনযুক্ত একটি ট্রান্সপোর্ট হেডার পুনরুদ্ধারের চ্যানেল চেনায়। এটি প্রকাশ্য বাহক শিল্পকর্মের সত্যতা যাচাই করে না; রিডারের মূল বাহক ছবিরও প্রয়োজন হয় না।

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

সংকেতের মাঝখানে জায়গা

QIM বা quantization-index modulation-এ প্রতীকগুলোকে ব্যবধান রেখে নির্দিষ্ট অবস্থানে বসানো হয়। এক বিটের শিক্ষামূলক মডেলে 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 প্যারিটি বাইট যোগ করে। এতে প্রতিটি কোডওয়ার্ডে সর্বোচ্চ 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 পরীক্ষায় quality 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 এবং raw 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