多方签名方案的工程陷阱:门限密码学与分布式密钥生成的实现陷阱
多方签名(Multi-party Signature)和门限签名(Threshold Signature)近年来在加密货币钱包、区块链共识和分布式系统中频繁出现。GG18(2018 年 Gennaro 等人)和 CGGMP20(2020 年 Compressed GG18)等方案被广泛采用。然而在工程实践中,看似简单的"分片签名再组合"背后藏着大量隐蔽陷阱。
本文基于实测经验,总结七个高频踩坑点,每个都附带真实代码级解决方案。
1. 为什么需要门限签名
基本动机:单点故障。无论是 HSM 还是热钱包,私钥集中在一处就是攻击者的单一目标。门限签名(t, n)方案将私钥分为 n 份分发给参与方,任意 t 份即可签名,任意 t-1 份无法还原私钥。
经典方案:
- Shamir 秘密共享:将多项式 f(x) = a₀ + a₁x + ... + a_{t-1}x^{t-1} 的常数项作为秘密,每个参与方获得 f(i)。
- GG18/CGGMP20:基于离散对数的门限 ECDSA/SM2 签名,核心是分布式密钥生成(DKG)和分段签名。
2. 陷阱一:共享参数的"伪造承诺"
问题描述
在 DKG(分布式密钥生成)阶段,每个参与方需广播自己的公钥承诺(commitment)。若使用简单的 Pedersen 承诺,恶意参与方可构造虚假承诺,导致最终公钥被某个参与方单方面控制。
攻击场景
假设参与方 Pᵢ 在生成份额时,私钥份额 dᵢ 和承诺 Cᵢ 之间没有零知识证明绑定。Pᵢ 可以在知道 dᵢ 的同时,让 Cᵢ 对应一个不同的秘密值。最终所有参与方验证通过,但 Pᵢ 能单独重放私钥。
防御方案
每个承诺必须附带零知识证明(ZKP),证明承诺内部值与份额生成的一致性。以 GG18 为例,使用 Schnorr 风格的 ZKP:
// 伪代码:承诺时的 ZKP
func ProveCommitment(commitment Commitment, secret secret) ZKP {
// 证明 commitment = g^secret · h^random
// 使用 Sigma 协议的 Fiat-Shamir 变换
challenge := Hash(commitment, commitment.R)
response := secret + random * challenge
return ZKP{Commitment: commitment, Response: response, Challenge: challenge}
}
// 验证 ZKP
func VerifyZKP(proof ZKP, commitment Commitment) bool {
// 重新计算并比对
}要点: 任何参与方发布的承诺,必须同时提供可验证的 ZKP,且 ZKP 必须绑定到该次 DKG 会话 ID,防止重放攻击。
3. 陷阱二:会话绑定丢失导致重放攻击
问题描述
门限签名通常涉及多轮通信(DKG → 初始化 → 签名请求 → 签名协作 → 结果聚合)。若各轮消息缺少会话绑定,攻击者可以截获某轮的承诺消息,在后续轮次中重放。
具体攻击
攻击者截获参与方 A 在 DKG 阶段的承诺消息 C_A。在签名阶段,攻击者伪造为 A 发送相同的 C_A,而其他参与方因为未验证承诺来源与会话绑定,接受了这个承诺。最终 A 的实际私钥份额无法参与签名,但承诺已被使用,导致私钥泄露。
防御方案
每轮消息携带会话绑定元数据:
type SessionContext struct {
SessionID uint64 // 本次签名会话的唯一 ID
Participant []*ParticipantID
Nonce []byte // 本轮随机数
Commitments map[ParticipantID]Commitment
}
func BindSession(msg Message, ctx SessionContext) []byte {
return Hash(ctx.SessionID, msg.Nonce, msg.Data)
}推荐实践: 使用标准的 HKDF 派生不同轮次的子密钥,确保每轮独立:
func DeriveRoundKey(masterKey []byte, round Round, ctx SessionContext) []byte {
info := []byte(fmt.Sprintf("round-%d-session-%d", round, ctx.SessionID))
return HKDF-SHA256(masterKey, info)
}4. 陷阱三:非预期零值泄露
问题描述
在基于 ElGamal 加密的门限签名中,签名协作阶段需要加密共享随机数 k。若 k 的小分片为 0,或参与方提交的随机数有特定结构,可能泄露私钥份额信息。
数学原理
设共享随机数 k 被 Shamir 分割为 k₁, k₂, ..., kₙ。若某个参与方 i 的份额 kᵢ = 0,则其他参与方可通过解密其他份额直接恢复 k(因为 0 不贡献任何信息量)。更严重的是,攻击者可通过多轮观察零值模式推断私钥份额。
防御方案
强制要求每个参与方生成的随机数份额满足:
// 生成非零份额
func GenerateNonZeroShare(degree int, threshold int) []byte {
for {
share := RandomBytes(32)
// 检查是否在安全子群内且非零
if !IsZeroInGroup(share) && IsValidInSubgroup(share) {
return share
}
}
}
// 验证所有份额非零且有效
func ValidateShares(shares [][]byte) error {
for i, share := range shares {
if len(share) == 0 || IsZeroInGroup(share) {
return fmt.Errorf("participant %d has invalid zero share", i)
}
}
return nil
}额外防护: 对随机数 k 的生成使用 CSRNG(密码学安全随机数生成器),并验证其统计特性。
5. 陷阱四:模逆运算的侧信道泄漏
问题描述
门限 ECDSA/SM2 签名需要计算 k⁻¹ mod n(n 为椭圆曲线阶)。这个模逆运算通常需要 O(log n) 次迭代,每次迭代都依赖前一步的结果,无法轻易并行化。使用不正确的实现可能导致常量时间违规,暴露 k 值。
安全实现
必须使用常量时间模逆:
// 使用扩展欧几里得算法的常量时间变体
func ConstantTimeModInverse(a, n uint256) (uint256, error) {
// 标准模逆无法保证常量时间
// 必须使用 Montgomery ladder 或类似技术
// 注意:Go 标准库 math/big 的 ModInverse 不是常量时间的
// 方案一:使用经过审计的库(如 google/tink 或 rust crypto 的恒等实现)
// 方案二:自实现 Montgomery 乘法
return montgomeryModInverse(a, n)
}
// Montgomery ladder 变体(简化示意)
func montgomeryModInverse(a, n uint256) (uint256, error) {
// 实际实现需处理 256-bit 大数运算
// 关键在于所有分支路径长度相同
}关键原则: 所有涉及密钥的运算必须经过常量时间审计。避免使用条件分支基于密钥值的代码路径。
6. 陷阱五:签名聚合时的验证疏忽
问题描述
门限签名的最后一步是聚合各参与方的部分签名。若验证逻辑过于宽松,攻击者可以通过提交"看起来合法"的假部分签名来绕过验证,最终得到一个无效的完整签名,但验证方未能发现。
具体漏洞
假设 t=3, n=5。参与方 A、B、C 提交部分签名 σₐ, σb, σc。若聚合函数直接求和,且验证只检查最终签名的有效性(而非每个部分签名),则恶意参与方可以提交精心构造的假签名,只要最终聚合结果碰巧通过验证即可。
防御方案
双重验证机制:
func VerifyThresholdSignature(
partialSigns []PartialSignature,
pubKeys []Point,
threshold int,
message []byte,
) ([]byte, error) {
// 步骤1:验证每个部分签名
for i, ps := range partialSigns {
if !VerifyPartial(ps, pubKeys[i], message) {
return nil, fmt.Errorf("invalid partial signature at index %d", i)
}
}
// 步骤2:聚合
combinedSig := AggregatePartial(partialSigns)
// 步骤3:验证完整签名
if !VerifyFullSignature(combinedSig, pubKeys, threshold, message) {
return nil, ErrInvalidThresholdSig
}
return combinedSig, nil
}要点: 不仅验证最终签名,还要验证每个部分签名的合法性,并将两者结合检查。
7. 陷阱六:参与方身份伪造与 Sybil 攻击
问题描述
在开放的 P2P 网络中,一个攻击者可以创建多个假身份参与门限签名协议,从而控制超过阈值份额。这是典型的 Sybil 攻击。
防御方案
基于 PKI 的身份绑定:
type Participant struct {
ID ParticipantID
PublicKey Point // 参与方的公钥
Certificate X509Cert // 身份证书
ProofOfPossession []byte // 公钥所有权证明
}
func VerifyParticipant(p Participant) bool {
// 1. 验证证书链
if !VerifyCertificateChain(p.Certificate) {
return false
}
// 2. 验证公钥与证书匹配
if !PublicKeyMatchesCertificate(p.PublicKey, p.Certificate) {
return false
}
// 3. 验证 Proof of Possession
if !VerifyProofOfPossession(p.PublicKey, p.ProofOfPossession) {
return false
}
return true
}推荐实践: 使用预分发的参与者列表(类似区块链的 validator set),而非动态加入。每次签名会话前验证参与方集合的合法性。
8. 陷阱七:错误处理中的信息泄漏
问题描述
门限签名过程中,任何中间步骤的错误都可能泄露有用信息。例如,当验证失败时,错误消息返回了具体的失败原因("部分签名 Sᵢ 无效" vs "聚合后签名无效"),这可以帮助攻击者定向构造攻击。
防御方案
统一错误处理:
func SafeVerify(sigs []PartialSignature) error {
// 所有错误返回统一错误类型
var lastErr error
for i, sig := range sigs {
if err := verifyIndividual(sig); err != nil {
lastErr = fmt.Errorf("verification failed") // 不泄露具体位置
continue
}
}
return lastErr
}额外防护: 所有调试信息仅在本地日志输出,不通过 API 返回给调用方。使用结构化日志,包含时间戳但不包含敏感数据。
9. SM2 特有的工程考量
9.1 ZA 值的处理
SM2 签名需要 ZA 值(身份标识哈希),与 ECDSA 不同。在门限场景中,ZA 的计算需要在签名前完成,且所有参与方必须使用相同的 ZA 值。
// SM2 ZA 值计算(GM/T 0009-2012)
func CalculateZA(userID []byte, publicKey Point) []byte {
// ZA = Hash(ENTL || ID || a || b || xG || yG || xA || yA)
entLength := uint16(len(userID) * 8)
entlBytes := []byte{byte(entLength >> 8), byte(entLength & 0xFF)}
// 拼接并哈希
data := append(append(append(append(entlBytes, userID...),
sm2Params.A[:]...), sm2Params.B[:]...)...,
append(sm2Params.Gx[:], append(sm2Params.Gy[:], publicKey.X[:]...)...),
publicKey.Y[:]...))
return sm3Hash(data)
}9.2 国密曲线的特殊要求
SM2 使用特定的椭圆曲线参数(定义在 GM/T 0002-2012 中)。在门限签名实现中,必须确保所有算术运算使用正确的曲线参数,不能使用通用的 EC 库而忽略国密曲线规范。
10. 测试与审计建议
10.1 模糊测试
对门限签名实现进行模糊测试,随机生成异常输入,验证代码不会崩溃或泄露信息:
func FuzzThresholdSign(data []byte) {
// 随机生成参与方数量、阈值、消息
// 验证签名验证始终正确
}10.2 形式化验证
对于核心密码原语,考虑使用形式化验证工具(如 Tamarin Prover、ProVerif)验证协议的安全性属性。
10.3 常量时间测试
使用侧信道分析工具检测常量时间违规:
# 使用 scapy 等工具捕获执行时间
# 分析不同输入下的时间方差11. 总结
门限签名方案的工程实现远比理论复杂。上述七个陷阱——伪造承诺、会话绑定丢失、零值泄露、模逆侧信道、聚合验证疏忽、Sybil 攻击和错误处理泄漏——是生产环境中最常见的问题源。
核心原则:
- 所有承诺必须有 ZKP 绑定
- 所有消息必须绑定会话上下文
- 所有密钥相关运算必须常量时间
- 所有输入必须双重验证
- 错误信息不得泄露内部状态
相关实践
- SM2 批量签名验证优化 — 同一主题下的批量签名场景优化
- SM2 签名 k 值重用攻击 — 单个签名场景的安全分析
- 国密密钥内存安全管理 — 密钥存储的工程实践
参考
- GG18: "Secure Distributed Key Generation for Bitcoin-Compatible Multi-signature" (2018)
- CGGMP20: "Compressed GG18: A Compact Threshold Signature Scheme" (2020)
- GM/T 0003.2-2012: SM2 数字签名算法标准
- GM/T 0009-2023: SM2 密码算法使用规范
- RFC 6979: Deterministic Usage of the Digital Signature Algorithm (DSA) and Elliptic Curve Digital Signature Algorithm (ECDSA)