Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 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。代码中不要把整数模数、曲线标量和曲线点混为同一种类型。

本章暂时假设:

  1. Dealer 诚实地产生多项式和分发份额;
  2. 参与者已经拿到可信的组公钥和公开验证份额;
  3. 协议消息来自已认证的参与者;
  4. 随机数生成器安全,每个签名 nonce 只用一次;
  5. 所有人对消息、签名者集合、承诺顺序和哈希编码得到完全相同的字节。

这些假设看起来很多,正是后续 VSS、DKG 和安全工程要逐项解决的内容。

5.2 回顾普通 Schnorr

签名者持有私钥 \(x\),签名消息 \(m\):

  1. 随机选择一次性 nonce \(r\leftarrow\mathbb F_q\);

  2. 计算 nonce 承诺 \(R=rG\);

  3. 计算挑战:

    \[ c=H(\text{suite},R,X,m) \]

  4. 计算响应:

    \[ 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\):

  1. 生成新的随机标量 \(r_i\);
  2. 计算 \(R_i=r_iG\);
  3. 把 \(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。不要真的把秘密类型实现为可随意复制、序列化和打印的公开元组结构体;上面的代码只是展示边界。

按以下顺序实现:

  1. lagrange_at_zero(i, signer_ids) -> Scalar;
  2. dealer_keygen(t, ids, rng),返回组公钥、私密份额和验证份额;
  3. commit_nonce(session_id, rng),返回私密 nonce 状态与公开承诺;
  4. sign_share(share, nonce_state, request),先验证 request context 再使用其 toy package;
  5. verify_share(verification_share, commitment, request, z_i);
  6. aggregate(&request.package, verified_shares);
  7. 用普通 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 给这些问题提供了经过分析的两轮结构。学习顺序上,本章是代数桥梁;工程实现上,不应把本章的简化协议包装成生产接口。

常见错误

  1. 在整数域中算份额。 Shamir 多项式和拉格朗日系数必须位于曲线标量域 \(\mathbb F_q\)。
  2. 按参与者数量写死系数。 系数依赖参与者 ID 和本次集合,不只依赖“这是两个人”。
  3. 使用数组下标作为 ID。 下标变化会改变份额语义;ID 应是密钥包的一部分。
  4. 直接相加私钥份额。 Shamir 份额通常要乘拉格朗日系数,只有特殊构造才可以直接相加。
  5. 跳过签名份额验证。 一个错误或恶意份额会污染最终签名,而且难以定位责任方。
  6. 让协调者替签名者计算 transcript。 每位签名者必须独立验证并重算关键派生值。
  7. nonce 重用或复制。 这会直接泄露长期密钥份额;“测试环境”也应默认禁止。
  8. 把教学协议称为 FROST。 FROST 的双 nonce 和绑定因子不是可选装饰。

本章检查题

  1. 为什么最终验证等式中出现的是组公钥 \(X\),而不是所有验证份额 \(X_i\) 的直接和?
  2. 对 \(S=\{2,5\}\),写出两名参与者在零点的拉格朗日系数表达式。
  3. 为什么 \(z_iG=R_i+c\lambda_iX_i\) 能在不知道 \(x_i\) 的情况下验证签名份额?
  4. 若协调者把某位签名者看到的集合从 \(\{1,2\}\) 换成 \(\{1,3\}\),会影响哪些量?
  5. 重用 nonce 时,攻击者还需要知道哪些公开量才能解出 \(x_i\)?
  6. 为什么达到门限后也不应在内存中重构完整私钥?
  7. 本章协议有哪些安全假设会在 FROST、VSS/DKG 和安全工程章节中被逐步移除或明确化?

完成标准

当你能让所有 2-of-3 子集生成可验证签名,能在聚合前拒绝坏份额,并能用公式解释每一项从哪里来时,就完成了这一章。下一章不要复用本章 nonce API;为 FROST 单独设计一次性双 nonce 状态。