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-बाइट सॉल्टपासून Argon2id v1.3 वापरून 32-बाइट की तयार करतो. त्यासाठी 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 बिटमध्ये असते; ऐच्छिक पुनर्प्राप्ती प्रत प्रतिमेच्या फ्रिक्वेन्सी गुणांकांमध्ये ठेवता येते.

यामुळे संपीडन रोखले जात नाही, कोणत्याही मेसेंजरमधून डेटा टिकेल याची हमी मिळत नाही आणि स्वरूप शोधता येणार नाही असेही होत नाही.

  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 म्हणजे क्वांटायझेशन-इंडेक्स मॉड्युलेशन. यात चिन्हे अंतर ठेवलेल्या स्थानांशी जोडली जातात. एक-बिटच्या शैक्षणिक मॉडेलमध्ये 24 च्या पटीतील मूल्ये आळीपाळीने 0 आणि 1 दर्शवतात. 1 साठवण्यासाठी एन्कोडर 70 चे 72 करतो. 72 मध्ये +11 बदल झाला तरी निकाल 1 राहतो; +13 मुळे सीमा ओलांडली जाते आणि बिट उलटतो. ही सीमा पाहण्यासाठी स्लायडर हलवा.

प्रत्यक्ष वाहक प्रत्येक निवडलेल्या गुणांकात ग्रे कोडिंग वापरून एक ते तीन बिट साठवतो. 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