راز و تصویر پوششی
SnapSeal بایتهای اصلی تصویر، فایل یا متن را همراه با فرادادهٔ آنها رمزگذاری میکند و محفظهٔ حاصل را در یک تصویر پوششی جای میدهد. گیرنده برای رمزگشایی فایل محافظتشده به گذرواژه نیاز دارد، اما نه به سرور یا تصویر پوششی اولیه.
رمزنگاری از محرمانگی محتوا محافظت میکند و نهاننگاری تصویر حامل آن را فراهم میآورد؛ هیچکدام ایدهٔ تازهای نیستند. تصویر و گذرواژهٔ آزمایشی این نمونه عمداً بهصورت عمومی منتشر شدهاند.
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"جایگذاری در دو بیت پایین
هر پیکسل RGB سه کانال 8 بیتی دارد. در این مثال، کدگذار کانالها را از چپ به راست میخواند، آلفا را کنار میگذارد، شش بیت بالایی را حفظ میکند و دو بیت پایین را جایگزین میکند. به این ترتیب، چهار کانال یک بایت را حمل میکنند.
لغزنده را حرکت دهید تا تغییر بیتهای مشخصشده را ببینید. همین نوشتنِ مستقل در بیتهای پایین، مقدار هر کانال را حداکثر 3 واحد تغییر میدهد؛ حامل اختیاری JPEG و تغییرات ظاهری دیگر ممکن است تصویر پوششی را بیشتر تغییر دهند.
R₀10101011170 → 171 Δ +1G₀10110101181 → 181 Δ 0B₀0101110094 → 92 Δ -2R₁11001011203 → 203 Δ 0def 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شمارش بایتهای قابل استفاده
دو بیت در هر یک از سه کانال، یعنی شش بیت در هر پیکسل. یک تصویر پوششی 1024×1024 پیش از کسر سرآیند 16 بایتی بستهٔ پیکسلی، ظرفیت 786,432 بایت دارد؛ پس از آن 786,416 بایت باقی میماند. عناصر گرافیکی بخشی از پیکسلها را رزرو میکنند و ظرفیت را کاهش میدهند.
یک نشانگر ثابت، قالب بسته را مشخص میکند و فیلد طول، پایان محفظه را نشان میدهد. نسخهٔ بسته و نسخهٔ رمزگذاری نامگذاری جداگانهای دارند: بستهٔ PNG2 میتواند محفظهٔ رمزنگاری v3 را حمل کند.
C = floor(W × H × 3 × 2 / 8) − 16
C(1024, 1024) = 786,416 bytes[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
استخراج کلید و احراز اصالت محفظه
نمایهٔ v3 در SnapSeal با استفاده از Argon2id v1.3 از گذرواژهٔ UTF-8 و نمک تصادفی 16 بایتی، کلیدی 32 بایتی استخراج میکند. این کار به 64 MiB حافظه، سه گذر و یک مسیر پردازش نیاز دارد. خواننده باید پیش از اجرای تابع استخراج کلید، پارامترهای کاری ثابت و پشتیبانیشده را بررسی کند.
AES-256-GCM بایتهای اصلی و فراداده را رمزگذاری میکند. برچسب 16 بایتی آن اصالت متن رمز و سرآیند عمومی 48 بایتی را بررسی میکند. اگر این بررسی شکست بخورد، خواننده هرگز نباید متن آشکار را منتشر کند.
[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)پیام در خود پیکسلهاست
داده در مقادیر RGB رمزگشاییشده قرار دارد، نه در فرادادهٔ PNG. دو فایل PNG میتوانند بایتهای متفاوتی داشته باشند، اما اگر مقادیر RGB رمزگشاییشدهٔ آنها یکسان باشد، پیامِ بیتهای پایین نیز حفظ میشود. در مقابل، اسکرینشات گرفتن، ویرایش پیکسلها یا تغییر اندازه ممکن است داده را از بین ببرد، حتی اگر تصویر همچنان قابل تشخیص باشد.
مگر آنکه فشردهسازی منبع صریحاً انتخاب شده باشد، بازیابی بایتها و فرادادهٔ اصلی را دقیقاً بازمیگرداند. نام فایل رمزگذاری میشود، اما بدون پدگذاری، اندازهٔ تقریبی از روی محفظه آشکار است.
PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)ضدفشردهسازی: مسیر دوم بازیابی از JPEG
ضدفشردهسازی تلاشی بدون تضمین برای بازیابی از JPEGهایی است که ابعادشان تغییر نکرده است. همان محفظهٔ رمزگذاریشده یک نسخهٔ بدون اتلاف در بیتهای پایین RGB تصویر PNG دارد و میتواند یک نسخهٔ اختیاری برای بازیابی در ضرایب فرکانسی تصویر هم داشته باشد.
این روش جلوی فشردهسازی را نمیگیرد، ماندگاری داده در هر پیامرسانی را تضمین نمیکند و قالب را نیز تشخیصناپذیر نمیسازد.
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → 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 نیست.
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قالب چه چیزهایی را آشکار میکند؟
نشانگرهای ثابت، قالب را قابل تشخیص میکنند. تصویر پوششی عمومی است و بهعنوان اثر تصویری احراز اصالت نمیشود؛ تغییر بیتهای بالایی آن ممکن است پیام جاسازیشده را دستنخورده باقی بگذارد. تصحیح خطا و CRC به آسیب انتقال میپردازند، در حالی که AES-GCM اصالت محتوای محافظتشده را بررسی میکند.
مهاجم همچنان میتواند گذرواژهها را آفلاین حدس بزند. Argon2id هزینهٔ هر حدس را بالا میبرد، اما گذرواژهٔ ضعیف را نجات نمیدهد. امکان بازیابی گذرواژه وجود ندارد و ادعایی نیز دربارهٔ ممیزی امنیتی مستقل مطرح نیست.
خواندن فایل بدون برنامه
خوانندهٔ قابل دانلود Python 3.12+ از PNGهای اصلی، از جمله PNG دارای نسخهٔ بازیابی JPEG، فایلهای ZIP محدودشدهٔ SnapSeal که یک PNG دارند، و محفظههای خام v3 پشتیبانی میکند. برای فایلهایی که به JPEG تبدیل شدهاند، کدک TypeScript لازم است.
آنچه در اینجا شرح داده شد، پیادهسازی متنباز v3 است؛ خوانندههای قدیمی منتشرشده ممکن است خروجی آن را نپذیرند. وابستگیهای فهرستشده را در یک محیط مجازی نصب کنید. متن کامل کد و دانلودهای اختیاری پس از مثالهای داخل متن آمدهاند.
python -m pip install -r requirements.txtfrom 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)

