แยกข้อมูลลับออกจากภาพปก
SnapSeal เข้ารหัสไบต์ต้นฉบับของภาพ ไฟล์ หรือข้อความพร้อมข้อมูลเมตา แล้วฝังซองข้อมูลที่ได้ลงในภาพปก ผู้รับใช้เพียงไฟล์ที่ปกป้องไว้กับรหัสผ่านในการถอดข้อมูล โดยไม่ต้องใช้เซิร์ฟเวอร์หรือภาพปกต้นฉบับ
การเข้ารหัสรักษาความลับของเนื้อหา ส่วนการซ่อนข้อมูลในภาพทำหน้าที่เป็นตัวพา ทั้งสองแนวคิดมีมานานแล้ว ภาพและรหัสผ่านทดสอบในตัวอย่างนี้เผยแพร่ต่อสาธารณะโดยตั้งใจ
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"ใช้สองบิตล่างของแต่ละช่องสี
พิกเซล RGB มี 3 ช่องสี ช่องละ 8 บิต ในตัวอย่างนี้ ตัวเข้ารหัสอ่านช่องสีจากซ้ายไปขวา ข้ามช่อง alpha เก็บ 6 บิตบนไว้ตามเดิม แล้วแทนที่ 2 บิตล่าง ดังนั้น 4 ช่องสีจึงบรรจุข้อมูลได้ 1 ไบต์
ลองเลื่อนแถบเพื่อดูบิตที่เน้นเปลี่ยนไป การเขียนเฉพาะบิตล่างเช่นนี้เปลี่ยนค่าของแต่ละช่องได้ไม่เกิน 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นับความจุที่ใช้ได้
ช่องสี 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[0:12] | SNAPSEALPNG2 | 12 bytes |
|---|---|---|
[12:16] | uint32_be(envelope_length) | 4 bytes |
[16:…] | envelope | envelope_length |
สร้างกุญแจและยืนยันความถูกต้องของซองข้อมูล
โปรไฟล์ v3 ของ SnapSeal ใช้ Argon2id v1.3 สร้างกุญแจขนาด 32 ไบต์จากรหัสผ่านที่เข้ารหัสเป็น UTF-8 และ salt สุ่มขนาด 16 ไบต์ โดยใช้หน่วยความจำ 64 MiB ประมวลผล 3 รอบ และใช้ 1 lane ตัวอ่านต้องตรวจสอบว่าค่าต้นทุนที่ระบุเป็นค่าคงที่ซึ่งรองรับก่อนเริ่ม KDF
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 ใช้ 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 ที่สมบูรณ์
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รู้ว่ารูปแบบนี้เปิดเผยอะไรบ้าง
เครื่องหมายคงที่ทำให้ตรวจพบรูปแบบนี้ได้ งานภาพปกเป็นข้อมูลสาธารณะและไม่ได้รับการยืนยันความถูกต้องในฐานะภาพ การเปลี่ยนบิตบนของภาพอาจไม่ทำลายข้อความที่ฝังอยู่ การแก้ไขข้อผิดพลาดและ CRC จัดการความเสียหายระหว่างส่ง ส่วน AES-GCM ยืนยันความถูกต้องของเนื้อหาที่ปกป้อง
ผู้โจมตียังเดารหัสผ่านแบบออฟไลน์ได้ Argon2id ทำให้การเดาแต่ละครั้งมีต้นทุนสูงขึ้น แต่ช่วยรหัสผ่านที่อ่อนแอไม่ได้ ไม่มีระบบกู้รหัสผ่าน และไม่มีการกล่าวอ้างว่าระบบผ่านการตรวจสอบความปลอดภัยโดยหน่วยงานอิสระ
อ่านข้อมูลโดยไม่ใช้แอป
สิ่งสำคัญของรูปแบบนี้คือการใช้วิธีเข้ารหัสที่มีอยู่แล้ว กำหนดรูปแบบและขีดจำกัดให้ชัดเจน และมีตัวอ่านแยกต่างหาก ตัวอ่าน Python 3.12+ รองรับ PNG ต้นฉบับ รวมถึง PNG ที่มีสำเนาสำหรับกู้คืนจาก JPEG รองรับ SnapSeal ZIP ที่มี PNG เพียงไฟล์เดียวภายใต้ขีดจำกัดที่กำหนด และรองรับซองข้อมูล v3 แบบดิบ ส่วนไฟล์ที่แปลงเป็น JPEG แล้วต้องใช้โคเดก TypeScript
บทความนี้อธิบายอิมพลีเมนเทชัน v3 แบบโอเพนซอร์ส ซึ่งตัวอ่านรุ่นเก่าที่ใช้งานอยู่บางตัวอาจปฏิเสธไฟล์ที่สร้างขึ้น ให้ติดตั้งแพ็กเกจที่ระบุใน virtual environment โดยซอร์สโค้ดฉบับเต็มและไฟล์ดาวน์โหลดเพิ่มเติมอยู่ถัดจากตัวอย่างโค้ด
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)

