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

استخراج کلید و احراز اصالت محفظه

نمایهٔ v3 در SnapSeal با استفاده از Argon2id v1.3 از گذرواژهٔ UTF-8 و نمک تصادفی 16 بایتی، کلیدی 32 بایتی استخراج می‌کند. این کار به 64 MiB حافظه، سه گذر و یک مسیر پردازش نیاز دارد. خواننده باید پیش از اجرای تابع استخراج کلید، پارامترهای کاری ثابت و پشتیبانی‌شده را بررسی کند.

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هایی است که ابعادشان تغییر نکرده است. همان محفظهٔ رمزگذاری‌شده یک نسخهٔ بدون اتلاف در بیت‌های پایین RGB تصویر PNG دارد و می‌تواند یک نسخهٔ اختیاری برای بازیابی در ضرایب فرکانسی تصویر هم داشته باشد.

این روش جلوی فشرده‌سازی را نمی‌گیرد، ماندگاری داده در هر پیام‌رسانی را تضمین نمی‌کند و قالب را نیز تشخیص‌ناپذیر نمی‌سازد.

  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 منتقل می‌شود. اگر 11 واحد به 72 اضافه شود، نتیجه همچنان 1 خوانده می‌شود؛ اضافه شدن 13 واحد از مرز می‌گذرد و بیت را برمی‌گرداند. لغزنده را حرکت دهید تا این مرز را ببینید.

کانال واقعی با کدگذاری گری، در هر ضریب منتخب یک تا سه بیت ذخیره می‌کند. قطعه‌کد QIM در Python فقط برای توضیح ایده است و رمزگشای 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های اصلی، از جمله PNG دارای نسخهٔ بازیابی JPEG، فایل‌های ZIP محدودشدهٔ SnapSeal که یک PNG دارند، و محفظه‌های خام 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