导读与学习约定
这本手册要解决什么问题
门限签名常被简单描述为“把私钥切成几片,凑够几片就能签名”。这个说法适合建立第一印象,但不够准确:
- 参与者持有的是满足代数关系的密钥份额,而不是把私钥字节切开后的碎片;
- 签名过程通常不会先恢复完整私钥;
- 签名 nonce 也需要门限化,并且其生命周期往往比长期密钥更容易出错;
- 协议安全依赖参与者身份、消息认证、可靠广播、状态机和持久化等额外条件;
- “最终签名可以验证”不代表密钥生成、签名份额或网络流程没有漏洞。
本手册以 Schnorr/FROST 为主线,因为 Schnorr 的线性结构能清晰展示 Shamir 份额如何进入签名方程。门限 ECDSA 被安排在最后,只用于理解复杂度来源,不作为第一个完整实现目标。
适合的读者
你需要具备:
- 基础 Rust:结构体、枚举、trait、模块、
Result、单元测试; - 高中代数水平,并愿意重新学习模运算;
- 能使用 Git 和 Cargo;
- 接受“先证明和测试,再扩展功能”的节奏。
不要求预先掌握椭圆曲线、零知识证明或多方安全计算。
两种实现层次
本工程始终区分两种代码:
toy:透明、可手算、故意不安全
用于理解有限域、多项式和攻击。可以使用很小的素数、确定性随机数和可打印的秘密。它的价值是教学透明度,绝不能承载真实秘密。
protocol:接近规范、仍非生产级
使用 Ristretto255、SHA-512、系统随机数、规范编码和严格状态机。它应当对照 RFC 9591 测试向量和成熟实现,但仍然只是学习工程。
不要让 toy 类型悄悄进入 protocol;最好使用独立模块和明显的类型名称。
威胁模型从简单到复杂
第一阶段的假设:
- 所有参与者运行在同一个本地进程中;
- 参与者名单固定;
- 有一个可信 Dealer 生成并分发份额;
- coordinator 可以看到公开消息并阻断协议,但不能伪造已认证参与者;
- 暂不处理网络丢包、进程崩溃、磁盘泄露和侧信道。
后续逐项移除假设:
- 用 VSS 检测 Dealer 发放错误份额;
- 用 DKG 移除知道完整私钥的 Dealer;
- 用显式状态机处理重复、乱序和跨会话消息;
- 用持久化的 nonce 消耗记录处理崩溃恢复;
- 用认证信道和可靠广播明确网络假设。
每次扩展前,都要写下“新增能力”和“仍未解决的问题”。
本手册的符号约定
| 符号 | 含义 |
|---|---|
| \(q\) | 素数阶群的阶,也是标量域模数 |
| \(\mathbb{F}_q\) | 模 \(q\) 的有限域 |
| \(G\) | 群生成元 |
| \(x\) | 私钥标量 |
| \(X=xG\) | 群公钥 |
| \(i\) | 非零、唯一的参与者标识符 |
| \(s_i\) | 参与者 \(i\) 的秘密份额 |
| \(Y_i=s_iG\) | 参与者 \(i\) 的公开验证份额 |
| \(t\) | 恢复或签名所需的最少参与者数 |
| \(n\) | 总参与者数,满足 \(1\le t\le n\) |
| \(H\) | 带域分离、输出到标量域的哈希函数 |
标量使用小写字母,群元素使用大写字母。所有除法都表示乘以有限域中的逆元,不是普通浮点除法。
三条贯穿全书的规则
- 秘密 nonce 只能使用一次。 “同一消息可以复用”也是错误的。
- 密码 transcript 必须规范编码。 不要哈希
Debug、JSON 文本或未规定字段顺序的序列化结果。 - 先验证再聚合。 参与者 ID、点、标量、承诺和签名份额都可能来自敌手。
如何使用检查题
不要只在脑中回答。把答案写下来,最好附一段公式、一个反例或一个测试名称。如果不能解释某个结论,先回到推导,而不是继续堆代码。
学习路线与里程碑
总体路线
有限域与拉格朗日插值
↓
Shamir Secret Sharing
↓
普通 Schnorr 签名
↓
可信 Dealer 的门限 Schnorr
↓
两轮 FROST + 份额验证
↓
Feldman VSS + DKG
↓
nonce 持久化、重放保护、崩溃恢复
↓
门限 ECDSA / 更高级研究方向
不要跳过普通 Schnorr。若不先理解单人签名中的 challenge 和 nonce,就很难判断 FROST 中每个绑定因子和承诺在防御什么。
理论与实践对应表
按下表交错推进,不建议先读完全部理论再一次性写代码。
| 学习单元 | 配套实践 | 本阶段产物 | 进入下一步的条件 |
|---|---|---|---|
| 导读、威胁模型 | 实验 0 | Rust 工程约定与安全声明 | 能区分 toy、教学规范版和生产实现 |
| 数学基础 | 实验 1 | 可手算的 F17 有限域 | 域运算与负例测试全部通过 |
| Shamir | 实验 2 | t-of-n 拆分、恢复与性质测试 | 能解释少于门限为何不泄密 |
| 普通 Schnorr | 实验 3 | Ristretto255 单签与规范解析 | 篡改、单位元、非法编码均拒绝 |
| 门限 Schnorr、FROST | 实验 4 | 可信 Dealer 的 2-of-3 FROST | 三个合法子集均通过普通验证器 |
| VSS/DKG、安全工程 | 实验 5、6 | 本地 DKG、nonce 状态机与故障注入 | 综合验收矩阵通过并能说明未覆盖项 |
| 门限 ECDSA | 选修研究 | 复杂度分析或玩具 MPC 实验 | 不作为本手册核心毕业条件 |
本手册有两个明确终点:
- 核心里程碑:完成可信 Dealer 的本地
2-of-3FROST; - 完整毕业项目:在单进程事件模拟器中完成 DKG → FROST → nonce 崩溃恢复与对抗测试。
不要为了“毕业”提前加入真实网络、生产数据库或真实资产;这些会把注意力从协议不变量移向部署细节。
里程碑 0:建立实验纪律
学习内容:
- 区分教学实现、规范兼容实现和生产实现;
- 写出第一版威胁模型;
- 建立测试命名、错误类型和秘密类型规则。
完成标准:
- 仓库中有醒目的非生产安全声明;
- 能解释为什么 Rust 的内存安全不等于密码协议安全;
- 固定种子 RNG 只存在于测试代码。
里程碑 1:Shamir Secret Sharing
学习内容:
- 有限域加减乘除;
- 次数为 \(t-1\) 的随机多项式;
- Horner 求值;
- 在 \(x=0\) 处计算拉格朗日插值;
- 非零且不重复的参与者标识符。
完成标准:
2-of-3和一般t-of-n均可工作;- 任意合法的 \(t\) 个份额恢复同一秘密;
- 少于 \(t\) 个份额的恢复请求被 API 拒绝;
- 重复 ID、零 ID、非法门限被拒绝;
- 能解释为什么 Shamir 不能检测恶意 Dealer。
里程碑 2:普通 Schnorr
学习内容:
- 标量乘法与群元素;
- Fiat–Shamir challenge;
- 签名和验证方程;
- 规范编码和域分离;
- nonce 重用攻击。
完成标准:
- 正确签名通过验证;
- 修改消息、承诺或响应后验证失败;
- 能通过一个故意脆弱的例子从重复 nonce 的两个签名恢复私钥;
- transcript 不依赖
Debug、JSON 或平台字节序。
里程碑 3:可信 Dealer 的门限 Schnorr/FROST
学习内容:
- 密钥份额与公开验证份额;
- 签名者集合对应的拉格朗日系数;
- hiding/binding nonce;
- commitment list、binding factor 与 group commitment;
- 单个签名份额验证;
- 聚合为普通 Schnorr 签名。
完成标准:
- 任意合法的
2-of-3子集都能签名; - 最终结果可由普通 Schnorr 验证器验证;
- 某个签名份额被篡改时能够定位并拒绝;
- 少于门限、重复参与者、消息不一致、承诺列表不一致均失败;
- nonce 对象被消费后无法再次进入签名 API。
里程碑 4:VSS 与 DKG
学习内容:
- Feldman coefficient commitments;
- 接收方如何验证自己的份额;
- 多个 Dealer 的贡献如何相加;
- 投诉、证据、剔除和中止;
- 认证广播与私密点对点信道假设。
完成标准:
- 错误份额会被接收方检测;
- 所有诚实参与者得到相同群公钥;
- DKG 输出可直接进入上一阶段签名;
- 同步本地模拟的故障注入覆盖错误份额、承诺分叉、漏发、虚假投诉和 Dealer 沉默。
重复、乱序、旧 session 消息和崩溃恢复需要下一里程碑的状态机;不把它们当作本里程碑公式验证的前置条件。
里程碑 5:协议工程
学习内容:
- 会话 ID 与协议版本;
- 显式状态机;
- nonce 的预生成、占用、消耗和崩溃恢复;
- 重放保护与幂等边界;
- 密钥份额静态保护、备份和刷新;
- 可审计但不泄密的日志。
完成标准:
- 在两轮之间模拟崩溃,重启后不会复用 nonce;
- 旧会话消息不能注入新会话;
- 每个状态只接受允许的消息类型;
- 所有网络输入的解析失败均返回错误而非 panic。
推荐节奏
每个里程碑至少完成四份材料:
- 一页自己的推导笔记;
- 一个最小实现;
- 一组性质测试与负例;
- 一个攻击或故障演示。
只有四项都完成,才进入下一阶段。门限密码学里,“缺失的失败测试”通常比“缺失的功能”更危险。
实验 0:Rust 环境与实验约定
目标
本实验不实现密码算法,而是建立后续实验共同使用的工程边界:
- 初始化 Rust library crate;
- 区分玩具数学代码与真实曲线上的协议实现;
- 约定错误处理、随机数和秘密类型规则;
- 建立最小测试与格式检查流程。
前置条件
- 已安装稳定版 Rust 和 Cargo;
- 当前目录是本学习工程根目录;
- 已阅读导读。
检查环境:
rustc --version
cargo --version
git status --short --branch
任务 1:初始化 library crate
在仓库根目录执行:
cargo init --lib .
选择 library 而不是 binary,因为协议逻辑、测试和不同实验程序应与命令行界面分离。
建议逐步形成下面的结构:
src/
lib.rs
toy_field.rs # 实验 1 的 F17,不进入协议路径
shamir.rs # 先用 F17 验证,再扩展到 Scalar
transcript.rs
schnorr.rs
frost/
mod.rs
types.rs
round1.rs
round2.rs
aggregate.rs
tests/
shamir_properties.rs
protocol_negative.rs
第一天不需要一次创建所有文件。只在开始对应实验时增加模块。
任务 2:记录依赖边界
第一版协议实验建议使用兼容的依赖族:
[dependencies]
curve25519-dalek = { version = "4.1", features = ["digest", "rand_core", "zeroize"] }
sha2 = "0.10"
rand_core = { version = "0.6.4", features = ["getrandom"] }
subtle = "2.6"
zeroize = { version = "1.8", features = ["zeroize_derive"] }
thiserror = "2"
[dev-dependencies]
rand_chacha = "0.3"
proptest = "1"
hex = "0.4"
trybuild = "1" # 对 move-only 等编译期 API 约束做 UI 测试
这里固定 rand_core 0.6 是为了与 curve25519-dalek 4.x 常用 API 保持 trait 兼容。不要看到更大的版本号就单独升级;开始编码时应再次核对所锁定版本的文档。
实验 1 的玩具有限域可以暂时完全不依赖这些密码库。
任务 3:建立代码规则
在动手前,把以下规则写入模块文档或项目约定:
toy_field.rs中的值可以打印,但类型名或模块名必须明确标记Toy;真实曲线协议不得依赖它。- 协议秘密类型不派生
Copy、Debug或默认Serialize。 - nonce 类型由签名函数按值消费,不提供复制接口。
- 网络或文件输入的解析返回
Result,不得对敌手输入unwrap()。 - transcript 编码显式定义字段顺序和长度,不哈希
Debug或 JSON。 - 固定种子 RNG 只用于测试,协议默认使用
OsRng。
任务 4:建立验证命令
每个实验结束前运行:
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
当加入文档测试后,再确认 cargo test --doc 通过。
验收标准
cargo test可以运行;- 玩具数学代码和真实曲线协议的职责在文件、类型名和注释中有明确区别;
- 仓库中没有真实私钥、助记词或生产数据;
- 你能解释为什么测试 RNG 不能进入签名路径;
- 你能指出哪些依赖提供基础原语,哪些协议逻辑必须自己实现。
观察记录
在学习日志中回答:
- 哪些 bug 可以被 Rust 类型系统减少?
- 哪些协议错误无法由内存安全阻止?
- 为什么
zeroize有帮助,但不能保证秘密从系统中彻底消失?
安全警告
本实验建立的是教学工程纪律,不是生产安全认证。即使所有 lint 和测试通过,也不能证明密码协议正确或实现具有常量时间性质。
第 2 章:门限签名所需的数学基础
学习定位:前置为导读与实验 0;配套完成实验 1:玩具有限域。本章产物是能手算并实现有限域、群和拉格朗日插值。
门限签名的公式通常不长,真正容易出错的是:一个数属于哪个集合、一次运算在哪个集合里完成。本章不会试图压缩一门完整的抽象代数课程,而是建立后续实现所需的“数学类型系统”。
本手册中的算法用于学习。即使测试全部通过,也不代表实现已经具备抵抗侧信道、恶意参与者或真实网络攻击的能力。
学习目标
完成本章后,你应该能够:
- 熟练进行模加、模乘、求逆和模除法;
- 解释有限域、循环群、生成元和群阶;
- 区分椭圆曲线的基域与签名算法使用的标量域;
- 判断一个公式两边的数学类型是否一致;
- 用拉格朗日插值恢复多项式在零点的值;
- 理解离散对数困难性为何能保护公钥对应的私钥。
2.1 先建立一张符号表
不同论文经常复用同一个字母。为了减少歧义,本手册采用以下约定:
| 符号 | 含义 | 类型 |
|---|---|---|
| \(p\) | 椭圆曲线坐标所在基域的模数 | 素数 |
| \(q\) | 所用素数阶子群的阶 | 素数 |
| \(\mathbb F_p\) | 椭圆曲线坐标使用的有限域 | 域 |
| \(\mathbb Z_q\) | 私钥、nonce、挑战值和签名响应所在的标量域 | 域 |
| \(\mathbb G\) | 阶为 \(q\) 的加法循环群 | 群 |
| \(G\) | \(\mathbb G\) 的生成元 | 群元素(点) |
| \(x,r,c,z\) | 私钥、nonce、挑战、响应 | 标量,属于 \(\mathbb Z_q\) |
| \(X,R\) | 公钥、nonce 承诺 | 群元素,属于 \(\mathbb G\) |
| \(m\) | 待签名消息 | 字节串 |
从实现角度看,最好把它们想象成完全不同的 Rust 类型:
Scalar + Scalar -> Scalar
Scalar * Point -> Point
Point + Point -> Point
Hash(bytes) -> bytes
HashToScalar(bytes) -> Scalar
下面这些表达式则没有意义:
Point + Scalar
Point * Point
message mod q
把一个任意字节串直接当成合法曲线点
数学类型意识是密码学实现中最便宜、也最有效的一道防线。
2.2 模运算:结果只关心余数类
若整数 \(a-b\) 能被 \(n\) 整除,就写作:
\[ a \equiv b \pmod n \]
例如:
\[ 20 \equiv 3 \pmod {17} \]
因为 \(20-3=17\)。在模 \(17\) 的世界里,\(20\)、\(3\)、\(-14\) 表示同一个余数类。
加法和乘法可以先运算再取模:
\[ (a+b)\bmod n \]
\[ (ab)\bmod n \]
例如在模 \(17\) 下:
\[ 13+9=22\equiv5\pmod {17} \]
\[ 7\cdot8=56\equiv5\pmod {17} \]
负数不是异常情况
在模 \(17\) 下:
\[ -3\equiv14\pmod {17} \]
实现时应把结果规范化到库约定的唯一表示,而不是让同一个标量存在多种字节编码。
模除法其实是乘逆元
有限域里没有普通意义上的除法。表达式
\[ \frac ab\pmod p \]
表示:
\[ a\cdot b^{-1}\pmod p \]
其中 \(b^{-1}\) 满足:
\[ b\cdot b^{-1}\equiv1\pmod p \]
例如在 \(\mathbb F_{17}\) 中,\(2^{-1}=9\),因为:
\[ 2\cdot9=18\equiv1\pmod {17} \]
于是:
\[ 3/2\equiv3\cdot9\equiv10\pmod {17} \]
若模数是素数 \(p\),每个非零元素都有乘法逆元。利用费马小定理还可写成:
\[ a^{-1}\equiv a^{p-2}\pmod p \]
真实实现应优先使用密码学库提供的常数时间求逆方法,而不是照搬一个会泄漏分支或执行时长的教学算法。
2.3 有限域:可安全做四则运算的有限集合
当 \(p\) 是素数时,
\[ \mathbb F_p=\{0,1,\ldots,p-1\} \]
在模 \(p\) 的加、减、乘、除(除数非零)下构成有限域。
“域”的关键性质是:
- 加法和乘法都封闭;
- 每个元素都有加法逆元;
- 每个非零元素都有乘法逆元;
- 运算满足结合律、交换律和分配律。
Shamir 秘密共享之所以选择在域上定义多项式,正是因为插值中的分母只要非零就一定可逆。
如果模数不是素数,非零元素未必有逆元。例如模 \(8\) 下不存在 \(2^{-1}\),因为 \(2k\) 不可能与 \(1\) 模 \(8\) 同余。随意把复合数当作域模数,会让插值公式失效。
2.4 多项式与“点决定曲线”
在域 \(\mathbb F\) 上,一个次数不超过 \(d\) 的多项式可以写成:
\[ f(X)=a_0+a_1X+a_2X^2+\cdots+a_dX^d \]
注意这里的大写 \(X\) 是形式变量,不是 Schnorr 公钥。多项式的常数项为:
\[ f(0)=a_0 \]
直觉上:
- 一条一次多项式(直线)需要两个不同点才能唯一确定;
- 一条二次多项式需要三个不同点;
- 一般地,次数至多为 \(d\) 的多项式需要 \(d+1\) 个横坐标互不相同的点。
这个事实正是 Shamir \(t\)-of-\(n\) 方案选择“次数 \(t-1\)”的原因。
2.5 拉格朗日插值
给定 \(t\) 个横坐标互不相同的点:
\[ (x_1,y_1),\ldots,(x_t,y_t) \]
存在唯一一个次数不超过 \(t-1\) 的多项式穿过这些点。拉格朗日插值写作:
\[ f(X)=\sum_{i=1}^{t} y_i L_i(X) \]
其中:
\[ L_i(X)=\prod_{\substack{j=1\\j\ne i}}^{t} \frac{X-x_j}{x_i-x_j} \]
秘密共享最关心 \(f(0)\),因此通常直接计算零点处的拉格朗日系数:
\[ \lambda_i=L_i(0)= \prod_{\substack{j\in S\\j\ne i}} \frac{-x_j}{x_i-x_j} \]
于是:
\[ f(0)=\sum_{i\in S}\lambda_i y_i \]
所有加、减、乘、求逆都在同一个域中进行。
小例子:在 \(\mathbb F_{17}\) 中恢复常数项
假设有两个点:
\[ (1,8),\quad(2,11) \]
零点处的系数为:
\[ \lambda_1=\frac{0-2}{1-2}=2 \]
\[ \lambda_2=\frac{0-1}{2-1}=-1\equiv16\pmod {17} \]
因此:
\[ f(0)=8\cdot2+11\cdot16=192\equiv5\pmod {17} \]
可以反推这两个点位于直线 \(f(X)=5+3X\) 上。
两个重要观察:
- 系数依赖于参与插值的横坐标集合 \(S\);更换集合后必须重算。
- 如果两个横坐标相同,就会出现 \(x_i-x_j=0\),而零没有乘法逆元。
2.6 群、标量乘法与离散对数
Schnorr 签名在一个阶为素数 \(q\) 的循环群 \(\mathbb G\) 中工作。采用加法记号时:
- 单位元写作 \(\mathcal O\);
- 群加法写作 \(P+Q\);
- 点 \(P\) 的逆元写作 \(-P\);
- 标量乘法 \(aP\) 表示把 \(P\) 相加 \(a\) 次。
若 \(G\) 是生成元,那么群中每个元素都能写成:
\[ P=aG,\qquad a\in\mathbb Z_q \]
群阶为 \(q\) 意味着:
\[ qG=\mathcal O \]
因此标量按 \(q\) 约简:
\[ (a+q)G=aG \]
由标量 \(a\) 和点 \(G\) 计算 \(P=aG\) 很容易;已知 \(G\) 与 \(P\),反求 \(a\) 被称为离散对数问题。在合适的群和参数下,这个反向问题被认为在计算上不可行。
这给出了公私钥关系:
\[ \text{私钥 }x\in\mathbb Z_q,\qquad \text{公钥 }X=xG\in\mathbb G \]
公钥可以公开,因为从 \(X\) 恢复 \(x\) 被离散对数困难性保护。
2.7 最重要的区别:基域不是标量域
以短 Weierstrass 形式为例,椭圆曲线方程可能写成:
\[ y^2=x^3+ax+b\pmod p \]
这里点坐标 \(x,y\) 和曲线参数位于基域 \(\mathbb F_p\)。
但私钥、nonce 和 Schnorr 响应是乘在群元素上的系数,位于标量域 \(\mathbb Z_q\):
\[ x_{\text{secret}},k,c,z\in\mathbb Z_q \]
虽然两个集合里的值在程序中最后都可能表现为若干字节,但它们不是同一种数学对象。通常 \(p\ne q\),也不能互换取模。
| 操作 | 正确模数或对象 |
|---|---|
| 曲线点坐标运算 | \(p\) |
| 私钥加法、nonce 加法 | \(q\) |
| Shamir 共享 Schnorr 私钥 | \(q\) |
| Schnorr 挑战和响应 | \(q\) |
| 点加法、点压缩与解压 | 由曲线/群库处理 |
在本项目采用 Ristretto255 一类抽象时,库会把底层 Edwards 曲线的坐标、余因子和编码细节封装起来。应用层仍然必须区分 Scalar 与 RistrettoPoint。
一个抽象群的小例子
假设 \(G\) 生成一个阶为 \(11\) 的群,则:
\[ 8G+7G=15G=4G \]
因为标量在 \(\mathbb Z_{11}\) 中相加,\(15\equiv4\pmod {11}\)。这不是在对点坐标逐项模 \(11\);点运算由群法则定义。
2.8 哈希到标量与域分离
普通哈希函数输出字节串,而签名公式需要 \(c\in\mathbb Z_q\)。“哈希到标量”是一个明确的转换过程,例如:
\[ c=H_{\mathrm{scalar}}(\text{domain}\parallel \operatorname{enc}(R)\parallel \operatorname{enc}(X)\parallel m) \]
实现必须规定:
- 使用哪个哈希函数;
- 点和长度如何编码;
- 如何把摘要映射到 \(\mathbb Z_q\);
- 使用哪个域分离标签;
- 是否有协议版本与上下文字符串。
域分离标签可以理解为“给哈希输入加上不可混淆的用途名称”。例如,生成挑战和生成 nonce 必须使用不同标签,防止同一串字节在两个协议位置被误作同一种对象。
不要用 hash[0..32] 后直接解释为标量,也不要用不带规范说明的“随手 % q”。成熟曲线库一般提供安全的宽字节约简或协议指定的映射接口。
2.9 随机性也是数学假设的一部分
当协议说“均匀随机选择 \(a\leftarrow\mathbb Z_q\)”时,它不只是建议调用任意随机函数,而是要求分布不可预测且偏差可忽略。
实践中应注意:
- 使用操作系统提供的密码学安全随机源;
- 避免简单的
random_u64 % q带来的模偏差; - 私钥、Shamir 系数和 nonce 不得来自时间戳或普通伪随机生成器;
- 测试中可以注入固定随机源以复现结果,生产接口必须阻止误用测试随机源。
2.10 常见误区
误区一:“所有大整数都可以统一 % p”
错误。曲线坐标通常模 \(p\),签名标量模 \(q\)。用错模数会破坏协议等式,某些情况下还会引入安全漏洞。
误区二:“点就是两个整数,所以自己操作坐标即可”
错误。合法性检查、单位元、子群、余因子、编码唯一性都很容易处理错。学习签名协议不等于自行实现曲线算术。
误区三:“除法就是整数先除再取模”
错误。模除法必须乘以分母在域中的逆元。
误区四:“两个份额的拉格朗日系数永远一样”
错误。系数由本次参与集合的标识符决定。
误区五:“哈希输出天然就是合法标量或合法点”
错误。两种转换都有协议规定的映射过程,并需要规范编码和域分离。
误区六:“公式正确就等于实现安全”
错误。侧信道、随机数质量、序列化、状态持久化、重放和恶意输入都不体现在一行代数等式中。
2.11 章节检查题
先独立作答,再展开参考答案。
- 在 \(\mathbb F_{17}\) 中,\(5/2\) 等于多少?
- 为什么 Shamir 的参与者标识符必须互不相同?
- 已知 \(P=xG\),为什么不能通过“点除以 \(G\)”轻易得到 \(x\)?
- 椭圆曲线坐标使用的模数 \(p\) 与私钥使用的模数 \(q\) 有什么区别?
- 在阶为 \(11\) 的群中,\(9G+8G\) 等于多少个 \(G\)?
- 为什么
Hash(bytes)的结果不能不经转换就当作Scalar? - 给定点 \((1,8)\)、\((2,11)\),在 \(\mathbb F_{17}\) 中求 \(f(0)\)。
参考答案
- \(2^{-1}=9\),所以 \(5/2=5\cdot9=45\equiv11\pmod {17}\)。
- 相同标识符会使拉格朗日插值的分母 \(x_i-x_j\) 为零,而零不可逆;它们本质上也只是同一个横坐标上的重复信息。
- 群没有这种普通除法;从 \(P=xG\) 反求 \(x\) 是离散对数问题。
- \(p\) 定义曲线坐标的基域;\(q\) 是所用子群的阶,并定义私钥、nonce、挑战和响应所在的标量域。二者通常不相等。
- \(17G=(17\bmod11)G=6G\)。
- 哈希输出是字节串,可能超出标量范围,编码也未必规范;必须使用协议指定的哈希到标量过程。
- \(f(0)=5\),推导见本章的拉格朗日示例。
本章小结
后续所有协议都可以用三类对象来阅读:字节串、标量和群元素。看到一个公式时,先检查类型,再检查它在哪个域中运算,最后才检查代数是否相等。这个习惯会贯穿整个门限签名实现。
实验 01:从零实现一个玩具有限域
本实验只用于理解代数结构。小素数域、分支、取模运算和普通整数都不具备生产级密码学安全性。
目标
完成一个 Fp<P> 类型,并通过测试亲自确认:
- 加、减、乘之后仍落在
0..P; - 每个非零元素都有乘法逆元;
- 除法本质上是乘以逆元;
- 拉格朗日插值所需的运算可以由这些基本操作组成。
本章固定使用素数 P = 17。先把每一步算清楚,再在后续实验中换成 Ristretto255 的标量域。
前置知识
- Rust 的结构体、泛型常量、
Result和单元测试; - 整数余数运算;
- 群、环、域的直观定义。
如果“域”仍然抽象,可以先把它理解为一个有限的钟面:超过 16 就绕回 0,但除 0 外的每个刻度都能做除法。
建议 API 与文件
建议文件:src/toy_field.rs
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fp<const P: u64>(u64);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldError {
ZeroHasNoInverse,
}
impl<const P: u64> Fp<P> {
pub fn new(value: u64) -> Self {
assert!(P > 1, "modulus must be greater than one");
Self(value % P)
}
pub fn value(self) -> u64 {
self.0
}
pub fn add(self, rhs: Self) -> Self {
// 使用 u128,避免两个 u64 相加时先溢出。
Self::new(((self.0 as u128 + rhs.0 as u128) % P as u128) as u64)
}
pub fn sub(self, rhs: Self) -> Self {
todo!("先在纸上推导不会发生无符号下溢的写法")
}
pub fn mul(self, rhs: Self) -> Self {
todo!("先扩大到 u128,再取模")
}
pub fn pow(self, exponent: u64) -> Self {
todo!("实现平方-乘算法,而不是循环 exponent 次")
}
pub fn invert(self) -> Result<Self, FieldError> {
todo!("处理 0;对本实验的素数模数使用费马小定理")
}
}
}
上面的代码是可编译骨架:todo!() 会让代码通过编译,但运行到对应分支时会主动终止。不要把 Fp<P> 的 P 可配置性误解为它能安全支持任意模数;本实验默认调用者保证 P 是素数。
分步任务
1. 画出 F17 的运算表
先不写代码,手算下列结果:
15 + 53 - 87 * 53的逆元
记录“普通整数结果”和“模 17 结果”。如果两者混在一起,后面的 Shamir 插值很容易写错。
2. 实现封闭的加、减、乘
补全 sub 和 mul。对减法,不要直接写 self.0 - rhs.0;先考虑 Rust debug 构建中的无符号下溢。
边界检查:
Fp::<17>::new(17)应等于 0;0 - 1应等于 16;- 中间乘积用
u128,最后再转回u64。
3. 实现快速幂
实现平方-乘算法。每处理一位指数,只做一次平方,并在对应位为 1 时多做一次乘法。
不要直接复制现成实现;用注释回答:循环过程中,base、accumulator 和尚未处理的指数分别代表什么?
4. 实现逆元
对于素数 P 和非零 a,费马小定理给出:
\[ a^{P-1}=1 \pmod P \]
请自行推导逆元应该使用哪个指数。0 必须返回 Err(FieldError::ZeroHasNoInverse),不能静默返回 0。
5. 再决定是否实现运算符
基础测试通过后,可为 Add、Sub、Mul 实现标准 trait。先保留命名方法有一个好处:调试时每一步的语义更显眼。
验收测试
至少写出以下测试;示例中的测试是可编译的,前提是你的模块路径一致。
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
type F17 = Fp<17>;
#[test]
fn arithmetic_wraps_modulo_p() {
assert_eq!(F17::new(15).add(F17::new(5)), F17::new(3));
assert_eq!(F17::new(3).sub(F17::new(8)), F17::new(12));
assert_eq!(F17::new(7).mul(F17::new(5)), F17::new(1));
}
#[test]
fn every_nonzero_element_has_an_inverse() {
for raw in 1..17 {
let a = F17::new(raw);
let inverse = a.invert().unwrap();
assert_eq!(a.mul(inverse), F17::new(1));
}
}
#[test]
fn zero_has_no_inverse() {
assert_eq!(F17::new(0).invert(), Err(FieldError::ZeroHasNoInverse));
}
#[test]
fn field_laws_hold_for_all_f17_values() {
for a in 0..17 {
for b in 0..17 {
for c in 0..17 {
let (a, b, c) = (F17::new(a), F17::new(b), F17::new(c));
assert_eq!(a.add(b), b.add(a));
assert_eq!(a.mul(b), b.mul(a));
assert_eq!(a.mul(b.add(c)), a.mul(b).add(a.mul(c)));
}
}
}
}
}
}
完成标准:测试全部通过,并且你能解释为什么穷举 F17 是可行的,而对真实标量域不可行。
观察记录
把结果写入实验日志,而不只是让测试变绿:
| 问题 | 你的记录 |
|---|---|
F17 中 3 的逆元是多少? | |
0 - 1 为什么不是错误? | |
a^(P-2) 为什么只对非零 a 和素数 P成立? | |
| 哪个测试最容易发现“忘记取模”? |
进阶挑战
- 用扩展欧几里得算法实现第二种求逆方式,并与快速幂版本逐项比较。
- 增加
TryFrom<u64>或更明确的构造 API,区分“规范编码”和“自动约简”。 - 用
proptest对更大的素数做性质测试;说明随机性质测试为什么不能代替证明。 - 尝试把
P改成合数 15,找出第一个没有逆元的非零元素,并解释失败原因。
安全警告
- 该实现不是常量时间:分支、指数位和普通整数运算都可能泄漏信息。
P没有经过素数验证,且这里只为中间运算选用了u128;它不是通用大整数有限域。- 禁止把本章代码用于密钥、真实签名、钱包或任何资产场景。
- 后续真实曲线实验必须使用
curve25519-dalek提供的Scalar和点运算,不要自行实现曲线或模数约简。
第 3 章:Shamir 秘密共享
学习定位:前置为第 2 章和实验 1;配套完成实验 2:Shamir。过关条件是任意合法 \(t\) 份恢复、非法 ID 被拒绝,并能解释少于门限为何不泄密。
Shamir Secret Sharing(SSS)解决的是这样一个问题:把秘密分散给 \(n\) 个参与者,使任意至少 \(t\) 人能够恢复秘密,而少于 \(t\) 人无法获得关于秘密的信息。
先把边界说清楚:**Shamir 是秘密共享方案,不是签名方案,也不是完整的门限签名协议。**它可以成为门限签名中的密钥共享层,但它本身不会验证消息、生成数字签名或抵抗所有恶意参与者。
学习目标
完成本章后,你应该能够:
- 解释 \(t\)-of-\(n\) 门限的含义;
- 用次数 \(t-1\) 的随机多项式生成份额;
- 用任意合格参与者集合恢复秘密;
- 解释为什么少于 \(t\) 个份额不泄漏秘密;
- 说明普通 Shamir 与 VSS、DKG、门限签名的区别;
- 识别重复标识符、零标识符、弱随机数和错误域等实现陷阱。
3.1 直觉:把秘密藏在多项式的截距里
一条直线由两个不同点唯一确定。只知道直线上的一个点时,可能穿过该点的直线有很多条,其纵轴截距也可能是任意值。
Shamir 把秘密 \(s\) 放在多项式常数项:
\[ f(0)=s \]
对于 2-of-n,选择一条随机直线:
\[ f(X)=s+a_1X \]
每个参与者得到直线上的一个不同点。任意两点能确定直线并得到截距,单独一点则不能。
对于一般的 t-of-n,选择次数为 \(t-1\) 的随机多项式:
\[ f(X)=s+a_1X+a_2X^2+\cdots+a_{t-1}X^{t-1} \]
次数不超过 \(t-1\) 的多项式由 \(t\) 个横坐标不同的点唯一确定,这就产生了门限。
3.2 参数与威胁模型
记:
- \(n\):参与者总数;
- \(t\):恢复门限,满足 \(1\le t\le n\);
- \(q\):素数域模数;
- \(s\in\mathbb Z_q\):待共享秘密;
- \(i\in\mathbb Z_q^*\):参与者的非零、唯一标识符;
- \(s_i=f(i)\):参与者 \(i\) 的份额。
如果共享的是 Schnorr 私钥,那么 Shamir 多项式必须在签名群的标量域 \(\mathbb Z_q\) 中运算,而不是在曲线坐标的基域 \(\mathbb F_p\) 中运算。
普通 Shamir 的经典保密结论基于以下条件:
- Dealer(分发者)诚实地选取并分发份额;
- 非常数系数均匀随机且独立;
- 参与者通过机密且认证的信道接收份额;
- 攻击者得到的有效份额少于 \(t\)。
普通 Shamir 本身不能让参与者发现 Dealer 是否给不同人发送了互不一致的份额,也不能阻止参与者在恢复时提交假份额。这些问题需要可验证秘密共享(VSS)等机制处理。
3.3 份额生成算法
由 Dealer 执行:
-
检查 \(1\le t\le n\),并准备 \(n\) 个互不相同的非零标识符;
-
将秘密编码为标量 \(s\in\mathbb Z_q\);
-
独立均匀采样 \(a_1,\ldots,a_{t-1}\leftarrow\mathbb Z_q\);
-
定义
\[ f(X)=s+\sum_{j=1}^{t-1}a_jX^j \]
-
对每个参与者 \(i\),计算并私下发送
\[ (i,s_i)=(i,f(i)) \]
这里的“份额”必须同时包含标识符和值。只保存 \(s_i\) 而丢失 \(i\),恢复时就不知道该使用哪个拉格朗日系数。
关于最高次系数为零
标准 Shamir 证明通常要求 \(a_1,\ldots,a_{t-1}\) 各自在整个域中独立均匀采样,零也包括在内。如果恰好 \(a_{t-1}=0\),实际多项式次数会降低;但观察者并不知道这一事件,标准分布下少于 \(t\) 个份额仍不泄漏秘密。在密码学使用的大域中,最高次系数为零的概率为 \(1/q\),可以忽略。
不要自作主张地把最高次系数条件化为“非零”:以 2-of-n 为例,强制 \(a_1\ne0\) 后,观察份额 \(s_i\) 会排除候选秘密 \(s=s_i\),从而破坏标准的完美隐私分布。教学用小域为了固定演示次数可以显式挑选一个非零系数,但必须说明这不是一般采样算法。正式实现应逐字遵循所选协议规范。
3.4 恢复算法
选择任意至少 \(t\) 个有效份额。为便于说明,先取恰好 \(t\) 个,其标识符集合为 \(S\)。对每个 \(i\in S\),计算零点处的拉格朗日系数:
\[ \lambda_i^S= \prod_{\substack{j\in S\\j\ne i}} \frac{0-j}{i-j} = \prod_{\substack{j\in S\\j\ne i}} \frac{-j}{i-j} \pmod q \]
然后恢复:
\[ s=f(0)=\sum_{i\in S}\lambda_i^S s_i\pmod q \]
上标 \(S\) 提醒我们:**拉格朗日系数依赖本次使用的参与者集合。**在 2-of-3 系统中,集合 \(\{1,2\}\) 和 \(\{1,3\}\) 使用的系数不同。
拥有多于 \(t\) 个份额时,可以选择其中任意 \(t\) 个有效份额进行插值,也可以对全部点进行相应插值。真实系统还必须考虑坏份额检测;“多拿几个未经验证的份额”不会自动带来健壮性。
3.5 完整小例子:在 \(\mathbb F_{17}\) 中实现 2-of-3
这里使用很小的域只是为了手算。它完全不安全,不能用于真实密钥。
令秘密为:
\[ s=5 \]
门限 \(t=2\),所以选择一次多项式。假设随机系数为 \(a_1=7\):
\[ f(X)=5+7X\pmod {17} \]
给三个参与者分配标识符 \(1,2,3\):
| 参与者 | 计算 | 份额 |
|---|---|---|
| 1 | \(f(1)=5+7\) | \((1,12)\) |
| 2 | \(f(2)=5+14\) | \((2,2)\) |
| 3 | \(f(3)=5+21\) | \((3,9)\) |
现在只使用参与者 1 和 3,即 \(S=\{1,3\}\)。
参与者 1 的系数:
\[ \lambda_1^{\{1,3\}} =\frac{-3}{1-3} =\frac{-3}{-2} =3\cdot2^{-1} =3\cdot9 \equiv10\pmod {17} \]
参与者 3 的系数:
\[ \lambda_3^{\{1,3\}} =\frac{-1}{3-1} =-1\cdot2^{-1} =16\cdot9 \equiv8\pmod {17} \]
恢复秘密:
\[ s=10\cdot12+8\cdot9=192\equiv5\pmod {17} \]
结果与原秘密一致。
顺便检查:
\[ \lambda_1+\lambda_3=10+8=18\equiv1\pmod {17} \]
常数多项式在插值后仍应保持原值,所以拉格朗日系数之和为 1 是一个有用的调试性质,但它不能替代完整测试。
3.6 为什么少于门限的份额不泄漏秘密
仍看 2-of-n。攻击者知道一个份额 \((i,s_i)\),而:
\[ s_i=s+a_1i \]
对任意候选秘密 \(s'\in\mathbb Z_q\),总能找到唯一一个斜率:
\[ a_1'=(s_i-s')i^{-1} \]
使直线 \(f'(X)=s'+a_1'X\) 穿过已知点。由于原斜率是均匀随机的,观察到这个点以后,每个候选秘密仍然同样可能。
一般情形中,少于 \(t\) 个约束仍留下足够的随机系数自由度,能与任意候选常数项兼容。因此 Shamir 在理想模型下提供信息论保密性:它不依赖攻击者算力有限。
但这项结论并不意味着任何“看起来像 Shamir”的实现都安全。随机系数可预测、份额通过日志泄漏或标量编码错误,都会破坏理论前提。秘密空间很小本身不会让少于门限的纯 Shamir 份额泄密;但若同时公开了 Feldman 承诺、校验值或其他猜测验证器,攻击者就可能枚举候选秘密,因此后续协议必须把这些公开信息一起纳入分析。
3.7 Shamir 与门限 Schnorr 如何连接
设 Schnorr 私钥为:
\[ x=f(0)\in\mathbb Z_q \]
参与者 \(i\) 持有密钥份额:
\[ x_i=f(i) \]
群公钥为:
\[ X=xG \]
若签名集合为 \(S\),则由插值关系:
\[ x=\sum_{i\in S}\lambda_i^Sx_i \]
两边同乘生成元 \(G\):
\[ X=xG =\sum_{i\in S}\lambda_i^S(x_iG) \]
定义验证份额(公开份额):
\[ X_i=x_iG \]
就得到:
\[ X=\sum_{i\in S}\lambda_i^S X_i \]
这说明密钥份额可以带权参与群运算,而不必先把完整私钥 \(x\) 重构到某台机器上。门限签名的价值恰恰在于:协作产生签名,但不恢复完整私钥。
如果某个所谓“门限签名实现”先收集 \(t\) 个份额、恢复 \(x\),再调用普通签名函数,它只是“先秘密共享、后集中签名”。恢复节点在那一刻拥有完整私钥,失去了 TSS 的核心安全性质。
3.8 普通 Shamir 不提供什么
它不是签名
Shamir 不接收消息 \(m\),不生成可由公钥验证的签名,也不提供不可伪造性。
它不验证份额
持有 \((i,s_i)\) 的参与者仅凭普通 Shamir 无法确认这个值是否与其他人的份额属于同一个多项式。
它不消除可信 Dealer
Dealer 在生成阶段知道完整秘密和所有份额,还可以作恶。分布式密钥生成(DKG)用于让多方共同生成共享密钥,使完整私钥从未在单点出现。
它不自动容错
一个恶意或损坏的份额可能导致恢复出错误结果。检测、归责、剔除和重试属于 VSS 或更完整协议的职责。
它不保护静态存储
份额仍是高价值秘密。磁盘加密、访问控制、硬件隔离、备份策略和安全删除需要另外设计。
3.9 从 Shamir 到可验证秘密共享
Feldman VSS 为多项式系数发布群承诺:
\[ A_j=a_jG,\qquad j=0,\ldots,t-1 \]
其中 \(a_0=s\),所以 \(A_0=sG=X\)。参与者 \(i\) 可以检查:
\[ s_iG\stackrel?= \sum_{j=0}^{t-1}i^jA_j \]
因为右侧等于:
\[ \sum_{j=0}^{t-1}i^j(a_jG) =\left(\sum_{j=0}^{t-1}a_ji^j\right)G =f(i)G \]
这让参与者验证自己的份额是否与公开承诺一致。
但要注意:
- Feldman 承诺依赖离散对数困难性,不提供信息论隐藏;
- 如果被承诺的秘密来自很小的可枚举空间,攻击者可能猜测后计算 \(sG\) 比较;
- 广播渠道必须一致,否则 Dealer 可能给不同参与者展示不同承诺;
- 完整的投诉、剔除和 DKG 流程远不止这一条等式。
Pedersen VSS 会加入额外随机盲化以提供隐藏性,代价是协议和承诺结构更复杂。
3.10 实现时必须明确的边界
参与者标识符
- 必须非零:若 \(i=0\),则 \(s_i=f(0)=s\),直接得到完整秘密;
- 必须唯一:重复标识符会导致插值分母为零;
- 必须规范映射到标量:字符串或用户 ID 应通过协议定义的分配/编码规则得到标量,并在当前 roster 内显式检查非零与唯一;不要假定任意字符串到有限域存在天然无碰撞的哈希映射;
- 不能把数组下标、显示顺序和密码学标识符悄悄混为一谈。
份额结构
一个可用的份额记录至少需要绑定:
- 协议和版本;
- 密钥或会话标识;
- 参与者标识符;
- 门限与总人数;
- 份额值;
- 所用群/套件;
- 完整性或来源认证信息。
把裸标量复制到另一个密钥会话中可能产生灾难性混淆。
序列化
标量必须使用固定长度、规范字节序和唯一编码。反序列化时应拒绝超出范围的非规范表示,而不是在所有场景下静默 % q;具体规则应服从所选协议和曲线库。
秘密生命周期
避免在调试输出、错误文本、panic 信息和测试快照中打印份额。Rust 的所有权可以减少意外复制,但不能自动保证内存清零、无 swap、无 core dump 或无侧信道。
3.11 建议的动手验证
实现完整门限签名前,可以先对一个小素数域版本做性质测试:
- 随机生成 \(s,t,n\) 和多项式;
- 枚举或随机选择多个大小为 \(t\) 的参与者子集;
- 检查每个子集都恢复同一个 \(s\);
- 打乱份额顺序,确认结果不变;
- 构造重复 ID、ID 为零、份额不足和份额值越界,确认接口明确拒绝;
- 篡改一个份额,观察普通 Shamir 为什么无法判断是谁作恶;
- 再加入 Feldman 承诺,验证篡改份额会使检查等式失败。
小素数版本的目的在于观察协议,而非复用到生产代码。随后应把字段类型替换为曲线库提供的 Scalar,并保留同样的性质测试。
3.12 常见误区
误区一:“把私钥切成三段就是 2-of-3”
把字节分成三段需要三段全部到齐;简单复制或 XOR 也不会自动得到任意两份可恢复的门限结构。Shamir 的门限来自多项式插值。
误区二:“Shamir 已经是一套门限签名”
错误。它只共享秘密。门限签名还必须安全地产生并协调 nonce、绑定消息与参与者、验证签名份额并抵抗恶意行为。
误区三:“恢复时把所有份额值直接相加”
通常错误。份额需要乘以与参与集合对应的拉格朗日系数。
误区四:“参与者 ID 可以从 0 开始”
错误。ID 0 的份额就是 \(f(0)=s\)。
误区五:“份额超过门限,坏份额会被多数自动抵消”
错误。普通插值不会自动识别错误值;必须增加验证或纠错机制。
误区六:“共享曲线私钥时在 \(\mathbb F_p\) 上插值”
错误。曲线私钥是群作用的标量,必须在子群阶定义的 \(\mathbb Z_q\) 上共享与插值。
误区七:“重构测试通过,就可以部署”
错误。测试只能说明某些功能路径正确,不能证明随机数、侧信道、恶意参与者处理、持久化和网络协议安全。
3.13 章节检查题
- 为什么
3-of-5Shamir 使用次数至多为 2 的多项式? - 最高次系数为零时,多项式次数会怎样变化?为什么标准 Shamir 仍不应擅自将采样改为“最高次系数必须非零”?
2-of-3中,参与者集合从 \(\{1,2\}\) 换成 \(\{1,3\}\) 后,原拉格朗日系数还能复用吗?- 在本章 \(\mathbb F_{17}\) 的例子中,仅持有份额 \((1,12)\) 时,秘密能否是 \(6\)?若能,对应斜率是多少?
- 为什么共享 Schnorr 私钥要使用 \(\mathbb Z_q\),而不是 \(\mathbb F_p\)?
- 普通 Shamir 为何无法发现 Dealer 给出的份额不一致?
- Feldman VSS 的份额验证等式是什么?
- “恢复私钥后再普通签名”和真正门限签名的安全边界有何不同?
参考答案
- 三个横坐标不同的点能唯一确定次数不超过 2 的多项式,而两个点仍不足以确定其常数项。
- 最高次项消失后,多项式次数降低。标准隐私证明依赖所有随机系数在整个域(包括零)中均匀采样;强制非零会改变条件分布,例如在
2-of-n中排除候选秘密 \(s=s_i\)。大域里最高次系数为零的概率仅为 \(1/q\)。 - 不能。拉格朗日系数由当前集合中的全部标识符计算。
- 能。需要 \(12=6+a_1\cdot1\pmod {17}\),所以 \(a_1=6\)。这体现了单个份额与任意候选秘密兼容。
- Schnorr 私钥是乘在群生成元上的标量,标量按群阶 \(q\) 运算;\(p\) 用于底层曲线坐标。
- 普通 Shamir 没有公开多项式承诺或其他一致性证明,参与者只看到自己的点。
- \(s_iG\stackrel?=\sum_{j=0}^{t-1}i^jA_j\),其中 \(A_j=a_jG\)。
- 前者会在恢复节点内出现完整私钥,节点一旦被攻破即可独立签名;真正门限签名只产生协作签名,不重构完整私钥。
本章小结
Shamir 用“随机多项式的常数项”承载秘密,用“多项式次数加一”定义门限。它为门限 Schnorr 提供了密钥份额和拉格朗日权重,但安全签名还需要 nonce 协议、消息绑定、签名份额验证以及应对恶意参与者的完整状态机。
实验 02:Shamir 秘密共享与拉格朗日插值
Shamir 秘密共享把秘密分成份额;它本身不是门限签名。真正的 TSS 签名过程中不应先重构完整私钥。
目标
在实验 01 的 F17 上实现一个确定性的 2-of-3 Shamir 示例,随后扩展为一般的 t-of-n:
- 用多项式在不同非零横坐标上生成份额;
- 用任意达到门限的份额在
x = 0处恢复秘密; - 拒绝重复参与者编号、编号 0 和份额不足;
- 理解“知道少于 t 个点”为什么无法唯一确定秘密。
前置知识
- 已完成实验 01,有限域加、减、乘、求逆测试全部通过;
- 能计算一次函数或多项式在某个点的值;
- 知道两个不同的点确定一条直线。
建议 API 与文件
建议文件:
src/toy_field.rs:复用实验 01;src/shamir.rs:份额、求值、拆分与恢复;tests/shamir_toy.rs:已知答案测试。
第一阶段不要加入随机数。让调用者显式提供系数,这样每个结果都可以手算:
#![allow(unused)]
fn main() {
use crate::toy_field::Fp;
pub type F17 = Fp<17>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Share {
pub id: u64,
pub value: F17,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ShamirError {
InvalidThreshold,
NotEnoughShares,
ZeroParticipantId,
DuplicateParticipantId,
}
/// coefficients[0] 是秘密,后面依次是 x、x^2 ... 的系数。
pub fn evaluate_polynomial(coefficients: &[F17], x: F17) -> F17 {
todo!("使用 Horner 法,从最高次系数向常数项折叠")
}
pub fn split_with_coefficients(
coefficients: &[F17],
participant_ids: &[u64],
) -> Result<Vec<Share>, ShamirError> {
todo!("验证参数,再计算每个 f(id)")
}
pub fn combine(shares: &[Share], threshold: usize) -> Result<F17, ShamirError> {
todo!("计算每个份额在 x=0 处的拉格朗日权重")
}
}
这是可编译骨架。本实验中 coefficients.len() 就是门限 t;实现时应拒绝空系数数组以及 t = 0。
分步任务
1. 手工生成 2-of-3 份额
取秘密 s = 5、斜率 a = 3,在 F17 中定义:
\[ f(x)=s+ax \]
分别计算 f(1)、f(2)、f(3)。把三个点画在普通坐标纸上时,它们看起来是一条直线;但要提醒自己,实际运算是在模 17 的域上。
2. 用 Horner 法求值
不要为一次、二次、三次多项式分别写分支。实现:
\[ a_0+x(a_1+x(a_2+\cdots)) \]
先写一个二次多项式测试,确认系数遍历方向没有颠倒。
3. 验证参与者编号
在生成和恢复两侧都检查:
- 编号不能是 0,因为
f(0)就是秘密; - 同一组中编号不得重复;
- 编号进入域后也不得碰撞。对 F17 而言,编号 1 和 18 会映射到同一横坐标,因此也应被视为重复。
最后一点很容易漏掉。建议比较 F17::new(id) 的值,而不只比较原始 u64。
4. 推导拉格朗日权重
对选中的参与者集合 S,在 x = 0 恢复:
\[ f(0)=\sum_{i\in S}\lambda_i f(x_i) \]
从拉格朗日基函数开始,自行推导 lambda_i 的分子与分母。写代码前,用参与者 1 和 2 的份额手算一次,并检查结果为 5。
实现时,每个分母都必须通过有限域求逆;任何求逆失败都代表前面的 ID 验证存在漏洞,不应 unwrap 掩盖。
5. 推广到 t-of-n
令系数数组长度为 t,生成 n 个点。用 t = 3, n = 5 再做一组固定系数实验,并遍历所有 3 人组合恢复秘密。
6. 迁移到 Ristretto255 标量域
小域版本完全通过后,再建立 src/scalar_shamir.rs:
- 用
curve25519_dalek::scalar::Scalar替代F17; - 用
Scalar::from(id)表示参与者横坐标; - 用
Scalar::random(&mut OsRng)生成非恒定项系数; - 继续拒绝 ID 0 和重复 ID。
不要删除 F17 版本。它是后面排查公式和符号错误的“透明参照物”。
建议依赖版本:
[dependencies]
curve25519-dalek = { version = "4", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
验收测试
以下已知答案测试应先通过:
#![allow(unused)]
fn main() {
#[test]
fn known_two_of_three_example_matches_by_hand() {
let coefficients = [F17::new(5), F17::new(3)];
let shares = split_with_coefficients(&coefficients, &[1, 2, 3]).unwrap();
assert_eq!(shares[0].value, F17::new(8));
assert_eq!(shares[1].value, F17::new(11));
assert_eq!(shares[2].value, F17::new(14));
assert_eq!(combine(&shares[0..2], 2).unwrap(), F17::new(5));
assert_eq!(combine(&shares[1..3], 2).unwrap(), F17::new(5));
assert_eq!(combine(&[shares[0], shares[2]], 2).unwrap(), F17::new(5));
}
#[test]
fn rejects_invalid_sets() {
let duplicate = [
Share { id: 1, value: F17::new(8) },
Share { id: 18, value: F17::new(8) },
];
assert_eq!(combine(&duplicate, 2), Err(ShamirError::DuplicateParticipantId));
assert_eq!(combine(&duplicate[..1], 2), Err(ShamirError::NotEnoughShares));
}
}
还需自行增加:
- 所有
3-of-5组合均恢复同一秘密; - 修改一个份额后,恢复值不再等于秘密;
- ID 为 0 时明确报错;
- 系数为空、门限为 0、门限大于份额数时明确报错;
- Scalar 版本的随机
2-of-3往返测试至少运行 100 组。
完成标准:除了测试通过,还能在不看代码的情况下解释恢复公式中每个权重为何依赖“本次实际参与签名的人”。
观察记录
| 问题 | 你的记录 |
|---|---|
| 三个已知份额分别是多少? | |
| 只拿到一个份额时,有多少个秘密仍与它相容? | |
| ID 1 和 18 为什么会冲突? | |
| 修改一个份额后为什么通常仍能算出一个值,却不能发现作弊? | |
F17 与 Scalar 版本的 API 有哪些相同和不同? |
进阶挑战
- 遍历 F17,展示单个份额对秘密没有偏好:固定一个份额后,每个可能秘密都能对应某个斜率。
- 实现份额刷新:加入常数项为 0 的随机多项式,使秘密不变而份额改变。
- 添加 Feldman VSS 承诺
A_j = a_j G,验证s_i G是否等于承诺多项式在 IDi的点值。 - 用组合迭代器测试所有恰好
t人和多于t人的子集。
安全警告
- “把份额收集到一台机器再恢复私钥”破坏了 TSS 的核心安全边界。这里只为理解 Shamir 才执行恢复。
- 玩具域份额可被穷举;固定系数、日志和测试向量都不是秘密材料。
- 生产系统必须使用密码学安全随机数生成多项式系数,并安全擦除临时秘密;测试 RNG 不得进入生产路径。
- 纯 Shamir 无法检测恶意 Dealer 发放不一致份额。进入 DKG 前需要 VSS、身份认证和一致的参与者集合。
第 4 章:Schnorr 签名
学习定位:前置为第 2 章;配套完成实验 3:普通 Schnorr。本章只建立单方签名和线性直觉,真实门限实现留到实验 4。
Schnorr 签名只有一条核心验证等式,却把公钥密码学中最重要的几个思想连在一起:离散对数、随机 nonce、承诺—挑战—响应和 Fiat–Shamir 变换。它的线性结构也使其非常适合构造门限签名。
这份简洁既是优点,也是危险:nonce 只要重用一次,就可能直接泄露私钥。
学习目标
完成本章后,你应该能够:
- 从密钥生成到验证完整描述 Schnorr 签名;
- 推导验证等式为何成立;
- 解释挑战值为何必须绑定承诺、公钥、消息和协议上下文;
- 从两个重用 nonce 的签名中推导私钥恢复公式;
- 区分标量与群元素,以及它们各自的运算;
- 说明普通 Schnorr 如何线性扩展到门限 Schnorr,以及为什么不能停在朴素方案。
4.1 参数和符号
选择一个阶为素数 \(q\) 的循环群 \(\mathbb G\),生成元为 \(G\)。采用加法记号。
| 符号 | 含义 | 类型 |
|---|---|---|
| \(x\) | 私钥 | \(\mathbb Z_q\) 中的非零标量 |
| \(X=xG\) | 公钥 | 群元素 |
| \(r\) | 单次签名 nonce | \(\mathbb Z_q^*\) 中的非零秘密标量 |
| \(R=rG\) | nonce 承诺 | 群元素 |
| \(c\) | Fiat–Shamir 挑战 | \(\mathbb Z_q\) 中的标量 |
| \(z\) | 签名响应 | \(\mathbb Z_q\) 中的标量 |
| \(m\) | 消息 | 字节串 |
本章采用签名 \(\sigma=(R,z)\) 和验证式 \(zG=R+cX\) 的记号。有些标准使用不同符号、符号方向或编码约定;公式相似不代表字节级兼容。
4.2 从身份识别协议获得直觉
先不讨论签名。设证明者知道 \(x\),验证者只知道 \(X=xG\)。证明者希望说明“我知道这个公钥对应的私钥”,但不公开 \(x\)。
一个三步交互协议如下:
-
承诺:证明者随机选择 \(r\leftarrow\mathbb Z_q^*\),发送
\[ R=rG \]
-
挑战:验证者随机发送 \(c\leftarrow\mathbb Z_q\)。
-
响应:证明者发送
\[ z=r+cx\pmod q \]
验证者检查:
\[ zG\stackrel?=R+cX \]
为什么成立?把诚实响应代入左侧:
\[ \begin{aligned} zG &=(r+cx)G\\ &=rG+c(xG)\\ &=R+cX \end{aligned} \]
整个推导中,\(r,c,x,z\) 都在 \(\mathbb Z_q\) 中,\(R,X,G\) 都在 \(\mathbb G\) 中。
4.3 Fiat–Shamir:把交互挑战变成哈希
数字签名不能要求每次都有在线验证者来随机出题。因此用哈希函数从当前签名上下文确定挑战:
\[ c=H_{\mathrm{scalar}}( \text{domain}\parallel \operatorname{enc}(R)\parallel \operatorname{enc}(X)\parallel \operatorname{enc}(m) ) \]
这一步称为 Fiat–Shamir 变换。哈希输出需要通过协议规定的方法映射到 \(\mathbb Z_q\)。
挑战中每个部分都有作用:
- \(R\):绑定本次 nonce 承诺;
- \(X\):绑定签名所对应的公钥;
- \(m\):绑定消息;
domain:绑定协议、套件、版本和用途,避免跨协议混淆;- 规范编码:确保不同实现哈希完全相同的字节。
若遗漏消息,签名就不能证明签署了哪条消息。若协议要求绑定公钥却遗漏它,某些密钥替换或多用户场景中的安全论证会改变。实现不应自行删减规范定义的 transcript 字段。
4.4 Schnorr 的三个算法
密钥生成
均匀选择非零标量:
\[ x\leftarrow\mathbb Z_q^* \]
计算公钥:
\[ X=xG \]
私钥是标量,公钥是群元素。私钥绝不能按曲线基域模数 \(p\) 约简。
签名
对消息 \(m\):
-
安全地产生一次性非零秘密 nonce \(r\in\mathbb Z_q^*\);
-
计算 \(R=rG\);
-
计算
\[ c=H_{\mathrm{scalar}}( \text{domain}\parallel\operatorname{enc}(R) \parallel\operatorname{enc}(X)\parallel m) \]
-
计算
\[ z=r+cx\pmod q \]
-
输出 \(\sigma=(R,z)\)。
验证
收到 \((X,m,\sigma)\) 后:
-
严格解析并验证 \(X\)、\(R\)、\(z\) 的规范编码;
-
按完全相同的 transcript 重新计算 \(c\);
-
接受当且仅当:
\[ zG=R+cX \]
验证器还需遵守具体协议对单位元、小阶点、子群和无效编码的规则。使用 Ristretto 一类素数阶群抽象能减少陷阱,但不能取消输入验证。
对本手册采用的 Schnorr/FROST 套件,公钥和 nonce 承诺都不得是群单位元 \(\mathcal O\)。若签名者使用 \(r=0\),则 \(R=\mathcal O\)、\(z=cx\);公开签名在 \(c\ne0\) 时会直接给出 \(x=z/c\)。因此生成端必须避免零 nonce,解析和验证端也必须在计算 challenge 前拒绝单位元。
4.5 一个可手算的玩具例子
假设有一个阶为 \(q=19\) 的抽象循环群。选择私钥:
\[ x=7,\qquad X=7G \]
本次 nonce 为:
\[ r=4,\qquad R=4G \]
假设哈希 transcript 后得到挑战 \(c=5\)。签名响应是:
\[ z=r+cx=4+5\cdot7=39\equiv1\pmod {19} \]
验证左侧:
\[ zG=1G \]
验证右侧:
\[ R+cX=4G+5(7G)=39G=1G \]
两边相等。
这里的 \(c=5\) 是为手算人为指定的。真实签名绝不能由调用者随意选择挑战,必须由规范定义的哈希 transcript 得出。
4.6 安全直觉:为何不能伪造
若攻击者先决定响应 \(z\) 和挑战 \(c\),可以代数地令:
\[ R=zG-cX \]
这样验证等式确实成立。但在非交互签名中,挑战必须同时满足:
\[ c=H_{\mathrm{scalar}}(R,X,m,\ldots) \]
攻击者改变 \(R\) 就会改变哈希挑战,因而不能自由地同时控制二者。
严格安全性需要明确的群假设、哈希模型和签名变体;“看起来形成循环依赖”不是形式化证明。对学习者而言,关键直觉是:哈希把承诺、密钥和消息不可预测地绑定在一起,而离散对数困难性阻止攻击者从公钥得到私钥。
4.7 致命问题:nonce 重用直接泄露私钥
假设同一个私钥 \(x\) 使用同一个 nonce \(r\) 签了两条产生不同挑战的消息:
\[ z_1=r+c_1x\pmod q \]
\[ z_2=r+c_2x\pmod q \]
两式相减,nonce 被消去:
\[ z_1-z_2=(c_1-c_2)x\pmod q \]
只要 \(c_1\ne c_2\),就能恢复:
\[ x=(z_1-z_2)(c_1-c_2)^{-1}\pmod q \]
攻击者只需要看到两个签名,不需要攻破设备或求解离散对数。
玩具泄露例子
仍在 \(\mathbb Z_{19}\) 中,私钥 \(x=7\),重复使用 \(r=4\):
\[ c_1=5,\quad z_1=1 \]
另一条消息产生 \(c_2=8\):
\[ z_2=4+8\cdot7=60\equiv3\pmod {19} \]
观察者计算:
\[ x=(1-3)(5-8)^{-1}\pmod {19} \]
\(-2\equiv17\),\(-3\equiv16\),而 \(16^{-1}=6\),所以:
\[ x=17\cdot6=102\equiv7\pmod {19} \]
私钥完整暴露。
4.8 nonce 不只是“不能重复”
可预测或有偏差的 nonce
多次签名中只泄漏 nonce 的部分比特、或 nonce 分布存在偏差,也可能通过格攻击等方法恢复私钥。它不需要出现完全相同的 \(R\)。
脆弱的随机数源
时间戳、计数器、普通 PRNG、短种子和错误恢复后的 RNG 回滚都不适合直接产生 nonce。
自创确定性 nonce
确定性 nonce 可以减少外部随机源失败风险,但不能简单写成 H(private_key || message) 就宣称安全。标准方案会规定域分离、密钥派生、辅助随机数、编码和故障假设。应实现选定标准,而不是发明变体。
预计算 nonce 的生命周期
预计算可以提高性能,却引入状态机风险:
- nonce 秘密与承诺必须一一对应;
- 一旦承诺发布,该 nonce 即使会话失败也通常必须销毁;
- 崩溃恢复不能让“未标记已用”的 nonce 再次出现;
- nonce 不得跨密钥、协议或参与者复用;
- 日志、备份和序列化不能泄漏 nonce 秘密。
对于签名密钥而言,nonce 管理往往比核心公式更难。
4.9 为什么 Schnorr 适合做门限签名
Schnorr 响应是线性的:
\[ z=r+cx \]
假设私钥通过 Shamir 共享,参与者 \(i\) 持有 \(x_i=f(i)\)。对本次签名集合 \(S\):
\[ x=\sum_{i\in S}\lambda_i^Sx_i \]
如果各参与者还有 nonce 份额 \(r_i\),使:
\[ r=\sum_{i\in S}r_i,\qquad R=\sum_{i\in S}R_i,\quad R_i=r_iG \]
那么可以构造签名份额:
\[ z_i=r_i+c\lambda_i^Sx_i \]
聚合后:
\[ \begin{aligned} z &=\sum_{i\in S}z_i\\ &=\sum_{i\in S}r_i +c\sum_{i\in S}\lambda_i^Sx_i\\ &=r+cx \end{aligned} \]
因此最终 \((R,z)\) 看起来就是一个普通 Schnorr 签名,验证者无需知道参与者是谁或门限是多少。
这段推导说明了线性结构,却不是可以直接部署的协议。朴素聚合没有完整处理恶意选择承诺、nonce 取消、参与者绑定、签名份额验证、会话重放和中止后的 nonce 状态。
4.10 FROST 如何补全朴素方案
FROST 是经过专门设计的门限 Schnorr 协议。以常见的两轮结构为直觉,每个签名者 \(i\) 会准备两个一次性 nonce:
\[ d_i,e_i\in\mathbb Z_q \]
并发布对应承诺:
\[ D_i=d_iG,\qquad E_i=e_iG \]
聚合者把消息、签名者集合和全部承诺组织成规范 transcript,并为每个参与者计算绑定因子 \(\rho_i\)。该参与者本次的有效承诺为:
\[ R_i=D_i+\rho_iE_i \]
群承诺为:
\[ R=\sum_{i\in S}R_i \]
挑战为:
\[ c=H_{\mathrm{scalar}}(R,X,m,\ldots) \]
签名份额具有如下结构:
\[ z_i=d_i+\rho_i e_i+c\lambda_i^Sx_i \]
聚合:
\[ z=\sum_{i\in S}z_i \]
绑定因子把每个参与者的有效 nonce 与本次消息、签名集合及承诺列表绑定,防止攻击者在观察其他承诺后自由操纵自己的组合。完整实现还应在聚合前验证每个签名份额,并严格遵循所选 FROST 规范的标识符、编码、群和哈希定义。
不同标准或密码套件可能对挑战符号、群承诺规范化及 transcript 字段作不同规定。实现时必须选定一个规范版本逐字节遵循,不能只凭本章公式拼装。
4.11 实现边界:哪些自己写,哪些交给库
为了学习,可以自己实现:
- Schnorr 协议流程与状态类型;
- transcript 构造;
- 签名与验证的组合逻辑;
- 单元测试、性质测试和故障注入;
- 后续的 Shamir 权重、签名份额与聚合逻辑。
不建议在同一项目中自己实现:
- 大整数有限域算术;
- 椭圆曲线点加法与标量乘法;
- SHA-512 等哈希函数;
- 操作系统安全随机源;
- 常数时间比较和底层点编码。
“从零实现协议”不等于“从零实现所有密码学原语”。复用审计充分的基础库,才能把注意力放在 TSS 真正困难的协议状态与对手行为上。
4.12 建议的动手实验
实验一:正确性与篡改
- 生成密钥并签署消息;
- 验证原签名成功;
- 分别修改消息、\(R\)、\(z\) 和公钥;
- 确认每种篡改都验证失败;
- 对空消息、大消息和包含零字节的消息测试。
- 构造单位元公钥或单位元 \(R\),确认在 challenge 计算前拒绝。
实验二:有意重用 nonce
仅在玩具测试代码中注入固定 nonce,对两条不同消息签名,然后根据:
\[ x=(z_1-z_2)(c_1-c_2)^{-1} \]
恢复私钥,并验证恢复出的 \(xG\) 与原公钥一致。这个实验会让 nonce 风险从一句警告变成可观察事实。
实验三:类型驱动接口
让 Rust 类型系统阻止以下误用:
- 把消息字节直接传给需要
Scalar的函数; - 把
Point当作私钥; - 将未验证的外部字节直接构造成已验证公钥;
- 在 API 中让调用者传入任意挑战 \(c\);
- 在生产签名接口中传入可复用的固定 nonce。
实验四:门限等式模拟
这是进入下一章前的可选代数预演。只在玩具域或测试模块里验证求和关系,不建立可复用的 nonce/网络 API;真实曲线上的门限实现统一留到实验 4。
先在一个进程内模拟多个参与者:
- 用 Shamir 创建 \(x_i\);
- 为一个固定集合 \(S\) 计算 \(\lambda_i^S\);
- 分别产生 nonce 和签名份额;
- 聚合并用群公钥验证;
- 更换签名集合但故意复用旧拉格朗日系数,观察验证失败;
- 最后再进入 FROST 的双 nonce 与绑定因子设计。
4.13 常见误区
误区一:“\(R\) 公开了,所以 \(r\) 也不再重要”
错误。\(R=rG\) 可以公开,\(r\) 必须保密且只用一次。从 \(R\) 反求 \(r\) 是离散对数问题,但日志直接泄漏 \(r\) 就没有这层保护。
误区二:“只要随机数大概率不重复就够了”
不够。nonce 还必须不可预测、近似均匀,并能抵抗崩溃回滚、偏差和部分泄漏。
误区三:“不同私钥可以安全共用 nonce”
协议没有授权这种优化。跨密钥复用会产生额外代数关系,破坏安全证明,也容易演变为同密钥复用。每个签名会话都应使用按规范独立生成并绑定的 nonce。
误区四:“验证等式成立,就说明输入解析安全”
错误。非规范标量、无效点、单位元、小阶点和歧义编码可能在到达等式前就破坏安全边界。
误区五:“所有 Schnorr 签名都能互相验证”
错误。BIP-340、Ed25519 风格方案、Ristretto Schnorr 和不同 FROST 密码套件在群、编码、哈希、域分离和符号约定上可能不同。
误区六:“把每个人的普通 Schnorr 签名相加就是门限签名”
错误。参与者需要对同一个群承诺和挑战生成按 Shamir 权重调整的签名份额,还需要防止恶意承诺与 nonce 攻击。
误区七:“会话失败后 nonce 没产生签名,可以重新使用”
危险。一旦 nonce 承诺或相关信息被对外公开,就应按协议将其视为已消费。重试必须使用全新 nonce。
4.14 章节检查题
- Schnorr 中哪些量是标量,哪些量是群元素?
- 从 \(z=r+cx\) 推导验证等式。
- 挑战哈希为什么应包含 \(R\)、\(X\)、\(m\) 和域分离标签?
- 同一 nonce 签署两条消息时,攻击者如何恢复私钥?
- 为什么“确定性生成 nonce”并不等于可以自由设计 nonce 哈希公式?
- 普通 Schnorr 的哪项代数性质使它适合门限化?
- 朴素门限推导中的签名份额 \(z_i\) 是什么?为什么该推导还不是完整安全协议?
- FROST 中第二个 nonce 和绑定因子大致解决什么问题?
- 签名会话在发布 nonce 承诺后崩溃,恢复时可以重用它吗?
参考答案
- \(x,r,c,z\) 是 \(\mathbb Z_q\) 中的标量;\(G,X,R\) 是群元素;\(m\) 是字节串。
- \(zG=(r+cx)G=rG+c(xG)=R+cX\)。
- 它们分别把挑战绑定到本次承诺、签名公钥、消息和具体协议用途;遗漏字段可能允许重放、替换或跨协议混淆,并改变安全模型。
- 由两个响应相减,得到 \(x=(z_1-z_2)(c_1-c_2)^{-1}\pmod q\)。
- nonce 派生涉及域分离、编码、密钥派生、辅助随机性和故障模型;自创公式没有标准方案的分析与互操作保证。
- 响应 \(z=r+cx\) 对 nonce 和私钥都是线性的,因此各参与者的加权份额可以相加成普通响应。
- 朴素形式为 \(z_i=r_i+c\lambda_i^Sx_i\)。它没有完整抵抗恶意承诺选择、nonce 取消、重放等攻击,也缺少严谨状态机和份额验证。
- 双 nonce 经 \(\rho_i\) 组合后,把参与者的有效承诺绑定到本次 transcript,限制恶意参与者观察他人承诺后操纵聚合 nonce。
- 不应重用。承诺一经发布,nonce 通常必须视为已消费;恢复或重试使用新 nonce。
本章小结
Schnorr 的核心是:用一次性秘密 \(r\) 隐藏私钥响应,用哈希挑战把承诺、公钥与消息绑定,再通过线性群等式验证。线性结构为门限签名打开了大门,而 nonce 的脆弱性决定了 FROST 实现必须把会话状态、承诺绑定和一次性消费当作核心安全逻辑,而不是外围工程细节。
实验 03:Ristretto255 上的 Schnorr 签名
先把单方 Schnorr 写对,再做门限化。FROST 的聚合结果仍是一份 Schnorr 形态的签名。
目标
使用 curve25519-dalek 4 和 SHA-512 实现教学版 Schnorr:
\[ R=rB,\qquad c=H(R,X,m),\qquad z=r+cx \]
验证等式:
\[ zB=R+cX \]
完成后应能说明标量和曲线点的区别、挑战值为何绑定消息与公钥,以及随机 nonce 重用为何会泄露私钥。
前置知识
- 已完成实验 01,理解标量上的加、乘和求逆;
- 知道
B是固定基点、x是私钥标量、X = xB是公钥点; - 熟悉 Rust 所有权、字节切片和错误处理。
不要求自己实现椭圆曲线。曲线点运算、标量运算和压缩编码必须交给成熟库。
建议 API 与文件
建议文件:
src/transcript.rs:域分离和挑战哈希;src/schnorr.rs:密钥、签名、验证与编解码;tests/schnorr.rs:正反例。
依赖:
[dependencies]
curve25519-dalek = { version = "4", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
建议类型:
#![allow(unused)]
fn main() {
use curve25519_dalek::{
ristretto::RistrettoPoint,
scalar::Scalar,
traits::Identity,
};
pub struct SigningKey(pub(crate) Scalar);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VerifyingKey(RistrettoPoint);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Signature {
commitment: RistrettoPoint,
response: Scalar,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SchnorrTypeError {
IdentityPublicKey,
IdentityCommitment,
}
impl VerifyingKey {
pub fn try_from_point(point: RistrettoPoint) -> Result<Self, SchnorrTypeError> {
if point == RistrettoPoint::identity() {
return Err(SchnorrTypeError::IdentityPublicKey);
}
Ok(Self(point))
}
pub fn as_point(&self) -> &RistrettoPoint {
&self.0
}
}
impl Signature {
pub fn try_from_components(
commitment: RistrettoPoint,
response: Scalar,
) -> Result<Self, SchnorrTypeError> {
if commitment == RistrettoPoint::identity() {
return Err(SchnorrTypeError::IdentityCommitment);
}
Ok(Self { commitment, response })
}
pub fn commitment(&self) -> &RistrettoPoint {
&self.commitment
}
pub fn response(&self) -> Scalar {
self.response
}
}
}
不要为 SigningKey 派生 Clone、Copy 或 Debug。公钥和签名字段保持私有,只允许通过会检查规范编码和单位元的构造器/from_bytes 创建;可以另加只读访问器。否则调用者可绕过解析验证直接构造退化对象。实验早期可暂不做序列化,但内部构造也必须维护相同不变量。
分步任务
1. 区分 Scalar 与 RistrettoPoint
在代码旁写下每个量的类型:x、r、c、z 是标量,B、X、R 是曲线点。尝试把错误类型相加,观察编译器如何阻止一部分代数错误。
2. 构造无歧义的挑战哈希
可以从下面的可编译函数开始:
#![allow(unused)]
fn main() {
use curve25519_dalek::{ristretto::RistrettoPoint, scalar::Scalar};
use sha2::{Digest, Sha512};
pub fn challenge(
commitment: &RistrettoPoint,
public_key: &RistrettoPoint,
message: &[u8],
) -> Scalar {
let mut hash = Sha512::new();
hash.update(b"TSS-STUDY/SCHNORR-RISTRETTO255-SHA512/v1");
hash.update(commitment.compress().as_bytes());
hash.update(public_key.compress().as_bytes());
hash.update((message.len() as u64).to_le_bytes());
hash.update(message);
let wide: [u8; 64] = hash.finalize().into();
Scalar::from_bytes_mod_order_wide(&wide)
}
}
回答两个问题后再继续:
- 为什么消息前要写入长度,而固定 32 字节的压缩点不需要?
- 为什么不能只计算
H(message)?
这个 transcript 是本学习工程自定义的,不与任何外部 Schnorr 或 FROST 标准互操作。
3. 生成密钥
用 Scalar::random(&mut OsRng) 生成 x,计算 X = xB。为教学代码决定如何处理极小概率的零标量,并用测试固定这个行为。
建议方法:
#![allow(unused)]
fn main() {
impl SigningKey {
pub fn generate() -> Self {
todo!("从 OsRng 采样;按你的策略排除零标量")
}
pub fn verifying_key(&self) -> VerifyingKey {
todo!("计算 xB,并通过 VerifyingKey::try_from_point 维护非单位元不变量")
}
pub fn sign(&self, message: &[u8]) -> Signature {
todo!("采样一次性 nonce,计算 R、c、z")
}
}
pub fn verify(key: &VerifyingKey, message: &[u8], signature: &Signature) -> bool {
todo!("比较 zB 与 R+cX")
}
}
以上是可编译骨架。固定基点可从 curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT 导入。
4. 实现签名和验证
先逐行对照公式实现,不要急于抽象。签名时 challenge 必须调用与验证端完全相同的函数;域标签、字段顺序、编码或消息长度只要有一处不同,验证就应失败。
为测试可增加一个接收外部 RNG 的内部方法,但公开 API 不应允许调用者直接传入任意 nonce 标量。
5. 增加规范序列化
建议线格式为版本号加固定长度字段,例如:
version(1 byte) || compressed_R(32 bytes) || canonical_z(32 bytes)
解析时必须:
- 检查总长度和版本;
CompressedRistretto::decompress()失败就拒绝;- 公钥或签名承诺解压为群单位元时拒绝(可导入
curve25519_dalek::traits::Identity与RistrettoPoint::identity()比较); - 用
Scalar::from_canonical_bytes()拒绝非规范标量编码; - 不要把任意 32 字节用
from_bytes_mod_order静默约简成签名响应。
6. 跟踪一次完整签名
在仅测试构建中记录压缩后的 X、R、challenge c 和 z。不要打印私钥 x 或 nonce r。观察修改一个消息字节后,c 和验证结果如何变化。
验收测试
至少覆盖:
#![allow(unused)]
fn main() {
#[test]
fn sign_then_verify() {
let signing_key = SigningKey::generate();
let verifying_key = signing_key.verifying_key();
let signature = signing_key.sign(b"threshold signatures");
assert!(verify(&verifying_key, b"threshold signatures", &signature));
}
#[test]
fn rejects_changed_message_and_wrong_key() {
let alice = SigningKey::generate();
let bob = SigningKey::generate();
let signature = alice.sign(b"pay 10");
assert!(!verify(&alice.verifying_key(), b"pay 100", &signature));
assert!(!verify(&bob.verifying_key(), b"pay 10", &signature));
}
}
还需自行编写:
- 修改
R后失败; - 修改
z后失败; - 空消息可被一致签名和验证;
- 序列化往返保持相同签名;
- 截断、附加字节、非法版本、不可解压点、非规范标量全部被拒绝;
- 单位元公钥和单位元
R在计算 challenge 前被拒绝; - 演示若错误接受单位元公钥,攻击者任选
z并令R = zB就能伪造,从而证明该负例的必要性; - 同一消息正常签两次时,随机
R通常不同但都能验证。
不要写“两个随机签名一定不同”的数学断言;说明这是概率事件,并讨论测试为什么仍实用。
观察记录
| 问题 | 你的记录 |
|---|---|
| 签名中哪两个值需要公开? | |
验证者为什么不需要知道 r 或 x? | |
| 把公钥从 challenge 中移除会失去什么绑定? | |
| 改一个消息字节后,哪一侧等式首先不再匹配? | |
| 解析阶段和代数验证阶段各拒绝哪些输入? |
进阶挑战
- 增加上下文字符串
context,使用长度前缀编码,并测试不同上下文的签名不能互用。 - 给内部签名函数注入确定性测试 RNG,生成稳定测试向量;确保生产 API 仍使用
OsRng。 - 实现批量验证的实验版本,并列出它额外依赖的随机性和失败定位问题。
- 阅读 RFC 9591 中 FROST ciphersuite 的 transcript 规则,对比本实验自定义 Schnorr 编码,暂时不要声称兼容。
安全警告
- nonce 必须不可预测且永不重用。相同
r对不同 challenge 签名会直接泄露x;实验 05 会亲自复现。 - nonce 还必须非零。若
R是单位元,则z = c*x会在c != 0时直接暴露私钥;不能只依赖“随机取到零的概率很小”。 - 不要打印、序列化或持久化原始私钥和 nonce;Rust 的普通析构也不等于可靠清零。
curve25519-dalek负责曲线算术,不代表外围协议自动安全。域分离、编码、状态机和随机数同样属于安全边界。- 本章协议是教学格式,不是 RFC 9591、Ed25519、Ristretto Schnorr 或任何钱包协议的兼容实现,禁止用于真实资产。
第 5 章:从 Schnorr 到门限 Schnorr
学习定位:前置为 Shamir 与普通 Schnorr。本章是纸面/玩具域桥梁,不产生正式协议 API;读完第 6 章后统一进入实验 4。
前面的秘密共享解决了“一个秘密如何由多人共同持有”,本章把它接到 Schnorr 签名上:任何达到门限的参与者集合都能生成一枚普通 Schnorr 签名,但任何少于门限的集合都不能恢复私钥或独立签名。
本章先实现一个带可信 Dealer 的教学协议。它能把代数关系讲清楚,却还不是生产级协议;下一章会用 FROST 补上绑定因子、双 nonce、明确的签名包和更完整的恶意参与者防护。
安全边界:本手册中的实现用于学习和测试,不能保管真实资产或生产密钥。
学习目标
完成本章后,你应该能够:
- 从普通 Schnorr 的验证等式推导门限签名份额公式;
- 解释 Shamir 份额为什么要在曲线的标量域中计算;
- 为任意签名者集合计算拉格朗日系数;
- 验证单个签名份额,再聚合为标准 Schnorr 签名;
- 说明这个教学协议与完整 FROST 之间的差距;
- 在 Rust 中实现
2-of-3本地模拟并测试失败路径。
5.1 统一记号与安全假设
设:
- \(G\) 是素数阶群的生成元,群阶为 \(q\);
- 标量都属于 \(\mathbb F_q\),点都属于由 \(G\) 生成的群;
- \(x\) 是组私钥,\(X=xG\) 是组公钥;
- \(H\) 是带协议域分离的哈希到标量函数;
- \(n\) 是参与者总数,\(t\) 是门限;
- 每个参与者使用唯一、非零的标量标识 \(i\),而不是数组下标或可重复的昵称。
这里的“加法群”写法适用于 Ristretto255。代码中不要把整数模数、曲线标量和曲线点混为同一种类型。
本章暂时假设:
- Dealer 诚实地产生多项式和分发份额;
- 参与者已经拿到可信的组公钥和公开验证份额;
- 协议消息来自已认证的参与者;
- 随机数生成器安全,每个签名 nonce 只用一次;
- 所有人对消息、签名者集合、承诺顺序和哈希编码得到完全相同的字节。
这些假设看起来很多,正是后续 VSS、DKG 和安全工程要逐项解决的内容。
5.2 回顾普通 Schnorr
签名者持有私钥 \(x\),签名消息 \(m\):
-
随机选择一次性 nonce \(r\leftarrow\mathbb F_q\);
-
计算 nonce 承诺 \(R=rG\);
-
计算挑战:
\[ c=H(\text{suite},R,X,m) \]
-
计算响应:
\[ z=r+cx \pmod q \]
签名为 \((R,z)\)。验证者重新计算 \(c\),检查:
\[ zG\stackrel?=R+cX \]
正确性直接来自分配律:
\[ zG=(r+cx)G=rG+c(xG)=R+cX \]
门限化的目标不是拼接多枚签名,而是让多方共同算出同一个 \(z\),使最终产物仍然是一枚普通 Schnorr 签名。
5.3 用 Shamir 共享组私钥
可信 Dealer 选择一个次数至多为 \(t-1\) 的随机多项式:
\[ f(u)=a_0+a_1u+\cdots+a_{t-1}u^{t-1}\pmod q \]
其中 \(a_0=x\)。参与者 \(i\) 得到私密份额:
\[ x_i=f(i) \]
公开材料包括:
\[ X=xG=a_0G,\qquad X_i=x_iG \]
其中 \(X_i\) 称为参与者 \(i\) 的公开验证份额。知道 \(X_i\) 不等于知道 \(x_i\),其安全性依赖离散对数困难。
对于本次实际签名集合 \(S\),参与者 \(i\in S\) 在零点处的拉格朗日系数为:
\[ \lambda_i^S=\prod_{j\in S,j\ne i}\frac{0-j}{i-j}\pmod q \]
于是:
\[ x=f(0)=\sum_{i\in S}\lambda_i^Sx_i\pmod q \]
注意 \(\lambda_i^S\) 依赖的是本次实际签名集合。对集合 \(\{1,2\}\) 预先计算的系数,不能用于 \(\{1,3\}\)。
一个小标量域练习
只为练习模运算,设 \(q=19\)、\(S=\{1,3\}\):
\[ \lambda_1=\frac{-3}{1-3}=\frac{3}{2}=3\cdot 10=11\pmod {19} \]
\[ \lambda_3=\frac{-1}{3-1}=-\frac{1}{2}=-10=9\pmod {19} \]
因为 \(11+9=20=1\pmod {19}\),常数多项式会被正确重构。这里的“除法”始终表示乘以模逆元,不能使用整数除法。
5.4 教学型门限 Schnorr 协议
假设协调者选定去重且排序规范化的签名者集合 \(S\),满足 \(|S|\ge t\)。每个签名者 \(i\) 持有 \(x_i\),所有人知道 \(X\) 和对应的 \(X_i\)。
阶段 A:nonce 承诺
每个 \(i\in S\):
- 生成新的随机标量 \(r_i\);
- 计算 \(R_i=r_iG\);
- 把 \(R_i\) 发送给协调者,私下保存 \(r_i\)。
协调者必须冻结两层数据:应用层签名请求绑定协议版本、会话 ID 和本地已知组公钥;本章的教学型密码学包只保留消息或消息摘要、规范签名者集合以及所有 \((i,R_i)\)。前一层用于让参与者确认自己正在参与同一轮协议,但不能随意塞进标准 Schnorr challenge。聚合 nonce 为:
\[ R=\sum_{i\in S}R_i \]
若目标是让最终结果由只接受 \((R,z,X,m)\) 的普通 Schnorr 验证器验证,所有人必须按该 Schnorr 密码套件计算:
\[ c=H(\text{domain},R,X,m) \]
签名者集合和各个承诺仍须在会话 transcript 中进行一致性检查,但它们不额外改变这个 challenge。若自行把 \(S\)、会话 ID 或承诺列表加入 \(c\),得到的就是一个不同的签名方案,不能再声称与目标 Schnorr 验证器兼容。完整 FROST 会用 binding factor 把消息、签名者和承诺列表绑定到每位签名者的有效 nonce,同时保持最终 Schnorr challenge 的规定格式。
阶段 B:签名份额
签名者 \(i\) 先重新验证应用上下文,再验证教学型密码学包并计算 \(\lambda_i^S\),然后产生:
\[ z_i=r_i+c\lambda_i^Sx_i\pmod q \]
协调者在聚合前验证每一份:
\[ z_iG\stackrel?=R_i+c\lambda_i^SX_i \]
只有全部所需份额都通过验证,才计算:
\[ z=\sum_{i\in S}z_i\pmod q \]
最终签名为 \((R,z)\)。正确性为:
\[ \begin{aligned} zG &=\sum_i(r_i+c\lambda_i^Sx_i)G\\ &=\sum_iR_i+c\left(\sum_i\lambda_i^Sx_i\right)G\\ &=R+cX \end{aligned} \]
协调者不需要、也不应该重构 \(x\)。
5.5 Rust 动手实践:2-of-3 本地模拟
本节是公式检查点,不是第二套正式协议实现。可以只在测试模块或一次性示例中完成,也可以完全用纸笔推导配合小域断言。不要把本章的单 nonce 类型做成项目公开 API;真实曲线、双 nonce、规范 transcript 和最终验收统一放在实验 4。
第一版把网络替换为函数调用,但仍应保留协议边界。建议先定义“不可混用”的领域类型:
#![allow(unused)]
fn main() {
struct ParticipantId(Scalar); // 构造时保证非零、唯一
struct SecretShare(Scalar); // Debug 时不得输出内部值
struct VerificationShare(Point);
struct NonceCommitment(Point);
struct SignatureShare(Scalar);
struct ToySigningPackage {
message: Vec<u8>,
signers: Vec<ParticipantId>,
commitments: Vec<(ParticipantId, NonceCommitment)>,
}
struct SigningRequest {
protocol_version: u16,
session_id: [u8; 32],
group_public_key: Point, // 必须与本地密钥包比对,不盲信请求值
package: ToySigningPackage,
}
}
这里的 Point 代表你为具体曲线点类型起的别名。ToySigningPackage 这个名字刻意提醒:它不是 RFC 9591 SigningPackage,也不应成为第二套公开协议 API。不要真的把秘密类型实现为可随意复制、序列化和打印的公开元组结构体;上面的代码只是展示边界。
按以下顺序实现:
lagrange_at_zero(i, signer_ids) -> Scalar;dealer_keygen(t, ids, rng),返回组公钥、私密份额和验证份额;commit_nonce(session_id, rng),返回私密 nonce 状态与公开承诺;sign_share(share, nonce_state, request),先验证 request context 再使用其 toy package;verify_share(verification_share, commitment, request, z_i);aggregate(&request.package, verified_shares);- 用普通 Schnorr 验证函数验证最终签名。
必做测试
2-of-3中集合 \(\{1,2\}\)、\(\{1,3\}\)、\(\{2,3\}\) 都能签名;- 同一消息由不同集合签出的签名都能由同一个组公钥验证;
- 单个参与者无法调用聚合成功;
- 修改消息、承诺、签名者 ID 或任一 \(z_i\) 后验证失败;
- 把 \(\{1,2\}\) 的拉格朗日系数用于 \(\{1,3\}\) 时失败;
- 重复 ID、零 ID、非规范点编码和非规范标量编码均被拒绝;
- 每个签名份额必须先验证,不能把“最终签名碰巧失败”当成定位手段。
攻击实验:重复 nonce 会泄露什么
如果同一参与者在两个不同签名包中重用了相同 \(r_i\),两次实际集合对应的系数分别为 \(\lambda_i\) 和 \(\lambda_i'\),则:
\[ z_i=r_i+c\lambda_i x_i,\qquad z_i'=r_i+c'\lambda_i' x_i \]
相减得到:
\[ x_i=\frac{z_i-z_i'}{c\lambda_i-c'\lambda_i'}\pmod q \]
只要分母非零就会泄露份额;两次 signer set 相同时,\(\lambda_i'=\lambda_i\),公式退化为 \((z_i-z_i')/[\lambda_i(c-c')]\)。
请在测试专用的小标量域代码里复现实验,并明确标为“攻击演示”。不要为了演示而给真实曲线实现增加可复用 nonce 的公开 API。
5.6 为什么还需要 FROST
本章协议展示了正确的核心等式,但它没有完整规定:
- 协调者如何生成一致且不可歧义的签名包;
- 恶意参与者能否根据别人承诺自适应选择自己的 nonce;
- 每个 nonce 如何绑定到消息、签名者集合和全部承诺;
- 两个预处理 nonce 的作用和正确组合方式;
- nonce 在并发、重试和崩溃恢复时如何保证只用一次;
- 标准化的编码、域分离和密码套件。
FROST 给这些问题提供了经过分析的两轮结构。学习顺序上,本章是代数桥梁;工程实现上,不应把本章的简化协议包装成生产接口。
常见错误
- 在整数域中算份额。 Shamir 多项式和拉格朗日系数必须位于曲线标量域 \(\mathbb F_q\)。
- 按参与者数量写死系数。 系数依赖参与者 ID 和本次集合,不只依赖“这是两个人”。
- 使用数组下标作为 ID。 下标变化会改变份额语义;ID 应是密钥包的一部分。
- 直接相加私钥份额。 Shamir 份额通常要乘拉格朗日系数,只有特殊构造才可以直接相加。
- 跳过签名份额验证。 一个错误或恶意份额会污染最终签名,而且难以定位责任方。
- 让协调者替签名者计算 transcript。 每位签名者必须独立验证并重算关键派生值。
- nonce 重用或复制。 这会直接泄露长期密钥份额;“测试环境”也应默认禁止。
- 把教学协议称为 FROST。 FROST 的双 nonce 和绑定因子不是可选装饰。
本章检查题
- 为什么最终验证等式中出现的是组公钥 \(X\),而不是所有验证份额 \(X_i\) 的直接和?
- 对 \(S=\{2,5\}\),写出两名参与者在零点的拉格朗日系数表达式。
- 为什么 \(z_iG=R_i+c\lambda_iX_i\) 能在不知道 \(x_i\) 的情况下验证签名份额?
- 若协调者把某位签名者看到的集合从 \(\{1,2\}\) 换成 \(\{1,3\}\),会影响哪些量?
- 重用 nonce 时,攻击者还需要知道哪些公开量才能解出 \(x_i\)?
- 为什么达到门限后也不应在内存中重构完整私钥?
- 本章协议有哪些安全假设会在 FROST、VSS/DKG 和安全工程章节中被逐步移除或明确化?
完成标准
当你能让所有 2-of-3 子集生成可验证签名,能在聚合前拒绝坏份额,并能用公式解释每一项从哪里来时,就完成了这一章。下一章不要复用本章 nonce API;为 FROST 单独设计一次性双 nonce 状态。
第 6 章:FROST 两轮门限 Schnorr 签名
学习定位:前置为第 5 章;配套完成实验 4:2-of-3 FROST。核心里程碑是三个合法签名子集都由同一个普通 Schnorr 验证器接受。
FROST(Flexible Round-Optimized Schnorr Threshold signatures)把门限 Schnorr 签名整理成一个可标准化、可验证签名份额、只需两轮在线交互的协议。本章聚焦签名阶段:密钥份额可以先由可信 Dealer 产生,下一章再替换为 VSS/DKG。
本章的公式采用加法群记号。实际编码、哈希函数和域分离标签必须完整选择并遵守同一个 FROST 密码套件;不要从不同资料或不同曲线的示例中拼装协议。
学习目标
完成本章后,你应该能够:
- 解释 hiding nonce、binding nonce 及其承诺各自扮演的角色;
- 描述 FROST 的第 1 轮承诺和第 2 轮签名份额流程;
- 从规范签名包独立派生 binding factor、组承诺和挑战;
- 正确计算拉格朗日系数与签名份额;
- 在聚合前验证每个签名份额;
- 识别恶意协调者、nonce 重用、签名包分叉和编码歧义等风险;
- 用 Rust 完成一个本地
2-of-3FROST 状态机及负向测试。
6.1 参与者、密钥包与签名包
设本次签名者集合为 \(S\),\(|S|\ge t\)。参与者 \(i\) 持有:
- 标量标识 \(i\),唯一且非零;RFC 9591 还要求它位于
[1, MAX_PARTICIPANTS]; - 长期签名份额 \(s_i\);
- 公开验证份额 \(Y_i=s_iG\);
- 共同组公钥 \(Y\)。
其中份额满足:
\[ Y=\left(\sum_{i\in S}\lambda_i^Ss_i\right)G =\sum_{i\in S}\lambda_i^SY_i \]
签名协调者负责收集消息,但不是可信密钥持有者。它可以帮助编排消息,却不应有能力伪造签名。恶意协调者仍然可以拒绝服务、漏发消息或尝试给不同签名者发送不同包,所以每位签名者必须自己验证整个签名包。
这里要区分两层数据:
- RFC 9591 的密码学签名包包含待签消息以及按标识符排序、无重复的 nonce 承诺列表;组公钥来自签名者已经验证的本地密钥包。
- 应用层认证 envelope 还应绑定协议/套件版本、组或密钥包标识、epoch 和唯一会话 ID,用于状态机、授权、防重放与归责。
应用层字段必须被认证并由签名者本地检查,但它们不会自动进入 RFC 9591 的 \(H_1\) 或 \(H_2\)。若 epoch、链 ID、金额或会话语义必须由最终普通 Schnorr 验证者确认,就应把它们编码进待签消息,而不是擅自修改 FROST 哈希输入。第 8 章会进一步区分系统 transcript 与标准 challenge。
6.2 为什么是两个 nonce
每个签名者 \(i\) 为一次签名生成两个相互独立、均匀随机的标量:
- hiding nonce:\(d_i\);
- binding nonce:\(e_i\)。
对应的公开承诺是:
\[ D_i=d_iG,\qquad E_i=e_iG \]
两个私密 nonce 都绝不能发送出去。公开的是曲线点 \(D_i,E_i\),不是标量 \(d_i,e_i\)。
名称不要按字面误解:并不是一个 nonce 公开、另一个 nonce 隐藏。两者都保密且一次性使用。协议随后为每位签名者计算一个 binding factor \(\rho_i\),将 \(E_i\) 混入有效 nonce:
\[ r_i^{\text{eff}}=d_i+\rho_i e_i \]
这样,每位签名者的有效 nonce 都绑定到本次消息和整组承诺,抵抗针对简单 nonce 聚合的已知攻击。双 nonce 和 binding factor 是 FROST 安全结构的一部分,不能为了“少算一次标量乘”而删除。
6.3 第 1 轮:生成并发布 nonce 承诺
签名者 \(i\) 执行:
- 按所选密码套件的 nonce 生成过程独立生成 \(d_i,e_i\);
- 计算 \(D_i=d_iG\)、\(E_i=e_iG\);
- 把私密状态与本次 nonce 的唯一 ID 持久化并原子地保留给一个会话;
- 向协调者发送
(participant_id, nonce_id, D_i, E_i); - 此后不允许把同一 nonce 对分配给另一个签名包。
第 1 轮承诺可以预计算,因为它不必依赖消息。但预计算只改变时间安排,不改变一次性约束:库存中的每一对 nonce 必须有唯一身份、持久状态和不可回滚的已用标记。
RFC 9591 的 nonce_generate(secret) 不是简单把随机字节直接解释为标量,而是计算 H3(random_bytes(32) || SerializeScalar(secret));commit 独立调用它两次。把新鲜随机数与秘密份额一起输入域分离哈希,能在随机源质量下降时提供额外防护,但不能挽救随机字节重复或状态回滚。实现 RFC 套件时应调用这一规定过程,而不是自行替换为“看起来等价”的 RNG API。
协调者收齐至少 \(t\) 份承诺后,选择最终集合 \(S\),按规范顺序形成不可再变的签名包。签名者收到第 2 轮请求时必须确认:
- 自己确实属于 \(S\);
- 包内自己的 \((D_i,E_i)\) 与本地预留记录逐字节一致;
- 所有 ID 唯一、非零,承诺点编码有效;
- 会话、组 ID、epoch、消息和套件符合请求;
- 签名包没有被本地状态接受过一个不同版本。
6.4 从签名包派生 binding factor
先对签名包中的全部承诺构造规范编码,例如记为:
\[ B=\operatorname{EncodeCommitmentList} ((i,D_i,E_i)_{i\in S}) \]
抽象地说,每位签名者的 binding factor 必须依赖组公钥、消息、完整承诺列表和该签名者 ID。RFC 9591 具体定义:
\[ \begin{aligned} m_h&=H_4(m)\\ B_h&=H_5(B)\\ \rho_i&=H_1( \operatorname{Encode}(Y)\parallel m_h\parallel B_h\parallel \operatorname{EncodeScalar}(i)) \in\mathbb F_q \end{aligned} \]
其中 \(B\) 按参与者标识升序编码每个 (identifier, D_i, E_i)。\(H_1,H_4,H_5\) 都由密码套件规定并进行域分离。其他 FROST 变体可能采用不同编码;实现时必须整体选择一份规范,不能只借用公式的一部分。
关键性质是:
- \(\rho_i\) 依赖签名者 \(i\);
- \(\rho_i\) 依赖消息;
- \(\rho_i\) 依赖本次全部有序承诺;
- 每个人从相同签名包得到完全相同的 \(\rho_i\)。
组 nonce 承诺为:
\[ R=\sum_{i\in S}(D_i+\rho_iE_i) \]
即使每个输入承诺都不是单位元,它们仍可能相消得到 \(R=\mathcal O\)。RFC 9591 的 SerializeElement 拒绝单位元,因此实现必须在计算挑战前检查并中止,且永久烧毁本轮全部 nonce。绝不能继续对单位元编码计算 \(H_2\):此时最终响应会退化为 \(z=cx\),当 \(c\ne0\) 时公开签名直接泄露组私钥。
随后计算 Schnorr 挑战:
\[ c=H_2( \operatorname{Encode}(R)\parallel \operatorname{Encode}(Y)\parallel m) \in\mathbb F_q \]
这与 binding factor 是两个不同的派生步骤:\(\rho_i\) 使用 \(H_1\) 并绑定完整承诺列表;挑战 \(c\) 使用 \(H_2\),保持最终 Schnorr 签名的验证格式。实现时应调用密码套件的明确函数,而不是散落在业务代码中的通用 hash()。
6.5 拉格朗日系数与签名份额
对本次集合 \(S\),参与者 \(i\) 的系数仍为:
\[ \lambda_i^S= \prod_{j\in S,j\ne i}\frac{0-j}{i-j}\pmod q \]
参与者在独立重算所有 \(\rho_j\)、\(R\)、\(c\) 和 \(\lambda_i^S\) 后,产生签名份额:
\[ z_i=d_i+\rho_i e_i+\lambda_i^Ss_i c\pmod q \]
注意运算顺序不影响域内结果,但每个输入的来源很重要:
- \(d_i,e_i\):本地一次性私密状态;
- \(\rho_i\):从完整签名包派生;
- \(\lambda_i^S\):从规范签名者集合派生;
- \(s_i\):本地长期密钥份额;
- \(c\):从组承诺、组公钥和消息派生。
在释放 \(z_i\) 之前,nonce 状态必须原子地转为已消耗。若在“已消耗”落盘后崩溃,宁可放弃本次签名,也不能在恢复后用相同 nonce 回答一个新请求。
6.6 签名份额验证与聚合
协调者根据公开材料验证每个签名份额:
\[ z_iG\stackrel?=D_i+\rho_iE_i+c\lambda_i^SY_i \]
推导很直接:
\[ \begin{aligned} z_iG &=(d_i+\rho_i e_i+\lambda_i^Ss_ic)G\\ &=D_i+\rho_iE_i+c\lambda_i^SY_i \end{aligned} \]
验证成功的份额才可进入聚合:
\[ z=\sum_{i\in S}z_i\pmod q \]
最终 Schnorr 签名为 \((R,z)\),普通验证者检查:
\[ zG\stackrel?=R+cY \]
因为:
\[ \begin{aligned} zG &=\sum_i(D_i+\rho_iE_i+c\lambda_i^SY_i)\\ &=R+c\sum_i\lambda_i^SY_i\\ &=R+cY \end{aligned} \]
签名份额验证带来可定位性:协调者能够指出哪一份不满足公开等式。但它不自动带来可用性;只要某个被选中的参与者在第 2 轮离线,本次会话通常就要中止并使用全新的 nonce 开始。
6.7 完整两轮流程
预置:每人持有 (i, s_i, Y_i),全体持有 Y
Round 1 — commitments
signer i:
d_i = nonce_generate(s_i)
e_i = nonce_generate(s_i)
D_i = d_i G
E_i = e_i G
reserve nonce pair persistently
send (i, D_i, E_i)
coordinator:
choose final signer set S
build canonical SigningPackage(m, S, all commitments)
bind it to an authenticated envelope(session, group, epoch)
Round 2 — signature shares
every signer i:
validate the complete package
derive every rho_j and aggregate R
c = H_c(R, Y, m)
lambda_i = Lagrange(i, S)
atomically consume nonce pair
z_i = d_i + rho_i e_i + lambda_i s_i c
send z_i
coordinator:
recompute the same derived values
verify each z_i
z = sum(z_i)
verify final signature (R, z)
output (R, z)
把第 1 轮承诺存在数据库、把第 2 轮通过 HTTP 传输,并不会增加新的密码学轮次;但工程状态机会更复杂。
6.8 Rust 动手实践
本章定义协议结构和安全检查;实验 4是唯一的真实曲线实现入口。以下任务是实验 4 的设计清单,不要求再维护一套与实验文件平行的实现。
任务一:让类型表达两类 nonce
不要用 (Scalar, Scalar) 在整个项目里裸传。至少区分:
#![allow(unused)]
fn main() {
struct HidingNonce(SecretScalar);
struct BindingNonce(SecretScalar);
struct NoncePair {
id: NonceId,
hiding: HidingNonce,
binding: BindingNonce,
}
struct CommitmentPair {
hiding: GroupElement,
binding: GroupElement,
}
}
秘密类型不应实现 Copy,调试输出应隐藏内部值,销毁时应尽可能清零。是否真正做到内存清零还取决于编译器、库类型和硬件,不能把 zeroize 当作绝对保证。
任务二:实现纯函数派生层
实现并单元测试:
#![allow(unused)]
fn main() {
fn binding_factor(
group_public_key: &GroupPublicKey,
package: &SigningPackage,
participant: ParticipantId,
) -> Scalar;
fn group_commitment(
group_public_key: &GroupPublicKey,
package: &SigningPackage,
) -> GroupElement;
fn challenge(
package: &SigningPackage,
group_commitment: &GroupElement,
group_public_key: &GroupPublicKey,
) -> Scalar;
}
协调者内部若从 map 等无序集合建包,专用 builder 必须先按参与者 ID 排序;因此同一集合即使插入顺序不同,也生成相同的规范包。但接收线上或已建好的 SigningPackage 时,验证器必须拒绝乱序或重复列表,不能悄悄重排。修改任何签名者 ID、承诺或消息后,至少相应的 binding factor 和后续量必须改变。
任务三:实现受状态层包裹的签名计算
下面的函数只是 crate 内部的纯密码学 helper。它只能由已完成 envelope/session/group/epoch 检查的状态层调用,不得作为绕过会话验证的公共 API:
#![allow(unused)]
fn main() {
fn compute_signature_share(
key_package: &KeyPackage,
nonce: &ReservedNoncePair,
package: &SigningPackage,
) -> Result<SignatureShare, SignError>;
}
状态层保有 ReservedNoncePair 的唯一所有权:计算失败时原子写入 Burned;成功时先持久化 ConsumedAndResponsePersisted(含精确响应),再向外释放 share。仅靠 Rust move 语义仍不足以处理进程重启、数据库回滚或多个设备同时使用一份备份;实验 4 给出统一入口,第 8 章和综合项目再完成崩溃恢复。
任务四:端到端与恶意输入测试
至少覆盖:
- 三个不同的
2-of-3集合都能产生普通验证器接受的签名; - 改动一字节消息后最终签名失败;
- 替换某位参与者的 \(D_i\) 或 \(E_i\),其签名者拒绝包;
- 以不同 map 插入顺序调用内部 builder 时生成相同的规范包;在线上或已建好的包中调换承诺列表则被拒绝;
- 重复或零参与者 ID 被拒绝;
- 伪造 \(z_i\) 被份额验证准确定位;
- 少于门限的承诺不能进入第 2 轮;
- 任一输入承诺或聚合后的组承诺为单位元时中止并烧毁 nonce;
- 已消费 nonce 无法第二次调用
sign_share; - 同一 nonce ID 搭配不同签名包时失败;
- 包中自己的承诺与本地记录不一致时失败。
任务五:差分验证
教学实现稳定后,选择一个成熟 FROST 实现或官方测试向量作差分验证:固定随机输入或直接使用向量,对比承诺、binding factor、组承诺、挑战、签名份额及最终签名。若结果不同,先检查密码套件、编码、哈希域分离和消息预处理,而不是修改公式“凑出相同结果”。
6.9 协调者不是可信方
安全实现应假设协调者可能:
- 给不同签名者发送不同的签名包;
- 替换、删除或重排承诺;
- 谎报组公钥、消息或 signer set;
- 收到足够份额后拒绝公布签名;
- 重放旧的第 2 轮请求。
签名者通过本地验证、规范 transcript、会话状态和 nonce 一次性规则防止被诱导泄露份额。协议通常无法阻止协调者拒绝服务或扣留最终签名;这属于可用性与系统治理问题,而非签名不可伪造性。
常见错误
- 只生成一个 nonce。 这已经不是本章描述的 FROST 结构。
- 把私密 nonce 发给协调者。 只能发送 \(D_i,E_i\) 两个点。
- binding factor 只哈希自己的承诺。 它必须绑定消息及完整承诺列表,并区分参与者。
- 每个人使用不同的承诺顺序。 transcript 字节不同会导致 \(\rho_i\)、\(R\)、\(c\) 全部不同。
- 协调者计算好 \(\rho_i\) 发给签名者。 签名者必须从完整包独立重算。
- 先发送 \(z_i\),再标记 nonce 已用。 崩溃窗口可能让 nonce 在恢复后再次使用。
- 第 2 轮有人掉线就复用剩余人的 nonce。 新集合意味着新的签名包,应全部换新 nonce。
- 跳过份额验证直接求和。 最终失败时既浪费会话,也无法可靠归因。
- 混用不同 FROST 套件。 曲线相同也不代表哈希、编码和域分离相同。
- 认为两轮等于两条网络消息。 每轮可包含多方并发消息,轮次描述的是依赖关系。
- 聚合出单位元 \(R\) 后仍继续。 这不仅是无效编码;继续生成响应可能直接泄露组私钥。
本章检查题
- hiding nonce 和 binding nonce 哪些内容公开,哪些内容必须保密?
- 为什么 \(\rho_i\) 必须依赖完整承诺列表和参与者 ID?
- 如果两名签名者对承诺列表顺序理解不同,最先出现分歧的派生量是什么?
- 写出签名份额验证等式,并逐项说明右侧数据来自哪里。
- 为什么协调者验证所有 \(z_i\) 后仍应再验证最终签名?
- 第 2 轮一名签名者离线后,为什么不能直接换入一个新签名者并保留其他 nonce?
- Rust 所有权能阻止同一进程内的许多误用,为什么仍不能替代持久化 nonce 台账?
- FROST 消除了可信 Dealer 吗?若没有,哪个阶段需要下一章的 DKG?
完成标准
当你的实现能严格走完两轮、对每个派生量有唯一编码、在聚合前验证份额,并在所有 nonce 重用与签名包篡改测试中失败关闭时,本章才算完成。此时你实现的是“带既有密钥份额的 FROST 签名阶段”;密钥份额如何在无可信 Dealer 的情况下生成,是下一章的主题。
实验 04:实现教学版 2-of-3 FROST
本章实现的是便于学习的 FROST 风格协议:Ristretto255、SHA-512、可信 Dealer、两轮签名。它帮助理解 RFC 9591,但 transcript、编码和状态机尚未声明与 RFC 兼容。
目标
让三个参与者持有 Shamir 私钥份额,任意两人不重构完整私钥就能生成一份普通 Schnorr 签名:
- Dealer 生成秘密份额和公开验证份额;
- 第一轮为每位签名者生成一对 nonce 与对应承诺;
- 第二轮把消息、签名者集合和全部承诺绑定到同一个签名包;
- 每个签名者生成一个签名份额;
- 协调者先验证份额,再聚合最终签名;
- 最终结果由实验 03 的普通 Schnorr 验证器验证。
前置知识
- 已完成 Shamir 和单方 Schnorr;
- 能解释实际签名集合对应的拉格朗日权重;
- 理解 nonce 是一次性秘密,而 nonce commitment 是可公开曲线点;
- 知道协调者负责传递消息,但不应被默认为可信。
建议 API 与文件
建议按协议边界拆分,而不是写成一个长函数:
src/frost/mod.rs
src/frost/types.rs # ID、份额、承诺、签名包
src/frost/dealer.rs # 仅教学阶段使用的可信 Dealer
src/frost/round1.rs # nonce 生成与公开承诺
src/frost/round2.rs # 签名份额与份额验证
src/frost/aggregate.rs # 聚合及最终验证
src/transcript.rs # 长度前缀、域标签、rho 与 challenge
tests/frost_two_of_three.rs
可从这些可编译类型骨架开始:
#![allow(unused)]
fn main() {
use crate::schnorr::{SchnorrTypeError, VerifyingKey};
use curve25519_dalek::{
ristretto::RistrettoPoint,
scalar::Scalar,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Identifier(u16);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IdentifierError {
Zero,
OutOfRange,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FrostError {
InvalidParticipantSet,
NotEnoughSigners,
CommitmentMismatch,
SessionMismatch,
NonceAlreadyConsumed,
StateConflict,
StorageFailure,
IdentityGroupCommitment,
InvalidSignatureShare,
InvalidEncoding,
InputTooLarge,
}
impl Identifier {
pub fn new(raw: u16, max_participants: u16) -> Result<Self, IdentifierError> {
if raw == 0 {
return Err(IdentifierError::Zero);
}
if raw > max_participants {
return Err(IdentifierError::OutOfRange);
}
Ok(Self(raw))
}
pub fn get(self) -> u16 {
self.0
}
}
/// 已验证且不可能表示群单位元的组公钥。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GroupPublicKey {
verifying_key: VerifyingKey,
}
impl GroupPublicKey {
pub fn try_from_point(point: RistrettoPoint) -> Result<Self, SchnorrTypeError> {
let verifying_key = VerifyingKey::try_from_point(point)?;
Ok(Self { verifying_key })
}
pub fn as_point(&self) -> &RistrettoPoint {
self.verifying_key.as_point()
}
pub fn as_verifying_key(&self) -> &VerifyingKey {
&self.verifying_key
}
}
pub struct SecretShare {
pub(crate) id: Identifier,
pub(crate) value: Scalar,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VerificationShare {
pub(crate) id: Identifier,
pub(crate) point: RistrettoPoint,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ciphersuite {
Ristretto255Sha512,
}
/// Dealer/DKG 输出的本地密钥包;长期秘密份额与组上下文不可分离。
pub struct KeyPackage {
pub(crate) ciphersuite: Ciphersuite,
pub(crate) group_id: [u8; 32],
pub(crate) epoch: u64,
/// 严格按 Identifier 递增的完整成员列表。
pub(crate) roster: Vec<Identifier>,
pub(crate) threshold: usize,
pub(crate) secret_share: SecretShare,
pub(crate) verification_share: VerificationShare,
pub(crate) group_public_key: GroupPublicKey,
}
impl KeyPackage {
pub fn ciphersuite(&self) -> Ciphersuite {
self.ciphersuite
}
pub fn group_id(&self) -> &[u8; 32] {
&self.group_id
}
pub fn epoch(&self) -> u64 {
self.epoch
}
pub fn roster(&self) -> &[Identifier] {
&self.roster
}
pub fn threshold(&self) -> usize {
self.threshold
}
pub fn group_public_key(&self) -> &GroupPublicKey {
&self.group_public_key
}
}
/// 第一轮的私密状态:刻意不实现 Clone、Copy、Debug。
pub struct SigningNonces {
pub(crate) hiding: Scalar,
pub(crate) binding: Scalar,
}
/// 持久 nonce 记录的不可猜测公开标识。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NonceId([u8; 32]);
/// nonce store 已持久化地绑定到某会话后返回的移动型句柄。
/// 不实现 Clone、Copy 或 Debug,也不提供公开构造器。
pub struct ReservedSigningNonces {
pub(crate) nonce_id: NonceId,
/// 用于 compare-and-swap,防止并发请求消费同一记录。
pub(crate) state_version: u64,
pub(crate) session_id: [u8; 32],
pub(crate) nonces: SigningNonces,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SigningCommitments {
pub(crate) hiding: RistrettoPoint,
pub(crate) binding: RistrettoPoint,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SignatureShare {
pub(crate) id: Identifier,
pub(crate) response: Scalar,
}
impl SignatureShare {
pub fn identifier(&self) -> Identifier {
self.id
}
pub fn response(&self) -> Scalar {
self.response
}
}
pub struct SigningPackage {
pub(crate) message: Vec<u8>,
/// 必须严格按 Identifier 升序,且不得重复。
pub(crate) commitments: Vec<(Identifier, SigningCommitments)>,
}
/// 应用层会话字段与密码学签名包分层;由认证边界在验证 MAC/签名后构造。
pub struct AuthenticatedSigningRequest {
pub(crate) protocol_version: u16,
pub(crate) ciphersuite: Ciphersuite,
pub(crate) session_id: [u8; 32],
pub(crate) group_id: [u8; 32],
pub(crate) epoch: u64,
pub(crate) package: SigningPackage,
}
impl AuthenticatedSigningRequest {
pub fn protocol_version(&self) -> u16 {
self.protocol_version
}
pub fn ciphersuite(&self) -> Ciphersuite {
self.ciphersuite
}
pub fn session_id(&self) -> [u8; 32] {
self.session_id
}
pub fn group_id(&self) -> &[u8; 32] {
&self.group_id
}
pub fn epoch(&self) -> u64 {
self.epoch
}
pub fn package(&self) -> &SigningPackage {
&self.package
}
}
/// 实验 04 可先用内存实现;实验 05/06 再换成能模拟崩溃的持久实现。
pub trait NonceStore {
fn reserve(
&mut self,
session_id: [u8; 32],
nonces: SigningNonces,
) -> Result<ReservedSigningNonces, FrostError>;
fn persist_consumed_with_response(
&mut self,
reservation: &ReservedSigningNonces,
share: &SignatureShare,
) -> Result<(), FrostError>;
fn persist_burned(
&mut self,
reservation: &ReservedSigningNonces,
) -> Result<(), FrostError>;
}
impl SigningPackage {
pub fn signers(&self) -> impl ExactSizeIterator<Item = Identifier> + '_ {
self.commitments.iter().map(|(id, _)| *id)
}
}
}
秘密类型不要派生 Debug。上面多数 pub(crate) 字段只是为了让 frost 的教学模块能够拆文件;它们不会向 crate 外暴露。对外 API 只接受经过验证的构造器结果,并通过只读访问器取值。GroupPublicKey 连 crate 内字段也保持私有,任何来源的点(包括反序列化结果)都必须经过 GroupPublicKey::try_from_point;它内部复用实验 03 的 VerifyingKey 验证,因此裸的单位元不能进入第二轮签名 API。若进一步收紧实现,可让其他类型的字段也完全私有,把验证集中在 types.rs。
SigningPackage::new 必须从已验证 KeyPackage 取得 roster 和 threshold,检查承诺列表已经按 Identifier 严格递增,所以乱序和重复 ID 都会失败;同时检查 ID 属于 roster、签名者数量达到门限,并拒绝每个 SigningCommitments 中作为签名元素的单位元 D_i 或 E_i。不要在构造器内部悄悄排序,因为签名者应能发现协调者给出的非规范输入。若目标是 RFC 9591 兼容,整数标识必须位于 [1, MAX_PARTICIPANTS];本实验的三人 roster 因而只接受 1..=3。字节输入还要先经过规范解码,再交给这些构造器。
AuthenticatedSigningRequest 是应用层 envelope,它把 session、group 和 epoch 与一个已验证的 SigningPackage 绑定。这三个字段不是 SigningPackage 的一部分。binding_factor(&GroupPublicKey, &SigningPackage, ...) 会显式读取 RFC 所需的组公钥,但它和 Schnorr challenge 都无法意外读取 session、group ID 或 epoch。
协议地图
设 Dealer 的 Shamir 多项式为 f,群私钥是 f(0),群公钥为:
\[ Y=f(0)B \]
参与者 i 持有秘密份额 s_i=f(i),公开验证份额为 Y_i=s_iB。
第一轮中,参与者生成两个独立 nonce d_i、e_i,只公开:
\[ D_i=d_iB,\qquad E_i=e_iB \]
第二轮从规范签名包计算每个参与者的绑定因子 rho_i,进而得到:
\[ R_i=D_i+\rho_i E_i,\qquad R=\sum_{i\in S}R_i \]
随后计算普通 Schnorr challenge c=H(R,Y,m)。签名份额形如:
\[ z_i=d_i+\rho_i e_i+\lambda_i s_i c \]
本章没有给出 transcript 字节格式或拉格朗日权重实现;它们正是需要你完成并交叉测试的部分。
在计算 c 前必须确认 R 不是群单位元。单个 D_i、E_i 都合法并不能保证聚合结果非单位元;若发生相消,本轮必须中止并烧毁所有 nonce。继续签名会得到 z = c*a0,在 c != 0 时直接泄露组私钥。
分步任务
1. 用可信 Dealer 建立 2-of-3 密钥
复用 Scalar 版 Shamir:采样非零 a0 作为组私钥,并从整个标量域均匀采样 a1,令 f(x)=a0+a1x。拒绝零 a0 是为了避免单位元群公钥;不要强制 a1 非零,因为标准 Shamir 的完美隐私证明依赖非常数系数在整个域(包括零)中均匀采样。a1=0 的概率为 1/q,在真实标量域中可忽略。为参与者 1、2、3 生成:
- 秘密份额
(i, f(i)); - 验证份额
(i, f(i)B); - 群公钥
GroupPublicKey::try_from_point(a0B)?。即使 Dealer 已经拒绝零a0,也不要跳过这个边界检查。
为每个参与者把这些值与 Ristretto255Sha512、严格有序 roster、门限 2、group_id 和初始 epoch 一起封装为 KeyPackage。构造时检查 roster 非空、ID 合法且唯一、1 <= threshold <= roster.len(),以及私密份额/验证份额属于同一参与者。
写一个仅用于测试的断言:任取两份恢复 a0 后得到的点等于 Y。断言通过后,不要让签名 API 暴露恢复函数。
2. 实现第一轮
每位本次参与签名的人独立采样两个新 nonce,并计算 D_i、E_i。建议先让 nonce 与承诺一起生成:
#![allow(unused)]
fn main() {
pub fn round1_commit() -> (SigningNonces, SigningCommitments) {
todo!("分别采样两个非零 Scalar,并计算对应基点乘法")
}
}
明确所有权:SigningNonces 留在参与者本地,协调者只能收到 SigningCommitments。承诺对外发布前,本地 nonce store 要用当前 session_id 原子预留这对 nonce,并返回 ReservedSigningNonces;第 2 轮公开 API 只接受这个句柄,不接受裸 SigningNonces。
3. 建立规范 SigningPackage
协调者收集达到门限的承诺后,构造签名包。参与者收到包时必须自行验证:
- 认证 envelope 的协议版本受支持、ciphersuite 与本地密钥包一致,
session_id长度固定且属于当前会话,group_id和epoch也必须匹配; - 消息与用户批准的消息逐字节相同;
- ID 严格递增、无重复、数量达到门限;
- 包含自己的 ID,且自己的承诺未被替换;
- 所有点编码可规范解码,必要时拒绝单位元;
- 聚合后的
R也不是单位元;这个检查必须发生在 challenge 和签名份额生成之前。
签名者不能直接相信协调者传入的 rho_i、R 或 c,应从完整签名包重新计算。
4. 定义 binding factor transcript
为每个参与者计算下面这种与 RFC 9591 依赖关系一致、但编码和哈希细节仍属教学版的值:
rho_i = H_to_scalar(
rho_domain || group_public_key || hash_message(message)
|| hash_commitment_list(ordered_signer_ids_and_commitments)
|| signer_id
)
你需要明确:
- 每个可变长度字段如何加长度前缀;
- ID 使用何种端序和固定宽度;
- 承诺列表为何必须规范排序;
rho_i的域标签为何不能和 Schnorr challenge 共用。
session_id 仍由认证 envelope 和本地状态机绑定,用于防重放和阻止 nonce 被分配给另一个会话;它不进入 RFC 9591 的 binding factor 或 challenge。若最终验证者必须确认某个 session、epoch 或业务字段,请把它规范编码进 message。这一区分保证应用状态不会被误当成标准签名 transcript。
先写 transcript 的已知字节测试,再写哈希函数。不要用 Debug 输出或 JSON 序列化充当密码学 transcript。
5. 计算本次集合的拉格朗日权重
对实际签名集合 S,在 0 处为每个参与者计算 lambda_i。不要缓存“参与者 1 永远使用某个权重”;{1,2} 与 {1,3} 中参与者 1 的权重不同。
加一个小域交叉测试:F17 实现与 Scalar 实现对小 ID 应给出结构一致的结果。
6. 生成签名份额并消费 nonce
建议让方法取得 ReservedSigningNonces 的所有权,并同时掌握能原子更新该记录的 store:
#![allow(unused)]
fn main() {
pub fn round2_sign<S: NonceStore>(
nonce_store: &mut S,
reserved_nonces: ReservedSigningNonces,
key_package: &KeyPackage,
request: &AuthenticatedSigningRequest,
) -> Result<SignatureShare, FrostError> {
todo!("先检查上下文并计算 share;失败就 persist_burned,成功则持久化 consumed+响应后才返回")
}
}
这个公开入口必须先比较 reserved_nonces 和 request 的 session,并用 request 的 ciphersuite / group_id() / epoch() 确认它们对应本地 KeyPackage;失败时在进入份额代数前拒绝并原子写入 Burned。成功路径要以 nonce_id + state_version 做 compare-and-swap,将 ConsumedAndResponsePersisted(含精确响应)一次持久化,确认写入成功后才向调用者释放 SignatureShare。写盘结果不确定时不得返回 share,恢复时保守烧毁该 nonce。若内部拆出只接受 &SigningPackage 的纯密码学 helper,它必须保持私有或 pub(crate),不能成为可绕过会话检查的对外 API。
所有权只能降低意外复用概率,不能替代持久化状态。真实系统必须在发出签名份额之前将该 nonce 标记为已消费,避免崩溃回滚后重用。
7. 在聚合前验证每个份额
协调者对参与者 i 检查:
\[ z_iB\stackrel{?}=D_i+\rho_iE_i+c\lambda_iY_i \]
自行从前面的定义推导这个等式。验证失败时报告对应参与者 ID,并停止聚合;不要把错误份额混入总和后只返回模糊的“最终签名无效”。
8. 聚合并用普通验证器验收
所有份额有效后,计算 z = sum(z_i),最终签名为 (R,z)。它应直接通过实验 03 的 verify(key_package.group_public_key().as_verifying_key(), message, signature),不需要向最终验证者暴露门限、参与者或份额。若 R 是单位元,应返回 IdentityGroupCommitment;相关 nonce 已经发布了承诺,每位签名者都必须将自己的记录永久烧毁。
实现时把“求和”和“验证结果”分开:生产函数先用固定的真实 H_to_scalar 求出候选 R,再交给一个私有纯 helper(例如 validate_group_commitment(candidate))拒绝单位元,只有成功后才计算 challenge。这个边界让单位测试能直接把单位元交给验证 helper;若还要覆盖从承诺到退化 R 的完整控制流,只在 #[cfg(test)] 的内部入口注入 binding-factor 计算器,不要把可替换哈希暴露为生产 API。
下面是实现顺序的伪代码,不是可直接复制的 Rust:
# 签名者的认证/状态入口
request = authenticate_and_validate_envelope(
protocol_version, ciphersuite, session_id, group_id, epoch,
validate_canonical_signing_package(message, strictly_ordered_commitments)
)
share = round2_sign(nonce_store, reserved_nonces, key_package, request)
send_authenticated_share_envelope(
protocol_version, ciphersuite, session_id, group_id, epoch, share
)
# round2_sign 内部:上下文验证必须早于代数计算
validate_context(reserved_nonces, key_package, request)
package = request.package()
for signer in package.signers():
rho[signer] = binding_factor(key_package.group_public_key(), package, signer)
candidate_R = sum(D[signer] + rho[signer] * E[signer])
validated_R = validate_group_commitment(candidate_R)
if validated_R is IdentityGroupCommitment error:
nonce_store.persist_burned(&reserved_nonces)
return IdentityGroupCommitment
R = validated_R.value()
c = schnorr_challenge(R, key_package.group_public_key(), message)
local_share = compute_local_signature_share(&reserved_nonces, key_package, package, c)
nonce_store.persist_consumed_with_response(&reserved_nonces, &local_share)
return local_share # 持久化成功后才释放
# 协调者:先验证每个 share envelope 与冻结会话一致
package = frozen_request.package()
recompute rho, R, and c from coordinator_key_package + package
for received_share in received_shares:
validate_share_envelope_context(received_share, frozen_request)
verify_signature_share(received_share, package, c)
z = sum(share.response() for share in received_shares)
return Signature::try_from_components(R, z)
其中 request 决定本地状态机是否允许继续;binding_factor 与 schnorr_challenge 只读取目标 FROST/Schnorr 规范定义的字段,不把整个应用 envelope 直接哈希进去。上面的 persist_* 代表带 compare-and-swap 的原子状态转换,不是先改内存标志再异步写盘。
验收测试
必须覆盖三个签名集合:{1,2}、{1,3}、{2,3}。每组都应:
- 完成两轮;
- 每个签名份额通过独立验证;
- 聚合后通过普通 Schnorr 验证;
- 最终签名不携带参与者列表。
再加入以下反例:
- 只有一人时返回
NotEnoughSigners; - 重复 ID、ID 0、超出
1..=MAX_PARTICIPANTS、乱序或未知 ID 被拒绝; - 协调者替换消息或某个承诺后,参与者拒绝签名或份额验证失败;
- 直接把单位元传给“验证聚合组承诺”的纯 helper,确认它返回
IdentityGroupCommitment;再让计算组承诺的内部测试入口注入单位元结果,确认在 challenge 前中止且所有关联 nonce 都进入Burned; - 修改一个
z_i时,在聚合前准确定位该 ID; - 固定消息、组公钥、签名者集合和承诺,只改变
session_id:签名者收到错会话 request 时在份额生成前拒绝且 nonce 不改绑;协调者收到错会话 share envelope 时在份额验证前拒绝,且不读写签名者 nonce store; - 用集合
{1,2}的权重为{1,3}签名时失败; - 两次正常签名产生不同承诺,但都通过最终验证。
不要要求测试在真实 Ristretto255/SHA-512 transcript 下“方便地构造”一组非单位元承诺,使依赖该承诺列表的 rho_i 恰好让聚合 R 成为单位元。因为 binding factor 本身又哈希完整承诺列表,这会变成哈希不动点/原像式搜索,而不是实用的确定性测试向量。若要端到端演示这种退化,应使用前面的小型玩具群,或仅在内部测试中注入一个确定性的 H_to_scalar 替身;生产路径仍使用真实哈希,并无条件保留 R == identity 时“中止并烧毁 nonce”的检查。
同理,裸 SignatureShare 只包含参与者 ID 和响应值,并不编码 session_id,所以它单独不能证明会话隔离。跨会话反例验证的是认证 envelope、会话状态机和 nonce store 的绑定,而不是指望 FROST 份额等式辨认应用会话。
先用一个完全自包含的 compile_fail 文档测试确认 Rust 的 move 语义;标注 E0382,避免测试因为无关的未定义名称而“碰巧失败”:
struct SigningNonces;
fn consume_for_round2(_nonces: SigningNonces) {}
fn main() {
let nonces = SigningNonces;
consume_for_round2(nonces);
consume_for_round2(nonces); // use of moved value: `nonces`
}
真实 API 完成后,再用 trybuild 增加 UI 测试:导入项目中的 SigningNonces 和 round2_sign,准备一套合法输入,断言第二次调用得到 E0382 的 use of moved value 诊断。这样才能证明实际签名 API 保持了消费语义,而不仅是演示语言规则。
观察记录
| 问题 | 你的记录 |
|---|---|
{1,2} 和 {1,3} 中,参与者 1 的权重分别是什么? | |
| 双 nonce 中 binding factor 绑定了哪些公开信息? | |
| 协调者能看到哪些值,始终看不到哪些值? | |
| 为什么最终验证者不知道这是门限签名? | |
| 哪个检查能最早定位恶意或损坏的签名份额? | |
| Rust move 能防住哪些 nonce 重用,防不住哪些? |
进阶挑战
- 实现 Feldman VSS,让参与者在接受 Dealer 份额前验证它与系数承诺一致。
- 用 DKG 替换可信 Dealer,记录 complaint、剔除和最终参与者集合;不要直接跳到网络代码。
- 为每个参与者实现显式状态机:
Idle -> Committed -> ConsumedAndResponsePersisted/Burned,并测试崩溃恢复。 - 对照 RFC 9591 的 ristretto255-SHA512 ciphersuite,列出本实现要达到互操作还缺少的编码、哈希到标量、错误检查和测试向量。
- 将协调者视为恶意方,设计同一会话包摘要的参与者间比对机制。
安全警告
- nonce 对必须每次新鲜、保密且原子消费。重复、备份回滚、并发复制或日志泄露都可能暴露长期秘密份额。
- 可信 Dealer 在生成时见过完整私钥;它只适合教学。删除变量或离开作用域不保证内存已安全清零。
- 身份认证、可靠广播、会话唯一性、持久化、防重放和拒绝服务处理都未因公式正确而自动获得。
- 不要把“最终 Schnorr 验证通过”当成整个实现生产安全的证据。该实验没有经过审计,也未声明 RFC 9591 兼容,禁止用于真实资产。
第 7 章:VSS 与分布式密钥生成(DKG)
学习定位:前置为 Shamir 和 FROST;本章先做同步、内存内 DKG。读完第 8 章后,再在综合项目中加入乱序、重放、持久化和崩溃。
可信 Dealer 能方便地生成 Shamir 份额,却是明显的单点:它曾经知道完整私钥,也可能给不同参与者发送不一致份额。可验证秘密共享(VSS)让接收者验证份额确实来自某个已承诺的多项式;分布式密钥生成(DKG)再让每名参与者都贡献一个多项式,使最终私钥从未在任何单一位置出现。
本章先实现教学型 Feldman VSS,再把多个 VSS 实例组合成 DKG。它能建立正确的心智模型,但“投诉与仲裁”的具体安全性高度依赖所选 DKG 协议、网络模型和广播原语。生产实现必须选择并严格实现一份明确规范,不能把本章流程图当作完整安全证明。
学习目标
完成本章后,你应该能够:
- 解释普通 Shamir 分享为什么无法让接收者验证 Dealer;
- 推导 Feldman VSS 的份额验证等式;
- 区分 Feldman 的计算隐藏性与 Pedersen 承诺的隐藏目标;
- 描述 DKG 中每名参与者同时作为 Dealer 和接收者的过程;
- 计算最终密钥份额、公开验证份额与组公钥;
- 明确认证私密信道、一致广播、同步模型和腐败阈值等假设;
- 解释投诉、响应、资格集合和公开份额泄露之间的关系;
- 在 Rust 中实现可注入恶意行为的本地 DKG 模拟器。
7.1 Shamir 还缺少什么
一个 Dealer 选择次数至多为 \(t-1\) 的多项式:
\[ f(u)=a_0+a_1u+\cdots+a_{t-1}u^{t-1}\pmod q \]
并向参与者 \(i\) 私下发送:
\[ s_i=f(i) \]
普通 Shamir 提供恢复门限和信息论保密性,却不回答:
- Dealer 发来的 \(s_i\) 是否真的位于一个次数小于 \(t\) 的共同多项式上?
- Dealer 是否给不同人承诺了不同多项式?
- 某个接收者声称“份额无效”时,究竟是谁撒谎?
- Dealer 是否选择了不符合协议的常数项或退化多项式?
VSS 的目的,就是让份额具有可验证的一致性证据。
7.2 Feldman VSS
Dealer 为每个多项式系数发布曲线点承诺:
\[ A_k=a_kG,\qquad k=0,1,\ldots,t-1 \]
参与者 \(i\) 收到私密份额 \(s_i\) 后检查:
\[ s_iG\stackrel?=\sum_{k=0}^{t-1}i^kA_k \]
正确性来自:
\[ \begin{aligned} s_iG &=f(i)G\\ &=\left(\sum_{k=0}^{t-1}a_ki^k\right)G\\ &=\sum_{k=0}^{t-1}i^k(a_kG)\\ &=\sum_{k=0}^{t-1}i^kA_k \end{aligned} \]
其中 \(A_0=a_0G\) 是该 Dealer 的公开密钥贡献。
Feldman 能保证什么
- 一旦一组承诺被全体一致接收,Dealer 很难为同一个 ID 找到两个不同且都通过验证的份额;
- 每个接收者可以本地验证自己的份额;
- 后续可以从承诺计算公开验证份额,而不公开私密份额。
Feldman 不能单独保证什么
- 承诺一致广播。 恶意 Dealer 可能向不同人发送不同的 \(A_k\);需要认证的一致/可靠广播阻止分叉。
- 无条件隐藏。 \(A_k=a_kG\) 的隐藏性依赖离散对数困难,不是信息论隐藏承诺。
- 自动仲裁投诉。 等式只能判定给定份额与给定承诺是否匹配,不能证明私聊中最初发送了哪些字节。
- 可用性。 Dealer 或接收者仍可离线、拒绝发送或制造投诉。
Pedersen VSS 通常使用额外随机多项式和第二生成元 \(H\) 来获得隐藏承诺。在循环群中总存在 \(H=hG\),关键不是“代数独立”,而是任何参与者都不知道 \(h=\log_G H\);否则知道该关系的人可以为同一承诺构造等价开口,破坏绑定性。\(H\) 应按具体协议通过可验证的 hash-to-group 或规定的生成过程得到。本项目应先吃透 Feldman,再选定一份具体 DKG 规范决定是否引入 Pedersen;不要自行把两套公式拼接。
7.3 从多个 VSS 组合出 DKG
设有 \(n\) 个参与者,每个参与者 \(j\) 都充当一次 Dealer。
参与者 \(j\) 选择:
\[ f_j(u)=a_{j,0}+a_{j,1}u+\cdots+a_{j,t-1}u^{t-1} \]
它一致广播系数承诺:
\[ A_{j,k}=a_{j,k}G \]
并通过认证且保密的点对点信道向参与者 \(i\) 发送:
\[ s_{j\rightarrow i}=f_j(i) \]
接收者 \(i\) 对每个 Dealer \(j\) 验证:
\[ s_{j\rightarrow i}G \stackrel?= \sum_{k=0}^{t-1}i^kA_{j,k} \]
投诉和解决阶段结束后,全体诚实参与者必须得到同一个合格 Dealer 集合 \(Q\)。最终,参与者 \(i\) 的私密份额为:
\[ s_i=\sum_{j\in Q}s_{j\rightarrow i}\pmod q \]
组私钥在数学上等于:
\[ x=\sum_{j\in Q}a_{j,0}\pmod q \]
但协议中任何人都不应计算或记录这个 \(x\)。组公钥直接由公开承诺得到:
\[ Y=\sum_{j\in Q}A_{j,0} \]
参与者 \(i\) 的公开验证份额为:
\[ \begin{aligned} Y_i &=s_iG\\ &=\sum_{j\in Q}\sum_{k=0}^{t-1}i^kA_{j,k} \end{aligned} \]
这正是 FROST 签名阶段需要的 (group public key, signing share, verification share) 密钥包。
定稿前必须按目标协议验证所有公开元素,并至少确认组公钥 \(Y\ne\mathcal O\)。若 \(Y=\mathcal O\),普通 Schnorr 验证退化为 \(zG=R\),任何人都能任选 \(z\) 并令 \(R=zG\) 伪造签名;本次 DKG 必须失败关闭,并按所选 DKG 规范用新会话和新随机性重新开始。
不要把 FROST 签名元素与 VSS 系数承诺当成同一种解析类型:RFC 9591 的签名公钥、nonce commitment 和最终 \(R\) 按套件拒绝单位元;而 VSS/DKG 系数允许标量系数为零,对应的系数承诺可能是单位元。后者必须使用所选 VSS/DKG 协议自己的 CoefficientCommitment 类型和编码规则,在协议允许的位置接受单位元,同时仍严格拒绝非法编码。不能把一个层的 identity 规则机械套到另一个层。
从某个对手联盟的视角看,只要合格集合中至少有一个常数贡献对该联盟仍然不可预测且未泄露,它就不能计算最终组私钥。这不是“任何单方都必然不知道 \(x\)”:知识取决于该方掌握哪些 Dealer 贡献;若 \(Q\) 只剩一个 Dealer,该 Dealer 就知道完整 \(x\)。具体 DKG 必须用明确的腐败阈值、合格集合条件和安全证明说明哪些联盟不知道密钥。更强的抗偏置、终止和鲁棒性结论也不能只从“有一个诚实 Dealer”推出。
7.4 DKG 的阶段与不可跳过的屏障
一个教学状态机可以划分为:
- 登记阶段:冻结组 ID、epoch、参与者身份、公钥、标量 ID、\(n\)、\(t\)、密码套件;
- 承诺阶段:每个 Dealer 产生多项式并一致广播系数承诺;
- 私密分发阶段:每个 Dealer 向每个接收者发送加密且认证的份额;
- 验证阶段:接收者针对已冻结的承诺验证所有份额;
- 投诉阶段:广播结构化投诉,不允许只在私聊中说“有问题”;
- 解决阶段:按所选协议提交响应证据,确定虚假投诉或无效 Dealer;
- 资格阶段:用确定性规则冻结共同集合 \(Q\);
- 定稿阶段:各自计算 \(s_i,Y_i,Y\),删除临时 Dealer 多项式和收到的分量份额;
- 确认阶段:证明各方得到同一组公钥和 roster/epoch,再允许进入签名。
每一阶段都应绑定前一阶段 transcript 的摘要。不能在一部分人仍收承诺时,让另一部分人先公布投诉;否则恶意方可能根据后来信息自适应改变自己的输入。
7.5 投诉与解决:最容易被低估的部分
若参与者 \(i\) 发现来自 Dealer \(j\) 的份额无效或未收到份额,应发布包含以下上下文的投诉:
- DKG 会话 ID、组 ID 和 epoch;
- Dealer 与接收者身份;
- 被引用的承诺集合摘要;
- 投诉类型:缺失、解码失败、认证失败或 VSS 等式失败;
- 所选规范要求的证据。
之后如何解决,必须由具体协议规定。一个常见的教学流程是 Dealer 对争议份额作认证广播,所有人按已冻结承诺验证:
- 若公开份额不满足等式,Dealer 被剔除;
- 若份额满足等式,则投诉者可能提交了虚假投诉;
- 若 Dealer 不按时响应,则按规则剔除。
但这里有严重的泄露预算:公开一个 \(s_{j\rightarrow i}\) 就公开了 Dealer 多项式上的一个点;攻击者累计知道达到 \(t\) 个不同点,即可重构该 Dealer 的常数贡献。预算不能只数公开投诉:若攻击者已经控制 \(f<t\) 个接收者并持有它们的私密份额,再公开 \(t-f\) 个新的不同份额就已足够。历史投诉、重试和日志中的点也必须累计计算。不同 DKG 协议会通过投诉上限、Dealer 剔除、加密证据、可验证加密或不同的分享结构处理这个问题。实现者不能简单规定“所有争议份额都上链/广播”,更不能无限重试同一多项式。
同样,采用“只要有人投诉就剔除 Dealer”的简化规则,会让恶意接收者任意排除诚实贡献,可能影响活性和输出偏置。教学模拟可以实现这种模式来观察问题,但必须命名为不安全策略,不能作为正式 DKG。
投诉流程必须解决三个目标之间的张力:
- 判断 Dealer 与接收者谁违反协议;
- 让所有诚实参与者得到相同的 \(Q\);
- 不因公开过多份额破坏秘密性。
7.6 必须明确写进设计的假设
身份和信道
- 参与者 roster 在会话开始前已认证并冻结;
- 广播消息具备来源认证、完整性与一致性,不能只靠普通群聊;
- 私密份额通过同时具备机密性和认证性的点对点信道发送;
- 每条消息绑定 session、epoch、sender、receiver、round 和 transcript 摘要。
网络模型
协议是同步、部分同步还是异步,会改变超时、投诉和终止规则。“十秒未收到就剔除”只是同步假设下的策略;网络分区时可能让诚实节点得到不同资格集合。
对手模型
要区分:
- 签名门限 \(t\);
- 参与者总数 \(n\);
- 最多恶意或被攻破的参与者数 \(f\);
- 对手是静态腐败还是可以在运行中自适应腐败;
- 对手能否读取崩溃后的磁盘或历史备份。
Shamir 层面少于 \(t\) 份不能恢复秘密,不代表任意 DKG 都能在 \(f<t\) 时保持一致和终止。不同同步模型与 DKG 协议可能要求 \(n>2f\)、\(n>3f\) 或其他条件。选择实现规范时必须原样记录它的限制,不要从本章推导一个通用比例。
密码学假设
- 所选群中的离散对数问题困难;
- 点和标量解码严格且规范;
- 随机数生成器安全,参与者之间的随机贡献独立;
- 哈希及域分离满足所选协议的模型;
- 临时多项式系数和收到的分量份额能在定稿后安全删除。
7.7 Rust 动手实践:先做可观察模拟器
本章只要求单进程、同步、内存内、确定性事件队列。参与者仍各自持有独立状态,但不实现真实 socket、TLS、数据库或超时器。这样可以先验证 VSS、投诉和共同资格集合的协议逻辑。
乱序/重放防护、持久化、并发与崩溃恢复依赖第 8 章的工程模型,应在读完第 8 章后放入实验 5 和综合项目验收;不要在尚未定义 envelope 与状态机时自行发明网络行为。
任务一:实现 Feldman VSS
建议的领域边界:
#![allow(unused)]
fn main() {
struct DealerPolynomial(Vec<SecretScalar>);
struct CoefficientCommitments(Vec<GroupElement>);
struct Share {
dealer: ParticipantId,
receiver: ParticipantId,
value: SecretScalar,
}
fn verify_vss_share(
receiver: ParticipantId,
share: &SecretScalar,
commitments: &CoefficientCommitments,
) -> bool;
}
测试:
- 所有正确份额通过;
- 改动份额一个标量单位后失败;
- 改动任一承诺后相关验证失败;
- 承诺数量不等于 \(t\)、ID 为零、点解码失败时拒绝;
- 用同一个多项式的任意 \(t\) 份可重构常数项,而 \(t-1\) 份不能唯一确定它。
最后一项最好先在小素数域中演示信息论不确定性,再回到真实曲线实现。
任务二:实现消息驱动的 DKG 状态机
不要写一个能直接访问所有参与者秘密的“大上帝函数”。为每个参与者建立独立状态,并通过显式消息驱动:
Registered
-> CommitmentsCollected
-> SharesDistributed
-> ComplaintsFrozen
-> ResolutionsFrozen
-> QualifiedSetFrozen
-> Finalized | Aborted
测试工具可以检查全局秘密来断言正确性,但生产模块不能暴露重构完整私钥的接口。
任务三:注入恶意行为
为模拟网络加入故障脚本:
- Dealer 给一个接收者发送错误份额;
- Dealer 对不同参与者广播不同承诺;
- Dealer 漏发一个份额;
- 接收者提出虚假投诉;
- Dealer 在解决阶段沉默;
- 两个节点尝试冻结不同的 \(Q\)。
- 多个 Dealer 的常数项承诺相消,使最终 \(Y=\mathcal O\)。
每次实验记录:谁能检测、需要什么证据、协议应继续还是中止、是否泄露了份额、所有诚实节点最终是否得到同一 \(Y\)。
读完第 8 章后再扩展事件队列,注入重复、乱序、旧 session 消息和崩溃;这些属于综合项目的工程验收,不是本章核心 DKG 公式的过关条件。
任务四:接入上一章 FROST
DKG 成功后,为每个参与者只输出:
#![allow(unused)]
fn main() {
struct KeyPackage {
ciphersuite: CiphersuiteId,
group_id: GroupId,
epoch: u64,
threshold: usize,
roster: Vec<ParticipantId>, // 严格递增、唯一、已验证
participant: ParticipantId,
signing_share: SecretShare,
verification_share: GroupElement,
group_public_key: GroupPublicKey, // 已验证且非单位元
roster_hash: [u8; 32],
}
}
让第 6 章的所有 2-of-3 FROST 测试在这些 DKG 输出上重新通过。额外断言所有参与者的 ciphersuite、group_public_key、threshold、有序 roster、roster_hash 和 epoch 完全相同。
7.8 份额刷新与成员变更不是同一件事
主动份额刷新通常让参与者贡献常数项为零的多项式:
\[ g_j(0)=0 \]
把新贡献加入旧份额后,组私钥和组公钥不变。诚实节点应立即停用并销毁旧份额,但旧份额并不会在密码学意义上失去签名能力:如果攻击者已经取得某一旧 epoch 的达到门限份额集合,它仍可为同一个组公钥生成有效签名,普通验证者无法从签名中识别 epoch。
刷新承诺需要单独的 identity 规则。因为 \(g_j(0)=0\),其常数项承诺必须是单位元;这在 RefreshCoefficientCommitment 中是合法且应被验证的性质,不能使用会拒绝单位元的 FROST 签名元素解析器。其他系数也应按刷新协议的规则编码和验证。
投诉泄露预算也更紧:次数至多为 \(t-1\) 且常数项已知为零的刷新多项式,只需 \(t-1\) 个不同非零点即可完全重构。若对手已控制 \(f<t-1\) 个接收者,再公开 \(t-1-f\) 个新的不同刷新份额就足够;若 \(f\ge t-1\),它已经拥有足够点。历史投诉与重试同样要累计。不要把普通 DKG 中的 \(t-f\) 预算直接复用到 refresh。
刷新只有在每个时期被攻破的份额始终少于门限、旧份额和刷新临时量确实及时删除时,才有助于抵抗跨时期逐个攻破参与者的移动对手。epoch 能约束诚实状态机并防止误混新旧份额,却不能撤销已经泄露的旧 quorum。若旧份额可能已达到门限泄露,应生成新的组密钥并让所有验证方迁移,而不是只做刷新。
成员增删、门限变化或把份额迁移给新 roster 属于 resharing,涉及旧组对新组的授权、身份映射和可用性假设。它不能简化为“给新成员复制一个份额”,也不应与零常数刷新共用含糊 API。若 resharing 保持原组公钥,达到旧门限的旧份额集合仍然具有签名能力;需要密码学意义上的撤销时,应迁移到新组公钥。
任何 DKG、刷新或 resharing 都应递增 epoch。应用层认证 envelope 和本地密钥包必须绑定 epoch,防止诚实节点混用不同时期的份额和验证份额;RFC 9591 的 \(H_1/H_2\) 本身不包含 epoch。若最终验证者也必须验证 epoch,它必须进入待签消息。
常见错误
- 把
A_0当成秘密。 它是 Dealer 的公开密钥贡献;秘密的是对应标量。 - 只验证最终组公钥。 单个坏份额可能让某参与者以后无法签名,必须逐 Dealer 验证。
- 用普通广播模拟一致广播。 恶意发送者可以向不同节点展示不同承诺。
- 私密发送但不认证。 加密不能证明份额来自声明的 Dealer,也不能防篡改。
- 无限公开争议份额。 攻击者累计掌握达到 \(t\) 个不同份额(包括已攻破节点的私密份额)就会重构 Dealer 的贡献。
- 每个节点自己决定超时和资格集合。 这会产生不同 \(Q\) 和不同组公钥。
- DKG 后重构完整私钥做“校验”。 应用公开等式与门限签名验证,而不是制造单点秘密。
- 假设
f<t自动保证 DKG 活性。 具体容错界取决于协议和网络模型。 - 保留所有临时多项式方便调试。 这扩大历史密钥泄露面,日志和崩溃转储尤其危险。
- 把刷新当作成员变更。 两者的安全目标、消息和 epoch 语义不同。
- 接受单位元组公钥。 它会让 Schnorr 验证失去不可伪造性;定稿必须拒绝。
- 所有公开点共用一种解析类型。 FROST 签名元素拒绝单位元,refresh 常数项承诺却必须是单位元;两层需要各自的类型和规则。
- refresh 沿用 DKG 的投诉泄露预算。 已知零常数项会把重构所需的非零点数从 \(t\) 降为 \(t-1\)。
本章检查题
- 推导 Feldman 份额验证等式,并说明验证者为何不需要知道多项式系数。
- 如果 Dealer 向 Alice 和 Bob 发送不同承诺列表,即便两人的份额都各自验证通过,会发生什么?
- DKG 中最终组公钥如何从公开数据计算?为何不需要重构组私钥?
- 为什么所有诚实参与者必须冻结完全相同的资格集合 \(Q\)?
- 若攻击者已控制 \(f\) 个接收者,还需看到多少个同一 Dealer 的额外不同有效份额,就能重构次数为 \(t-1\) 的多项式?
- “少于 \(t\) 人不知道私钥”和“协议在 \(f\) 个恶意节点下会终止”为什么是不同结论?
- 一个虚假投诉可能造成哪些安全性或可用性影响?
- 份额刷新为什么可以保持组公钥不变?
完成标准
当本地模拟器能检测错误份额和承诺分叉,能按确定性规则让所有诚实节点得到相同的 \(Q\)、\(Y\) 与验证份额,并能把 DKG 输出直接用于 FROST 测试时,本章学习目标已经达到。进入真实网络前,还必须为所选 DKG 规范写清对手界、网络假设、广播实现和投诉泄露预算。
第 8 章:把协议变成不会轻易出事故的系统
学习定位:前置为 FROST 与本地 DKG;配套完成实验 5:对抗性测试和实验 6:综合项目。产物是显式状态机、nonce 台账和故障恢复证据。
公式正确只是起点。真实门限签名事故更常来自 nonce 重用、消息编码分歧、状态回滚、身份冒充、并发竞态或“重试”逻辑,而不是椭圆曲线加法写错。本章把 FROST 和 DKG 放进一个可恢复、可审计、默认失败关闭的系统外壳。
这里的目标仍是教学工程,不是生产安全认证。若要保护真实资产,需要选定标准协议与密码套件、使用经过审计的实现、完成独立安全评审,并把部署、运维和治理一并纳入威胁模型。
学习目标
完成本章后,你应该能够:
- 设计规范、域分离且无歧义的 transcript;
- 把 nonce 当作持久化的一次性资产管理,而不是临时随机变量;
- 为签名者和协调者设计可验证的状态机;
- 使用独立身份密钥认证协议消息;
- 拒绝跨会话、跨 epoch、跨轮次和跨 transcript 的重放;
- 设计“先持久化,再发送”的崩溃恢复顺序;
- 用并发、故障注入、模糊测试和恢复测试验证安全不变量;
- 区分密码学不可伪造性、系统可用性与业务授权策略。
8.1 先定义资产和对手
至少保护以下资产:
- 长期签名份额与 DKG/刷新时的临时多项式;
- 未使用的 hiding/binding nonce;
- 用户真正同意签署的业务语义;
- roster、组公钥、验证份额和当前 epoch;
- 会话状态、已消耗 nonce 台账及审计证据;
- 节点身份密钥和信道凭据。
考虑以下能力不同的对手:
- 控制协调者,但未控制足够签名者;
- 控制少数签名节点并发送任意协议消息;
- 观察、延迟、重复、乱序或丢弃网络消息;
- 读取旧备份或让服务回滚到旧快照;
- 同时启动同一签名节点的两个实例;
- 诱导操作者签署显示内容与实际字节不一致的消息;
- 读取日志、崩溃转储、指标标签或错误报告;
- 通过大量无效点、超大消息或未完成会话消耗资源。
“攻击者不能解离散对数”并不能解决上述工程问题。
8.2 Transcript:让所有人签的是同一件事
Transcript 是协议会话的规范记录。它既用于派生哈希输入,也用于把消息和本地状态绑定到唯一上下文。
一个协议消息的逻辑字段通常包括:
protocol_id 例如 FROST-sign 或 DKG
suite_id 曲线、哈希、编码和协议套件
version 线格式与规则版本
group_id, epoch 哪组密钥、哪个份额时期
session_id 本次运行的随机唯一标识
message_type commitment / package / share / complaint ...
round 协议轮次
sender, receiver 经认证的身份;广播可使用明确的广播标记
message_digest 待签字节的规范摘要或规范要求的原消息
signer_set 按规范顺序、无重复的参与者 ID
commitments 按参与者 ID 规范排序
previous_hash 需要时链接前一阶段 transcript
payload 当前消息的规范负载
规范编码应满足:
- 每个逻辑对象只有一种合法字节表示;
- 字段有类型和长度边界,不能靠字符串拼接猜分隔位置;
- map/set 在编码前按规范键排序,重复键直接拒绝;
- 整数宽度、字节序、Unicode 策略和空值表示固定;
- 点和标量只接受密码套件规定的规范编码;
- 未知版本或未知必选字段失败关闭。
可以定义会话摘要:
\[ T=H_{\text{transcript}}( \text{domain}\parallel\operatorname{CanonicalEncode}(fields)) \]
其中 domain 至少区分协议、版本、密码套件和消息类型。不要直接哈希普通 JSON:对象键顺序、数字形式、重复键、Unicode 和空白都可能产生歧义。可使用经过定义的 canonical CBOR、Protobuf 的受限确定性配置,或自己极小且有测试向量的长度前缀编码;关键是规范,而不是格式名称。
Transcript 哈希不等于 Schnorr challenge
FROST 的 binding factor 与最终 Schnorr challenge 有各自规定的输入。系统 transcript 可以包含 session、epoch 和业务审计字段,但不能擅自把所有字段塞进 \(c\),否则会破坏标准签名兼容性或安全证明。
如果“转账链 ID、金额、订单号”等业务语义需要被最终签名验证者确认,它们必须进入待签消息的规范表示,或进入由应用层明确验证的签名上下文。只写入本地审计日志,不能防止签名被挪作他用。
8.3 nonce 是状态,不只是随机数
FROST 每次签名需要一对一次性秘密 nonce。核心不变量是:
一对 nonce 最多为一个冻结的签名包释放一次签名份额,且持久状态回滚后也不能被重新分配。
推荐生命周期:
Generated
-> Reserved(session_id)
-> CommitmentPublished(commitment_hash)
-> ConsumedAndResponsePersisted(transcript_hash, exact_response)
-> SecretDeletedOrSealed
ConsumedAndResponsePersisted 必须是一个原子持久化结果,不是两个中间可崩溃的步骤;其中 nonce 已不可再次分配。最后一个状态表示秘密材料已经删除或被不可恢复地封存,而保留最小消费墓碑和幂等响应记录。
中止分支也要安全:
- 承诺一旦可能被外界看到,该 nonce 对就必须烧毁;
- 简单实现可以在
Reserved时就规定“永不退回库存”,用少量可用性换清晰安全性; - 只有能证明任何承诺字节从未离开受控进程时,才可能安全回收预留 nonce;生产设计通常不值得依赖这种证明。
数据库记录至少包含:
- 随机
nonce_id,不能只用自增 ID 充当秘密随机性; - 组 ID、epoch、所属参与者;
- 两个承诺和加密保存的两个秘密 nonce;
- 当前状态、绑定的 session/transcript 摘要;
- 单调版本号、创建/消费审计时间;
- 若支持幂等重发,保存加密的完全相同响应字节。
预计算 nonce 库不应跟随普通虚拟机快照或数据库备份回滚。最简单的恢复策略是:备份长期份额,但重启或还原后丢弃全部预计算 nonce,重新生成。若业务必须备份 nonce 库,就需要快照之外不可回滚的消耗台账,例如受保护的单调计数器或独立一致存储。
8.4 安全的第 2 轮提交顺序
危险顺序是:
计算 z_i -> 发送 z_i -> 标记 nonce 已消费
若进程在发送后、落盘前崩溃,恢复后可能重用 nonce。安全的“事务 outbox”模式是:
- 对 nonce 记录加锁或做 compare-and-swap,确认状态与本次 session 匹配;
- 完整验证并冻结签名包,计算 transcript 摘要;
- 在内存中计算 \(z_i\),但尚不允许网络层发送;
- 同一持久事务内:把 nonce 转为已消费、删除或封存秘密 nonce,并保存绑定该 transcript 的精确响应;
- 确认事务已耐久提交;
- 由 outbox 发送保存的响应;
- 重试时只允许重发同一 transcript 下逐字节相同的响应。
如果在第 4 步前崩溃,没有响应离开系统;如果在第 4 步后、第 6 步前崩溃,恢复进程只能发送已保存的相同响应;如果发送后崩溃,台账已经显示 nonce 消耗。这个顺序牺牲了一些吞吐量,却封住最危险的崩溃窗口。
如果不想持久化签名份额响应,可以采用更简单的失败关闭策略:消费记录耐久提交后才发送,一旦不确定是否已发送就永远中止该 nonce。不要根据旧承诺“重新算一次看看”。
8.5 显式协议状态机
签名者和协调者应有不同状态机。
签名者状态
| 当前状态 | 允许输入 | 成功后状态 | 关键检查 |
|---|---|---|---|
Idle | 本地创建/取得 nonce | Reserved | group、epoch、库存原子分配 |
Reserved | 发送第 1 轮承诺 | CommitmentPublished | 先落盘,后发送 |
CommitmentPublished | 收到签名包 | PackageAccepted | 自己的承诺、消息、集合、transcript |
PackageAccepted | 生成签名份额 | ConsumedAndResponsePersisted | CAS 消费 nonce,原子保存响应,先提交后释放 |
ConsumedAndResponsePersisted | 精确重发/确认 | Completed | nonce 已消费;transcript 和响应字节完全相同 |
| 任意未终态 | 中止 | AbortedConsumed | 已发布或不确定的 nonce 永不复用 |
协调者状态
Created
-> CollectingCommitments
-> PackageFrozen
-> CollectingShares
-> SharesVerified
-> Completed
任何阶段都可进入 Aborted,但不能从 Aborted 返回旧会话继续签。第 2 轮更换 signer set、消息或任一承诺时,必须新建会话并使用全新 nonce。
状态转换规则:
- 收到未来轮次消息:拒绝,不暂存为“稍后自动处理”;
- 收到过去轮次的完全相同消息:按幂等规则返回旧结果或确认;
- 同一消息 ID 但内容不同:视为冲突并告警;
- 同一 session 出现两个签名包摘要:中止并保留证据;
- 状态未知或恢复不确定:失败关闭,烧毁相关 nonce。
用一个集中的 transition(state, authenticated_message) 实现规则,避免每个 HTTP handler 自己猜当前状态。
8.6 身份认证和信道保护
门限签名份额不应兼任节点的网络身份密钥。每个节点应有独立身份凭据,例如硬件保护的 Ed25519/P-256 身份密钥或双向 TLS 证书,用于:
- 认证承诺、签名包、签名份额、投诉和 DKG 广播的发送者;
- 把消息签名覆盖到完整规范 envelope;
- 支持 roster 更新、凭据吊销和独立轮换;
- 为审计提供不可抵赖或至少可归因的证据。
一个消息 envelope 至少包含:
protocol/version/suite
group_id/epoch/session_id
round/message_type
sender_id/receiver_id
sequence_or_message_id
payload_hash/transcript_hash
identity_signature
加密和认证解决不同问题:TLS 能保护传输,但如果协调者终止 TLS 后再转发多方消息,签名者还需要端到端认证原始发送者。DKG 私密份额还要求只让指定接收者解密。
身份认证不等于业务授权。节点还应在本地执行策略,例如允许的链、金额上限、请求来源、人工审批或速率限制;不能因为请求来自“合法协调者”就盲签。
8.7 重放防护
每次会话使用高熵随机 session_id,并同时绑定 group_id + epoch。单靠时间戳不够:时钟会漂移,旧快照也可能让本地时间倒退。
接收消息时依次检查:
- 协议、版本、密码套件受支持;
- group、epoch 和 roster 与本地当前配置一致;
- 身份签名有效,sender 在 roster 中且角色允许该消息类型;
- session 存在或能在该消息类型上合法创建;
- round、状态、message ID/sequence 合法;
- transcript 链接与本地冻结摘要一致;
- 重复消息若被允许,必须逐字节等同于先前已接受消息;
- nonce 未被其他 session 或 transcript 使用。
保留已完成/中止 session 的防重放墓碑,期限至少覆盖系统定义的最大消息寿命;对于 nonce 消耗记录,不能仅因业务会话过期就删除后允许恢复。epoch 升级后拒绝旧 epoch 消息,但仍应保留足够审计记录定位攻击。
8.8 崩溃恢复、备份与并发
恢复原则
- 任何外发安全敏感消息都先有耐久本地记录;
- 恢复程序只根据数据库/WAL 中已提交状态行动,不根据“协调者说你之前没发”;
- 无法判断 nonce 是否暴露时,视为已经暴露并烧毁;
- 恢复后只重发已保存的相同响应,不重新接受修改后的签名包;
- 启动时校验 group、epoch、roster hash 和密钥包是否一致。
快照回滚
恢复旧虚拟机或数据库快照可能同时恢复“未使用”nonce 和旧 session 计数器,这是门限签名的高危操作。可选防护包括:
- nonce 库不进入可回滚备份,恢复后全部重建;
- 使用数据库外的不可回滚 epoch/单调计数器;
- 启动时与独立一致存储核对最高消费水位;
- 每次恢复强制切换新 epoch 并执行规范的 refresh/resharing 或新 DKG。
最后一项不是万能修复:若已有足够份额泄露或发生 nonce 重用导致份额泄露,应按密钥失陷响应,通常需要停止签名并迁移到新组密钥。
多实例与并发
同一参与者的两个进程绝不能各自从同一快照拿到同一个 nonce。可用:
- 单写者架构;
- 提供线性一致 compare-and-swap 的共享存储;
- 每实例独立且不重叠的 nonce 命名空间,并有不可回滚租约;
- HSM/安全模块内部的一次性句柄。
数据库“读到 available,然后 update”不是原子分配;应使用带版本条件的更新,并断言恰好修改一行。
8.9 输入验证、秘密处理和抗拒绝服务
密码学输入
- 严格按套件解码点和标量,拒绝非规范编码;
- 按套件要求检查单位元、小子群或无效点;Ristretto 编码简化了子群处理,但仍要检查解码失败和规范性;
- 参与者 ID 必须非零、唯一且属于 roster;
- signer set 大小满足门限并不超过 roster;
- 承诺、验证份额、组公钥的数量和归属匹配;
- 最终输出前再执行一次普通 Schnorr 验证。
秘密数据
- 秘密类型不实现明文
Debug、Display或通用序列化; - 磁盘上的份额和 nonce 使用独立密钥加密,并保护完整性;
- 尽量缩短明文生命周期,避免复制,使用受审查的常数时间库;
- 禁止在日志、metrics label、trace、panic、core dump 中出现份额、nonce 或私密 DKG 消息;
zeroize是纵深防御,不是对编译器、交换分区和硬件残留的完整证明。
资源限制
在进行昂贵曲线运算前先完成长度、版本、身份和会话配额检查。限制:
- 单消息和待签消息大小;
- 每身份并发 session 数;
- 预留 nonce 的速率和总量;
- DKG 投诉与响应数量;
- 未完成会话的生存时间。
超时清理只能把 nonce 烧毁,不能把已发布承诺的 nonce 放回库存。
8.10 Rust 动手实践
任务一:实现规范 transcript 编码
定义带版本的结构,不允许业务层自己拼字节:
#![allow(unused)]
fn main() {
trait CanonicalEncode {
fn encode_canonical(&self, out: &mut Vec<u8>) -> Result<(), EncodeError>;
}
fn transcript_hash<T: CanonicalEncode>(
domain: &'static [u8],
value: &T,
) -> Result<[u8; 32], EncodeError>;
}
测试包括黄金向量、字段边界、空值、重复 ID、乱序输入、未知版本和跨平台一致性。使用 property test 验证“解码后再编码只有一种表示”,并用 fuzzing 确认任意字节不会 panic。
任务二:让状态机拒绝非法转换
用枚举表达状态,用一个入口处理消息:
#![allow(unused)]
fn main() {
enum SignerState {
Idle,
Reserved,
CommitmentPublished,
PackageAccepted,
ConsumedAndResponsePersisted,
Completed,
AbortedConsumed,
}
fn apply(
state: SignerState,
message: AuthenticatedMessage,
) -> Result<(SignerState, Vec<Effect>), ProtocolError>;
}
Effect 描述“写数据库”“发送消息”等意图;测试解释器可以在每个 effect 前后注入崩溃,生产解释器则保证提交顺序。
任务三:实现原子 nonce 仓库
先定义接口,再用 SQLite 或其他支持事务的存储实现:
#![allow(unused)]
fn main() {
trait NonceStore {
fn reserve(&self, group: GroupId, epoch: u64, session: SessionId)
-> Result<ReservedNonce, StoreError>;
fn persist_response_once(
&self,
nonce_id: NonceId,
expected_version: u64,
transcript: TranscriptHash,
response: EncryptedResponse,
) -> Result<PersistOutcome, StoreError>;
}
}
启动 100 个并发任务争抢同一条 nonce,断言只有一个成功。对事务提交前、提交后、网络发送前和发送后分别注入崩溃,恢复后断言:同一个 nonce ID 从未对应两个不同 transcript 或两个不同响应。
任务四:认证 envelope 与重放测试
为每个模拟节点生成独立身份密钥,对完整 envelope 签名。测试:
- 篡改 sender、round、payload 或 transcript 后认证失败;
- 把旧 epoch 的有效消息放进新 session 被拒绝;
- 同一 message ID 的相同消息幂等,不同消息触发冲突;
- 协调者向两个签名者发送不同包时,两者产生可比较的分叉证据;
- 合法身份但无业务权限的请求仍被策略层拒绝。
任务五:故障与恢复矩阵
为每个外发消息测试以下时刻:
验证前崩溃
计算后、事务前崩溃
事务提交中崩溃
提交后、发送前崩溃
发送后、确认前崩溃
收到重复确认后崩溃
从旧快照启动
两个实例同时启动
每个测试都检查三个不变量:
- nonce 不跨 transcript 重用;
- 不会对未验证的签名包释放份额;
- 恢复后要么精确幂等,要么安全中止。
8.11 可观测性与事故响应
可以记录:
- group/epoch/session 的不敏感标识或审计哈希;
- 状态转换、拒绝原因类别和消息身份;
- nonce 库存数量、消耗数量和过期烧毁数量;
- 份额验证失败、transcript 分叉、旧 epoch 重放和并发 CAS 冲突;
- DKG 投诉数、资格集合结果和组公钥指纹。
不要记录私密 nonce、签名份额的内部标量、长期份额、DKG 私密分量或未经审查的完整业务消息。
发现以下任一情况应停止相关组签名,而不是自动重试:
- 同一 nonce ID 对应两个 transcript;
- 已消费台账回退或无法证明连续性;
- 同一 session 出现两个冻结签名包;
- 密钥份额、身份密钥或 nonce 库可能泄露;
- 节点对 roster、epoch 或组公钥不一致;
- 出现无法解释的有效身份签名消息。
事故处理首先保存不含秘密的证据并隔离节点,再判断恶意数量是否可能达到门限。仅执行份额刷新不能修复一个已经泄露的完整组私钥;必要时应新建组密钥并迁移依赖方。
常见错误
- 直接哈希 JSON。 非规范编码会让不同实现得到不同 transcript,甚至产生歧义。
- 把审计 transcript 当成标准 challenge。 擅自改 challenge 输入会改变签名方案。
- 发送后才记 nonce 已用。 崩溃恢复会打开重用窗口。
- 从快照恢复预计算 nonce。 已经用过的 nonce 可能重新出现在库存。
- 用时间戳当唯一 session ID。 时钟碰撞、回退和多实例都会破坏假设。
- TLS 之后不认证多方消息。 中间协调者可能替换消息来源或内容。
- 重复消息一律重新计算。 正确幂等是重发完全相同的持久响应,不是重新签一次。
- 错误时把秘密写进日志。 可观测性系统往往比签名节点暴露面更大。
- 状态只存在内存。 重启后系统无法判断承诺或响应是否已经发出。
- 把协议安全等同于业务授权。 密码学只能证明达到门限的人签了某些字节,不能判断交易是否合理。
本章检查题
- 为什么
session_id、epoch 和 transcript 摘要需要同时存在? - 规范编码必须解决哪几类“一对象多表示”问题?
- 为什么 outbox 要在网络发送前耐久保存消费状态和响应?
- 已发布承诺但未产生签名份额的 nonce,为什么仍应烧毁?
- TLS 已启用时,哪些拓扑仍需要端到端消息签名?
- 从旧快照恢复后,为什么简单地“生成一个新 session ID”仍不能保证 nonce 安全?
- 精确幂等重发与重新计算签名份额有什么区别?
- 哪些业务字段必须进入最终待签消息,而不能只出现在本地日志?
- 如果同一参与者的两个实例同时运行,数据库需要提供什么原子性质?
- 发现 nonce 可能重用时,为什么自动重试是最危险的选择之一?
完成标准
当实现满足以下条件,本阶段才算完成:
- 所有协议对象有唯一、带版本、带域分离的编码;
- nonce 生命周期在持久存储中可证明且不可通过普通备份回滚;
- 每条消息经过身份认证、会话/epoch/轮次和 transcript 检查;
- 状态机拒绝乱序、分叉和跨会话重放;
- 在每个事务与发送边界注入崩溃后,nonce 仍不会跨 transcript 重用;
- 并发实例无法重复领取同一 nonce;
- 日志、指标和错误中不出现秘密;
- 最终签名仍由目标普通 Schnorr 验证器验证。
到这里,你已经有了一套完整的教学路线:Shamir 提供门限共享,Schnorr 提供签名等式,FROST 提供两轮协作签名,VSS/DKG 去掉可信 Dealer,安全状态机则保证这些公式不会在重试、崩溃和真实网络中失去意义。
实验 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 是否消费存在任何不确定性,唯一安全默认是永久烧毁,而不是重试。
- 本工程仍是教学实现。即使本章测试全部通过,也禁止承载真实资产或对外宣称生产安全。
实验 06:综合项目验收
本实验不再引入新的公式,而是把前五个实验组合成一套边界清楚、可以重复演示的本地 2-of-3 FROST 学习系统。
综合项目通过不代表生产安全。最终产物仍应醒目标记为教育实现,不得承载真实资产或生产身份。
本项目只要求单进程事件队列和可注入崩溃的教学存储。真实网络/TLS、生产数据库、HSM、侧信道保证、云部署和真实资产全部排除在验收范围外。RFC 9591 字节互操作作为单独加分项,不与核心公式正确性混为一个结论。
最终目标
三个参与者 Alice、Bob、Carol 各自持有一份长期密钥份额。任意两人可以在不恢复完整私钥的情况下,对一条明确的消息生成普通 Schnorr 签名。
系统必须同时展示四类结果:
- 正确路径:三个合法
2-of-3子集都能签名; - 错误路径:篡改、重复 ID、非法编码和坏份额被准确拒绝;
- 攻击路径:测试专用代码复现 nonce 重用泄露;
- 恢复路径:在关键持久化边界崩溃后,nonce 不会跨 transcript 重用。
先用可信 Dealer 完成核心里程碑;完整毕业项目必须再把密钥来源替换为第 7 章的本地 DKG 模拟器。两者分开验收,避免同时调试密钥生成与签名公式。
两个完成级别
- Level 1 — 核心里程碑:可信 Dealer → 三个
2-of-3子集完成 FROST → 对抗性测试。 - Level 2 — 完整毕业项目:本地同步 DKG → FROST → 持久 nonce 状态 → 崩溃/乱序/重放故障注入。
Level 1 已经是一套完整的门限签名学习成果;Level 2 才表示你把“无可信 Dealer”和协议工程也接入了同一条可验证主线。
前置闸门
开始综合项目前,确认:
- 玩具有限域的全部穷举测试通过;
- Scalar 版 Shamir 的所有
2-of-3子集通过; - 普通 Schnorr 已拒绝单位元公钥和单位元承诺;
- 普通 Schnorr nonce 重用实验能恢复测试私钥;
- FROST binding factor 和 Schnorr challenge 使用不同的域分离函数;
SigningNonces/ReservedSigningNonces不能被复制,且第二轮按值移动预留句柄,用持久化 compare-and-swap 完成消费;- 你能口头解释为什么这些条件仍不足以证明生产安全。
任一条件不满足,就先回到对应实验修复,不要在综合层用特殊分支掩盖底层错误。
建议的最终目录
src/
lib.rs
error.rs
toy_field.rs # 仅学习/测试,真实协议不依赖
suite.rs # Ristretto255/SHA-512 与规范编码
identifier.rs
transcript.rs # H1/H2/H3/H4/H5 或教学阶段的明确等价层
shamir.rs
schnorr.rs
frost/
mod.rs
types.rs
dealer.rs
round1.rs
round2.rs
aggregate.rs
state/
mod.rs
nonce_store.rs
signer.rs
examples/
two_of_three.rs
tests/
shamir_properties.rs
schnorr_negative.rs
frost_subsets.rs
frost_adversarial.rs
crash_recovery.rs
encoding.rs
ui.rs
tests/ui/
nonces_cannot_be_reused.rs
这只是职责划分,不要求第一天建立所有文件。一次只迁移一个已通过测试的模块。
核心 API 边界
最终实现至少应让下列对象不可混用:
ParticipantId
SigningShare secret, long-lived
VerifyingShare public
GroupPublicKey public, validated, non-identity
KeyPackage secret share + suite/group/epoch/roster/threshold
SigningNonces secret, one-time, move-only
NonceId / ReservedSigningNonces
SigningCommitments public, validated
CoefficientCommitment public, protocol-specific identity rules
SigningPackage message + ordered commitments
AuthenticatedSigningRequest / AuthenticatedSignatureShare
SignatureShare public protocol response
Signature public, validated
SessionId / GroupId / Epoch
设计检查:
SigningShare与普通Scalar不能随意互换;KeyPackage不可把秘密份额与 ciphersuite、group/epoch、有序 roster 或 threshold 分离使用;SigningNonces不实现Clone、Copy、明文Debug或通用序列化;- 公开 Round 2 入口只接受 nonce store 生成的
ReservedSigningNonces和通过消息认证的 request,不暴露只接受裸 package/nonce 的绕过路径; GroupPublicKey只能从验证过的非单位元点创建;CoefficientCommitment与 FROST 的SigningCommitments使用不同构造器;不要把签名层的“拒绝单位元”规则套给所有 VSS 系数;Signature的字段私有,解析器拒绝单位元R与非规范z;SigningPackage构造时要求承诺已按 ID 严格递增,并拒绝乱序、重复、未知 ID 和未达门限的输入;只有协调者内部从 map 建包的 builder 可在编码前显式排序;- RFC 9591 兼容路径还检查 participant identifier 位于
[1, MAX_PARTICIPANTS]; - 应用 envelope 的 session/epoch 不被擅自塞入 RFC 9591 的
H1/H2; - 若这些业务字段需要最终验证者确认,它们进入被签消息。
阶段 A:密钥材料
任务
用可信 Dealer 生成:
- 非零组私钥
x,但只允许存在于 keygen 的受限作用域; - 三份
2-of-3Shamir signing share; - 三份 verification share;
- 非单位元 group public key;
- roster、threshold、suite 和 key package 标识。
Dealer 返回以后,主签名 API 不得再提供恢复 x 的入口。
验收
- 任意两份在测试夹具中可验证对应同一个 group public key;
- 一份不能调用恢复 API;
- 重复/零 participant ID 被拒绝;
- 组公钥为单位元时 keygen 失败;
- key package 的 group、epoch、roster hash 完全一致。
阶段 A+:用本地 DKG 替换 Dealer
完成 Level 1 后,保持 FROST 签名 API 不变,只替换 KeyPackage 的来源。使用单进程同步事件队列模拟三个独立节点:
- 每个节点各自生成次数至多为 1 的随机多项式;
- 广播 Feldman 系数承诺;
- 向另外两名参与者发送私密分量份额;
- 每个接收者逐 Dealer 验证份额;
- 冻结投诉、响应和共同资格集合
Q; - 各自聚合自己的 signing share 与 verification share;
- 从常数项承诺计算共同、非单位元的 group public key;
- 删除临时 Dealer 多项式和分量份额;
- 把三个
KeyPackage原样交给阶段 B。
核心 DKG 验收:
- 三方得到相同的 group public key、roster hash、epoch 和资格集合;
Q满足所选教学 DKG 的最小合格集合条件;不能在只剩一个 Dealer、使其知道完整组私钥时仍宣称“分布式生成成功”;- 三方各自的 verification share 与 signing share 对应;
- 正常路径从不计算完整组私钥;
- 篡改一个私密份额会被指定接收者检测;
- 对不同接收者分叉承诺时,所有诚实节点按同一规则共同中止;
- 虚假投诉、Dealer 沉默或最终
Y为单位元时,共同中止而不是各自定稿; - DKG 输出继续通过阶段 B 的三个签名子集测试。
本阶段仍不实现真实网络。乱序、重放和崩溃由后面的事件队列故障注入统一验收。
阶段 B:两轮签名
Round 1
每位候选签名者:
- 按选定套件生成 hiding/binding nonce;
- 计算两个公开承诺;
- 在本地 nonce store 中原子预留给一个 session;
- 发布承诺;
- 从此不允许把该 nonce 对退回可用库存。
Round 2
每位最终签名者:
- 验证应用 envelope、自己的承诺和完整 signing package;
- 独立重算 binding factors、group commitment、challenge 和拉格朗日系数;
- 若任一输入点或聚合
R为单位元,失败并烧毁 nonce; - 先耐久记录消费状态与响应,再释放 signature share;
- 协调者验证每一份,再聚合;
- 用普通 Schnorr 验证器检查最终签名。
验收
对 {Alice,Bob}、{Alice,Carol}、{Bob,Carol} 分别运行:
- Round 1 使用全新 nonce;
- 每个 signature share 单独验证;
- 聚合签名由同一 group public key 验证;
- 最终签名字节不包含 roster、门限或签名者身份;
- 三组签名都不能访问完整私钥。
阶段 C:失败矩阵
为每一行建立独立测试。不要只断言 is_err(),还要断言错误发生在预期边界。
| 场景 | 最早拒绝位置 | nonce 结果 |
|---|---|---|
| 少于门限 | signing package 构造 | 未发布者可保留;已发布者烧毁 |
| participant ID 为 0/重复 | 解码或 package 构造 | 未使用 |
| 自己的承诺被替换 | 签名者接受 package 前 | 烧毁 |
| 消息与批准内容不同 | 应用策略检查 | 烧毁 |
| commitment list 乱序 | 规范解析 | 烧毁或按 API 约定拒绝前不发布 |
| 输入点为单位元 | 点解析/验证 | 烧毁 |
聚合 R 为单位元 | challenge 计算前 | 全部烧毁 |
| signature share 被修改 | 聚合前份额验证 | 已消费,不能重试 |
| nonce 为 A 预留,却收到 session B 的签名请求 | 签名者 envelope / nonce store 前置检查 | A 的记录烧毁,不改绑 B |
| share envelope 来自另一 session | 协调者 envelope / session store 前置检查 | 不触碰签名者 nonce store |
| 最终签名验证失败 | 发布签名前 | 已消费,创建新会话 |
同一种错误在不同状态发生时,nonce 处理可能不同;测试名称应把状态写出来。
阶段 D:崩溃恢复
为签名者建立最小状态:
Fresh -> Reserved -> CommitmentPublished
-> ConsumedAndResponsePersisted -> Completed
\-> Burned
ConsumedAndResponsePersisted 是一次原子事务,不是先保存 response、后标记 consumed 的两个可崩溃步骤。Completed 只表示传输/确认完成,nonce 在前一状态就已不可再用。
在下面每个边界注入崩溃:
- nonce 生成后、预留前;
- 预留提交后、承诺发送前;
- 承诺发送后、package 到达前;
- response 计算后、消费事务提交前;
- 消费事务提交后、response 发送前;
- response 发送后、确认前;
- 从旧数据库快照启动;
- 两个进程同时争抢同一个 nonce。
每个恢复测试必须证明:
- 同一 nonce ID 不会对应两个不同 transcript;
- 已发布或状态不确定的 nonce 不会回到库存;
- 允许幂等重发时,只重发已持久化的完全相同响应;
- 无法证明安全时中止,而不是重新计算。
阶段 E:标准对照
把“协议逻辑正确”和“RFC 9591 字节兼容”分成两个独立标签。
edu
- 公式、类型和失败测试正确;
- 可能使用自定义域标签或简化线格式;
- 不声称与其他实现互操作。
rfc9591-conformant-study
- 锁定 RFC 9591 的 Ristretto255/SHA-512 ciphersuite;
- 实现规定的
H1至H5、nonce_generate、序列化和输入验证; - participant identifier 是
[1, MAX_PARTICIPANTS]内的NonZeroScalar,且互不重复; - 官方 ristretto255 测试向量逐项一致;
- 与成熟实现做差分测试;
- 仍然保留“未经审计、禁止生产使用”的声明。
不要为了让最终签名“碰巧验证”而跳过中间值对照。至少比较:
- nonce commitments;
- binding factors;
- group commitment;
- challenge;
- 每个 signature share;
- 最终签名字节。
核心验收矩阵
| 能力 | 必须提供的证据 |
|---|---|
| 数学正确性 | 手算例与性质测试得到相同结果 |
| DKG 正常流程 | 三个节点得到同一非单位元公钥,正常路径从不重构完整私钥 |
| DKG 恶意输入 | 错误份额、承诺分叉、虚假投诉导致共同剔除或共同中止 |
三个 2-of-3 子集 | 三组独立成功测试 |
| 普通验证兼容 | 聚合签名由单方 Schnorr verifier 接受 |
| 不重构私钥 | 正常签名路径无 combine/recover 调用 |
| 份额可归责 | 修改每个 z_i 都定位到对应 ID |
| identity 防护 | X=O、输入 commitment =O、聚合 R=O 均有负例 |
| nonce 一次性 | 编译期 move 测试 + 持久化/崩溃测试 |
| transcript 唯一 | 黄金字节向量、乱序/重复/长度歧义负例 |
| 会话隔离 | 跨 session、group、epoch 消息全部拒绝 |
| 解析健壮性 | 任意畸形字节返回错误且不 panic |
| 安全边界 | README 和实验报告明确未覆盖项 |
RFC 9591 对照不在上表的核心毕业门槛中。只有选择 rfc9591-conformant-study 加分路线时,才额外要求官方向量逐项一致并与成熟实现完成差分测试。
演示程序
examples/two_of_three.rs 只演示公开流程,不打印秘密:
1. 显示 group public key 指纹和 roster
2. 选择 Alice + Carol
3. 显示两轮中的公开 commitment
4. 显示每个 signature share 的验证结果
5. 显示最终签名与普通验证结果
6. 篡改消息并显示验证失败
禁止输出 signing share、nonce、Dealer 多项式或完整私钥。若需要攻击演示,使用单独的 #[cfg(test)] 夹具和固定玩具参数。
项目报告
综合项目完成时,提交一份短报告,至少回答:
- 你的实现采用哪个确切密码套件和版本?
- 哪些模块是自己实现的,哪些基础原语来自库?
- 正常路径中是否任何时刻出现完整私钥?
- nonce 在发布承诺、崩溃和重试后如何处理?
- 哪些网络和身份假设尚未实现?
- 哪三个测试最可能阻止真实事故?为什么?
- 与 RFC 9591 测试向量还有哪些差异?
- 如果旧 epoch 的完整份额集合泄露,为什么 refresh 不能撤销它?
- 当前项目最不确定的安全结论是什么?
完成定义
只有当以下三句话都真实,综合项目才算完成:
- “我能从公式解释每个公开量和秘密量。”
- “我能用测试展示错误输入在何处、为什么被拒绝。”
- “我能清楚说明这套实现为何仍不能用于生产。”
如果只能生成一枚有效签名,却不能回答这三点,项目仍处于演示阶段。
门限 ECDSA:为什么放在最后
学习定位(选修):完成 FROST 综合项目后再读。本章目标是理解非线性 MPC 难点,不要求把门限 ECDSA 纳入核心毕业项目。
学习目标
完成本章后,你应当能够:
- 对比 Schnorr 与 ECDSA 的签名方程;
- 指出门限 ECDSA 中最困难的非线性步骤;
- 解释为什么 Shamir 份额不能直接套入 ECDSA;
- 为后续研究选择合适的协议范围,而不是混合多篇论文。
普通 ECDSA 方程
令私钥为 \(x\),临时随机数为 \(k\),消息摘要为 \(m\)。先计算:
\[ R=kG,\qquad r=x\text{-coordinate}(R)\bmod q \]
签名响应为:
\[ s=k^{-1}(m+rx)\bmod q \]
验证者通过等价的群关系检查签名。
与 Schnorr 的关键差异
Schnorr 的响应:
\[ z=k+cx \]
只包含秘密值的线性组合。Shamir 份额具有线性,因此参与者可以分别计算贡献,再把贡献相加。
ECDSA 中出现了:
- 秘密 \(k\) 的逆元 \(k^{-1}\);
- 秘密值之间的乘法;
- 从秘密共享点 \(R\) 中提取并共同认可 \(r\);
- 中止、恶意输入和偏置 nonce 等额外问题。
加法份额或 Shamir 份额可以本地相加,但两个被分享秘密的乘积通常不能仅靠各方本地计算后直接相加。协议需要安全乘法,例如 MtA、Paillier 同态加密、OT/Beaver triple,以及相应的范围证明或零知识证明。
一个重要的反例
若 \(k=k_1+k_2\),通常有:
\[ (k_1+k_2)^{-1}\ne k_1^{-1}+k_2^{-1} \]
因此“每个人把自己的 nonce 份额求逆,再把结果相加”不是门限求逆。这正是门限 ECDSA 不能照搬门限 Schnorr 的原因之一。
建议的学习顺序
完成 FROST、VSS、DKG 和状态机实验之后,再按下面两层进入 ECDSA:
教学层
- 实现普通 ECDSA;
- 用一个极小、明确标记为不安全的两方算术实验理解秘密乘法;
- 学习加法共享、Beaver triple 或 MtA 的基本思想;
- 只在本地、半诚实模型中演示最终签名等式。
教学层的目标是理解困难来自哪里,而不是得到安全协议。
研究层
- 选择一个现代且修订状态明确的协议族;
- 固定论文版本、安全模型和参与者数量;
- 严格实现其 Paillier/MtA、范围证明、零知识证明和 complaint/abort 过程;
- 对照论文测试向量、参考实现和安全公告;
- 单独评估并发会话、nonce 生成、DoS 和 identifiable abort。
不要把 GG18、GG20、CGGMP21 或其他论文中的局部步骤自由拼接。它们的证明依赖完整协议和特定假设。
为什么“能生成有效签名”远远不够
一个错误的门限 ECDSA 实现仍可能持续生成验证通过的签名,同时泄露长期密钥。以下问题都不会自动体现在最终验证结果中:
- Paillier 参数或证明验证不完整;
- 恶意参与者选择带偏置的输入;
- nonce 预处理材料重复使用;
- 并发会话混淆;
- 范围证明缺失或验证错误;
- 协议中止时泄露可关联信息。
因此本工程在 FROST 完成以前,不把门限 ECDSA 代码列入主线目标。
检查题
- 为什么 \(k^{-1}\) 是门限 ECDSA 的核心难点之一?
- Shamir 的线性性质允许哪些操作,不允许哪些操作?
- 为什么两个参与者各自选择一个 nonce 还不足以构造安全的 ECDSA?
- “签名能通过标准验证器”遗漏了哪些协议安全性质?
- 为什么实现研究论文时必须固定版本和安全模型?
术语表
基础概念
门限(threshold)
完成恢复或签名所需的最少参与者数,通常记作 \(t\)。2-of-3 表示总共三方,任意两方即可完成操作。
份额(share)
由秘密共享协议生成、分配给单个参与者的秘密值。它不是把私钥字节机械切开后的一段。
参与者标识符(identifier)
映射到标量域的非零唯一值,用作 Shamir 多项式的横坐标。它不是随意显示名称;RFC 9591 进一步要求标识符位于 [1, MAX_PARTICIPANTS]。
有限域(finite field)
元素数量有限、支持加减乘除(除数非零)且运算封闭的代数结构。
标量域(scalar field)
用于私钥、nonce、challenge 和份额的有限域,其模数通常是群的阶 \(q\)。不要与曲线坐标所在的基域混淆。
群(group)
签名协议中用于点加法和标量乘法的代数结构。本手册首先使用素数阶的 Ristretto255 群。
生成元(generator)
固定群元素 \(G\)。私钥 \(x\) 对应公钥 \(X=xG\)。
秘密共享与密钥生成
Shamir Secret Sharing
把秘密作为随机多项式的常数项,通过不同横坐标处的求值得到份额;任意至少 \(t\) 个份额可以插值恢复秘密。
可信 Dealer
生成完整秘密并负责分发份额的一方。它简化第一版实现,但曾经知道完整私钥。
VSS(Verifiable Secret Sharing)
可验证秘密共享。参与者可检查收到的份额是否与公开承诺一致。
Feldman VSS
通过发布多项式系数的群承诺来验证份额。承诺不隐藏秘密取值分布,因此秘密必须具有足够熵。
DKG(Distributed Key Generation)
分布式密钥生成。多方共同贡献随机性,得到密钥份额和群公钥;在具体协议规定的腐败阈值、合格集合和保密假设成立时,任何未达到门限的对手联盟都不能计算完整私钥。
份额刷新(share refresh)
在群公钥不变的情况下更新各参与者份额,通常通过加入常数项为零的随机多项式完成。旧份额必须被停用和销毁,但已经泄露的旧门限集合仍可为同一公钥签名;刷新不能撤销这种泄露。
签名协议
Schnorr 签名
基于离散对数困难问题的线性签名方案。其结构适合门限化。
FROST
Flexible Round-Optimized Schnorr Threshold signatures。一个两轮门限 Schnorr 签名协议。
nonce
每次签名使用的一次性秘密随机数。重复使用可能直接泄露长期私钥或密钥份额。
commitment
第一轮中由 nonce 乘以生成元得到的公开群元素。FROST 每位签名者通常提交 hiding 与 binding 两个 nonce commitment。
challenge
由所选 Schnorr 密码套件按规范输入计算的标量;在 RFC 9591 中,它把组承诺、组公钥和待签消息绑定到签名响应。不要把应用层 session/epoch transcript 擅自加入标准 challenge。
binding factor
FROST 中针对每名参与者计算的标量。RFC 9591 将它绑定到组公钥、待签消息、完整 commitment list 和参与者标识;应用层 session ID 或 epoch 不会自动进入该值。
签名份额(signature share)
单个参与者在第二轮计算的响应贡献。聚合前应使用其公开验证份额进行验证。
coordinator
收集承诺、构造签名包、收集并聚合签名份额的协调角色。它不必持有密钥份额,但可能阻断协议或发送不一致视图。
SigningPackage(密码学签名包)
在本手册的 FROST 路径中,只包含待签消息和严格按参与者标识排序的 nonce commitments。组公钥来自本地已验证 KeyPackage;session、group ID 和 epoch 属于外层认证 envelope,不是该结构的字段。
KeyPackage(本地密钥包)
把一名参与者的长期秘密份额、验证份额、非单位元组公钥、roster、threshold、ciphersuite、group ID 和 epoch 绑定为不可混用的本地状态。
工程安全
密码学 transcript
某个哈希函数按所选协议/密码套件精确规定的有序输入,必须使用规范、无歧义的编码。不要默认它包含应用 session 或 epoch;RFC 9591 的 binding factor 和 challenge 只能读取各自规定的字段。
系统 transcript / 认证 envelope
应用状态机用于认证、授权、防重放和归责的上下文,通常绑定协议与套件版本、group ID、epoch、session ID、round、sender/receiver 和密码学包摘要。它必须在进入份额生成/验证前检查,但不会自动成为标准哈希的输入。
域分离(domain separation)
为不同哈希用途添加不同固定标签,避免同一字节串在不同协议位置被误解。
规范编码(canonical encoding)
每个有效对象只有一种允许的字节表示。解析时应拒绝非规范标量、非法点和多义编码。
重放(replay)
把旧会话中的合法消息再次注入当前会话。会话 ID、状态机和已消费记录用于防御重放。
identifiable abort
协议失败时能够识别提交无效签名份额的参与者,而不仅仅得到“签名失败”。
CSPRNG
密码学安全伪随机数生成器。生产路径应从操作系统获得安全随机性并在失败时停止。
参考资料
本页区分规范、论文、实现文档和辅助解释。阅读顺序不是权威等级:实现时应优先服从所选协议的规范和测试向量。
核心规范
-
D. Connolly, C. Komlo, I. Goldberg, C. A. Wood, RFC 9591: The Flexible Round-Optimized Schnorr Threshold (FROST) Protocol for Two-Round Schnorr Signatures, 2024。
本工程 FROST 主线的首要规范。注意它是 IRTF Informational RFC,并非 IETF Standards Track。 -
S. Josefsson, I. Liusvaara, RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA), 2017。
用于理解 EdDSA 编码和签名背景;本工程第一版使用 Ristretto255,不应把两者混称。 -
J. Nick, T. Ruffing, E. Seurin, P. Wuille, BIP 340: Schnorr Signatures for secp256k1。
后续学习 secp256k1/x-only Schnorr 时使用。BIP340 的 tagged hash、偶数 Y 归一化和编码规则不能直接套用到 Ristretto255。
奠基论文
-
Adi Shamir, How to Share a Secret, Communications of the ACM, 1979。
-
Claus-Peter Schnorr, Efficient Identification and Signatures for Smart Cards, CRYPTO 1989 / published 1990。
-
Chelsea Komlo, Ian Goldberg, FROST: Flexible Round-Optimized Schnorr Threshold Signatures, 2020。
-
Paul Feldman, A Practical Scheme for Non-interactive Verifiable Secret Sharing, 1987。
Rust 与曲线资料
-
curve25519-dalek文档。
第一版借用其Scalar、RistrettoPoint、压缩点和常量时间基础操作。 -
The Ristretto Group。
理解 Ristretto 如何提供素数阶群抽象,以及它与 Edwards25519 的关系。 -
frost-ristretto255文档。
后期只作为测试 oracle、API 参考和互操作对照;不要用它替代自己的协议实现。 -
RustCrypto
k256。
第二阶段实现 secp256k1 对照版本时使用。
辅助阅读
- ZKDocs: Shamir Secret Sharing。
- ZKDocs: Schnorr Signatures。
- The Rustonomicon,用于理解 Rust 中内存、未定义行为与安全边界;它不替代密码学审计。
如何使用资料
- 理论学习可以同时参考论文和辅助解释。
- 编写规范兼容代码时,以 RFC 的算法、编码和测试向量为准。
- 发现论文、RFC、库实现存在差异时,先记录版本和差异,不要自行混合。
- 库文档的
latest页面会变化;真正开始实现时,应把 Cargo 依赖版本锁定并记录审阅日期。
学习日志模板
复制下面的模板,为每个章节或实验保存一份记录。日志的重点不是重复教材,而是留下自己的推导、失败和安全判断。
# 学习记录:<章节或实验名称>
- 日期:
- 对应提交:
- 开始前我能解释的内容:
- 本次目标:
## 我自己的推导
不要直接粘贴手册。用自己的符号重写关键等式,并解释每一项来自哪里。
## 手算例子
- 参数:
- 步骤:
- 预期结果:
- 代码结果:
## 实现决策
- 新增类型/API:
- 哪些字段是秘密:
- 哪些输入来自不可信边界:
- 为什么采用这种编码:
## 测试
| 测试名称 | 要保护的性质 | 结果 |
| --- | --- | --- |
| | | |
## 失败与攻击实验
- 我故意破坏了什么:
- 实际观察:
- 哪个检查阻止了问题:
- 若没有阻止,应如何修复:
## 当前安全假设
- 已由协议保证:
- 依赖调用者或网络保证:
- 尚未解决:
## 我还不能解释的问题
1.
2.
## 进入下一章前的复述
不用看手册,用三到五句话说明本章解决了什么、没有解决什么。
建议把“我还不能解释的问题”保留下来,而不是为了完成感删除。后续章节经常会回答前面的疑问,这些记录能帮助你看到理解发生变化的过程。