从Lamport到Winternitz:基于哈希的后量子签名算法原理与Python实现

📅 2026/7/1 21:54:03
从Lamport到Winternitz:基于哈希的后量子签名算法原理与Python实现
1. 项目概述为什么我们需要关注哈希签名算法如果你在密码学或者区块链领域摸爬滚打过一阵子肯定对“数字签名”这个词不陌生。它就像是数字世界的印章和手写签名用来验证一份文件、一条消息的发送者身份以及内容的完整性。但你可能不知道在那些我们熟知的RSA、ECDSA椭圆曲线签名等公钥密码体系大行其道之前有一类基于哈希函数的签名方案以其独特的思想和结构为后量子密码学铺平了道路。今天我们就来聊聊这类方案中的两位“祖师爷”Lamport一次性签名和它的高效继承者Winternitz一次性签名WOTS。简单来说这哥俩的核心思路非常“暴力美学”它们不依赖大数分解或离散对数这些复杂的数学难题而是完全建立在哈希函数的单向性上。你给一个消息我通过预先生成的一堆“密钥对”和一系列哈希计算给你生成一个签名。验证方也只需要进行哈希计算就能完成校验。这种纯粹依赖哈希的设计让它们天生就对量子计算机的攻击有潜在的抵抗力因为量子计算机目前对破解哈希函数尤其是抗量子哈希函数并没有像对分解大数那样的指数级加速优势。所以这个内容适合谁如果你是密码学爱好者想理解后量子密码的基石之一如果你是区块链开发者在研究诸如Merkle签名、XMSS或SPHINCS这些高级后量子签名方案时感到底层原理晦涩难懂或者你就是一个喜欢用Python把抽象理论“敲”出来的实践派那么这个从Lamport到Winternitz的演进之旅会给你带来很多“原来如此”的顿悟时刻。我会用最直白的语言和可运行的Python代码带你亲手实现这两个算法并直观感受它们之间的效率鸿沟与设计智慧。2. 核心原理拆解从“一锤子买卖”到“精打细算”在深入代码之前我们必须把两者的设计哲学和核心机制掰开揉碎讲清楚。这决定了后续所有参数选择和性能表现。2.1 Lamport签名最直观的“一人一密”Lamport签名的思想简单到令人发指它就是为了签一个单比特的消息而设计的。想象一下你要对一位朋友承诺“是”1或“否”0。Lamport的做法是密钥生成你先随机生成两把“私钥”一个对应“0”记作sk0一个对应“1”记作sk1。然后你把这两个私钥分别用哈希函数比如SHA256哈希一次得到两个“公钥”pk0 H(sk0),pk1 H(sk1)。你把(pk0, pk1)公开出去自己牢牢保管(sk0, sk1)。签名当你要对消息比特b(0或1) 签名时你直接亮出对应的私钥sk_b。验证验证者拿到消息比特b和签名sig sk_b。他只需做一件事计算H(sig)然后检查结果是否等于你之前公开的公钥pk_b。如果相等说明这个签名确实是用你持有的私钥生成的因为哈希不可逆别人无法从pk_b反推出sk_b。看到问题了吗一次签名私钥即暴露。如果你用sk0签了一个“0”那么sk0就公开了它永远不能再被用来签名。如果你想签一个256比特的消息比如一个SHA256哈希值你就需要为每一个比特都准备一对独立的公私钥。最终你的私钥大小 消息长度比特 * 2 * 哈希输出长度比特。对于256比特的消息私钥大小是 256 * 2 * 256 bit 16 KB公钥大小也一样。签名大小则是直接放出256个私钥片段也是16 KB。注意这里有一个关键细节。直接对原始消息的每个比特签名是不安全的因为攻击者可以通过翻转消息的某些比特来伪造签名如果他知道对应比特的私钥。因此在实际应用中总是先对任意长度的消息进行哈希得到一个固定长度如256比特的摘要然后对这个摘要的每个比特进行Lamport签名。我们后续的实战也遵循这个原则。Lamport签名的核心特点优点概念极其简单安全性直接规约到哈希函数的抗碰撞性和原像攻击难度。缺点密钥和签名尺寸巨大是典型的“一次性”签名用完即废。2.2 Winternitz签名WOTS哈希链的威力Winternitz签名WOTS是Lamport的“效率改良版”。它不再傻傻地为每一个比特都准备一对密钥而是引入了“哈希链”的概念让一个私钥通过多次哈希能够代表一个“数值”从而一次签名多个比特。它的核心参数是wWinternitz参数通常取 2, 4, 8, 16。w决定了我们将消息分组的大小。例如w16时我们把256比特的消息摘要分成若干组每组能表示0~15共16个数值。密钥生成假设我们使用SHA256输出256比特即32字节w16。我们需要生成len个私钥种子随机数。len的计算稍复杂它等于len1 len2。len1 ceil(256 / log2(w))。对于w16,log2(16)4所以len1 ceil(256/4) 64。这意味着原始消息摘要被分成64块每块4比特刚好表示0-15。len2是一个校验和块的数量用于防止签名被轻易篡改。len2 floor(log2(len1 * (w-1)) / log2(w)) 1。计算下来大约是3。所以总共len 64 3 67。我们生成67个随机数作为私钥sk[0]...sk[66]。公钥的生成对每一个私钥sk[i]进行(w-1)15次哈希迭代得到pk[i] H^(15)(sk[i])。将67个pk[i]连接起来作为公钥。签名首先计算消息的哈希摘要msg_hash。将msg_hash的256比特分成64组每组4比特转换成64个介于0到15之间的整数记作数组b[0..63]。计算校验和checksum sum_{i0}^{63} (15 - b[i])。这个校验和保证了任何试图增大b[i]来伪造签名的行为都会导致校验和减小从而在验证时失败。将校验和也表示成基数为w的数分成len2块得到b[64..66]。现在我们有67个整数b[0..66]每个都在0~15之间。对于第i块签名sig[i]就是对私钥sk[i]进行b[i]次哈希迭代的结果sig[i] H^(b[i])(sk[i])。验证验证者同样计算出消息摘要和校验和得到67个整数b[0..66]。对于每一个签名片段sig[i]验证者继续对其进行(15 - b[i])次哈希迭代。理论上H^(15 - b[i])(sig[i]) H^(15 - b[i])(H^(b[i])(sk[i])) H^(15)(sk[i]) pk[i]。如果对所有i计算得到的值都与公钥pk[i]相等则签名有效。WOTS的核心突破密钥压缩通过哈希链一个私钥种子可以对应w种不同的签名状态哈希0次到哈希w-1次。签一个4比特的值Lamport需要4对密钥WOTS只需要1个私钥种子加上最多15次哈希计算。尺寸优化对于256比特消息w16时WOTS的私钥、公钥、签名大小都是67 * 32字节 ≈ 2.1 KB。相比Lamport的16 KB缩小了将近8倍计算开销签名和验证需要大量的哈希迭代。签名平均需要(w-1)/2 * len次哈希验证平均需要(w-1)/2 * len次哈希。这是一种“以计算换空间”的经典权衡。3. 实战对比用Python实现Lamport与Winternitz理论说得再多不如一行代码。我们选择Python来实现因为它语法简洁适合表达算法逻辑。我们将使用Python内置的hashlib库进行SHA256哈希。实操心得在密码学实现中一个容易被忽略但至关重要的点是随机数的生成。绝对不要使用普通的伪随机数生成器如random模块。必须使用密码学安全的随机数生成器CSPRNG。在Python中我们使用os.urandom()或secrets模块。本例中使用secrets.token_bytes。3.1 Lamport一次性签名实现首先我们定义一些常量和辅助函数。import hashlib import secrets from typing import Tuple, List # 定义哈希函数输出256比特32字节 def H(data: bytes) - bytes: return hashlib.sha256(data).digest() # 消息哈希的输出长度比特 HASH_OUTPUT_BITS 256 # 哈希字节长度 HASH_BYTES HASH_OUTPUT_BITS // 8 def lamport_keygen() - Tuple[List[bytes], List[bytes]]: 生成Lamport签名所需的私钥和公钥。 返回: (private_key, public_key) private_key: 一个包含 2 * HASH_OUTPUT_BITS 个字节串的列表每串HASH_BYTES长。 public_key: 对应私钥哈希后的列表。 private_key [] public_key [] # 我们需要为哈希摘要的每一个比特准备一对密钥0和1 for _ in range(HASH_OUTPUT_BITS): # 生成对应比特位为0的私钥和公钥 sk0 secrets.token_bytes(HASH_BYTES) pk0 H(sk0) private_key.append(sk0) public_key.append(pk0) # 生成对应比特位为1的私钥和公钥 sk1 secrets.token_bytes(HASH_BYTES) pk1 H(sk1) private_key.append(sk1) public_key.append(pk1) return private_key, public_key def bits_from_hash(msg: bytes) - List[int]: 将消息哈希并将哈希值的每个比特展开成一个列表。 返回: 一个包含 0 或 1 的整数列表长度为 HASH_OUTPUT_BITS。 msg_digest H(msg) bits [] # 遍历每个字节 for byte in msg_digest: # 遍历字节中的每个比特从最高位开始 for i in range(7, -1, -1): bit (byte i) 1 bits.append(bit) return bits def lamport_sign(private_key: List[bytes], msg: bytes) - List[bytes]: 使用Lamport私钥对消息进行签名。 返回: 签名是一个字节串列表。 bits bits_from_hash(msg) signature [] for i, bit in enumerate(bits): # 第i个比特选择私钥列表中对应位置i*2 bit的密钥 sig_part private_key[i * 2 bit] signature.append(sig_part) return signature def lamport_verify(public_key: List[bytes], msg: bytes, signature: List[bytes]) - bool: 使用Lamport公钥验证消息签名。 返回: True 如果验证成功否则 False。 bits bits_from_hash(msg) if len(signature) ! len(bits): return False for i, bit in enumerate(bits): sig_part signature[i] # 计算签名部分的哈希 computed_pk H(sig_part) # 应该对应的公钥 expected_pk public_key[i * 2 bit] if computed_pk ! expected_pk: return False return True # 简单测试 if __name__ __main__: print( Lamport Signature Demo ) sk, pk lamport_keygen() message bHello, Lamport! sig lamport_sign(sk, message) print(fMessage: {message}) print(fSignature valid? {lamport_verify(pk, message, sig)}) # 尝试验证一个错误的消息 bad_message bHello, Hacker! print(fBad Message Signature valid? {lamport_verify(pk, bad_message, sig)})代码解读与注意事项lamport_keygen生成了256 * 2 512个随机私钥和对应的公钥。私钥列表的前256个对应比特0后256个对应比特1。这是一种组织方式清晰对应了bits_from_hash函数中的索引计算i*2 bit。bits_from_hash函数将SHA256摘要转换成比特列表。注意字节到比特的转换顺序这里从最高位开始必须与签名/验证时保持一致但具体顺序不影响安全性只要一致即可。签名时我们从未直接暴露与消息比特相反的私钥。例如消息某比特为0我们只放出sk0而sk1依然保密。这是安全性的关键。验证是确定性的非常快就是计算512次哈希并比较。你可以打印一下len(sk)和len(sig)来感受尺寸每个元素32字节总共512个所以是512 * 32 16384字节即16KB。3.2 Winternitz一次性签名WOTS实现WOTS的实现比Lamport复杂主要是因为校验和与哈希链迭代。import hashlib import secrets import math from typing import Tuple, List # 使用SHA256 def H(data: bytes) - bytes: return hashlib.sha256(data).digest() # 参数配置 HASH_OUTPUT_BITS 256 HASH_BYTES HASH_OUTPUT_BITS // 8 # Winternitz参数 w表示基数为w每个块可以表示0到w-1 W 16 # 计算 log2(w) LOG2_W int(math.log2(W)) # 检查 w 是否是2的幂 assert (W (W - 1)) 0, W should be a power of 2 for simplicity. def calculate_len() - Tuple[int, int, int]: 计算WOTS的 len1, len2, 和总长度 len。 返回: (len1, len2, len) # len1: 编码消息哈希所需的块数 len1 math.ceil(HASH_OUTPUT_BITS / LOG2_W) # 最大校验和值: len1 * (w-1) max_checksum len1 * (W - 1) # len2: 编码校验和所需的块数 len2 math.floor(math.log2(max_checksum) / LOG2_W) 1 total_len len1 len2 return len1, len2, total_len LEN1, LEN2, LEN calculate_len() print(fWOTS参数: w{W}, len1{LEN1}, len2{LEN2}, len{LEN}) def hash_chain(start: bytes, steps: int) - bytes: 从起始值开始迭代哈希指定次数。 steps0 返回 start 本身。 current start for _ in range(steps): current H(current) return current def wots_keygen() - Tuple[List[bytes], List[bytes]]: 生成WOTS私钥和公钥。 返回: (private_key, public_key) private_key: LEN 个随机种子。 public_key: 每个私钥种子迭代哈希 (W-1) 次的结果。 private_key [secrets.token_bytes(HASH_BYTES) for _ in range(LEN)] public_key [hash_chain(sk, W - 1) for sk in private_key] return private_key, public_key def message_to_base_w(msg_hash: bytes) - List[int]: 将消息哈希字节串转换为基数为w的整数列表len1长度。 每个整数在 0 到 w-1 之间。 # 我们需要 LOG2_W 比特来表示一个w进制的数字。 # 将消息哈希的所有比特展开。 bits [] for byte in msg_hash: for i in range(7, -1, -1): bits.append((byte i) 1) # 现在将比特流分组每组 LOG2_W 比特转换成整数。 # 注意如果比特数不是 LOG2_W 的整数倍最后一部分可能不足需要填充标准做法是填充0。 # 根据NIST SP 800-208等规范通常采用“编码时填充”的方式。 # 这里我们简化处理因为 len1 * LOG2_W 256我们只取前 256 比特正好够分。 values [] for i in range(LEN1): value 0 for j in range(LOG2_W): bit_index i * LOG2_W j if bit_index HASH_OUTPUT_BITS: bit bits[bit_index] else: bit 0 # 填充0 value (value 1) | bit values.append(value) return values def wots_sign(private_key: List[bytes], msg: bytes) - List[bytes]: 使用WOTS私钥对消息签名。 msg_digest H(msg) # 1. 将消息哈希转换为基w整数 (b[0..len1-1]) msg_base_w message_to_base_w(msg_digest) # 2. 计算校验和 checksum 0 for value in msg_base_w: checksum (W - 1 - value) # 3. 将校验和转换为基w整数得到 b[len1..len-1] checksum_base_w [] temp checksum for _ in range(LEN2): checksum_base_w.append(temp % W) temp temp // W # 注意顺序我们是从低位到高位计算的但通常签名/验证时按索引顺序处理所以这里需要反转或不反转只要与验证一致。 # 为了清晰我们保持这个列表在后续循环中一起处理。 # 更好的做法是生成一个完整的b数组 b_values msg_base_w checksum_base_w # 确保长度是LEN assert len(b_values) LEN, fLength mismatch: {len(b_values)} ! {LEN} # 4. 对每个私钥种子迭代哈希 b[i] 次 signature [] for i in range(LEN): sig_part hash_chain(private_key[i], b_values[i]) signature.append(sig_part) return signature def wots_verify(public_key: List[bytes], msg: bytes, signature: List[bytes]) - bool: 验证WOTS签名。 if len(signature) ! LEN: return False msg_digest H(msg) msg_base_w message_to_base_w(msg_digest) checksum 0 for value in msg_base_w: checksum (W - 1 - value) checksum_base_w [] temp checksum for _ in range(LEN2): checksum_base_w.append(temp % W) temp temp // W b_values msg_base_w checksum_base_w assert len(b_values) LEN # 验证对每个签名片段继续哈希 (W-1 - b[i]) 次应该等于公钥 for i in range(LEN): # 需要继续哈希的次数 steps (W - 1) - b_values[i] computed_pk hash_chain(signature[i], steps) if computed_pk ! public_key[i]: return False return True # 测试 if __name__ __main__: print(\n Winternitz (WOTS) Signature Demo ) print(fKey/Signature size per element: {HASH_BYTES} bytes) print(fTotal elements: {LEN}) print(fTotal key/signature size: {LEN * HASH_BYTES} bytes ≈ {LEN * HASH_BYTES / 1024:.2f} KB) sk_wots, pk_wots wots_keygen() message bHello, Winternitz! sig_wots wots_sign(sk_wots, message) print(f\nMessage: {message}) print(fWOTS Signature valid? {wots_verify(pk_wots, message, sig_wots)}) bad_message bTampered message print(fBad Message Signature valid? {wots_verify(pk_wots, bad_message, sig_wots)}) # 性能简单对比哈希次数 # 签名哈希次数约等于 sum(b_values)平均每个b是 (w-1)/2 avg_hash_per_sig sum([(W-1)/2 for _ in range(LEN)]) print(f\nEstimated average hash operations per signature/verification: {avg_hash_per_sig:.0f})代码解读与关键点参数计算calculate_len函数是核心它根据哈希输出长度和w确定了密钥/签名向量的长度。len2的计算保证了校验和一定能被编码进去。哈希链hash_chain函数是WOTS的引擎。注意steps0时返回原值这在签名时如果某块b[i]0意味着直接放出私钥种子本身。消息编码message_to_base_w函数将256比特的哈希值“切割”成多个log2(w)比特的块。这里我们做了简化处理不足位补0。在实际标准如XMSS/RFC 8391中会使用更复杂的编码函数确保无歧义。校验和校验和的计算sum(W-1 - b[i])是WOTS安全性的关键一环。它确保了对签名任何部分的修改试图增加b[i]的值都会导致校验和部分无法通过验证。验证逻辑验证时计算H^(W-1-b[i])(sig[i])必须等于公钥pk[i]。这个等式源于pk[i] H^(W-1)(sk[i])和sig[i] H^(b[i])(sk[i])。尺寸与计算量打印出的尺寸大约为2.1KB相比Lamport的16KB是巨大的进步。但计算量也显著增加大约需要67 * 7.5 ≈ 500次哈希运算。4. 演进对比与深度分析现在我们把Lamport和WOTS放在一起从多个维度进行对比你就能清晰看到演进的脉络。特性维度Lamport一次性签名Winternitz一次性签名 (WOTS)分析与启示核心思想每个消息比特对应一对独立的密钥使用哈希链一个私钥种子通过不同迭代次数代表一个“数值”多个比特WOTS引入了“状态”概念用计算复杂度换取了存储空间的指数级优化。密钥/签名尺寸巨大。对于256比特摘要约16 KB。显著减小。与参数w和len相关w16时约2.1 KB。空间效率的飞跃。WOTS通过参数w实现了可调节的时空权衡。w越大单个块能表示的比特数越多log2(w)len1越小但哈希链更长计算量越大。计算开销较低。签名只需读取私钥验证需进行2*n次哈希n为摘要比特数。较高。签名和验证都需要进行大量哈希迭代约len * (w-1)/2次。以时间换空间。WOTS的签名/验证时间与w成正比。w256时len1可降至32但哈希链长达255计算量激增。通常w16是平衡点。安全性基础哈希函数的抗第二原像攻击。即给定H(x)难以找到x ! x使得H(x) H(x)。哈希函数的抗第二原像攻击同时依赖哈希链的不可逆性。给定H^k(x)难以找到y使得H^m(y) H^k(x)且m ! k。两者安全性最终都归约到哈希函数的安全性。WOTS由于哈希链需要更强的安全假设抗“扩展”原像攻击但主流抗碰撞哈希函数如SHA2, SHA3被认为满足要求。应用场景主要用于教学和理解一次性签名原理。直接使用不现实。是许多可扩展多次签名方案的基石如Merkle签名方案MSS、XMSS、SPHINCS。Lamport是理论原型WOTS是实用组件。现代后量子签名方案通过Merkle树将无数个WOTS公钥哈希成一个短根公钥并用WOTS来签名Merkle树的节点从而实现多次签名。从Lamport到Winternitz的演进本质是从比特级的一一映射升级到了数值级的哈希链压缩。这好比早期的莫尔斯电码短点、长点代表0和1与后期数据压缩算法的区别。Winternitz找到了在哈希函数这个单一原语上构建更高效签名的方法。实操心得参数w的选择在实际选择w时例如在XMSS中你需要做一个权衡w较小如4, 8哈希链短签名/验证速度快但len较大导致密钥和签名尺寸较大。w较大如256len很小尺寸优势极大但哈希链极长计算慢到无法接受。 因此w16是一个经典折中选择它在尺寸和速度之间取得了很好的平衡。这也是很多标准如XMSS的默认参数。5. 常见问题与进阶思考在实际理解和应用这些算法时你可能会遇到以下问题Q1: 为什么叫“一次性”签名我用它签了两个不同的消息会怎样A1: 对于Lamport如果你签了消息M1暴露了部分私钥对应M1哈希为1的比特的sk1和 为0的比特的sk0。攻击者可以构造一个消息M2其哈希值在所有M1暴露了私钥的比特位上与M1相同而在其他比特位上任意。那么攻击者就可以用已暴露的私钥伪造出M2的有效签名。WOTS同理签名后私钥的状态哈希迭代次数被部分揭示签名两个不同消息极大提高了攻击者成功伪造签名的概率。因此一个密钥对绝对只能使用一次。Q2: 既然只能一次性使用那有什么实用价值A2: 单次使用确实是致命缺点。但密码学家通过“树结构”巧妙地解决了这个问题。Merkle签名方案MSS使用一棵二叉树叶子节点是许多个比如2^20个WOTS公钥的哈希。用这些WOTS密钥对去签名消息而树的根节点哈希作为整个系统的公钥。每次签名消耗一个叶子节点并附上从该叶子到根节点的路径上的哈希值Merkle路径以供验证。这样一个MSS公钥可以签名大量消息如2^20次。XMSS和SPHINCS是更高效、更标准的树状多次签名方案其底层都依赖于WOTS或其变种。Q3: 我实现的WOTS代码和标准如RFC 8391有什么区别A3: 我们的实现是高度简化的教学版本。主要区别在于编码函数标准中使用了更安全、无歧义的“链式编码”或“哈希函数地址编码”来将消息映射到b[i]值并处理比特到w进制转换的边界问题。哈希函数标准中通常使用一个带密钥的哈希函数如PRF从种子生成私钥而不是直接存储随机数进一步压缩私钥。校验和编码校验和的拆分和编码也有更规范的定义。安全参数标准会明确考虑抗碰撞、抗第二原像攻击的具体安全强度比特数。建议如果你要在生产环境中使用绝对不要使用自己编写的教学代码。必须使用经过严格审计的密码学库如liboqs、PQClean或特定语言的成熟实现。Q4: 这些算法真的能抗量子计算吗A4: 它们被归类为“后量子密码学”的候选者原因在于其安全性不依赖于量子计算机能高效解决的数学问题如大数分解、离散对数。量子计算机对基于哈希的签名方案的主要威胁是Grover算法它能将哈希函数的搜索速度从O(2^n)提升到O(2^(n/2))相当于将安全强度减半。因此应对方法是简单地使用更长的哈希输出例如从SHA-256128位抗量子强度升级到SHA-512256位抗量子强度。这比重构整个公钥密码体系要简单得多。Q5: Python代码的性能太慢有优化空间吗A5: 当然有。我们的演示代码为了清晰每次哈希迭代都重新调用hashlib.sha256并进行了大量的列表操作和字节串复制。生产级优化包括使用更快的哈希实现如pycryptodome库的SHA256实现。预计算哈希链对于WOTS如果允许更大的私钥存储可以预计算哈希链上所有中间值这样签名时就是O(1)复杂度。但这违背了WOTS压缩私钥的初衷。一种折中是使用“树哈希”或“分块”技术。使用C扩展或Rust编写核心模块这是高性能密码学库的常见做法。并行计算WOTS中每个哈希链的计算是独立的可以并行处理。最后亲手实现一遍Lamport和Winternitz再去看XMSS或SPHINCS的论文你会发现自己能看懂那些层层嵌套的密钥生成和签名验证过程了。它们不再是黑盒而是由这些简单而坚固的“乐高积木”搭建起来的宏伟建筑。理解基础构件是理解复杂系统的第一步。