第 6 章 · 口令、SHA-256 与“键控”安全设计(第 6–7 周)#

动手做| 本章目标:理解为什么嵌入位置要“由密钥决定”;会讲清解码自同步与篡改感知的原理;知道这套设计的边界在哪里。我们配了两张真实计算图(图 6-1 / 图 6-2)。

6.1 如果嵌入位置是固定的,会怎样?#

假设每次都是从左上角开始顺序嵌。攻击者知道算法后,可以:① 直接读取前 N 个像素的 LSB 还原消息;② 把消息原位复制到另一张“看起来相同”的图像上(位置替换攻击);③ 对固定区域做针对性扰动,既破坏你的消息又不改动其余图像。

因此现代隐写会把嵌入路径做成密钥控制的伪随机顺序:没有口令(或不知道内容哈希)的人,不知道消息在哪一块,也无法进行位置级攻击。

图 6-1 键控置换路径

图 6-1(真实计算:用项目 permute_index() 的 splitmix64 + Fisher–Yates 算法,在 64×64 格子上画出“访问顺序”。向左用口令 A,向右用口令 B —— 两幅图的“先后次序”完全不同,看起来都像随机噪声)

读图要点| 颜色越亮越“先被访问”。换成不同口令,整张图的次序就彻底不同——攻击者不知道口令,就无法预测“消息藏在哪些位置”。这就是键控的意义:安全来自“不知道位置”,而不是“藏得看不见”。

6.2 两条种子规则:先认图,再找正文#

项目把像素分成两个池:头部池(开头 N_h 个块)与正文池(其余像素)。密钥派生用两个独立的种子:

  • seed0 = 仅由口令派生:决定头部池的置乱顺序。头部里预埋的是 cover_hash——由原图像字节计算的 SHA-256 截断前 16 字节(128 位);

  • seedB = cover_hash + 口令联合派生:决定正文池的置乱顺序与分块。

解码时,接收方先用自己的口令重建 seed0,读回头部取出 cover_hash,再与口令一起重建 seedB,才能找到正文。口令错误或头部被破坏,后续路径立刻失效——test_pwd_mismatch() 测的就是这一点。

项目中的种子派生(真实代码)

def derive_seed(image_bytes, password=""):
    base = hashlib.sha256(image_bytes).hexdigest()
    if password:
        base = hashlib.sha256(
            base.encode() + password.encode()).hexdigest()
    return int(base, 16)

6.3 确定性置乱:splitmix64 + Fisher–Yates#

有了种子还不够,还要把它变成“1..N 的一个确定排列”。项目用 splitmix64 作为伪随机源,执行 Fisher–Yates 洗牌:从末尾向前,每一步用伪随机数选一个位置交换。同样的种子必然产生同样的排列——这正是“自同步”的数学基础。

图 6-2 键控置换散点

图 6-2(真实计算:横轴=像素位置,纵轴=访问次序。口令 A(蓝)、口令 B(红) 各自都是一条“每点只出现一次”的置换;灰色虚线是不置乱的顺序(可直接预测=危险)。同一口令总是同一条曲线→自同步;换口令则完全打散)

回到项目看代码| 打开 src/ns5_core.py 的 permute_index():DLL 可用时调用 C++ 的 nsf5_permute,否则走 Python 同算法 fallback。两套实现必须给出完全相同的排列,否则用 DLL 嵌入的图在无 DLL 的机器上无法解码。src/cppembed.py 的 selfcheck() 专门做这种跨语言一致性校验。

6.4 篡改感知:到底能感知什么#

嵌入端在报告中给出 cover_hash(原图哈希)。任何像素被改动,图像字节的 SHA-256 都会改变。把收到的图像重新计算哈希,与嵌入端报告的(或头部解出的)哈希比较:不一致 = 图像被动过。更妙的是,由于正文路径依赖这个哈希,即使攻击者只改了一个像素,正文置乱种子也随之错位,解码结果会立刻崩溃或乱码。

同时要诚实地说清边界:这套机制是键控(keying)与篡改感知,不是密码学意义上的完整性认证。头部哈希本身没有用密钥做 MAC,理论上有能力重嵌整张图的攻击者可以同步更新头部哈希。论文与 README 都把这个列为后续工作(例如引入密钥化 MAC),阅读时注意区分“检测普通篡改”与“抵御恶意重嵌”。

新手提示| 一句话区分:篡改感知 = “我发现图被动了”;完整性认证 = “我能证明这图一定没被动过、且动过的人无法掩盖”。前者是“检测”,后者是“抵御”,强度不同。

6.5 动手实验:口令错误与像素篡改#

实验 A:口令不匹配应失败;实验 B:改 1 像素后解码

import sys; sys.path.insert(0, "src")
import numpy as np; import image_io as IO
from ns5_core import embed_string, extract_string, get_image_hash
 
img = IO.load_as_gray("img/cover.png")
stego, rep, _ = embed_string(img, "secret", method="nsF5",
                             p=3, password="A")
try:                       # 口令不对: 解出来的是乱码, 长度校验会直接报错
    print(extract_string(stego, method="nsF5", p=3, password="B"))
except ValueError as e:
    print("口令不对 -> 解码失败:", e)
tampered = stego.copy(); tampered[0, 0] ^= 1   # 只改 1 个像素
print(get_image_hash(stego)[:16], "vs", get_image_hash(tampered)[:16])
try:                       # 篡改后哈希对不上, 解码也解不回来
    print(extract_string(tampered, method="nsF5", p=3, password="A"))
except ValueError as e:
    print("图像被改动 -> 解码失败:", e)

避坑提醒| 实验 B 的失败方式可能是 ValueError、乱码或截断消息,取决于被改像素落在哪块。不要期待“总是抛异常”——篡改感知的工程表现就是:与哈希比对失败,且正文无法自同步。

6.6 小结与自我检查#

  • 固定路径 = 可预测 = 可被复制与针对;键控路径让隐藏位置由口令+内容决定(图 6-1);

  • 头部放 128 位内容哈希,正文种子依赖该哈希 → 解码无需外部同步;

  • 确定性置换(splitmix64 + Fisher–Yates)是自同步的数学保证(图 6-2);

  • 键控 ≠ 完整性认证;强对抗场景需要密钥化 MAC。

想一想| 如果两张不同的原图被嵌入了同一条消息,含密图的隐藏位置会一样吗?为什么?(提示:正文种子由什么决定?)再想一层:既然安全性靠“不知道位置”,那攻击者能不能通过大量统计“猜”出置乱规律?(参见第 8 章,这正是检测的切入点。)