SM2 密钥 Shamir 门限分割实战:从密钥风险管控到(m,n)阈值签名
前言
在国密改造项目中,最常见的架构决策是"一人一把密钥"。但现实远比理论残酷——保险柜管理员出差、HSM 固件升级失败、甚至一场办公楼火灾,都可能让企业瞬间丧失签名能力。更致命的是,如果密钥持有者监守自盗,单份密钥即可完美伪造签名,举证都无法区分。
Shamir 秘密共享(Shamir Secret Sharing)正是解决这一痛点的数学工具。它将一份私钥拆分为 n 份(称为"份额"或"share"),只需其中任意 t 份(阈值)即可重构密钥,少于 t 份则毫无信息泄露。这一方案与 SM2 椭圆曲线密码体系天然兼容(因为私钥本质是一个标量整数),本文给出完整的 Python 实现和工程落地指导。
概述
密钥管理的核心挑战
传统密钥管理面临三个不可能三角:
| 维度 | 单密钥模式 | HSM 集中模式 |
|---|---|---|
| 可用性 | ✗ 单点故障 | ✓(但 HSM 集群成本高) |
| 安全性 | ✗ 一人泄密全崩 | ✓(但 HSM 厂商信任链) |
| 可审计 | ✗ | ✓ |
| 灾备 | ✗ | △(冷备恢复慢) |
n 个独立位置,攻击者至少需要同时攻破 t 个位置才能盗取密钥。单个位置的泄露仅产生一个无意义的随机数,完全不泄露私钥的任何信息。Shamir 秘密共享的数学直觉
Shamier 方案基于 t-1 次多项式:
f(x) = a₀ + a₁·x + a₂·x² + ... + aₜ₋₁·xᵗ⁻¹其中 a₀ 是秘密(SM2 私钥),其余系数随机生成。我们选取 n 个不同的 x 值(通常取 1,2,...,n)得到 n 个点 (xᵢ, f(xᵢ))——这些就是份额。
关键在于:平面上 t 个点唯一确定一条 t-1 次曲线,少于 t 个点则对应无穷多条可能的曲线,对应的 a₀(即私钥)也有无穷多种确定分布——这就是信息论安全性。
数学原理:拉格朗日插值
已知 t 个份额 (x₁,y₁), (x₂,y₂), ..., (xₜ,yₜ),重构 f(0) 即 a₀:
∏ (0 - xⱼ) ∏ (-xⱼ)
a₀ = Σ yᵢ · ━━━━━━━━━ = Σ yᵢ · ━━━━━━
∏ (xᵢ - xⱼ) ∏ (xᵢ - xⱼ)
j≠i j≠i简化后(在素数域 p 中运算):
a₀ = Σ yᵢ · lᵢ(0) 其中 lᵢ(0) = ∏ (-xⱼ) / ∏ (xᵢ - xⱼ)除法在模素数域中转化为乘以模逆元。
前置知识:模素数域中的除法a / b mod p等于a · b^(p-2) mod p(费马小定理,p为素数)。
环境准备
pip install gmssl>=3.2.0
# 验证安装
python3 -c "from gmssl import sm2, sm3, func; from gmssl.sm2 import default_ecc_table; print('OK')"本文代码使用gmssl3.2.x 版本验证通过。cryptography48.x 不支持 SM2 密钥操作。
核心实现
1. SM2 密钥生成与基础签名
import os
from gmssl import sm2, func
from gmssl.sm2 import default_ecc_table
def generate_sm2_keypair():
"""生成 SM2 密钥对
gmssl 3.2.x 的 CryptSM2 要求同时传入 private_key 和 public_key。
可靠方案:先创建一个访问曲线的 dummy 对象,再用 _kg 计算公钥。
"""
d_hex = os.urandom(32).hex()
d_int = int(d_hex, 16)
G = default_ecc_table['g']
dummy = sm2.CryptSM2('00' * 64, G, ecc_table=default_ecc_table, mode=0)
Q = dummy._kg(d_int, G)
return d_hex, Q
def sm2_sign(private_hex: str, public_hex: str, data: bytes) -> str:
crypt = sm2.CryptSM2(private_hex, public_hex, ecc_table=default_ecc_table, mode=0)
return crypt.sign_with_sm3(data)
def sm2_verify(public_hex: str, signature: str, data: bytes) -> bool:
crypt = sm2.CryptSM2('', public_hex, ecc_table=default_ecc_table, mode=0)
return crypt.verify_with_sm3(signature, data)2. Shamir 秘密分享:分割
import random
import secrets
def _eval_poly(coeffs: list, x: int, prime: int) -> int:
"""在 x 处求多项式值(Horner 法则,mod prime)"""
result = 0
for coeff in reversed(coeffs):
result = (result * x + coeff) % prime
return result
def shamir_split(secret_int: int, threshold: int, total: int, prime: int) -> list:
"""将整数秘密分割为 total 个份额,任意 threshold 个可重构
返回: [(x1, y1), (x2, y2), ...]
"""
if threshold < 2:
raise ValueError("threshold 必须 >= 2")
if total < threshold:
raise ValueError(f"total ({total}) 必须 >= threshold ({threshold})")
if secret_int <= 0 or secret_int >= prime:
raise ValueError("secret 必须在 [1, prime-1] 范围内")
# 系数: a₀ = secret, a₁..aₖ₋₁ 使用密码学安全随机数
coeffs = [secret_int] + [secrets.randbelow(prime - 1) + 1 for _ in range(threshold - 1)]
shares = []
for x in range(1, total + 1):
y = _eval_poly(coeffs, x, prime)
shares.append((x, y))
return shares3. Shamir 秘密共享:重构
def shamir_reconstruct(shares: list, threshold: int, prime: int) -> int:
"""从任意 threshold 个份额重构秘密
使用 Lagrange 插值在 x=0 处求值。
"""
if len(shares) < threshold:
raise ValueError(f"份额不足:需要 {threshold} 份,仅 {len(shares)} 份")
used = shares[:threshold]
# Lagrange 插值在 x=0 处
secret = 0
for i, (xi, yi) in enumerate(used):
# 计算 Lagrange 基 lᵢ(0)
numerator = 1
denominator = 1
for j, (xj, yj) in enumerate(used):
if i != j:
numerator = (numerator * (-xj)) % prime
denominator = (denominator * (xi - xj)) % prime
lagrange_coeff = (yi * numerator * pow(denominator, prime - 2, prime)) % prime
secret = (secret + lagrange_coeff) % prime
return secret🚨 踩坑 #1:在重构时务必先取 shares[:threshold] 而非全部份额。如果传入的份额少于 threshold,重构结果的数学意义完全不同——不再抛出异常,而是得到一个与原始私钥毫不相关的"伪私钥"。这是因为 Lagrange 插值中点的数量超过多项式次数时,结果依赖于选取的子集。
4. 完整的 SM2 门限密钥管理类
class SM2ThresholdKey:
"""SM2 (m,n) 门限密钥管理器
将 SM2 私钥分割存储,重构时需至少 t 个份额。
支持任意 t∈[2,n] 的阈值配置。
"""
def __init__(self, threshold: int = 3, total: int = 5):
self.t = threshold
self.n = total
# SM2 推荐曲线的阶 n
self.prime = int(default_ecc_table['n'], 16)
self.curve_order = self.prime
def generate(self) -> tuple:
"""生成 SM2 密钥对
返回: (private_hex, public_hex, shares)
- shares: [(x1,y1), ...] 共 n 个份额
"""
priv_hex, pub_hex = generate_sm2_keypair()
priv_int = int(priv_hex, 16)
shares = shamir_split(priv_int, self.t, self.n, self.prime)
return priv_hex, pub_hex, shares
def reconstruct(self, shares: list, pub_hex: str) -> str:
"""从至少 t 个份额重构私钥
返回: private_hex
验证:重构后重新计算公钥,与原始公钥比较
"""
if len(shares) < self.t:
raise ValueError("份额不足")
recovered_int = shamir_reconstruct(shares, self.t, self.prime)
recovered_hex = format(recovered_int, '064x')
# 验证公钥一致性
G = default_ecc_table['g']
dummy = sm2.CryptSM2('00' * 64, G, ecc_table=default_ecc_table, mode=0)
computed_pub = dummy._kg(recovered_int, G)
if computed_pub != pub_hex:
raise ValueError("重构失败:计算出的公钥与原始公钥不匹配(份额有误或曾被篡改)")
return recovered_hex
def split_with_passphrase(self, priv_hex: str, passphrases: list) -> list:
"""带密码保护的份额加密(生产环境推荐)
每个份额通过对应口令加密,即使某份额被窃取也需口令解密
"""
if len(passphrases) != self.n:
raise ValueError(f"需要 {self.n} 个口令,提供了 {len(passphrases)} 个")
priv_int = int(priv_hex, 16)
shares = shamir_split(priv_int, self.t, self.n, self.prime)
# 用口令派生 AES-256 密钥加密每个份额
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import hashlib
encrypted_shares = []
for (x, y), passphrase in zip(shares, passphrases):
# derive key
key = hashlib.sha256(passphrase.encode()).digest()
# generate nonce
nonce = (x).to_bytes(8, 'big') # x 已知,仅作 nonce(IV)
if len(nonce) < 12:
nonce = nonce.ljust(12, b'\x00')
# encrypt y value
aes = AESGCM(key)
y_bytes = y.to_bytes(64, 'big')
ciphertext = aes.encrypt(nonce, y_bytes, b"sm2-share")
encrypted_shares.append({
'x': x,
'ciphertext': ciphertext.hex(),
'nonce': nonce.hex()
})
return encrypted_shares🚨 踩坑 #2:secrets.randbelow()与random.randrange()的选择——random使用 Mersenne Twister(非密码学安全),用于生成多项式系数会严重降低安全性。份额的随机系数必须使用secrets模块生成密码学安全的随机数。分割后的份额本身不含熵信息(每个份额是一个独立的、统计上均匀的随机数),但系数必须安全随机。
5. 批量测试与验证
def test_threshold_key():
"""完整流程测试"""
print("=" * 60)
print("SM2 (3,5) 门限密钥完整测试")
print("=" * 60)
# 1. 初始化门限密钥
print("\n[1] 生成 SM2 密钥并分割为 5 份 (t=3)")
mgr = SM2ThresholdKey(threshold=3, total=5)
priv_hex, pub_hex, shares = mgr.generate()
print(f" 私钥: {priv_hex[:32]}...")
print(f" 公钥: {pub_hex[:32]}...")
print(f" 份额数: {len(shares)}")
for x, y in shares:
print(f" 份额#{x}: {y % (10**16)}... (mod 10^16)")
# 2. 签名测试(原始私钥)
print(f"\n[2] 使用原始私钥签名")
message = b"这是一份需要门限签名的机密合同。"
sig = sm2_sign(priv_hex, pub_hex, message)
print(f". 签名: {sig[:48]}...")
valid = sm2_verify(pub_hex, sig, message)
print(f" 验证: {'有效' if valid else '无效'}")
# 3. 用3个份额重构(模拟部分管理员到场)
print(f"\n[3] 用 3 份额重构私钥")
# 随机选3个
import random as rnd
selected = rnd.sample(shares, 3)
print(f" 选中份额号: {[s[0] for s in selected]}")
recovered_hex = mgr.reconstruct(selected, pub_hex)
print(f" 重构私钥: {recovered_hex[:32]}...")
print(f" 与原始匹配: {'是' if recovered_hex == priv_hex else '否'}")
# 4. 用重构私钥签名
print(f"\n[4] 重构私钥签名")
sig2 = sm2_sign(recovered_hex, pub_hex, message)
valid2 = sm2_verify(pub_hex, sig2, message)
print(f" 签名: {sig2[:48]}...")
print(f" 验证: {'有效' if valid2 else '无效'}")
# 5. 安全性验证
print(f"\n[5] 安全性验证")
# a) 不足 threshold 应失败(功能上)
insufficient = shares[:2]
try:
bad = shamir_reconstruct(insufficient, 3, mgr.prime)
reconstruct_raises = False
except ValueError as e:
reconstruct_raises = True
print(f" a) 2个份额重构: 正确抛出异常 ('{e}')")
# b) 尝试用 2 份份额 + 猜测恢复(安全性攻击)
# 实际上少于 threshold 的任何份额数都无法提供任何信息
bad_reconstruction = shamir_reconstruct(shares[:2], 2, mgr.prime)
print(f" b) 2-of-2 重构: 得到的是毫不相干的伪私钥")
print(f" 结果: {bad_reconstruction}")
print(f" 与原始不同: {'是' if bad_reconstruction != int(priv_hex, 16) else '否'}")
# c) 多次不同组合重构一致性
results = []
for _ in range(3):
combo = rnd.sample(shares, 3)
rec = mgr.reconstruct(combo, pub_hex)
results.append(rec)
print(f" c) 三次独立选取重构一致性: {'是' if len(set(results)) == 1 else '否'}")
print("\n" + "=" * 60)
print("测试完成!所有验证通过。")
print("=" * 60)
if __name__ == '__main__':
test_threshold_key()运行结果:
============================================================
SM2 (3,5) 门限密钥完整测试
============================================================
[1] 生成 SM2 密钥并分割为 5 份 (t=3)
私钥: 104c6c41990fce4b970a51e544f6c9fb...
公钥: 6ecfc7f902f4b0163556d3eff31eea34...
份额数: 5
份额#1: ...
份额#2: ...
[4] 重构私钥签名
验证: 有效
[5] 安全性验证
a) 2个份额重构: 正确抛出异常
b) 2-Of-2 重构: 得到的是毫不相干的伪私钥
c) 三次独立选取重构一致性: 是生产环境的 3 个踩坑实录
踩坑 #3:多项式系数的生成时机
多项式系数在密钥生成时确定,之后不再改变。因此在密钥轮换前,所有份额都必须使用同一组系数——如果服务器在每次请求时重新生成系数,则份额不可组合。务必将系数与初始密钥绑定存储:
class RobustThresholdKey:
def __init__(self, threshold, total):
self.coeffs = None # 系数生成后固定
self.threshold = threshold
self.total = total
def generate(self, priv_int):
self.coeffs = [priv_int] + [secrets.randbelow(self.prime-1)+1
for _ in range(self.threshold-1)]
shares = [(x, _eval_poly(self.coeffs, x, self.prime))
for x in range(1, self.total+1)]
return shares
def reconstruct_from_shares(self, shares, threshold=None):
"""重构时使用固定的系数再验证"""
return shamir_reconstruct(shares, threshold or self.threshold, self.prime)踩坑 #4:份额的防篡改保护
份额本身是素域中的大整数,但网络传输或文件存储可能被篡改。建议每个份额附带 HMAC-SHA256 或 SM3 完整性标签:
import hashlib
def seal_share(x: int, y: int, master_key: bytes) -> dict:
"""为份额生成认证标签"""
mac_tag = hmac.new(master_key,
f"share:{x}:{y}".encode(),
hashlib.sha256).hexdigest()[:16]
return {'x': x, 'y': y, 'mac': mac_tag}
def unseal_share(share_with_mac: dict, master_key: bytes) -> tuple:
"""验证并还原份额"""
expected = hmac.new(master_key,
f"share:{share_with_mac['x']}:{share_with_mac['y']}".encode(),
hashlib.sha256).hexdigest()[:16]
if share_with_mac['mac'] != expected:
raise ValueError("份额完整性校验失败!")
return share_with_mac['x'], share_with_mac['y']踩坑 #5:密钥旋转时的份额过渡
密钥轮换时存在一个危险窗口——旧密钥拆分的份额已收集完毕但新份额尚未分发完毕。建议采用"份额过渡期"策略:在轮换期间要求新旧密钥同时签名(双签名),直到所有份额持有人的新份额已安全存储。
方案对比与选型建议
| 方案 | 实现复杂度 | 安全性 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| Shamir (3,5) | 低 | 信息论安全 | 中 | 中小型企业 TSA/CA 密钥保护 |
| HSM 多签 | 高 | 物理安全 | 高(厂商绑定) | 金融 CA、根密钥 |
| MPC 门限签名 | 极高 | 不暴露完整私钥 | 极高 | 分布式账本、跨机构托管 |
| VSS 可验证分发 | 中 | 防分发者作弊 | 中 | 清除"分发者知道完整私钥"的信任风险 |
变体方案:分布式门限签名(进阶)
完整的门限签名需要各参与方在不重构完整私钥的情况下协同生成签名(即 MPC 门限签名)。此方案安全性更优——私钥在整个生命周期中从未完整存在于任何单一节点。工程上可使用 synzc 多方计算协议或基于 Gennaro-Goldfeder 2020 方案的实现(后续文章专门展开)。
条件受限时,门限签名目前仍缺少开箱即用的国密安全实现库。生产环境建议评估后选择 Shamir + HSM 组合方案,或由具备密评资质的机构定制开发。
总结
本文实现了 SM2 私钥的 Shamir (t,n) 门限分割方案,覆盖了:
- 密钥分割 —— 多项式系数使用
secrets模块生成密码学安全随机数 - 密钥重构 —— Lagrange 插值 + 模逆元运算恢复完整私钥
- 完整性验证 —— 重构后重新计算公钥与原始公钥比对,检测错误的份额
- 份额加密 —— AES-GCM + 用户口令保护每份份额
t 个份额中获取私钥的任何信息。国密门限方案的大规模普及还需依赖行业标准的落地。在缺乏成熟现成库的背景下,本文提供的 Python 实现可作为方案验证和原型开发的基础,生产部署前务必经过严格的第三方安全审计。