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

第 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-3 FROST 状态机及负向测试。

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\) 执行:

  1. 按所选密码套件的 nonce 生成过程独立生成 \(d_i,e_i\);
  2. 计算 \(D_i=d_iG\)、\(E_i=e_iG\);
  3. 把私密状态与本次 nonce 的唯一 ID 持久化并原子地保留给一个会话;
  4. 向协调者发送 (participant_id, nonce_id, D_i, E_i);
  5. 此后不允许把同一 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 一次性规则防止被诱导泄露份额。协议通常无法阻止协调者拒绝服务或扣留最终签名;这属于可用性与系统治理问题,而非签名不可伪造性。

常见错误

  1. 只生成一个 nonce。 这已经不是本章描述的 FROST 结构。
  2. 把私密 nonce 发给协调者。 只能发送 \(D_i,E_i\) 两个点。
  3. binding factor 只哈希自己的承诺。 它必须绑定消息及完整承诺列表,并区分参与者。
  4. 每个人使用不同的承诺顺序。 transcript 字节不同会导致 \(\rho_i\)、\(R\)、\(c\) 全部不同。
  5. 协调者计算好 \(\rho_i\) 发给签名者。 签名者必须从完整包独立重算。
  6. 先发送 \(z_i\),再标记 nonce 已用。 崩溃窗口可能让 nonce 在恢复后再次使用。
  7. 第 2 轮有人掉线就复用剩余人的 nonce。 新集合意味着新的签名包,应全部换新 nonce。
  8. 跳过份额验证直接求和。 最终失败时既浪费会话,也无法可靠归因。
  9. 混用不同 FROST 套件。 曲线相同也不代表哈希、编码和域分离相同。
  10. 认为两轮等于两条网络消息。 每轮可包含多方并发消息,轮次描述的是依赖关系。
  11. 聚合出单位元 \(R\) 后仍继续。 这不仅是无效编码;继续生成响应可能直接泄露组私钥。

本章检查题

  1. hiding nonce 和 binding nonce 哪些内容公开,哪些内容必须保密?
  2. 为什么 \(\rho_i\) 必须依赖完整承诺列表和参与者 ID?
  3. 如果两名签名者对承诺列表顺序理解不同,最先出现分歧的派生量是什么?
  4. 写出签名份额验证等式,并逐项说明右侧数据来自哪里。
  5. 为什么协调者验证所有 \(z_i\) 后仍应再验证最终签名?
  6. 第 2 轮一名签名者离线后,为什么不能直接换入一个新签名者并保留其他 nonce?
  7. Rust 所有权能阻止同一进程内的许多误用,为什么仍不能替代持久化 nonce 台账?
  8. FROST 消除了可信 Dealer 吗?若没有,哪个阶段需要下一章的 DKG?

完成标准

当你的实现能严格走完两轮、对每个派生量有唯一编码、在聚合前验证份额,并在所有 nonce 重用与签名包篡改测试中失败关闭时,本章才算完成。此时你实现的是“带既有密钥份额的 FROST 签名阶段”;密钥份额如何在无可信 Dealer 的情况下生成,是下一章的主题。