先分清秘密和载体
SnapSeal 从图像、文件或文本的原始字节开始,先加密这些字节及其元数据,再把所得封装嵌入封面图像的像素。接收者只需要受保护文件和密码,不需要服务器或原始封面。
密码学保护内容的机密性,图像隐写提供载体;两者都是成熟的技术。演示所用的封面图和测试密码本来就是公开的。
original_bytes + metadataArgon2id → AES-GCMheader + ciphertext + tagRGB → PNG
password = "Public SnapSeal article example"借用 RGB 的最低两位
每个 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)消息在像素里,不在 PNG 元数据里
载荷存放在解码后的 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 则会越过边界,使结果翻转。移动滑块,可以看到边界在哪里。
实际通道使用格雷码,每个选定系数承载 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)

