把秘密與封面分開
SnapSeal 從圖片、檔案或文字的原始位元組開始,先加密這些位元組及其中繼資料,再將加密封裝嵌入封面圖片的像素。接收者只需受保護的檔案和密碼,不需要伺服器或原始封面。
密碼學負責保護內容的機密性,圖片隱寫則提供載體;兩者都是既有的技術。示範使用的封面圖與測試密碼都是刻意公開的。
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"使用最低的兩個位元
每個 RGB 像素有三個 8 位元通道。在這個例子中,編碼器由左至右讀取通道,跳過 alpha 透明度通道,保留每個通道的高六位元,替換最低兩位元。因此,四個通道就能攜帶一個位元組。
移動滑桿,觀察標示的位元如何改變。單就這個低位元寫入步驟而言,每個通道的數值最多改變 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算出可用容量
三個通道各放兩位元,每個像素就有六位元的容量。1024×1024 封面在扣除 16 位元組的像素封包標頭前,可容納 786,432 位元組;扣除後剩下 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 |
衍生金鑰,驗證封裝
SnapSeal 的 v3 設定使用 Argon2id v1.3,以 UTF-8 編碼的密碼和隨機產生的 16 位元組鹽值衍生 32 位元組金鑰。參數為 64 MiB 記憶體、三次迭代及一條平行通道。讀取程式必須先確認工作參數符合固定支援的設定,才能執行金鑰衍生。
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 盡力提供復原途徑。同一份加密封裝在 PNG 的 RGB 低位元中有一份副本,也可以選擇在圖片的頻率係數中加入復原副本。
這條額外路徑為部分 JPEG 轉換保留容錯空間,但不會阻止壓縮,也不能讓格式變得無法偵測。
envelope → RS(255,223)interleave → spread blocksQIM → luma DCT 8×8repair → 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 解碼器。
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看清格式暴露了什麼
固定標記使這個格式可以被偵測。封面圖是公開的,其圖像內容也不受認證;修改高位元後,嵌入訊息仍可能完好。錯誤更正與 CRC 處理傳輸損壞,AES-GCM 則驗證受保護的內容。
攻擊者仍可離線猜測密碼。Argon2id 增加每次猜測的成本,卻救不了弱密碼。系統沒有密碼找回功能,本文也不宣稱它經過獨立安全稽核。
不靠應用程式讀取
這個格式使用既有的密碼學原語,明確列出限制,並提供獨立的讀取程式。Python 3.12+ 讀取程式支援原始 PNG,包括帶有 JPEG 復原副本的 PNG;也支援大小受限、內含單一 PNG 的 SnapSeal ZIP,以及原始 v3 封裝。已轉換成 JPEG 的檔案則需要 TypeScript 編解碼器。
本文介紹的是開放原始碼的 v3 實作;較早部署的讀取程式可能拒絕其輸出。請在虛擬環境中安裝所列相依套件。完整原始碼與選用下載項目位於程式範例之後。
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)

