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 的最低两位

每个 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

消息在像素里,不在 PNG 元数据里

载荷存放在解码后的 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 则会越过边界,使结果翻转。移动滑块,可以看到边界在哪里。

实际通道使用格雷码,每个选定系数承载 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