实验 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 兼容,禁止用于真实资产。