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 יש מקום ל־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 משתמש בהתמרת קוסינוס בדידה (DCT) כדי להפוך בלוקי בהירות בגודל 8×8 למקדמי תדר, ואז מכמת אותם. 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 חוצה את הגבול והופך את הביט. הזיזו את המחוון כדי לראות היכן נמצא הגבול.

הערוץ בפועל שומר בין ביט אחד לשלושה ביטים בכל מקדם נבחר, באמצעות קידוד Gray. קטע ה־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) מוסיף 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, בקובצי 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