Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

实验 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。断言:

  1. 返回 SessionMismatch;
  2. nonce store 绝不改绑 B;按本实验的保守策略,将 Reserved(session_A) 原子地转为 Burned,绝不能回到 Fresh;
  3. 私有的代数份额生成 helper 尚未被调用。可在状态层测试中注入计数器/spy,断言调用次数为 0;
  4. 拒绝事件被记录,但日志不含 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 重用需要怎样的独立方程?
协调者分叉最早在哪一步可被发现?
哪些重放由密码协议阻止,哪些必须由业务状态阻止?
解析器接受的唯一规范编码是什么?
尚未覆盖的三项威胁是什么?

进阶挑战

  1. 使用 cargo-fuzz 对签名、承诺和 SigningPackage 的解码器做覆盖引导模糊测试。
  2. 使用 proptest 生成参与者子集和单字段变异,断言“达到门限且输入一致”才可能成功。
  3. 使用 loom 或模型化状态机探索两个并发请求抢占同一 nonce 记录的竞态。
  4. 把 F17 的双 nonce 泄露实验迁移到 Scalar,并验证恢复出的份额对应同一个公开验证份额 Y_i。
  5. 建立差分测试:在严格采用同一标准和 ciphersuite 后,与一个成熟 RFC 9591 实现交换官方测试向量。
  6. 记录拒绝服务预算:消息长度、签名者数量、解码耗时和并发会话上限。

安全警告

  • 所有“故意指定 nonce”“导出秘密份额”“恢复私钥”的 helper 必须受 #[cfg(test)] 限制,并在发布构建中不可达。
  • 这些 helper 应位于相关实现文件的私有 #[cfg(test)] mod tests 中;不要放在供 tests/*.rs 调用的 src/test_support.rs,也不要用发布 feature 暴露它们。
  • 不要把真实密钥、生产消息或钱包数据放入攻击夹具、快照、CI 日志和崩溃语料库。
  • 测试只能证明已覆盖行为;它不能替代协议证明、第三方审计、安全硬件、供应链审查和真实故障演练。
  • 如果 nonce 是否消费存在任何不确定性,唯一安全默认是永久烧毁,而不是重试。
  • 本工程仍是教学实现。即使本章测试全部通过,也禁止承载真实资产或对外宣称生产安全。