第 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 的情况下生成,是下一章的主题。