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 بتات. في هذا المثال يقرأ المُرمّز القنوات من اليسار إلى اليمين، متجاوزًا قناة alpha، ويحافظ على البتات الستة العليا ويستبدل البتين الأدنى. وبذلك تحمل كل أربع قنوات بايتًا واحدًا.

حرّك المنزلق لترى تغيّر البتات المظلّلة. هذا التعديل المعزول للبتات الدنيا لا يغيّر قيمة القناة بأكثر من 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 إلى 786,432 بايتًا قبل ترويسة حزمة البكسلات ذات 16 بايتًا، وإلى 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 لاشتقاق مفتاح طوله 32 بايتًا من كلمة المرور المرمّزة بـ UTF-8 وملح عشوائي طوله 16 بايتًا. تتطلب العملية ذاكرة قدرها 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. وتبقى القراءة 1 إذا زادت القيمة بمقدار +11، لكن زيادة +13 تتجاوز الحد وتقلب البت. حرّك المنزلق لترى موضع هذا الحد.

أما القناة الفعلية فتخزّن من بت واحد إلى ثلاثة بتات في كل معامل مختار باستخدام ترميز Gray. ومقتطف 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) عدد 32 بايت تكافؤ إلى كل 223 بايت بيانات، ويمكنها تصحيح ما يصل إلى 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، وملفات SnapSeal ZIP محدودة الحجم التي تحتوي على ملف 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