第 8 章:把协议变成不会轻易出事故的系统
学习定位:前置为 FROST 与本地 DKG;配套完成实验 5:对抗性测试和实验 6:综合项目。产物是显式状态机、nonce 台账和故障恢复证据。
公式正确只是起点。真实门限签名事故更常来自 nonce 重用、消息编码分歧、状态回滚、身份冒充、并发竞态或“重试”逻辑,而不是椭圆曲线加法写错。本章把 FROST 和 DKG 放进一个可恢复、可审计、默认失败关闭的系统外壳。
这里的目标仍是教学工程,不是生产安全认证。若要保护真实资产,需要选定标准协议与密码套件、使用经过审计的实现、完成独立安全评审,并把部署、运维和治理一并纳入威胁模型。
学习目标
完成本章后,你应该能够:
- 设计规范、域分离且无歧义的 transcript;
- 把 nonce 当作持久化的一次性资产管理,而不是临时随机变量;
- 为签名者和协调者设计可验证的状态机;
- 使用独立身份密钥认证协议消息;
- 拒绝跨会话、跨 epoch、跨轮次和跨 transcript 的重放;
- 设计“先持久化,再发送”的崩溃恢复顺序;
- 用并发、故障注入、模糊测试和恢复测试验证安全不变量;
- 区分密码学不可伪造性、系统可用性与业务授权策略。
8.1 先定义资产和对手
至少保护以下资产:
- 长期签名份额与 DKG/刷新时的临时多项式;
- 未使用的 hiding/binding nonce;
- 用户真正同意签署的业务语义;
- roster、组公钥、验证份额和当前 epoch;
- 会话状态、已消耗 nonce 台账及审计证据;
- 节点身份密钥和信道凭据。
考虑以下能力不同的对手:
- 控制协调者,但未控制足够签名者;
- 控制少数签名节点并发送任意协议消息;
- 观察、延迟、重复、乱序或丢弃网络消息;
- 读取旧备份或让服务回滚到旧快照;
- 同时启动同一签名节点的两个实例;
- 诱导操作者签署显示内容与实际字节不一致的消息;
- 读取日志、崩溃转储、指标标签或错误报告;
- 通过大量无效点、超大消息或未完成会话消耗资源。
“攻击者不能解离散对数”并不能解决上述工程问题。
8.2 Transcript:让所有人签的是同一件事
Transcript 是协议会话的规范记录。它既用于派生哈希输入,也用于把消息和本地状态绑定到唯一上下文。
一个协议消息的逻辑字段通常包括:
protocol_id 例如 FROST-sign 或 DKG
suite_id 曲线、哈希、编码和协议套件
version 线格式与规则版本
group_id, epoch 哪组密钥、哪个份额时期
session_id 本次运行的随机唯一标识
message_type commitment / package / share / complaint ...
round 协议轮次
sender, receiver 经认证的身份;广播可使用明确的广播标记
message_digest 待签字节的规范摘要或规范要求的原消息
signer_set 按规范顺序、无重复的参与者 ID
commitments 按参与者 ID 规范排序
previous_hash 需要时链接前一阶段 transcript
payload 当前消息的规范负载
规范编码应满足:
- 每个逻辑对象只有一种合法字节表示;
- 字段有类型和长度边界,不能靠字符串拼接猜分隔位置;
- map/set 在编码前按规范键排序,重复键直接拒绝;
- 整数宽度、字节序、Unicode 策略和空值表示固定;
- 点和标量只接受密码套件规定的规范编码;
- 未知版本或未知必选字段失败关闭。
可以定义会话摘要:
\[ T=H_{\text{transcript}}( \text{domain}\parallel\operatorname{CanonicalEncode}(fields)) \]
其中 domain 至少区分协议、版本、密码套件和消息类型。不要直接哈希普通 JSON:对象键顺序、数字形式、重复键、Unicode 和空白都可能产生歧义。可使用经过定义的 canonical CBOR、Protobuf 的受限确定性配置,或自己极小且有测试向量的长度前缀编码;关键是规范,而不是格式名称。
Transcript 哈希不等于 Schnorr challenge
FROST 的 binding factor 与最终 Schnorr challenge 有各自规定的输入。系统 transcript 可以包含 session、epoch 和业务审计字段,但不能擅自把所有字段塞进 \(c\),否则会破坏标准签名兼容性或安全证明。
如果“转账链 ID、金额、订单号”等业务语义需要被最终签名验证者确认,它们必须进入待签消息的规范表示,或进入由应用层明确验证的签名上下文。只写入本地审计日志,不能防止签名被挪作他用。
8.3 nonce 是状态,不只是随机数
FROST 每次签名需要一对一次性秘密 nonce。核心不变量是:
一对 nonce 最多为一个冻结的签名包释放一次签名份额,且持久状态回滚后也不能被重新分配。
推荐生命周期:
Generated
-> Reserved(session_id)
-> CommitmentPublished(commitment_hash)
-> ConsumedAndResponsePersisted(transcript_hash, exact_response)
-> SecretDeletedOrSealed
ConsumedAndResponsePersisted 必须是一个原子持久化结果,不是两个中间可崩溃的步骤;其中 nonce 已不可再次分配。最后一个状态表示秘密材料已经删除或被不可恢复地封存,而保留最小消费墓碑和幂等响应记录。
中止分支也要安全:
- 承诺一旦可能被外界看到,该 nonce 对就必须烧毁;
- 简单实现可以在
Reserved时就规定“永不退回库存”,用少量可用性换清晰安全性; - 只有能证明任何承诺字节从未离开受控进程时,才可能安全回收预留 nonce;生产设计通常不值得依赖这种证明。
数据库记录至少包含:
- 随机
nonce_id,不能只用自增 ID 充当秘密随机性; - 组 ID、epoch、所属参与者;
- 两个承诺和加密保存的两个秘密 nonce;
- 当前状态、绑定的 session/transcript 摘要;
- 单调版本号、创建/消费审计时间;
- 若支持幂等重发,保存加密的完全相同响应字节。
预计算 nonce 库不应跟随普通虚拟机快照或数据库备份回滚。最简单的恢复策略是:备份长期份额,但重启或还原后丢弃全部预计算 nonce,重新生成。若业务必须备份 nonce 库,就需要快照之外不可回滚的消耗台账,例如受保护的单调计数器或独立一致存储。
8.4 安全的第 2 轮提交顺序
危险顺序是:
计算 z_i -> 发送 z_i -> 标记 nonce 已消费
若进程在发送后、落盘前崩溃,恢复后可能重用 nonce。安全的“事务 outbox”模式是:
- 对 nonce 记录加锁或做 compare-and-swap,确认状态与本次 session 匹配;
- 完整验证并冻结签名包,计算 transcript 摘要;
- 在内存中计算 \(z_i\),但尚不允许网络层发送;
- 同一持久事务内:把 nonce 转为已消费、删除或封存秘密 nonce,并保存绑定该 transcript 的精确响应;
- 确认事务已耐久提交;
- 由 outbox 发送保存的响应;
- 重试时只允许重发同一 transcript 下逐字节相同的响应。
如果在第 4 步前崩溃,没有响应离开系统;如果在第 4 步后、第 6 步前崩溃,恢复进程只能发送已保存的相同响应;如果发送后崩溃,台账已经显示 nonce 消耗。这个顺序牺牲了一些吞吐量,却封住最危险的崩溃窗口。
如果不想持久化签名份额响应,可以采用更简单的失败关闭策略:消费记录耐久提交后才发送,一旦不确定是否已发送就永远中止该 nonce。不要根据旧承诺“重新算一次看看”。
8.5 显式协议状态机
签名者和协调者应有不同状态机。
签名者状态
| 当前状态 | 允许输入 | 成功后状态 | 关键检查 |
|---|---|---|---|
Idle | 本地创建/取得 nonce | Reserved | group、epoch、库存原子分配 |
Reserved | 发送第 1 轮承诺 | CommitmentPublished | 先落盘,后发送 |
CommitmentPublished | 收到签名包 | PackageAccepted | 自己的承诺、消息、集合、transcript |
PackageAccepted | 生成签名份额 | ConsumedAndResponsePersisted | CAS 消费 nonce,原子保存响应,先提交后释放 |
ConsumedAndResponsePersisted | 精确重发/确认 | Completed | nonce 已消费;transcript 和响应字节完全相同 |
| 任意未终态 | 中止 | AbortedConsumed | 已发布或不确定的 nonce 永不复用 |
协调者状态
Created
-> CollectingCommitments
-> PackageFrozen
-> CollectingShares
-> SharesVerified
-> Completed
任何阶段都可进入 Aborted,但不能从 Aborted 返回旧会话继续签。第 2 轮更换 signer set、消息或任一承诺时,必须新建会话并使用全新 nonce。
状态转换规则:
- 收到未来轮次消息:拒绝,不暂存为“稍后自动处理”;
- 收到过去轮次的完全相同消息:按幂等规则返回旧结果或确认;
- 同一消息 ID 但内容不同:视为冲突并告警;
- 同一 session 出现两个签名包摘要:中止并保留证据;
- 状态未知或恢复不确定:失败关闭,烧毁相关 nonce。
用一个集中的 transition(state, authenticated_message) 实现规则,避免每个 HTTP handler 自己猜当前状态。
8.6 身份认证和信道保护
门限签名份额不应兼任节点的网络身份密钥。每个节点应有独立身份凭据,例如硬件保护的 Ed25519/P-256 身份密钥或双向 TLS 证书,用于:
- 认证承诺、签名包、签名份额、投诉和 DKG 广播的发送者;
- 把消息签名覆盖到完整规范 envelope;
- 支持 roster 更新、凭据吊销和独立轮换;
- 为审计提供不可抵赖或至少可归因的证据。
一个消息 envelope 至少包含:
protocol/version/suite
group_id/epoch/session_id
round/message_type
sender_id/receiver_id
sequence_or_message_id
payload_hash/transcript_hash
identity_signature
加密和认证解决不同问题:TLS 能保护传输,但如果协调者终止 TLS 后再转发多方消息,签名者还需要端到端认证原始发送者。DKG 私密份额还要求只让指定接收者解密。
身份认证不等于业务授权。节点还应在本地执行策略,例如允许的链、金额上限、请求来源、人工审批或速率限制;不能因为请求来自“合法协调者”就盲签。
8.7 重放防护
每次会话使用高熵随机 session_id,并同时绑定 group_id + epoch。单靠时间戳不够:时钟会漂移,旧快照也可能让本地时间倒退。
接收消息时依次检查:
- 协议、版本、密码套件受支持;
- group、epoch 和 roster 与本地当前配置一致;
- 身份签名有效,sender 在 roster 中且角色允许该消息类型;
- session 存在或能在该消息类型上合法创建;
- round、状态、message ID/sequence 合法;
- transcript 链接与本地冻结摘要一致;
- 重复消息若被允许,必须逐字节等同于先前已接受消息;
- nonce 未被其他 session 或 transcript 使用。
保留已完成/中止 session 的防重放墓碑,期限至少覆盖系统定义的最大消息寿命;对于 nonce 消耗记录,不能仅因业务会话过期就删除后允许恢复。epoch 升级后拒绝旧 epoch 消息,但仍应保留足够审计记录定位攻击。
8.8 崩溃恢复、备份与并发
恢复原则
- 任何外发安全敏感消息都先有耐久本地记录;
- 恢复程序只根据数据库/WAL 中已提交状态行动,不根据“协调者说你之前没发”;
- 无法判断 nonce 是否暴露时,视为已经暴露并烧毁;
- 恢复后只重发已保存的相同响应,不重新接受修改后的签名包;
- 启动时校验 group、epoch、roster hash 和密钥包是否一致。
快照回滚
恢复旧虚拟机或数据库快照可能同时恢复“未使用”nonce 和旧 session 计数器,这是门限签名的高危操作。可选防护包括:
- nonce 库不进入可回滚备份,恢复后全部重建;
- 使用数据库外的不可回滚 epoch/单调计数器;
- 启动时与独立一致存储核对最高消费水位;
- 每次恢复强制切换新 epoch 并执行规范的 refresh/resharing 或新 DKG。
最后一项不是万能修复:若已有足够份额泄露或发生 nonce 重用导致份额泄露,应按密钥失陷响应,通常需要停止签名并迁移到新组密钥。
多实例与并发
同一参与者的两个进程绝不能各自从同一快照拿到同一个 nonce。可用:
- 单写者架构;
- 提供线性一致 compare-and-swap 的共享存储;
- 每实例独立且不重叠的 nonce 命名空间,并有不可回滚租约;
- HSM/安全模块内部的一次性句柄。
数据库“读到 available,然后 update”不是原子分配;应使用带版本条件的更新,并断言恰好修改一行。
8.9 输入验证、秘密处理和抗拒绝服务
密码学输入
- 严格按套件解码点和标量,拒绝非规范编码;
- 按套件要求检查单位元、小子群或无效点;Ristretto 编码简化了子群处理,但仍要检查解码失败和规范性;
- 参与者 ID 必须非零、唯一且属于 roster;
- signer set 大小满足门限并不超过 roster;
- 承诺、验证份额、组公钥的数量和归属匹配;
- 最终输出前再执行一次普通 Schnorr 验证。
秘密数据
- 秘密类型不实现明文
Debug、Display或通用序列化; - 磁盘上的份额和 nonce 使用独立密钥加密,并保护完整性;
- 尽量缩短明文生命周期,避免复制,使用受审查的常数时间库;
- 禁止在日志、metrics label、trace、panic、core dump 中出现份额、nonce 或私密 DKG 消息;
zeroize是纵深防御,不是对编译器、交换分区和硬件残留的完整证明。
资源限制
在进行昂贵曲线运算前先完成长度、版本、身份和会话配额检查。限制:
- 单消息和待签消息大小;
- 每身份并发 session 数;
- 预留 nonce 的速率和总量;
- DKG 投诉与响应数量;
- 未完成会话的生存时间。
超时清理只能把 nonce 烧毁,不能把已发布承诺的 nonce 放回库存。
8.10 Rust 动手实践
任务一:实现规范 transcript 编码
定义带版本的结构,不允许业务层自己拼字节:
#![allow(unused)]
fn main() {
trait CanonicalEncode {
fn encode_canonical(&self, out: &mut Vec<u8>) -> Result<(), EncodeError>;
}
fn transcript_hash<T: CanonicalEncode>(
domain: &'static [u8],
value: &T,
) -> Result<[u8; 32], EncodeError>;
}
测试包括黄金向量、字段边界、空值、重复 ID、乱序输入、未知版本和跨平台一致性。使用 property test 验证“解码后再编码只有一种表示”,并用 fuzzing 确认任意字节不会 panic。
任务二:让状态机拒绝非法转换
用枚举表达状态,用一个入口处理消息:
#![allow(unused)]
fn main() {
enum SignerState {
Idle,
Reserved,
CommitmentPublished,
PackageAccepted,
ConsumedAndResponsePersisted,
Completed,
AbortedConsumed,
}
fn apply(
state: SignerState,
message: AuthenticatedMessage,
) -> Result<(SignerState, Vec<Effect>), ProtocolError>;
}
Effect 描述“写数据库”“发送消息”等意图;测试解释器可以在每个 effect 前后注入崩溃,生产解释器则保证提交顺序。
任务三:实现原子 nonce 仓库
先定义接口,再用 SQLite 或其他支持事务的存储实现:
#![allow(unused)]
fn main() {
trait NonceStore {
fn reserve(&self, group: GroupId, epoch: u64, session: SessionId)
-> Result<ReservedNonce, StoreError>;
fn persist_response_once(
&self,
nonce_id: NonceId,
expected_version: u64,
transcript: TranscriptHash,
response: EncryptedResponse,
) -> Result<PersistOutcome, StoreError>;
}
}
启动 100 个并发任务争抢同一条 nonce,断言只有一个成功。对事务提交前、提交后、网络发送前和发送后分别注入崩溃,恢复后断言:同一个 nonce ID 从未对应两个不同 transcript 或两个不同响应。
任务四:认证 envelope 与重放测试
为每个模拟节点生成独立身份密钥,对完整 envelope 签名。测试:
- 篡改 sender、round、payload 或 transcript 后认证失败;
- 把旧 epoch 的有效消息放进新 session 被拒绝;
- 同一 message ID 的相同消息幂等,不同消息触发冲突;
- 协调者向两个签名者发送不同包时,两者产生可比较的分叉证据;
- 合法身份但无业务权限的请求仍被策略层拒绝。
任务五:故障与恢复矩阵
为每个外发消息测试以下时刻:
验证前崩溃
计算后、事务前崩溃
事务提交中崩溃
提交后、发送前崩溃
发送后、确认前崩溃
收到重复确认后崩溃
从旧快照启动
两个实例同时启动
每个测试都检查三个不变量:
- nonce 不跨 transcript 重用;
- 不会对未验证的签名包释放份额;
- 恢复后要么精确幂等,要么安全中止。
8.11 可观测性与事故响应
可以记录:
- group/epoch/session 的不敏感标识或审计哈希;
- 状态转换、拒绝原因类别和消息身份;
- nonce 库存数量、消耗数量和过期烧毁数量;
- 份额验证失败、transcript 分叉、旧 epoch 重放和并发 CAS 冲突;
- DKG 投诉数、资格集合结果和组公钥指纹。
不要记录私密 nonce、签名份额的内部标量、长期份额、DKG 私密分量或未经审查的完整业务消息。
发现以下任一情况应停止相关组签名,而不是自动重试:
- 同一 nonce ID 对应两个 transcript;
- 已消费台账回退或无法证明连续性;
- 同一 session 出现两个冻结签名包;
- 密钥份额、身份密钥或 nonce 库可能泄露;
- 节点对 roster、epoch 或组公钥不一致;
- 出现无法解释的有效身份签名消息。
事故处理首先保存不含秘密的证据并隔离节点,再判断恶意数量是否可能达到门限。仅执行份额刷新不能修复一个已经泄露的完整组私钥;必要时应新建组密钥并迁移依赖方。
常见错误
- 直接哈希 JSON。 非规范编码会让不同实现得到不同 transcript,甚至产生歧义。
- 把审计 transcript 当成标准 challenge。 擅自改 challenge 输入会改变签名方案。
- 发送后才记 nonce 已用。 崩溃恢复会打开重用窗口。
- 从快照恢复预计算 nonce。 已经用过的 nonce 可能重新出现在库存。
- 用时间戳当唯一 session ID。 时钟碰撞、回退和多实例都会破坏假设。
- TLS 之后不认证多方消息。 中间协调者可能替换消息来源或内容。
- 重复消息一律重新计算。 正确幂等是重发完全相同的持久响应,不是重新签一次。
- 错误时把秘密写进日志。 可观测性系统往往比签名节点暴露面更大。
- 状态只存在内存。 重启后系统无法判断承诺或响应是否已经发出。
- 把协议安全等同于业务授权。 密码学只能证明达到门限的人签了某些字节,不能判断交易是否合理。
本章检查题
- 为什么
session_id、epoch 和 transcript 摘要需要同时存在? - 规范编码必须解决哪几类“一对象多表示”问题?
- 为什么 outbox 要在网络发送前耐久保存消费状态和响应?
- 已发布承诺但未产生签名份额的 nonce,为什么仍应烧毁?
- TLS 已启用时,哪些拓扑仍需要端到端消息签名?
- 从旧快照恢复后,为什么简单地“生成一个新 session ID”仍不能保证 nonce 安全?
- 精确幂等重发与重新计算签名份额有什么区别?
- 哪些业务字段必须进入最终待签消息,而不能只出现在本地日志?
- 如果同一参与者的两个实例同时运行,数据库需要提供什么原子性质?
- 发现 nonce 可能重用时,为什么自动重试是最危险的选择之一?
完成标准
当实现满足以下条件,本阶段才算完成:
- 所有协议对象有唯一、带版本、带域分离的编码;
- nonce 生命周期在持久存储中可证明且不可通过普通备份回滚;
- 每条消息经过身份认证、会话/epoch/轮次和 transcript 检查;
- 状态机拒绝乱序、分叉和跨会话重放;
- 在每个事务与发送边界注入崩溃后,nonce 仍不会跨 transcript 重用;
- 并发实例无法重复领取同一 nonce;
- 日志、指标和错误中不出现秘密;
- 最终签名仍由目标普通 Schnorr 验证器验证。
到这里,你已经有了一套完整的教学路线:Shamir 提供门限共享,Schnorr 提供签名等式,FROST 提供两轮协作签名,VSS/DKG 去掉可信 Dealer,安全状态机则保证这些公式不会在重试、崩溃和真实网络中失去意义。