01

Tách nội dung bí mật khỏi ảnh bìa

SnapSeal mã hóa byte gốc của hình ảnh, tệp hoặc văn bản cùng siêu dữ liệu, rồi nhúng vùng chứa mã hóa vào pixel của ảnh bìa. Người nhận cần tệp được bảo vệ và mật khẩu để giải mã, nhưng không cần ảnh bìa gốc hay máy chủ.

Mật mã giữ bí mật nội dung; kỹ thuật giấu tin cung cấp vật mang. Cả hai đều đã có từ lâu. Công việc kỹ thuật ở đây là xác định rõ ranh giới giữa chúng. Trong ví dụ này, ảnh bìa và mật khẩu thử nghiệm được chủ ý công bố.

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

Ghi vào hai bit thấp

Mỗi pixel RGB có ba kênh 8 bit. Trong ví dụ này, bộ mã hóa đọc các kênh từ trái sang phải, bỏ qua alpha, giữ nguyên sáu bit cao và thay hai bit thấp. Bốn kênh vì thế mang được một byte.

Kéo thanh trượt để xem các bit được tô sáng thay đổi. Riêng thao tác ghi bit thấp này chỉ làm giá trị mỗi kênh đổi tối đa 3; kênh JPEG tùy chọn và các bước xử lý hình thức khác có thể làm ảnh bìa thay đổi nhiều hơn.

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

Tính dung lượng có thể dùng

Ba kênh, mỗi kênh hai bit, cho sáu bit trên mỗi pixel. Ảnh bìa 1024×1024 chứa được 786.432 byte trước phần đầu gói pixel 16 byte, còn 786.416 byte sau khi trừ phần đầu. Hình trang trí chiếm một số pixel và làm giảm dung lượng này.

Một dấu cố định nhận diện cấu trúc gói, còn trường độ dài xác định điểm kết thúc của vùng chứa. Phiên bản gói và phiên bản mã hóa được đánh số độc lập: gói PNG2 có thể mang vùng chứa mã hóa 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

Dẫn xuất khóa và xác thực vùng chứa

Cấu hình v3 của SnapSeal dùng Argon2id v1.3 để dẫn xuất khóa 32 byte từ mật khẩu UTF-8 và salt ngẫu nhiên 16 byte. Thuật toán dùng 64 MiB bộ nhớ, ba lượt và một luồng xử lý. Trước khi chạy KDF, bộ đọc phải kiểm tra các tham số chi phí cố định có thuộc cấu hình được hỗ trợ hay không.

AES-256-GCM mã hóa byte gốc và siêu dữ liệu. Thẻ 16 byte xác thực cả bản mã lẫn phần đầu công khai 48 byte. Nếu xác thực thất bại, bộ đọc tuyệt đối không được trả ra bản rõ.

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

Thông điệp nằm trong pixel

Dữ liệu được mang bởi các giá trị RGB sau khi giải mã ảnh, không nằm trong siêu dữ liệu PNG. Hai tệp PNG có thể khác nhau về byte mà vẫn giữ cùng thông điệp trong các bit thấp, miễn là giá trị RGB sau giải mã không đổi. Ngược lại, chụp màn hình, sửa pixel hoặc đổi kích thước có thể phá dữ liệu dù hình vẫn dễ nhận ra.

Trừ khi người dùng chủ động chọn nén nguồn, quá trình khôi phục trả lại chính xác byte gốc và siêu dữ liệu. Tên tệp được mã hóa, nhưng nếu không thêm đệm, vùng chứa vẫn để lộ kích thước gần đúng.

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

Chống nén: đường khôi phục thứ hai qua JPEG

“Chống nén” ở đây là cách cố gắng khôi phục dữ liệu từ JPEG còn nguyên kích thước, không bảo đảm thành công. Một bản sao của vùng chứa mã hóa nằm trong các bit RGB thấp của PNG. Nếu được bật, bản sao thứ hai sẽ nằm trong các hệ số tần số của ảnh để phục vụ khôi phục.

Cơ chế này không ngăn ảnh bị nén và cũng không làm định dạng trở nên không thể phát hiện.

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

Từ pixel sang tần số

JPEG áp dụng phép biến đổi cosin rời rạc (DCT) cho các khối độ sáng 8×8, rồi lượng tử hóa những hệ số thu được. SnapSeal ghi dữ liệu bằng cách đặt các hệ số được chọn vào những giá trị có khoảng cách chủ ý. Nhờ đó, bộ đọc có thể chịu được phần nào thay đổi do mã hóa JPEG.

Phần đầu truyền tải có sửa lỗi cho biết cách bố trí kênh khôi phục. Bộ đọc không cần ảnh bìa gốc.

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

Chừa khoảng cách giữa các ký hiệu

QIM, tức điều chế chỉ số lượng tử hóa, gán ký hiệu vào các vị trí cách nhau trên một lưới. Trong mô hình minh họa một bit, các bội của 24 luân phiên biểu thị 0 và 1. Để lưu 1, bộ mã hóa chuyển 70 thành 72. Nếu giá trị tăng thêm 11 từ 72, nó vẫn được đọc là 1; tăng 13 thì vượt ranh giới và làm bit đảo. Hãy kéo thanh trượt để xem ranh giới đó.

Kênh thực tế lưu một đến ba bit trên mỗi hệ số được chọn bằng mã Gray. Đoạn Python về QIM chỉ minh họa nguyên lý, không phải bộ giải mã JPEG hoàn chỉnh.

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

Sửa lỗi trước, xác thực sau

Reed–Solomon (255,223) thêm 32 byte kiểm tra vào 223 byte dữ liệu và sửa được tối đa 16 byte sai trên mỗi từ mã. Kỹ thuật xen kẽ cùng việc phân tán các khối trên ảnh giúp trải hư hại ra, thay vì dồn vào một vài từ mã.

Bộ đọc sửa dữ liệu truyền tải, kiểm tra CRC rồi xác thực vùng chứa bằng AES-GCM. CRC kiểm tra tính toàn vẹn khi truyền, không phải xác thực mật mã. Sửa lỗi thành công cũng không cho phép trả bản rõ nếu kiểm tra GCM thất bại.

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

Kiểm tra vật mang đã kết hợp

Hai bản sao cùng chiếm các pixel nên việc ghi bit thấp cũng làm thay đổi hệ số DCT. Vì vậy, các phép kiểm tra dùng ảnh cuối cùng sau khi kết hợp cả hai. Trong thử nghiệm cục bộ, vùng chứa phải được khôi phục chính xác sau khi mã hóa JPEG bằng trình duyệt ở chất lượng 0.60 và sau chuỗi mã hóa 0.85→0.60. Số byte được sửa tối đa là 12 trên mỗi từ mã, thấp hơn giới hạn 16 byte. Đây là các phép thử cục bộ, không phải bảo đảm cho mọi bộ mã hóa JPEG.

Giới hạn thử tự động là 280.000 byte mã hóa, tính cả phần phụ trợ. Dữ liệu dư thừa và tín hiệu mạnh hơn làm giảm chất lượng và dung lượng ảnh bìa; bản gốc riêng tư không bị nén lại. Nếu kiểm tra thất bại, hệ thống chuyển sang truyền PNG hoặc ZIP không mất dữ liệu. Cắt, đổi kích thước, xoay hoặc dùng bộ mã hóa khác đều có thể khiến khôi phục thất bại. Đáng tin cậy hơn là gửi PNG hoặc ZIP gốc dưới dạng tệp.

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

Những gì định dạng để lộ

Các dấu cố định khiến định dạng có thể bị phát hiện. Ảnh bìa là công khai và không được xác thực như một tác phẩm; thay đổi các bit cao của ảnh vẫn có thể để nguyên thông điệp nhúng. Sửa lỗi và CRC xử lý hư hại khi truyền, còn AES-GCM xác thực nội dung được bảo vệ.

Kẻ tấn công vẫn có thể đoán mật khẩu ngoại tuyến. Argon2id làm mỗi lần đoán tốn kém hơn, nhưng không cứu được mật khẩu yếu. Không có cách khôi phục mật khẩu, và đây cũng không phải tuyên bố rằng định dạng đã qua kiểm toán bảo mật độc lập.

08

Đọc dữ liệu không cần ứng dụng

Thiết kế này kết hợp các thuật toán mật mã sẵn có, một định dạng với giới hạn rõ ràng và một bộ đọc độc lập. Bộ đọc Python 3.12+ hỗ trợ PNG gốc, kể cả PNG có bản sao khôi phục JPEG, ZIP SnapSeal chứa một PNG trong giới hạn kích thước cho phép, và vùng chứa v3 thô. Với tệp đã chuyển sang JPEG, cần dùng codec TypeScript.

Bài viết mô tả bản triển khai v3 nguồn mở; các bộ đọc cũ đã triển khai có thể từ chối những tệp do bản này tạo ra. Hãy cài các phần phụ thuộc được liệt kê trong môi trường ảo. Mã nguồn đầy đủ và các bản tải xuống tùy chọn nằm sau những ví dụ trong bài.

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