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 बदलता है। वैकल्पिक 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 प्रोफ़ाइल UTF-8 पासवर्ड और यादृच्छिक 16-बाइट सॉल्ट से 32-बाइट कुंजी बनाने के लिए Argon2id v1.3 इस्तेमाल करती है। इसके लिए 64 MiB मेमोरी, तीन पास और एक लेन तय हैं। 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 बिट में बिना हानि के रखी जाती है; वैकल्पिक रूप से एक रिकवरी कॉपी तस्वीर के फ़्रीक्वेंसी गुणांकों में भी रखी जा सकती है।

यह दूसरा रास्ता फ़ॉर्मेट को पहचान से छिपाता भी नहीं है। उसकी उपयोगिता इस पर निर्भर करती है कि JPEG बनने के बाद कितनी जानकारी बची है।

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

पिक्सेल से फ़्रीक्वेंसी तक

JPEG डिस्क्रीट कोसाइन ट्रांसफ़ॉर्म (DCT) से चमक के 8×8 ब्लॉक को फ़्रीक्वेंसी गुणांकों में बदलता है और फिर उन्हें क्वांटाइज़ करता है। 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 करता है। इसके बाद +11 का बदलाव होने पर भी बिट 1 पढ़ा जाता है; +13 सीमा पार कर देता है और बिट पलट जाता है। सीमा देखने के लिए स्लाइडर चलाएँ।

वास्तविक चैनल Gray coding से हर चुने हुए गुणांक में एक से तीन बिट रखता है। 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 परीक्षणों में गुणवत्ता 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