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 位元通道。在這個例子中,編碼器由左至右讀取通道,跳過 alpha 透明度通道,保留每個通道的高六位元,替換最低兩位元。因此,四個通道就能攜帶一個位元組。

移動滑桿,觀察標示的位元如何改變。單就這個低位元寫入步驟而言,每個通道的數值最多改變 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 封面在扣除 16 位元組的像素封包標頭前,可容納 786,432 位元組;扣除後剩下 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

衍生金鑰,驗證封裝

SnapSeal 的 v3 設定使用 Argon2id v1.3,以 UTF-8 編碼的密碼和隨機產生的 16 位元組鹽值衍生 32 位元組金鑰。參數為 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 盡力提供復原途徑。同一份加密封裝在 PNG 的 RGB 低位元中有一份副本,也可以選擇在圖片的頻率係數中加入復原副本。

這條額外路徑為部分 JPEG 轉換保留容錯空間,但不會阻止壓縮,也不能讓格式變得無法偵測。

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

從像素轉到頻率

JPEG 對 8×8 亮度區塊進行離散餘弦轉換(DCT),得到頻率係數,再將係數量化。SnapSeal 把資料寫入選定的係數,讓數值落在刻意分隔的位置,為 JPEG 編碼造成的變動保留一些容忍空間。

受錯誤更正保護的傳輸標頭會指出復原資料的配置;它不會驗證公開封面圖的內容。讀取時也不需要原始封面。

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

在符號之間留出餘裕

QIM(量化索引調變)將符號對應到彼此有間距的位置。單一位元的教學模型中,24 的倍數交替代表 0 和 1:要儲存 1,就把 70 調整為 72。從 72 增加 11,解碼結果仍是 1;增加 13 則會跨過邊界,使位元翻轉。移動滑桿,就能看到這個邊界。

實際通道使用 Gray coding(葛雷碼),每個選定係數儲存 1 至 3 個位元。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) 在 223 個資料位元組後加入 32 個同位檢查位元組,每個碼字最多可更正 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 個位元組,才宣稱具備復原能力。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 復原副本的 PNG;也支援大小受限、內含單一 PNG 的 SnapSeal ZIP,以及原始 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