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

<!-- lang-switch -->
> [🌐 English version](https://yukinoshita-lin.github.io/nsf5-steganography/en/content/ch06.html)




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

## 6.1 如果嵌入位置是固定的，会怎样？

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

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

![图 6-1 键控置换路径](../assets/hash_permute_path.png)

*图 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()` 测的就是这一点。

*项目中的种子派生（真实代码）*

```python
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 键控置换散点](../assets/hash_permute_scatter.png)

*图 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 像素后解码*

```python
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 章，这正是检测的切入点。）
