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

第 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           当前消息的规范负载

规范编码应满足:

  1. 每个逻辑对象只有一种合法字节表示;
  2. 字段有类型和长度边界,不能靠字符串拼接猜分隔位置;
  3. map/set 在编码前按规范键排序,重复键直接拒绝;
  4. 整数宽度、字节序、Unicode 策略和空值表示固定;
  5. 点和标量只接受密码套件规定的规范编码;
  6. 未知版本或未知必选字段失败关闭。

可以定义会话摘要:

\[ 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”模式是:

  1. 对 nonce 记录加锁或做 compare-and-swap,确认状态与本次 session 匹配;
  2. 完整验证并冻结签名包,计算 transcript 摘要;
  3. 在内存中计算 \(z_i\),但尚不允许网络层发送;
  4. 同一持久事务内:把 nonce 转为已消费、删除或封存秘密 nonce,并保存绑定该 transcript 的精确响应;
  5. 确认事务已耐久提交;
  6. 由 outbox 发送保存的响应;
  7. 重试时只允许重发同一 transcript 下逐字节相同的响应。

如果在第 4 步前崩溃,没有响应离开系统;如果在第 4 步后、第 6 步前崩溃,恢复进程只能发送已保存的相同响应;如果发送后崩溃,台账已经显示 nonce 消耗。这个顺序牺牲了一些吞吐量,却封住最危险的崩溃窗口。

如果不想持久化签名份额响应,可以采用更简单的失败关闭策略:消费记录耐久提交后才发送,一旦不确定是否已发送就永远中止该 nonce。不要根据旧承诺“重新算一次看看”。

8.5 显式协议状态机

签名者和协调者应有不同状态机。

签名者状态

当前状态允许输入成功后状态关键检查
Idle本地创建/取得 nonceReservedgroup、epoch、库存原子分配
Reserved发送第 1 轮承诺CommitmentPublished先落盘,后发送
CommitmentPublished收到签名包PackageAccepted自己的承诺、消息、集合、transcript
PackageAccepted生成签名份额ConsumedAndResponsePersistedCAS 消费 nonce,原子保存响应,先提交后释放
ConsumedAndResponsePersisted精确重发/确认Completednonce 已消费;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。单靠时间戳不够:时钟会漂移,旧快照也可能让本地时间倒退。

接收消息时依次检查:

  1. 协议、版本、密码套件受支持;
  2. group、epoch 和 roster 与本地当前配置一致;
  3. 身份签名有效,sender 在 roster 中且角色允许该消息类型;
  4. session 存在或能在该消息类型上合法创建;
  5. round、状态、message ID/sequence 合法;
  6. transcript 链接与本地冻结摘要一致;
  7. 重复消息若被允许,必须逐字节等同于先前已接受消息;
  8. 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 的相同消息幂等,不同消息触发冲突;
  • 协调者向两个签名者发送不同包时,两者产生可比较的分叉证据;
  • 合法身份但无业务权限的请求仍被策略层拒绝。

任务五:故障与恢复矩阵

为每个外发消息测试以下时刻:

验证前崩溃
计算后、事务前崩溃
事务提交中崩溃
提交后、发送前崩溃
发送后、确认前崩溃
收到重复确认后崩溃
从旧快照启动
两个实例同时启动

每个测试都检查三个不变量:

  1. nonce 不跨 transcript 重用;
  2. 不会对未验证的签名包释放份额;
  3. 恢复后要么精确幂等,要么安全中止。

8.11 可观测性与事故响应

可以记录:

  • group/epoch/session 的不敏感标识或审计哈希;
  • 状态转换、拒绝原因类别和消息身份;
  • nonce 库存数量、消耗数量和过期烧毁数量;
  • 份额验证失败、transcript 分叉、旧 epoch 重放和并发 CAS 冲突;
  • DKG 投诉数、资格集合结果和组公钥指纹。

不要记录私密 nonce、签名份额的内部标量、长期份额、DKG 私密分量或未经审查的完整业务消息。

发现以下任一情况应停止相关组签名,而不是自动重试:

  • 同一 nonce ID 对应两个 transcript;
  • 已消费台账回退或无法证明连续性;
  • 同一 session 出现两个冻结签名包;
  • 密钥份额、身份密钥或 nonce 库可能泄露;
  • 节点对 roster、epoch 或组公钥不一致;
  • 出现无法解释的有效身份签名消息。

事故处理首先保存不含秘密的证据并隔离节点,再判断恶意数量是否可能达到门限。仅执行份额刷新不能修复一个已经泄露的完整组私钥;必要时应新建组密钥并迁移依赖方。

常见错误

  1. 直接哈希 JSON。 非规范编码会让不同实现得到不同 transcript,甚至产生歧义。
  2. 把审计 transcript 当成标准 challenge。 擅自改 challenge 输入会改变签名方案。
  3. 发送后才记 nonce 已用。 崩溃恢复会打开重用窗口。
  4. 从快照恢复预计算 nonce。 已经用过的 nonce 可能重新出现在库存。
  5. 用时间戳当唯一 session ID。 时钟碰撞、回退和多实例都会破坏假设。
  6. TLS 之后不认证多方消息。 中间协调者可能替换消息来源或内容。
  7. 重复消息一律重新计算。 正确幂等是重发完全相同的持久响应,不是重新签一次。
  8. 错误时把秘密写进日志。 可观测性系统往往比签名节点暴露面更大。
  9. 状态只存在内存。 重启后系统无法判断承诺或响应是否已经发出。
  10. 把协议安全等同于业务授权。 密码学只能证明达到门限的人签了某些字节,不能判断交易是否合理。

本章检查题

  1. 为什么 session_id、epoch 和 transcript 摘要需要同时存在?
  2. 规范编码必须解决哪几类“一对象多表示”问题?
  3. 为什么 outbox 要在网络发送前耐久保存消费状态和响应?
  4. 已发布承诺但未产生签名份额的 nonce,为什么仍应烧毁?
  5. TLS 已启用时,哪些拓扑仍需要端到端消息签名?
  6. 从旧快照恢复后,为什么简单地“生成一个新 session ID”仍不能保证 nonce 安全?
  7. 精确幂等重发与重新计算签名份额有什么区别?
  8. 哪些业务字段必须进入最终待签消息,而不能只出现在本地日志?
  9. 如果同一参与者的两个实例同时运行,数据库需要提供什么原子性质?
  10. 发现 nonce 可能重用时,为什么自动重试是最危险的选择之一?

完成标准

当实现满足以下条件,本阶段才算完成:

  • 所有协议对象有唯一、带版本、带域分离的编码;
  • nonce 生命周期在持久存储中可证明且不可通过普通备份回滚;
  • 每条消息经过身份认证、会话/epoch/轮次和 transcript 检查;
  • 状态机拒绝乱序、分叉和跨会话重放;
  • 在每个事务与发送边界注入崩溃后,nonce 仍不会跨 transcript 重用;
  • 并发实例无法重复领取同一 nonce;
  • 日志、指标和错误中不出现秘密;
  • 最终签名仍由目标普通 Schnorr 验证器验证。

到这里,你已经有了一套完整的教学路线:Shamir 提供门限共享,Schnorr 提供签名等式,FROST 提供两轮协作签名,VSS/DKG 去掉可信 Dealer,安全状态机则保证这些公式不会在重试、崩溃和真实网络中失去意义。