01

Το μυστικό και η εικόνα που το μεταφέρει

Το SnapSeal ξεκινά από τα αρχικά byte εικόνων, αρχείων ή κειμένου. Ο codec τα κρυπτογραφεί μαζί με τα μεταδεδομένα τους και ενσωματώνει τον κρυπτογραφικό φάκελο σε μια εικόνα κάλυψης. Ο παραλήπτης χρειάζεται το προστατευμένο αρχείο και τον κωδικό πρόσβασης, αλλά όχι την αρχική εικόνα κάλυψης ή έναν διακομιστή.

Η κρυπτογραφία κρατά το περιεχόμενο απόρρητο· η στεγανογραφία εικόνας παρέχει τον φορέα. Και οι δύο τεχνικές είναι καθιερωμένες. Η εικόνα και ο δοκιμαστικός κωδικός της δημόσιας επίδειξης έχουν δημοσιευτεί σκόπιμα.

  1. original_bytes + metadata
  2. Argon2id → AES-GCM
  3. header + ciphertext + tag
  4. RGB → PNG
password = "Public SnapSeal article example"
GitHub · snapseal-codec
02

Τα δύο χαμηλότερα bit

Κάθε pixel RGB έχει τρία κανάλια των 8 bit. Στο παράδειγμα, ο κωδικοποιητής διαβάζει τα κανάλια από αριστερά προς τα δεξιά, παραλείπει το άλφα, διατηρεί τα έξι υψηλότερα bit και αντικαθιστά τα δύο χαμηλότερα. Έτσι, τέσσερα κανάλια μεταφέρουν ένα byte.

Μετακινήστε τον ολισθητήρα για να δείτε τα επισημασμένα bit να αλλάζουν. Αυτή η μεμονωμένη εγγραφή μεταβάλλει την τιμή ενός καναλιού το πολύ κατά 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

Πόσα byte χωρούν;

Τρία κανάλια με δύο bit το καθένα δίνουν έξι bit ανά pixel. Σε εικόνα κάλυψης 1024×1024 χωρούν 786.432 byte πριν από την κεφαλίδα πακέτου των 16 byte και 786.416 μετά από αυτήν. Τα γραφικά δεσμεύουν pixel και μειώνουν τη χωρητικότητα.

Ένας σταθερός δείκτης προσδιορίζει τη διάταξη του πακέτου και ένα πεδίο μήκους το τέλος του κρυπτογραφικού φακέλου. Οι εκδόσεις του πακέτου και της κρυπτογράφησης ανήκουν σε διαφορετικά συστήματα αρίθμησης: ένα πακέτο 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 byte από τον κωδικό πρόσβασης σε UTF-8 και ένα τυχαίο salt 16 byte. Οι παράμετροι είναι 64 MiB μνήμης, τρία περάσματα και μία λωρίδα. Ο αναγνώστης πρέπει να ελέγχει ότι οι σταθερές παράμετροι κόστους υποστηρίζονται πριν εκτελέσει τη συνάρτηση παραγωγής κλειδιού.

Το AES-256-GCM κρυπτογραφεί τα αρχικά byte και τα μεταδεδομένα. Η ετικέτα των 16 byte αυθεντικοποιεί το κρυπτοκείμενο και τη δημόσια κεφαλίδα των 48 byte. Αν η αυθεντικοποίηση αποτύχει, ο αναγνώστης δεν πρέπει ποτέ να αποδώσει τα αποκρυπτογραφημένα δεδομένα.

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

Το μήνυμα βρίσκεται στα pixel

Το ωφέλιμο φορτίο βρίσκεται στις αποκωδικοποιημένες τιμές RGB, όχι στα μεταδεδομένα του PNG. Δύο αρχεία PNG μπορεί να διαφέρουν ως byte και παρ’ όλα αυτά να διατηρούν το ίδιο μήνυμα στα χαμηλά bit, εφόσον οι αποκωδικοποιημένες τιμές RGB παραμένουν ίδιες. Αντίθετα, ένα στιγμιότυπο οθόνης, μια επεξεργασία pixel ή μια αλλαγή μεγέθους μπορεί να καταστρέψει τα δεδομένα, παρότι η εικόνα παραμένει αναγνωρίσιμη.

Εκτός αν έχει επιλεγεί ρητά συμπίεση της πηγής, τα αρχικά byte και μεταδεδομένα ανακτώνται ακριβώς. Το όνομα αρχείου είναι κρυπτογραφημένο, αλλά χωρίς συμπλήρωση ο φάκελος αποκαλύπτει το κατά προσέγγιση μέγεθος.

PNG bytes A ≠ PNG bytes B
RGB(A) = RGB(B), α(A) = α(B) = 255
→ envelope(A) = envelope(B)
PNG · W3C
06

Αντιμετώπιση της συμπίεσης: μια δεύτερη διαδρομή μέσω JPEG

Η αντιμετώπιση της συμπίεσης είναι μια προσπάθεια ανάκτησης από JPEG που διατηρούν τις ίδιες διαστάσεις, χωρίς εγγύηση επιτυχίας. Ο ίδιος κρυπτογραφικός φάκελος έχει ένα αντίγραφο χωρίς απώλειες στα χαμηλά bit RGB του PNG και, προαιρετικά, ένα αντίγραφο ανάκτησης στους συντελεστές συχνοτήτων της εικόνας.

Αυτό δεν εμποδίζει τη συμπίεση ούτε υπόσχεται επιβίωση μέσα από οποιαδήποτε εφαρμογή ανταλλαγής μηνυμάτων. Επίσης, δεν κάνει τη μορφή μη ανιχνεύσιμη.

  1. envelope → RS(255,223)
  2. interleave → spread blocks
  3. QIM → luma DCT 8×8
  4. repair → CRC → AES-GCM

Από τα pixel στις συχνότητες

Το JPEG μετασχηματίζει μπλοκ φωτεινότητας 8×8 σε συντελεστές συχνοτήτων με τον διακριτό μετασχηματισμό συνημιτόνου, γνωστό ως DCT, και στη συνέχεια τους κβαντίζει. Το SnapSeal χρησιμοποιεί επιλεγμένους συντελεστές για να μεταφέρει δεδομένα σε τιμές με σκόπιμη απόσταση μεταξύ τους. Έτσι αφήνει περιθώριο για ορισμένες αλλαγές που προκαλεί η κωδικοποίηση JPEG.

Μια κεφαλίδα μεταφοράς με διόρθωση σφαλμάτων προσδιορίζει τη διάταξη ανάκτησης. Δεν αυθεντικοποιεί τα δημόσια γραφικά της εικόνας κάλυψης, ενώ ο αναγνώστης δεν χρειάζεται την αρχική εικόνα.

RGB → Y → DCT(8×8)
q = round(coefficient / JPEG_quantizer)
coefficient′ = q × JPEG_quantizer

Απόσταση ανάμεσα στα σύμβολα

Η QIM, δηλαδή η διαμόρφωση δείκτη κβαντισμού, αντιστοιχίζει σύμβολα σε θέσεις που απέχουν μεταξύ τους. Στο εκπαιδευτικό μοντέλο του ενός bit, τα πολλαπλάσια του 24 αντιστοιχούν εναλλάξ σε 0 και 1. Για να αποθηκευτεί το 1, το 70 μετακινείται στο 72. Μια μεταβολή +11 από το 72 εξακολουθεί να αποκωδικοποιείται ως 1· με +13 περνά το όριο και το bit αντιστρέφεται. Μετακινήστε τον ολισθητήρα για να δείτε το όριο.

Το πραγματικό κανάλι αποθηκεύει από ένα έως τρία bit ανά επιλεγμένο συντελεστή, με κωδικοποίηση 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 byte ισοτιμίας σε 223 byte δεδομένων και μπορεί να διορθώσει έως 16 λανθασμένα byte ανά κωδική λέξη. Η παρεμβολή δεδομένων και η διασπορά των μπλοκ στην εικόνα βοηθούν ώστε οι φθορές να μη συγκεντρώνονται σε λίγες κωδικές λέξεις.

Ο αναγνώστης διορθώνει πρώτα τα δεδομένα μεταφοράς, ελέγχει το 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

Έλεγχος του τελικού συνδυασμένου φορέα

Τα δύο αντίγραφα καταλαμβάνουν τα ίδια pixel, οπότε οι εγγραφές στα χαμηλά bit μεταβάλλουν και τους συντελεστές DCT. Οι αυτοέλεγχοι γίνονται επομένως στον τελικό συνδυασμένο φορέα. Η δυνατότητα ανάκτησης δηλώνεται μόνο αν ο φάκελος ανακτηθεί ακριβώς σε τοπικές δοκιμές JPEG του προγράμματος περιήγησης, τόσο με ποιότητα 0,60 όσο και με διαδοχική κωδικοποίηση 0,85→0,60, με το πολύ 12 διορθωμένα byte ανά κωδική λέξη, κάτω από το όριο των 16. Τα τοπικά αποτελέσματα δεν ισχύουν απαραίτητα για άλλους κωδικοποιητές JPEG.

Οι αυτόματες προσπάθειες σταματούν στα 280.000 κρυπτογραφημένα byte, μαζί με τα πρόσθετα δεδομένα. Ο πλεονασμός και τα ισχυρότερα σήματα κοστίζουν σε ποιότητα και χωρητικότητα της εικόνας κάλυψης· δεν επανασυμπιέζουν το ιδιωτικό πρωτότυπο. Αν οι δοκιμές αποτύχουν, χρησιμοποιείται 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

Τι αποκαλύπτει η μορφή

Οι σταθεροί δείκτες κάνουν τη μορφή ανιχνεύσιμη. Τα γραφικά της εικόνας κάλυψης είναι δημόσια και δεν αυθεντικοποιούνται ως εικόνα· αλλαγές στα υψηλότερα bit μπορεί να αφήσουν το ενσωματωμένο μήνυμα άθικτο. Η διόρθωση σφαλμάτων και το CRC αντιμετωπίζουν φθορές στη μεταφορά, ενώ το AES-GCM αυθεντικοποιεί το προστατευμένο περιεχόμενο.

Ένας επιτιθέμενος μπορεί ακόμη να δοκιμάζει κωδικούς εκτός σύνδεσης. Το Argon2id αυξάνει το κόστος κάθε δοκιμής, αλλά δεν μπορεί να σώσει έναν αδύναμο κωδικό. Δεν υπάρχει ανάκτηση κωδικού, ούτε διατυπώνεται ισχυρισμός για ανεξάρτητο έλεγχο ασφαλείας.

08

Ανάγνωση χωρίς την εφαρμογή

Η μορφή συνδυάζει καθιερωμένες κρυπτογραφικές τεχνικές, σαφή όρια και έναν ανεξάρτητο αναγνώστη. Ο αναγνώστης Python 3.12+ υποστηρίζει αρχικά PNG, ακόμη και εκείνα με αντίγραφο ανάκτησης JPEG, SnapSeal ZIP με ένα μόνο PNG και όρια μεγέθους, καθώς και ακατέργαστους κρυπτογραφικούς φακέλους v3. Για αρχεία που έχουν μετατραπεί σε JPEG απαιτείται ο codec 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