第 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 状态。