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 มี 3 ช่องสี ช่องละ 8 บิต ในตัวอย่างนี้ ตัวเข้ารหัสอ่านช่องสีจากซ้ายไปขวา ข้ามช่อง alpha เก็บ 6 บิตบนไว้ตามเดิม แล้วแทนที่ 2 บิตล่าง ดังนั้น 4 ช่องสีจึงบรรจุข้อมูลได้ 1 ไบต์

ลองเลื่อนแถบเพื่อดูบิตที่เน้นเปลี่ยนไป การเขียนเฉพาะบิตล่างเช่นนี้เปลี่ยนค่าของแต่ละช่องได้ไม่เกิน 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

นับความจุที่ใช้ได้

ช่องสี 3 ช่องที่เก็บช่องละ 2 บิตให้พื้นที่ 6 บิตต่อพิกเซล ภาพปกขนาด 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 และ salt สุ่มขนาด 16 ไบต์ โดยใช้หน่วยความจำ 64 MiB ประมวลผล 3 รอบ และใช้ 1 lane ตัวอ่านต้องตรวจสอบว่าค่าต้นทุนที่ระบุเป็นค่าคงที่ซึ่งรองรับก่อนเริ่ม KDF

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 หรือ discrete cosine transform แปลงบล็อกความสว่างขนาด 8×8 ให้เป็นสัมประสิทธิ์ความถี่ แล้วควอนไทซ์ค่าเหล่านั้น SnapSeal ใช้สัมประสิทธิ์บางตัวบรรจุข้อมูล โดยจัดค่าให้เว้นระยะกันอย่างตั้งใจ เพื่อให้ตัวอ่านทนต่อการเปลี่ยนแปลงบางส่วนจากการเข้ารหัส JPEG ได้

ส่วนหัวสำหรับการส่งข้อมูลซึ่งมีรหัสแก้ไขข้อผิดพลาดระบุผังของช่องกู้คืน ส่วนหัวนี้ไม่ได้ยืนยันความถูกต้องของงานภาพปกที่เปิดเผยต่อสาธารณะ และตัวอ่านไม่จำเป็นต้องมีภาพปกต้นฉบับ

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

เว้นระยะให้สัญลักษณ์

QIM หรือ quantization-index modulation กำหนดตำแหน่งที่เว้นระยะกันให้แทนสัญลักษณ์ ในแบบจำลองสอนหลักการที่เก็บ 1 บิต พหุคูณของ 24 สลับกันแทน 0 และ 1 เมื่อต้องการเก็บ 1 ตัวเข้ารหัสจะปรับ 70 เป็น 72 หากค่าจาก 72 เพิ่มไป 11 ก็ยังถอดได้เป็น 1 แต่ถ้าเพิ่ม 13 จะข้ามขอบเขตและทำให้บิตกลับค่า ลองเลื่อนแถบเพื่อดูขอบเขตนี้

ช่องข้อมูลจริงเก็บ 1 ถึง 3 บิตต่อสัมประสิทธิ์ที่เลือก โดยใช้ Gray coding ส่วนตัวอย่าง 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 ต้นฉบับ รวมถึง PNG ที่มีสำเนาสำหรับกู้คืนจาก JPEG รองรับ SnapSeal ZIP ที่มี PNG เพียงไฟล์เดียวภายใต้ขีดจำกัดที่กำหนด และรองรับซองข้อมูล v3 แบบดิบ ส่วนไฟล์ที่แปลงเป็น JPEG แล้วต้องใช้โคเดก TypeScript

บทความนี้อธิบายอิมพลีเมนเทชัน v3 แบบโอเพนซอร์ส ซึ่งตัวอ่านรุ่นเก่าที่ใช้งานอยู่บางตัวอาจปฏิเสธไฟล์ที่สร้างขึ้น ให้ติดตั้งแพ็กเกจที่ระบุใน virtual environment โดยซอร์สโค้ดฉบับเต็มและไฟล์ดาวน์โหลดเพิ่มเติมอยู่ถัดจากตัวอย่างโค้ด

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