实验 05:攻击驱动测试与失败状态
密码协议的 happy path 只证明“有时能工作”。本章从攻击者视角验证:篡改、重放、会话混淆、nonce 重用和畸形输入不会静默变成有效签名。
目标
建立一套可重复的对抗测试,并至少亲自复现一次密钥泄露:
- 复现单方 Schnorr nonce 重用恢复私钥;
- 阻止 FROST nonce 状态被第二次消费;
- 检测消息、承诺、参与者集合和签名份额被替换;
- 拒绝跨会话混用、重复 ID、非规范编码和超大输入;
- 区分密码学验证、协议状态验证和业务级防重放。
前置知识
- 实验 03 的 Schnorr 正反例全部通过;
- 实验 04 的三个
2-of-3组合全部通过; - transcript 使用规范排序、明确域标签和长度前缀;
- 能在实现 Schnorr 的同一模块内,通过
#[cfg(test)] mod tests注入确定性 nonce;生产 API 和集成测试都没有这个入口。
建议 API 与文件
建议按“是否需要访问私有实现”拆分测试,而不是建立一个公共的测试后门:
src/schnorr.rs # 文件末尾放 #[cfg(test)] mod tests:nonce 注入与密钥恢复
src/frost/round2.rs # 文件末尾放需要观察内部状态/不变量的单元测试
tests/adversarial_schnorr.rs # 只调用公开 sign/verify API 做篡改测试
tests/adversarial_frost.rs # 只调用公开 FROST API 做端到端拒绝测试
tests/adversarial_encoding.rs # 向公开解码入口提交恶意字节
tests/adversarial_replay.rs # 测试公开 envelope/状态接口
tests/*.rs 是集成测试:Cargo 会把库作为普通依赖另行编译,因此库内部的 #[cfg(test)] 项对它们不可见。不要在 src/test_support.rs 中藏一组 #[cfg(test)] helper,再试图从集成测试调用;也不要为了让它“能用”而增加公开 feature 或 pub 测试后门。
- 模块内单元测试可以通过
use super::*访问相邻私有实现,适合确定性 nonce 注入、秘密恢复和内部状态转换测试; tests/*.rs只通过正常公共 API 测试恶意字节、非规范编码、公开状态接口和端到端拒绝行为;- 生产公开 API 只负责安全采样 nonce,不能接受调用者提供的 nonce,也不能导出秘密份额。
统一错误类型至少应能在内部区分:
#![allow(unused)]
fn main() {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FrostError {
InvalidParticipantSet,
NotEnoughSigners,
CommitmentMismatch,
SessionMismatch,
NonceAlreadyConsumed,
StateConflict,
StorageFailure,
IdentityGroupCommitment,
InvalidSignatureShare,
InvalidEncoding,
InputTooLarge,
}
}
外部网络响应不一定要暴露这些细节;内部审计日志可以记录错误类别和参与者 ID,但绝不能记录秘密份额、nonce 或未脱敏的敏感消息。
威胁模型清单
测试前先写清边界。首版至少假设:
- 攻击者可以控制协调者和部分参与者;
- 攻击者可以延迟、丢弃、重排、复制和修改网络消息;
- 攻击者可以提交任意字节作为公钥、承诺或签名;
- 攻击者不能破解 SHA-512 或
curve25519-dalek的离散对数假设; - 进程可能在写盘与发包之间崩溃并从旧快照恢复;
- 本实验暂不声称抵抗主机完全失陷、侧信道或恶意依赖。
每增加一种假设,都要对应至少一个测试或明确的未覆盖项。
分步任务
1. 建立“单点变异”测试表
从一份有效签名会话复制输入,每次只修改一个字段:
| 变异 | 预期失败位置 |
|---|---|
| 消息翻转 1 bit | 最终 Schnorr 验证或签名前批准检查 |
| 群公钥替换 | challenge/最终验证 |
某个 D_i 或 E_i 替换 | 本地承诺核对或份额验证 |
某个 z_i 加 1 | 聚合前的份额验证 |
| 签名者 ID 重复 | 签名包解析 |
| 签名者列表乱序 | 规范编码检查 |
nonce 已为 A 预留,签名者收到 session_id = B 的 Round 2 请求 | 签名者 envelope/nonce store 检查,且早于份额生成 |
携带会话 A 份额的认证 envelope 以 session_id = B 提交 | 协调者 envelope/session store 检查,且早于份额验证 |
先预测失败位置,再运行测试。若实际在更晚阶段才失败,记录是否需要把校验前移。
2. 复现 Schnorr nonce 重用泄露
把攻击测试直接放在 src/schnorr.rs 文件末尾的 #[cfg(test)] mod tests 中,并只在这个私有测试模块里增加可以指定 nonce 的 helper。让同一密钥、同一 nonce 对两个不同消息签名。若:
\[ z_1=r+c_1x,\qquad z_2=r+c_2x \]
请先在纸上消去 r,再补全测试中的 recovered_key:
#![allow(unused)]
fn main() {
#[test]
fn reused_schnorr_nonce_reveals_secret_key() {
let secret = test_scalar(7);
let nonce = test_scalar(11);
let public = secret * RISTRETTO_BASEPOINT_POINT;
let sig1 = sign_with_test_nonce(secret, nonce, b"message one");
let sig2 = sign_with_test_nonce(secret, nonce, b"message two");
assert_eq!(sig1.commitment(), sig2.commitment());
let c1 = challenge(sig1.commitment(), &public, b"message one");
let c2 = challenge(sig2.commitment(), &public, b"message two");
assert_ne!(c1, c2, "choose other messages in the negligible collision case");
let recovered_key = todo!("由 z1-z2 与 c1-c2 求出 x");
assert_eq!(recovered_key, secret);
}
}
这是可编译测试骨架;todo!() 留给你完成。把它和 test_scalar、sign_with_test_nonce 一起包在下面的结构中,而不是放进 tests/adversarial_schnorr.rs:
#![allow(unused)]
fn main() {
// src/schnorr.rs
#[cfg(test)]
mod tests {
use super::*;
// test_scalar、sign_with_test_nonce 和上述 #[test] 都放在这里。
// 它们保持私有,不从 crate 导出。
}
}
这样 cargo test 的库单元测试能访问相邻私有实现,而集成测试和 cargo build --release 都无法引用这些 helper。tests/adversarial_schnorr.rs 仍可通过正常的 sign/verify 公共 API 做篡改测试,但不得要求调用方指定 nonce。
3. 让 FROST nonce 状态只能消费一次
先保留实验 04 的 move-only SigningNonces,添加 compile_fail 测试。再模拟真实持久化层:
Fresh -> Reserved(session_id) -> ConsumedAndResponsePersisted
\-----> Burned
要求:
- nonce 创建后有唯一不可猜测的记录 ID;
- 会话不匹配时不能消费;
- 任何签名尝试后都进入
ConsumedAndResponsePersisted或Burned,不能回到Fresh; - “消费标记 + 精确响应”在同一事务中持久化成功后,才能把
z_i发给协调者; - 从旧快照恢复时,无法确认状态的 nonce 一律烧毁。
写一个内存仓库模拟 crash:分别在保留前、保留后、消费写盘后和发包后中断,验证恢复策略。
4. 理解双 nonce 重用的后果
故意绕过状态机,让同一参与者的 (d_i,e_i) 对同一签名集合的三个不同消息生成份额:
\[ z_j=d_i+\rho_j e_i+(\lambda_i c_j)s_i \]
这给出了关于三个未知量 d_i、e_i、s_i 的三条线性方程。先在 F17 上实现 3x3 高斯消元,确认系数矩阵可逆时能恢复 s_i;再选择是否迁移到 Scalar。
这里不提供消元实现。你的实验报告必须记录:需要几条独立方程、矩阵何时不可逆、为什么“只重用一次似乎没立即泄密”仍绝不安全。
5. 测试会话混淆和协调者分叉
先单独证明应用层会话隔离。建立会话 A、B 时,除 session_id 外保持所有密码学输入逐字节相同:
- 同一条消息;
- 同一个群公钥;
- 同一个有序签名者集合;
- 同一份有序 commitments 列表;
- 同一个规范
SigningPackage。
因此两个会话按 RFC transcript 算出的 rho_i、R 和 c 也应相同;不要通过更换消息或承诺来让代数验证“碰巧失败”。然后把签名者入口和协调者入口分成两个测试,不要在同一场景里混用两端的状态。
A. 签名者收到错会话的 Round 2 请求
将签名者 nonce 记录预先置为 Reserved(session_A),再提交一个格式与身份认证都有效、但 session_id = session_B 的 AuthenticatedSigningRequest。此时尚未产生 SignatureShare。断言:
- 返回
SessionMismatch; - nonce store 绝不改绑 B;按本实验的保守策略,将
Reserved(session_A)原子地转为Burned,绝不能回到Fresh; - 私有的代数份额生成 helper 尚未被调用。可在状态层测试中注入计数器/spy,断言调用次数为 0;
- 拒绝事件被记录,但日志不含 nonce、秘密份额或完整敏感消息。
B. 协调者收到错会话的签名份额
用一个已生成的 SignatureShare 构造通过消息认证、但标为 session_B 的 AuthenticatedSignatureShare envelope,而协调者本地对应的冻结会话是 A。断言状态层返回 SessionMismatch,且 verify_signature_share 调用次数为 0。协调者不拥有签名者的 nonce store,这个测试不应读写任何签名者 nonce 状态。
这里必须明确:裸 SignatureShare 只携带参与者标识和响应标量,不编码 session_id。当消息、群公钥、签名者集合和 commitments 都相同时,把同一个裸 share 放到 A 或 B,代数验证器无法区分;所以“裸 share 在 B 中验证失败”不是可要求的安全性质,也不能单独证明会话隔离。这个性质必须由经过认证的应用 envelope 与本地 session store 在调用代数生成/验证前保证。
然后再单独测试协调者分叉:协调者向 Alice 和 Bob 发送不同的 commitments 列表,或把两个不同签名包都声称属于 A。不同包的规范摘要必须不同;参与者若已为 A 绑定一个包摘要,看到第二个摘要就应中止并烧毁 nonce。此时两边的 rho、R 或 c 可能不一致,聚合不得产生有效签名。记录协议应如何让参与者或审计系统发现这种 equivocation,而不只是最后失败。
6. 测试 transcript 歧义
先写一个故意错误的拼接函数,展示没有长度前缀时不同字段可能得到相同字节串:
#![allow(unused)]
fn main() {
#[test]
fn raw_concatenation_is_ambiguous() {
let left = [b"ab".as_slice(), b"c".as_slice()].concat();
let right = [b"a".as_slice(), b"bc".as_slice()].concat();
assert_eq!(left, right);
}
}
随后为正式 transcript 编码器写相反测试:以上两组字段编码必须不同。还应分开测试两个边界:协调者内部的 builder 从不同 map 插入顺序生成相同的规范包;线上或已建好的 package 若 signer 列表乱序,解码/构造器必须拒绝,不得悄悄重排。
7. 攻击解析器,而不只攻击公式
对所有公开 from_bytes 入口测试:
- 长度从 0 到合法长度前后若干字节;
- 全 0、全
0xff、截断、尾随垃圾和未知版本; - 不可解压的 Ristretto 编码;
- 非规范 Scalar 编码;
- 重复字段、超大消息和超大参与者列表;
- 随机字节永不导致 panic。
可编写如下测试框架;它是可编译骨架,需替换为你的解析函数:
#![allow(unused)]
fn main() {
#[test]
fn arbitrary_short_inputs_never_panic() {
for len in 0..=96 {
let bytes = vec![0xff; len];
let outcome = std::panic::catch_unwind(|| Signature::from_bytes(&bytes));
assert!(outcome.is_ok(), "parser panicked for length {len}");
}
}
}
“不 panic”只是最低标准;接着断言畸形输入返回正确错误,不能被静默约简或忽略尾随字节。
8. 区分签名重放与业务重放
对完全相同的公钥、消息和签名,密码学验证器再次验证成功是正常行为。它不知道签名是否已使用过。
定义一个业务消息信封,例如:
protocol_version || network || account || sequence || expires_at || payload
所有字段都进入被签名字节。测试相同 sequence 被状态层第二次提交时拒绝,网络或账户变化时旧签名不能迁移。不要在签名验证器里虚构“见过的签名”全局缓存。
验收测试
完成以下检查矩阵,并在 CI 中固定运行:
| 类别 | 必测结果 |
|---|---|
| Schnorr nonce 重用 | 测试确实恢复出原私钥 |
| FROST nonce 生命周期 | 二次消费、错会话、回滚状态全部拒绝 |
| 参与者集合 | 0、重复、未知、乱序、人数不足全部拒绝 |
| 篡改 | 消息、公钥、承诺、签名份额任一改变均不能聚合为有效签名 |
| 会话隔离 | 仅改变 session_id:签名者端份额生成调用数为 0 且 nonce 不改绑;协调者端份额验证调用数为 0 且不触碰 signer nonce store;裸 share 局限有显式记录 |
| 编解码 | 非规范、截断、尾随和超大输入返回错误且不 panic |
| 最终签名 | 三个合法 2-of-3 组合仍通过普通 Schnorr 验证 |
| 业务重放 | 相同 sequence 第二次提交由状态层拒绝 |
推荐额外执行:
cargo test
cargo test --release
cargo build --release
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --check
检查发布构建时,不要定义 test-support、dangerous-nonce 一类 feature 来携带测试 helper;cargo build --release 产物中不应存在指定 nonce、导出秘密或恢复密钥的可调用符号。这里的 cargo test --release 只是用优化配置编译并运行测试,不能代替对正常发布产物的检查。
如果引入 fuzzing,可将解析器和 SigningPackage 解码器作为首批 target。模糊测试运行若干分钟不是安全证明;保存每个崩溃样本为回归测试才形成长期价值。
观察记录
| 问题 | 你的记录 |
|---|---|
| nonce 重用实验恢复出的私钥是否与原值逐字节相同? | |
| 哪个测试证明了 move-only 类型不足以处理崩溃回滚? | |
| 双 nonce 重用需要怎样的独立方程? | |
| 协调者分叉最早在哪一步可被发现? | |
| 哪些重放由密码协议阻止,哪些必须由业务状态阻止? | |
| 解析器接受的唯一规范编码是什么? | |
| 尚未覆盖的三项威胁是什么? |
进阶挑战
- 使用
cargo-fuzz对签名、承诺和 SigningPackage 的解码器做覆盖引导模糊测试。 - 使用
proptest生成参与者子集和单字段变异,断言“达到门限且输入一致”才可能成功。 - 使用
loom或模型化状态机探索两个并发请求抢占同一 nonce 记录的竞态。 - 把 F17 的双 nonce 泄露实验迁移到 Scalar,并验证恢复出的份额对应同一个公开验证份额
Y_i。 - 建立差分测试:在严格采用同一标准和 ciphersuite 后,与一个成熟 RFC 9591 实现交换官方测试向量。
- 记录拒绝服务预算:消息长度、签名者数量、解码耗时和并发会话上限。
安全警告
- 所有“故意指定 nonce”“导出秘密份额”“恢复私钥”的 helper 必须受
#[cfg(test)]限制,并在发布构建中不可达。 - 这些 helper 应位于相关实现文件的私有
#[cfg(test)] mod tests中;不要放在供tests/*.rs调用的src/test_support.rs,也不要用发布 feature 暴露它们。 - 不要把真实密钥、生产消息或钱包数据放入攻击夹具、快照、CI 日志和崩溃语料库。
- 测试只能证明已覆盖行为;它不能替代协议证明、第三方审计、安全硬件、供应链审查和真实故障演练。
- 如果 nonce 是否消费存在任何不确定性,唯一安全默认是永久烧毁,而不是重试。
- 本工程仍是教学实现。即使本章测试全部通过,也禁止承载真实资产或对外宣称生产安全。