密码学严肃指南第二版-全-
密码学严肃指南第二版(全)
原文:
zh.annas-archive.org/md5/153e15695934484c346cb77e8fae46cb译者:飞龙
前言

我写这本书是为了它成为我当初开始学习加密学时所希望拥有的那本书。2005 年,我在巴黎附近攻读硕士学位,迫不及待地注册了即将开设的加密课程。不幸的是,由于注册人数太少,课程被取消了。“加密学太难了,”学生们争辩道,最后集体报名了计算机图形学和数据库课程。
从那时起,我已经听过“加密学很难”无数次。但它真的那么难吗?要掌握一种乐器、精通一门编程语言,或将任何领域的应用付诸实践,你需要学习一些概念和符号,但做到这一点并不需要博士学位。成为一名合格的密码学家也是如此。也许加密学被认为很难,是因为密码学家在教学上做得不好。
我写这本书的另一个原因是加密学已经扩展成了一个跨学科的领域。要在加密领域做一些有用且相关的事情,你需要理解加密学周围的概念:网络和计算机是如何工作的,用户和系统需要什么,攻击者如何滥用算法及其实现。换句话说,你需要与现实世界建立联系。
本书的方法
本书的初始标题是Crypto for Real,旨在强调我遵循的面向实践、现实世界、直接了当的方法。我希望通过将加密学与实际应用联系起来,而非简化内容,使加密学变得更加易于接近。我提供源代码示例,并描述实际的漏洞和恐怖故事。
本书的其他基石除了与现实世界的紧密联系外,还有它的简洁性和现代性。我更注重形式上的简洁而非内容上的简化:我呈现的是非平凡的概念,而没有枯燥的数学形式主义。相反,我试图传达加密学核心思想的理解,这比记住一堆方程式更为重要。为了确保本书的现代性,我涵盖了加密学的最新发展和应用,例如 TLS 1.3 和后量子加密。我不会讨论过时或不安全的算法,如 DES 或 MD5。例外是 RC4,但它的出现仅仅是为了说明它的弱点,并展示它这种流加密算法的工作原理。
严肃的加密学不是一本加密软件的指南,也不是技术规范的汇编——这些内容你可以轻松在网上找到。它的首要目标是让你对加密学产生兴趣,并在这个过程中教会你它的基本概念。
本书适合谁阅读
在写作过程中,我常常设想读者是一个接触过加密学但在阅读深奥的教科书和研究论文后依然感到困惑和沮丧的开发者。开发者通常需要——而且希望——更好地理解加密,以避免做出不幸的设计选择,我希望这本书能有所帮助。
如果你不是开发者,别担心!这本书不要求编码技能,任何理解计算机科学基础和高中数学(如概率、模运算等)的人都能轻松阅读。
这本书尽管相对易读,但仍然可能让人感到有些压迫,需要一定的努力才能从中获得最大收益。我喜欢用登山的比喻:作者铺设了道路,提供了绳索和冰镐来帮助你,但攀登的过程是你自己的。学习这本书中的概念需要努力,但也是值得的。
本书结构
这本书有 15 章,大致分为四个部分。各章节之间大多是独立的,除了第九章,它为随后的三章打下了基础。我建议先阅读前三章,再继续其他内容。
第一部分: 基础
**第一章: 加密 **介绍了安全加密的概念,从简单的纸笔密码到强大的随机化加密
**第二章: 随机性 **描述了伪随机生成器是如何工作的,它需要具备哪些特性才能确保安全,以及如何安全地使用它
**第三章: 加密安全 **讨论了安全性的理论和实践概念,并对可证明安全性与可能安全性进行了比较
第二部分: 对称加密
**第四章: 分组密码 **讲解了逐块处理消息的密码,重点介绍了最著名的分组密码——高级加密标准(AES)
**第五章: 流密码 **介绍了生成一串看似随机的比特流的密码,这些比特流与待加密的消息进行异或运算
**第六章: 哈希函数 **讨论了唯一不使用秘密密钥的算法,而这些算法恰恰是最广泛应用的加密基础模块
**第七章: 密钥哈希 **解释了将哈希函数与秘密密钥结合使用时会发生什么,以及这如何用于认证消息
**第八章: 认证加密 **展示了一些算法如何同时加密和认证消息,其中包括标准的 AES-GCM 示例
第三部分: 非对称加密
第九章:难题 阐述了公钥加密的基本概念,使用了计算复杂度的相关概念。
第十章:RSA 利用因式分解问题来构建安全的加密和签名方案,这只需要一个简单的算术运算。
第十一章:Diffie–Hellman 扩展了非对称加密技术到密钥协商的概念,其中两方仅使用非秘密值来建立一个秘密值。
第十二章:椭圆曲线 提供了椭圆曲线密码学的温和介绍,它是最快的非对称加密技术。
第 IV 部分:应用
第十三章:TLS 重点介绍了传输层安全性(TLS),可以说是网络安全中最重要的协议。
第十四章:量子与后量子 介绍了量子计算和后量子密码学的概念。
第十五章:加密货币密码学 总结了区块链应用中发现的先进加密方案。
关于第二版
《严肃的密码学》第二版发布于第一版七年后。从那时起,密码学经历了重大的变化。如今,加密一词常常让人联想到区块链、比特币及其他加密货币,而非密码学本身。尽管这些技术的社会效益存在争议,但它们对密码学研究和工程进步的不可否认影响是无法忽视的。意识到这一点,我写了第十五章《加密货币密码学》,深入探讨了区块链应用中使用的迷人加密技术,代表了密码学领域一些最引人入胜的进展。
我对每一章都进行了大量修改,更新了关于新密码学发展的内容,并提高了文本的清晰度和简洁性。其中最重要的新增内容包括:第二章对 Linux 内核随机性的讨论已更新,描述了/dev/random和/dev/urandom接口的新行为,第十二章增加了关于 EdDSA 和 Ed25519 签名方案的新部分,第十四章介绍了 NIST 的后量子密码学标准化项目。
第一章:缩略语
| AE | 认证加密 |
|---|---|
| AEAD | 带相关数据的认证加密 |
| AES | 高级加密标准 |
| AES-NI | AES 原生指令 |
| AKA | 认证密钥协商 |
| API | 应用程序接口 |
| ARX | 加法-旋转-XOR |
| ASIC | 应用特定集成电路 |
| BLS | Barreto–Lynn–Scott |
| BLS | Boneh–Lynn–Shacham |
| CA | 证书授权机构 |
| CAESAR | 认证加密竞赛:安全性、适用性与鲁棒性 |
| CBC | 密码块链 |
| CCA | 选择密文攻击者 |
| CDH | 计算迪菲–赫尔曼 |
| CMAC | 基于密码的消息认证码 |
| COA | 仅密文攻击者 |
| CPA | 选择明文攻击者 |
| CRT | 中国剩余定理 |
| CTR | 计数器模式 |
| CVP | 最近向量问题 |
| DDH | 可判定的迪菲–赫尔曼 |
| DES | 数据加密标准 |
| DH | 迪菲–赫尔曼 |
| DLP | 离散对数问题 |
| DRBG | 确定性随机比特生成器 |
| ECB | 电子密码本 |
| ECC | 椭圆曲线密码学 |
| ECDH | 椭圆曲线迪菲–赫尔曼 |
| ECDLP | 椭圆曲线离散对数问题 |
| ECDSA | 椭圆曲线数字签名算法 |
| FDH | 完全域哈希 |
| FHE | 完全同态加密 |
| FIPS | 联邦信息处理标准 |
| FPE | 格式保留加密 |
| FPGA | 现场可编程门阵列 |
| FSR | 反馈移位寄存器 |
| GCD | 最大公约数 |
| GCM | 伽罗瓦计数器模式 |
| GNFS | 通用数域筛法 |
| HKDF | 基于 HMAC 的密钥派生函数 |
| HMAC | 基于哈希的消息认证码 |
| HTTPS | 安全超文本传输协议 |
| IND | 不可区分性 |
| IP | 网络协议 |
| IV | 初始值 |
| KDF | 密钥派生函数 |
| KPA | 已知明文攻击者 |
| LFSR | 线性反馈移位寄存器 |
| LSB | 最不重要位 |
| LWE | 学习与错误 |
| MAC | 消息认证码 |
| MD | 消息摘要 |
| MitM | 中间人攻击 |
| MPC | 多方计算 |
| MQ | 多变量二次方程 |
| MQV | Menezes–Qu–Vanstone |
| MSB | 最重要位 |
| MT | 梅森旋转 |
| NFSR | 非线性反馈移位寄存器 |
| NIST | 国家标准与技术研究院 |
| NM | 非可篡改性 |
| NP | 非确定性多项式时间 |
| OAEP | 最优非对称加密填充 |
| OCB | 偏移代码本 |
| P | 多项式时间 |
| PLD | 可编程逻辑设备 |
| PoW | 工作量证明 |
| PRF | 伪随机函数 |
| PRNG | 伪随机数生成器 |
| PRP | 伪随机置换 |
| PSK | 预共享密钥 |
| PSS | 概率签名方案 |
| QR | 四分之一轮 |
| QRNG | 量子随机数生成器 |
| RFC | 请求评论 |
| RNG | 随机数生成器 |
| RSA | Rivest–Shamir–Adleman |
| SHA | 安全哈希算法 |
| SIS | 短整数解法 |
| SIV | 合成初始化向量 |
| SNARK | 简洁的非交互式知识证明 |
| SPN | 替代–置换网络 |
| SSH | 安全外壳协议 |
| SSL | 安全套接字层 |
| TE | 可调加密 |
| TLS | 传输层安全 |
| TMTO | 时间-记忆权衡 |
| UDP | 用户数据报协议 |
| UH | 通用哈希 |
| WEP | 无线加密协议 |
| WOTS | 温特尼茨一次性签名 |
| XOR | 排他性或 |
| ZKP | 零知识证明 |
第一部分 基础
第二章:1 加密

加密是密码学的主要应用,它使得数据变得不可理解,以确保其机密性。加密使用一种叫做密码算法的算法和一个称为密钥的秘密值。如果你不知道这个秘密密钥,就无法解密,也无法获取任何加密消息中的信息——攻击者也是如此。
本章重点介绍对称加密,这是最简单的加密方式。在对称加密中,解密密钥与加密密钥相同(与非对称加密或公钥加密不同,后者的密钥是不同的)。你将从学习最简单的对称加密方法开始,这些经典密码仅能抵御最不懂密码学的攻击者,然后我们将继续学习最强的对称加密方法,它们能保证永久的安全。
基础知识
在加密消息时,明文指的是未加密的消息,密文指的是加密后的消息。因此,密码算法由两个功能组成:加密将明文转化为密文,解密将密文转回明文。但我们通常会说“密码”时,实际上是指“加密”。例如,图 1-1 展示了一个密码E,它接收明文P和密钥K作为输入,并输出密文C。我会将这个关系写作 C = E(K, P)。类似地,当密码处于解密模式时,我会写作 D(K, C)。

图 1-1:基本加密与解密
注意
对于某些密码算法,密文与明文的大小相同;对于其他算法,密文会稍微长一些。但密文永远不可能比明文短。
经典密码
经典密码出现在计算机之前,因此它们作用于字母而非比特,这使得它们比现代密码算法(如数据加密标准)要简单得多。例如,在古罗马或第一次世界大战期间,你无法利用计算机芯片的能力来加密一条信息;你只能用笔和纸来完成一切。经典密码有很多种,但最著名的有凯撒密码和维吉尼亚密码。
凯撒密码
凯撒密码之所以得名,是因为罗马历史学家苏托尼乌斯报告说尤利乌斯·凯撒使用了它。它通过将每个字母在字母表中向下移动三个位置来加密信息,如果位移到达Z,则会回绕到A。例如,ZOO加密为CRR,FDHVDU解密为CAESAR,等等,如图 1-2 所示。数字 3 并没有什么特别之处;它只是比 11 或 23 更容易在脑海中计算。

图 1-2:凯撒密码
凯撒密码非常容易破解:要解密一个给定的密文,只需将字母向回移位三个位置,就能恢复明文。话虽如此,凯撒密码在克拉苏斯和西塞罗时代可能足够强大。因为它没有涉及任何秘密密钥(始终是 3),凯撒密码的用户假设攻击者是文盲或太缺乏教育以至于无法破解——而这种假设今天显然不再现实。(事实上,在 2006 年,意大利警方破译了用凯撒密码变种加密的纸条,成功逮捕了一名黑帮头目。例如,ABC被加密为 456,而不是DEF。)
凯撒密码能否变得更加安全?你可能会想象一种版本,使用一个秘密的位移值,而不是始终使用 3,但这也帮助不大,因为攻击者可以尝试所有 25 个可能的位移值,直到解密出的消息有意义为止。
维吉尼亚密码
大约花费了 1500 年,凯撒密码才在 16 世纪通过意大利人 Giovan Battista Bellaso 的创造得到了有意义的改进,变成了维吉尼亚密码。维吉尼亚这个名字来源于法国人布莱兹·德·维吉尼亚,他在 16 世纪发明了另一种密码,但由于历史上的错误归因,维吉尼亚的名字最终被使用了。尽管如此,维吉尼亚密码还是变得非常流行,后来在美国内战中被南方联邦军使用,并且在第一次世界大战期间也被瑞士军队使用,等等。
维吉尼亚密码与凯撒密码类似,不同之处在于字母的位移不是固定的 3 个位置,而是由一个密钥定义的值,密钥是由表示字母位置的字母集合构成,表示数字。例如,如果密钥是 DUH,明文中的字母会按照 3、20、7 的值进行位移,因为D是比A多 3 个字母,U是比A多 20 个字母,H是比A多 7 个字母。3、20、7 的模式会一直重复,直到加密整个明文。例如,使用 DUH 作为密钥,CRYPTO会加密为FLFSNV:C被移位 3 个位置变成F,R被移位 20 个位置变成L,依此类推。图 1-3 展示了加密句子THEY DRINK THE TEA时的原理。

图 1-3:维吉尼亚密码
维吉尼亚密码显然比凯撒密码更安全,但它仍然相对容易破解。解密的第一步是找出密钥的长度。以图 1-3 中的例子为例,THEY DRINK THE TEA被加密为WBLBXYLHRWBLWYH,密钥为 DUH。(通常会去除空格以隐藏单词边界。)请注意,在密文WBLBXYLHRWBLWYH中,字母组WBL在密文中每隔九个字母就出现一次。这表明相同的三字母单词使用相同的移位值进行了加密,每次都会生成WBL。密码分析员可以推测密钥的长度是九,或者是可以整除九的值(即三)。此外,他们还可以猜测这个重复的三字母单词是THE,从而确定 DUH 是一个可能的加密密钥。
破解维吉尼亚密码的第二步是使用一种叫做频率分析的方法来确定实际的密钥,这种方法利用了语言中字母分布的不均匀性。例如,在英语中,E是最常见的字母,因此如果你发现X是密文中最常见的字母,那么这个位置最可能的明文值就是E。
尽管相对较弱,维吉尼亚密码在其时代可能足以安全地加密消息。频率分析的局限性在于它需要几个句子,这意味着如果密码用来加密短消息,它就无法奏效。而且,大多数消息只需要在短时间内保密,因此即使密文最终被敌人解密,也没有太大问题。(19 世纪的密码学家奥古斯特·凯尔科夫斯估计,大多数加密的战时消息只需要保密三到四个小时。)
密码的工作原理
基于简单的凯撒密码和维吉尼亚密码,我们可以尝试通过识别密码的两个主要组成部分:排列和运作模式,来抽象出密码的工作原理。排列是一种函数,它将一个项(在加密学中,指的是一个字母或一组比特)转换,使得每个项都有一个唯一的逆(例如,凯撒密码中的三字母移位)。运作模式是一个算法,它利用排列来处理任意大小的消息。凯撒密码的运作模式是微不足道的——它只是对每个字母重复相同的排列——但正如你所看到的,维吉尼亚密码有一个更复杂的模式,其中不同位置的字母会经历不同的排列。
在接下来的章节中,我将更详细地讨论这些组成部分是什么,以及它们与密码安全的关系。我会使用每个组成部分来展示为什么经典的密码注定是不安全的,而不像现代密码那样可以在高速计算机上运行。
排列
大多数经典密码都是通过将每个字母替换为另一个字母来工作的——换句话说,通过执行 替代。在凯撒密码和维吉尼亚密码中,替代是字母表的移位,尽管字母表或符号集可以有所不同:例如,它可以是阿拉伯字母表,而不是英语字母表;它也可以是单词、数字或表意符号,而不是字母。信息的表示或编码是一个单独的问题,通常与安全性无关。(我们考虑拉丁字母,因为经典密码使用的是这些字母。)
一种密码的替代方式不能是随便的替代方式。它应该是一个置换,即字母 A 到 Z 的重新排列,使得每个字母都有一个独特的逆转。例如,将字母 A、B、C 和 D 分别转换为 C、A、D 和 B 的替代就是一个置换,因为每个字母都映射到另一个单独的字母。但将 A、B、C、D 转换为 D、A、A、C 的替代就不是一个置换,因为 B 和 C 都映射到 A。对于置换,每个字母都有唯一的逆转。
但是,并非所有置换都是安全的。为了确保安全,密码的置换应满足三个标准:
**置换应该由密钥决定 **这可以确保只要密钥是保密的,置换也是保密的。在维吉尼亚密码中,如果你不知道密钥,你就无法知道使用了哪一个置换;因此,你无法轻易地解密。
**不同的密钥应产生不同的置换 **否则,在没有密钥的情况下解密会变得更容易:如果不同的密钥产生相同的置换,那么说明不同的密钥数量少于置换的数量,因此在没有密钥的情况下解密时可尝试的可能性就会减少。在维吉尼亚密码中,每个密钥中的字母决定一个替代方式;有 26 个不同的字母,也有相同数量的不同置换。
**置换应该看起来是随机的,简单来说 **在执行置换后,密文中不应有任何规律,因为模式会使置换对攻击者来说变得可预测,从而降低安全性。例如,维吉尼亚密码的替代方式是相当可预测的:对于给定的偏移量,如果你确定 A 加密为 F,你就可以推断出移位值是 5,并且你也会知道 B 加密为 G,C 加密为 H,以此类推。然而,使用随机选择的置换,如果你知道 A 加密为 F,你只能知道 B 不会 加密为 F。
我们将满足这些标准的置换称为 安全置换。正如你接下来会看到的,一个安全置换是建立一个安全密码所必需的,但仅凭这一点并不足以保证安全。密码还需要一种操作模式来支持任意长度的消息。
操作模式
假设我们有一个安全的置换,它将 A 转换为 X,B 转换为 M,N 转换为 L,例如。因此,单词 BANANA 会加密为 MXLXLX,其中每个 A 被替换为 X。对于明文中的所有字母使用相同的置换,揭示了任何重复的字母。通过分析这些重复字母,你可能无法了解整个信息,但你会了解 某些 信息。在 BANANA 的例子中,你不需要密钥就能猜测出明文中的三个 X 位置共享一个字母,而两个 L 位置共享另一个字母。如果你知道这个信息是某种水果的名字,你可以确定它是 BANANA(香蕉),而不是 CHERRY(樱桃)、LYCHEE(荔枝)或其他六个字母的水果。
密码的操作模式(或 模式)通过对重复字母使用不同的置换,减少了明文中重复字母的暴露。维吉尼亚密码的模式部分解决了这个问题:如果密钥长度为 N,那么每 N 个连续字母将使用 N 种不同的置换。然而,这仍然可能导致密文中的模式,因为每个第 N 个字母使用相同的置换。这就是为什么频率分析能够破解维吉尼亚密码的原因。
如果维吉尼亚密码只对与密钥长度相同的明文进行加密,那么频率分析可以被击败。但即便如此,仍然存在另一个问题:多次重复使用相同的密钥会暴露明文之间的相似性。例如,使用密钥 KYN 时,单词 TIE 和 PIE 会分别加密为 DGR 和 ZGR。两者都以相同的两个字母(GR)结尾,揭示了两个明文的最后两个字母也相同。一个安全的密码不应该能发现这些模式。
要构建一个安全的密码,必须将安全的置换与安全的模式结合起来。理想情况下,这种结合应该阻止攻击者从消息中获取除长度之外的任何信息。
为什么经典密码不安全
经典密码注定不安全,因为它们仅限于你可以用头脑或纸张进行的操作。它们缺乏计算机的计算能力,且容易被简单的计算机程序破解。让我们看看这种简单性为何使它们在今天的世界中变得不安全。
记住,密码的置换应该看起来是随机的,才能是安全的。当然,最好的方法就是 变 随机——也就是从所有置换中随机选择每个置换。而且有很多置换可以选择。以 26 个字母的英语字母表为例,大约有 2⁸⁸ 种置换:

这里,感叹号 (!) 是阶乘符号,定义如下:

(要理解为什么我们得出这个数字,可以将置换看作是重新排列字母的列表:第一个字母有 26 种选择,第二个字母有 25 种可能,第三个字母有 24 种可能,以此类推。)这个数字是巨大的:它和人体中的原子数量在同一个数量级。但经典密码只能使用这些置换中的一小部分——即那些需要简单操作(如移位)并且有简短描述(如简短算法或小型查找表)的置换。问题在于,一个安全的置换不能同时满足这两个限制。
你可以通过简单的操作获得安全的置换,方法是选择一个随机的置换,将其表示为一个包含 25 个字母的表格(足以表示 26 个字母的置换,其中缺少第 26 个字母),然后通过查找这个表中的字母来应用它。但那样你就不会有简短的描述了。例如,描述 10 种不同的置换将需要 250 个字母,而不仅仅是维吉尼亚密码中使用的 10 个字母。
你还可以通过简短的描述生成安全的置换。与仅仅移位字母表不同,你可以使用更复杂的操作,如加法和乘法。这就是现代密码的工作方式:给定一个通常为 128 位或 256 位的密钥,它们执行数百次比特操作来加密一个字母。在每秒可以进行数十亿次比特操作的计算机上,这一过程非常迅速,但如果手动操作则需要数小时,而且仍然容易受到频率分析的攻击。
完美的密码:一次性密钥
本质上,经典密码在没有巨大的密钥的情况下无法保持安全,但使用巨大的密钥进行加密是不实际的。然而,一次性密钥就是这样一种密码,它是最安全的密码。事实上,它保证了完美的保密性:即使攻击者拥有无限的计算能力,也无法了解关于明文的任何信息,除了其长度。
在接下来的部分,我将向你展示一次性密钥是如何工作的,并提供其安全性证明的概要。
加密与解密
一次性密钥采用一个明文,P,和一个与P长度相同的随机密钥,K,并生成一个密文,C,其定义为:

其中C、P和K是相同长度的比特串,⊕是按位异或操作(XOR),定义为:0 ⊕ 0 = 0,0 ⊕ 1 = 1,1 ⊕ 0 = 1,1 ⊕ 1 = 0。
注意
我展示的是一次性密钥的常见形式,基于比特操作,但它可以适配其他符号。例如,使用字母时,你将得到一种变种的凯撒密码,其中每个字母的位移指数是随机选定的。
一次性密码本的解密与加密是完全相同的;它只是一个异或操作:P = C ⊕ K。事实上,我们可以验证* C* ⊕ K = P ⊕ K ⊕ K = P,因为将K与自身进行异或会得到全零字符串 000...000。这就是全部——甚至比凯撒密码还简单。
例如,如果P = 01101101,K = 10110100,那么我们可以计算出以下内容:

解密通过计算以下内容来恢复P:

重要的是,一次性密码本只能使用一次:每个密钥K应该仅使用一次。如果相同的K用于加密P[1]和P[2],生成的密文是C[1]和C[2],那么窃听者可以计算以下内容:

因此,窃听者会了解到P[1]和P[2]的异或差异,这本应是保密的信息。此外,如果知道其中一个明文消息,那么另一个消息也能被恢复。
一次性密码本的使用极其不方便,因为它需要一个与明文一样长的密钥,并且每个新消息或数据组都需要一个新的随机密钥。要加密一个 1TB 的硬盘,你还需要另一个 1TB 的硬盘来存储密钥!尽管如此,一次性密码本在历史上被广泛使用——第二次世界大战期间英国特种行动执行部、苏联间谍、美国国家安全局(NSA)都曾使用过,至今在特定的情况下仍然被使用。(我听说过一些瑞士银行家,他们无法达成一致选择一个双方都信任的密码,最后不得不用一次性密码本,但我不建议这样做。)
为什么一次性密码本是安全的?
虽然一次性密码本不实用,但理解它的安全性是非常重要的。在 20 世纪 40 年代,美国数学家克劳德·香农证明了,要实现完美的保密性,一次性密码本的密钥必须至少与消息一样长。这个证明的思路相当简单。假设攻击者具有无限的能力,因此可以尝试所有的密钥。目标是加密,使得攻击者在给定一些密文的情况下无法排除任何可能的明文。
一次性密码本完美保密性的直觉如下:如果K是随机的,那么生成的密文C对于攻击者来说看起来就像K一样随机,因为将一个随机字符串与任何固定字符串进行异或运算都会得到一个随机字符串。为了理解这一点,考虑一下随机字符串的第一个比特是 0 的概率(为 1/2)。那么随机比特与第二个比特进行异或得到 0 的概率是多少呢?对,仍然是 1/2。这个论证可以在任意长度的比特串上进行迭代。因此,密文C对于不知道K的攻击者来说看起来是随机的,所以即使是拥有无限时间和能力的攻击者,也无法从C中得出关于P的任何信息。换句话说,知道密文并不能提供关于明文的任何信息,除了它的长度——这几乎就是安全密码的定义。
例如,如果密文长度为 128 位(意味着明文也是 128 位),则有 2¹²⁸种可能的密文;因此,从攻击者的角度来看,应该有 2¹²⁸种可能的明文。但是,如果可能的密钥少于 2¹²⁸,攻击者可以排除一些明文。例如,如果密钥只有 64 位,攻击者可以确定 2⁶⁴种可能的明文,并排除绝大多数 128 位字符串。攻击者不能得知明文是什么,但他们能知道明文是什么不是,这使得加密的保密性变得不完美。
要实现完美的安全性,你必须拥有与明文一样长的密钥,但这对于实际使用来说很快就变得不切实际。接下来,我将讨论现代加密方法,以实现既可能又实际的最佳安全性。
加密安全
经典的密码不安全,但像一次性密钥这样的完全安全的密码是不切实际的。因此,如果我们想要既安全又可用的密码,就必须在安全性上做出一定的妥协。但是,除了显而易见的、非正式的“窃听者无法解密安全消息”之外,安全到底意味着什么呢?
一个密码是安全的,如果即使给定大量明文–密文对,也无法得知该密码在应用于其他明文或密文时的行为。这引出了新的问题:
-
攻击者是如何得到这些明文–密文对的?什么是“足够大”的数字?这些都由攻击模型定义,即对攻击者能做什么和不能做什么的假设。
-
什么是可以“学到”的?我们说的“密码的行为”是什么?这些由安全目标定义,即对什么算作成功攻击的描述。
攻击模型和安全目标必须配合使用;你不能仅仅声称一个系统是安全的,而不解释它是针对谁或防止什么的。安全概念是安全目标与攻击模型的结合。如果在给定模型中,任何攻击者都无法突破安全目标,我们就说某个密码实现了某个安全概念。
攻击模型
攻击模型是一组关于攻击者如何与密码交互以及他们能做什么和不能做什么的假设。攻击模型的目标如下:
-
为设计密码的密码学家设定要求,使他们知道要保护自己免受哪些攻击者及其攻击类型。
-
为了给用户提供关于某个密码是否在他们的环境中安全使用的指导。
-
为了给试图破解密码的密码分析师提供线索,让他们知道某个攻击是否有效。只有在考虑的模型中可行的攻击才是有效的。
攻击模型不需要完全符合现实;它们只是近似值。正如统计学家乔治·E·P·博克斯所说:“所有模型都是错误的;实际的问题是它们必须错到什么程度才不再有用。”为了在密码学中有用,攻击模型至少应涵盖攻击者可以实际用来攻击加密算法的手段。如果一个模型高估了攻击者的能力,这是有益的,因为它有助于预测未来的攻击技术——只有偏执的密码学家才能生存。一个糟糕的模型低估了攻击者的能力,通过让它看起来理论上安全而实际上不安全,提供了对加密算法的虚假信心。
克尔克霍夫原则
所有模型中都有一个假设,即克尔克霍夫原则,该原则指出,加密算法的安全性应仅依赖于密钥的保密性,而不是加密算法的保密性。今天,当加密算法和协议是公开指定并为所有人使用时,这听起来可能显而易见。但从历史上看,荷兰语言学家奥古斯特·克尔克霍夫指的是专门为特定军队或分队设计的军事加密机器。引用他 1883 年在《军事密码学》一文中的话,他列出了军事加密系统的六个要求:“该系统不需要保密,且可以被敌人窃取而不会造成问题。”
黑盒模型
让我们考虑一些有用的攻击模型,这些模型通过攻击者能够观察到的内容和他们可以对加密算法进行的查询来表达。对于我们来说,一个查询是将一个输入值发送到某个函数并返回输出的操作,而不暴露该函数的详细信息。例如,一个加密查询会接受一个明文并返回相应的密文,而不透露秘密密钥。
我们称这些为黑盒模型,因为攻击者只能看到加密算法的输入和输出。例如,一些智能卡芯片能够安全地保护加密算法的内部工作原理及其密钥,但你可以连接到该芯片并要求它解密任何密文。攻击者随后会收到相应的明文,这可能有助于他们确定密钥。这是一个解密查询可行的真实示例。
黑盒攻击模型有几种不同类型。这里,我将它们按从最弱到最强的顺序列出,并描述每种模型下攻击者的能力:
仅密文攻击者(COA)观察密文,但不知道关联的明文或明文是如何选择的。COA 模型中的攻击者是被动的,无法执行加密或解密查询。
已知明文攻击者(KPA)观察密文并且知道关联的明文。KPA 模型中的攻击者因此可以得到一组明文–密文对,假设这些明文是随机选择的。KPA 是一种被动攻击模型。
选择明文攻击者(CPAs)可以执行选择明文的加密查询,并观察生成的密文。该模型捕捉了攻击者可以选择所有或部分加密明文并查看其密文的情形。与 COA 或 KPA 等被动模型不同,CPAs 是主动攻击者,因为他们影响加密过程,而不是被动监听。
选择密文攻击者(CCAs)可以执行加密和解密操作;也就是说,他们可以进行加密查询和解密查询(对不同于目标密文的密文进行查询)。最初,CCA 模型可能听起来很荒谬——如果你能解密,还有什么需要的吗?——但与 CPA 模型一样,它旨在表示攻击者可以对密文施加某些影响并随后获取明文的情况。此外,解密并不总是足以破解系统。例如,某些视频保护设备允许攻击者使用设备芯片执行加密查询和解密查询,但在这种情况下,攻击者对密钥感兴趣,以便重新分发密钥;在这种情况下,仅仅能够“免费”解密并不足以破解系统。
在前述模型中,被观察和查询的密文并不是免费的。每个密文都是通过加密函数计算得来的。这意味着,通过加密查询生成 2^N对明文–密文对的计算量大致相当于尝试 2^N个密钥的计算量。例如,在计算攻击成本时应该考虑查询的成本。
灰盒模型
在灰盒模型中,攻击者可以访问加密算法的实现。这使得灰盒模型比黑盒模型更具现实性,尤其是在智能卡、嵌入式系统和虚拟化系统等应用中,因为攻击者通常可以物理接触到这些系统,从而有可能篡改算法的内部细节。因此,灰盒模型比黑盒模型更难定义,因为它们依赖于物理、模拟特性,而不仅仅是依赖于算法的输入和输出,而密码学理论往往无法抽象出现实世界的复杂性。
旁路攻击是一类在灰盒模型中的攻击。旁路是指依赖于密码实现的一个信息源,无论是在软件中还是硬件中。旁路攻击者观察或测量密码实现的模拟特征,但不会改变其完整性;它们是非侵入性的。对于纯软件实现,典型的旁路通道包括执行时间和系统中与密码相关的行为,如错误消息、返回值和分支。以智能卡实现为例,典型的旁路攻击者可能会测量功耗、电磁辐射或声学噪音。
侵入性攻击是一类针对密码实现的攻击,比旁路攻击更为强大且成本更高,因为它们通常需要复杂的设备。你可以使用标准 PC 和普通示波器进行基本的旁路攻击,但侵入性攻击可能需要如高分辨率显微镜和化学实验室等工具。侵入性攻击包括一整套技术和程序,包括使用硝酸去除芯片包装、获取显微图像、部分逆向工程,以及通过激光故障注入和电磁注入等技术修改芯片行为。
安全目标
我非正式地将安全的目标定义为“无法从密码的行为中获得任何信息”。为了将这个概念转化为严格的数学定义,密码学家定义了两个安全目标,分别对应于了解密码行为的不同概念:
**不可区分性 (IND) **密文应该与随机字符串无法区分。通常通过一个假设的游戏来说明:如果攻击者选择两个明文,并随机获得其中一个明文的密文,那么他们应该无法判断出到底是哪一个明文被加密,即使他们通过对这两个明文进行加密查询(如果模型是 CCA 而非 CPA,还包括解密查询)也无法得出结论。
**不可篡改性 (NM) **给定密文C[1] = E(K, P[1]),应该不可能创建另一个密文C[2],其对应的明文P[2]在某种有意义的方式上与P[1]相关(例如,创建一个P[2],使其等于P[1] ⊕ 1,或等于P[1] ⊕ X,其中X是已知的某个值)。令人惊讶的是,一次性密钥是可篡改的:给定密文C[1] = P[1] ⊕ K,你可以定义C[2] = C[1] ⊕ 1,这也是一个有效的密文,解密得到P[2] = P[1] ⊕ 1,且使用相同的密钥K。哎呀,看来我们的完美密码也不是那么完美。
接下来,我将讨论这些安全目标在不同攻击模型中的应用。
安全概念
安全目标只有在结合攻击模型时才有意义。惯例是将安全概念写作目标-模型。例如,IND-CPA 表示针对选择明文攻击者的不可区分性,NM-CCA 表示针对选择密文攻击者的不可篡改性,等等。让我们从攻击者的安全目标开始。
语义安全与随机化加密:IND-CPA
最重要的安全概念是 IND-CPA,也称为语义安全。它表达了这样一种直觉:只要密钥保密,密文就不应该泄漏任何关于明文的信息。为了实现 IND-CPA 安全,加密必须在对相同明文加密两次时返回不同的密文;否则,攻击者就可能通过密文识别出重复的明文,从而违背了密文不应泄漏任何信息的定义。但需要注意的是,即使是 IND-CPA 安全的方案,也不可避免地会泄漏关于明文的一条信息:它的长度,或者至少是大致长度。这就是为什么加密压缩数据通常不是一个好主意,因为压缩数据的大小可能会泄漏原始数据的一些信息。
实现 IND-CPA 安全性的一种方法是使用随机化加密。顾名思义,它随机化加密过程,并且当相同明文被加密两次时,返回不同的密文。加密可以表示为C = E(K, R, P),其中R是新生成的随机位。然而,解密仍然是确定性的,因为给定D(K, R, C),无论R的值如何,你总是应该得到P。
如果加密没有随机化怎么办?在前一页“安全目标”部分介绍的 IND 游戏中,攻击者选择两个明文,P[1] 和 P[2],并收到其中一个的密文,但不知道密文对应的是哪个明文。也就是说,他们得到Ci = E(K, Pi),并必须猜测i是 1 还是 2。在 CPA 模型中,攻击者可以执行加密查询来确定C[1] = E(K, P[1])和C[2] = E(K, P[2])。如果加密没有随机化,只需查看Ci 是否等于C[1]或C[2],就可以确定加密的是哪个明文,从而赢得 IND 游戏。因此,随机化是 IND-CPA 概念的关键。
如果你没有伪随机生成器,仍然可以通过使用需要唯一数值(或仅使用一次的数字)的加密方案来实现 IND-CPA 安全,而不是使用随机且不可预测的值。每次新的加密调用必须使用唯一的 nonce。一个简单的计数器(1, 2, 3, ...)就能起作用。例如,AES-CTR(在 CTR 模式下使用的 AES 块密码)是 IND-CPA 安全的,只要它的附加输入 nonce 是唯一的。与一些随机化加密方案不同,后者仅将随机性作为加密过程的一部分,nonce 是解密时必需的,对于使用 nonce 的算法尤为重要。
注意
对于随机化加密,密文必须比明文稍长,以允许每个明文对应多个可能的密文。例如,如果每个明文有 2**⁶⁴ 种可能的密文,密文必须比明文长至少 64 位。
语义安全加密
构造一个语义安全密码最简单的方式之一是使用确定性随机位生成器(DRBG),这是一种给定某个秘密值后返回看似随机位的算法:

在这里,R是为每个新的加密随机选择的字符串,并与密钥一起传递给 DRBG(K || R表示由K后跟R组成的字符串)。这种方法让人联想到一次性密钥:我们不是选择与消息长度相同的随机密钥,而是利用随机位生成器得到一个看似随机的字符串。
证明该密码是 IND-CPA 安全的过程很简单,前提是我们假设 DRBG 生成随机位。该证明是归谬法:如果你能区分密文与随机字符串,意味着你能区分DRBG(K || R) ⊕ P与随机字符串,那么这意味着你能区分DRBG(K || R)与随机字符串。请记住,CPA 模型允许你对选择的P值获得密文,所以你可以将P与DRBG(K || R) ⊕ P进行异或,从而得到DRBG(K || R)。但现在我们有一个矛盾,因为我们假设DRBG(K || R)无法与随机值区分开来,它生成的是随机字符串。因此,我们得出结论,密文无法与随机字符串区分开来,因此该密码是安全的。
注意
作为练习,尝试确定该密码E(K,* R, P) = (DRBG(K || R) ⊕ P, R)满足哪些其他安全性概念。它是 NM-CPA 吗?IND-CCA 吗?你将在下一节找到答案。*
安全性概念对比
你已经学过,攻击模型如 CPA 和 CCA 与安全目标如 NM 和 IND 结合起来,构建了安全性概念如 NM-CPA、NM-CCA、IND-CPA 和 IND-CCA。这些概念之间有什么关系?我们能否证明满足概念 X 意味着满足概念 Y?
一些关系是显而易见的:IND-CCA 意味着 IND-CPA,NM-CCA 意味着 NM-CPA,因为任何 CPA 攻击者能做的,CCA 攻击者也能做。也就是说,如果你不能通过执行选择密文和选择明文查询来破解一个密码,那么你仅通过执行选择明文查询也无法破解它。
一个不太明显的关系是,IND-CPA 并不意味着 NM-CPA。为了理解这一点,观察前面提到的 IND-CPA 构造(DRBG(K, R) ⊕ P, R)并不是 NM-CPA:给定一个密文(X, R),你可以创建密文(X ⊕ 1, R),这其实是P ⊕ 1 的有效密文,从而与不可篡改性(nonmalleability)的概念相矛盾。
但相反的关系成立:NM-CPA 意味着 IND-CPA。直观上,IND-CPA 加密就像把物品放入袋子里:你看不到它们,但可以通过上下摇晃袋子改变它们的位置。NM-CPA 更像是一个保险箱:一旦放进去,你就无法与里面的东西互动。这个类比不适用于 IND-CCA 和 NM-CCA,它们是等价的概念,彼此意味着对方的存在。我就不赘述证明了,那个比较技术性。
到目前为止,我们只讨论了对称加密,其中两方共享一个密钥。在非对称加密中,有两个密钥:一个用于加密,另一个用于解密。加密密钥被称为公钥,通常认为是公开的,任何想要向你发送加密消息的人都可以使用。而解密密钥则必须保密,被称为私钥。
公钥可以从私钥计算得出,但私钥无法从公钥计算得出。换句话说,计算一个方向很容易,但反方向则不行——这就是公钥加密的关键,它的函数在一个方向上容易计算,但实际上不可能逆转。
非对称加密的攻击模型和安全目标与对称加密大致相同,不同之处在于因为加密密钥是公开的,任何攻击者都可以通过使用公钥进行加密查询。因此,非对称加密的默认模型是选择明文攻击者。
对称加密和非对称加密是两种主要的加密方式,它们通常结合使用以构建安全的通信系统。它们也构成了更复杂方案的基础,正如你接下来会看到的。
当密码不只是加密
基本加密将明文转换为密文,再将密文转换为明文,除了安全性外没有其他要求。然而,一些应用程序常常需要更多的功能,无论是额外的安全特性还是其他功能。这就是为什么密码学家创建了对称加密和非对称加密的变体。一些加密方法已被广泛理解、高效且被广泛部署,而另一些则是实验性的,几乎未被使用,并且性能较差。
认证加密
认证加密(AE)是一种对称加密,它除了返回密文外,还会返回认证标签。图 1-4 展示了认证加密的公式AE(K, P) = (C, T),其中认证标签T是一个短字符串,在没有密钥的情况下几乎无法猜测。解密时需要K、C和T,并且只有当T验证为该明文-密文对的有效标签时,才能返回明文P;否则,解密操作会中止并返回错误。

图 1-4:认证加密
标签确保消息的完整性,并作为证据,表明接收到的密文与最初由知道密钥K的合法方发送的密文是相同的。当K只与另一方共享时,标签还可以保证消息是由该方发送的;也就是说,它隐式地认证了预期的发送者为消息的实际创建者。
注意
我在这里使用“创建者”而不是“发送者”,因为窃听者可以记录一些由 A 方发送到 B 方的(C, T)对,然后再次将它们发送给 B 方,假装是 A 方。这个被称为重放攻击,可以通过包括计数器号码在消息中来防止。例如,当消息被解密时,它的计数器i增加 1:i* + 1。通过这种方式,可以检查计数器以查看消息是否被发送了两次,从而指示攻击者试图通过重新发送消息来进行重放攻击。这也可以帮助检测丢失的消息。*
带关联数据的认证加密(AEAD)是认证加密的扩展,它将某些明文和未加密数据结合起来,并用它生成认证标签AEAD(K, P, A) = (C, A, T)。AEAD 的一个典型应用是保护协议的数据报,其中包含明文头和加密负载。在这种情况下,至少有一些头部数据必须保持明文;例如,目的地地址需要保持明文,以便路由网络包。
欲了解更多认证加密的内容,请跳转到第八章。
格式保持加密
一个基本的密码算法将比特流作为输入,输出比特流;它并不关心这些比特是表示文本、图像,还是 PDF 文档。密文可能会被编码成原始字节、十六进制字符、base64 或其他格式。但如果你需要密文与明文保持相同格式呢?这是一些数据库系统所要求的,它们只能以规定的格式记录数据。
格式保留加密(FPE) 解决了这个问题。它能够生成与明文格式相同的密文。例如,FPE 可以将 IP 地址加密成 IP 地址(如图 1-5 所示),将邮政编码加密成邮政编码,将信用卡号加密成具有有效校验和的信用卡号,等等。

图 1-5:用于 IP 地址的格式保留加密
全同态加密
全同态加密(FHE) 是密码学家们的圣杯:它允许用户将一个密文 C = E(K, P) 替换为另一个密文 C ′ = E(K, F(P)),其中 F(P) 可以是 P 的任何函数,而无需解密初始密文 C。例如,P 可以是一个文本文件,F 可以是修改文本的某一部分。想象一下,一个云应用程序存储你的加密数据,但云提供商不知道数据是什么,或者你修改数据时做了什么更改。听起来是不是很棒?
但是,这也有一个缺点:这种加密方式非常慢——慢到连最基本的操作都需要不可接受的时间。第一个全同态加密(FHE)方案是在 2009 年创建的,从那时起,出现了更高效的变种,但仍不清楚 FHE 是否会足够快以供实际使用。然而,(部分)同态加密在某些特定应用场景下,特别是在评估机器学习模型时,已经证明比其他方法更高效。
可搜索加密
可搜索加密 使得可以在加密的数据库中进行搜索,而不会泄露搜索词,方法是加密搜索查询本身。像全同态加密一样,可搜索加密能够通过隐藏搜索内容来增强许多基于云的应用程序的隐私,防止云服务提供商看到你的搜索内容。虽然一些商业解决方案声称提供可搜索加密,但它们大多基于标准加密技术,并通过一些技巧实现部分可搜索性。截至目前,可搜索加密仍然是研究社区中的实验性技术。
可调加密
可调加密(TE) 类似于基本加密,唯一的不同是多了一个称为 tweak 的额外参数,它旨在模拟不同版本的密码算法(参见 图 1-6)。这个 tweak 可能是每个客户独有的值,用以确保某个客户的密码不会被其他使用相同产品的第三方克隆,但 TE 的主要应用是 磁盘加密。然而,TE 并不限于单一应用,它是一种低层次的加密方式,用于构建其他加密方案,比如认证加密模式。

图 1-6: 可调加密
在磁盘加密中,TE 加密存储设备的内容,如硬盘或固态硬盘。(不能使用随机化加密,因为它会增加数据大小,这对存储介质中的文件来说是不可接受的。)为了使加密不可预测,TE 使用依赖于加密数据位置的 tweak 值,通常这个位置是扇区号或块索引。
可能出错的地方
加密算法或其实现方式可能以多种方式无法保护机密性。这可能是因为未能满足安全要求(例如“必须是 IND-CPA 安全”)或未设定与实际情况相匹配的要求(如果你仅针对 IND-CPA 安全,而攻击者实际上可以执行选择密文查询)。遗憾的是,许多工程师甚至没有考虑过密码安全要求,只是想要“安全”,却不理解这到底意味着什么。通常,这种做法是灾难的导火索。让我们看看两个例子。
弱密码
我们的第一个例子涉及那些可以通过密码分析技术攻击的密码,正如 2G 移动通信标准所发生的那样。2G 移动电话中的加密使用了名为 A5/1 的密码,结果证明它比预期的要弱,任何具备相应技能和工具的人都可以拦截通话。电信运营商不得不找到解决办法以防止这种攻击。
注意
2G 标准还定义了 A5/2,这是一个适用于欧盟和美国以外地区的密码。A5/2 被故意设计得较弱,以防止强加密在所有地方的使用。
尽管如此,攻击 A5/1 并非易事,研究人员花了超过 10 年时间才提出一种有效的密码分析方法。此外,这种攻击是一种时间-空间折衷(TMTO),这是一种方法,首先进行长时间(几天或几周)的计算,构建大型查找表,然后将这些表用于实际攻击。对于 A5/1,预计算的表大约有 1TB。后来的移动加密标准,如 3G 和 LTE,指定了更强的密码,但这并不意味着它们的加密不会被破解;它只是意味着通过破解系统中的对称密码,无法破坏其加密。
错误模型
下一个例子涉及一个忽视了一些侧信道的无效攻击模型。
使用加密的许多通信协议确保它们使用在 CPA 或 CCA 模型中被认为是安全的密码。然而,一些攻击并不需要加密查询(如在 CPA 模型中那样),也不需要解密查询(如在 CCA 模型中那样)。它们只需要有效性查询来判断密文是否有效,而这些查询通常是发送给负责解密密文的系统的。填充 oracle 攻击就是这种攻击的一个例子,在这种攻击中,攻击者能够判断一个密文是否符合要求的格式。
具体来说,在填充 oracle 攻击的情况下,只有当密文的明文具有正确的填充时,密文才是有效的,填充是附加到明文上以简化加密的字节序列。如果填充不正确,解密会失败,攻击者通常可以检测到解密失败并尝试利用这些失败。例如,Java 异常javax.crypto.BadPaddingException的存在表明观察到了不正确的填充。
2010 年,研究人员在多个 Web 应用服务器中发现了填充 oracle 攻击。有效性查询包括将一个密文发送到某个系统并观察是否抛出错误。通过这些查询,他们可以在不知道密钥的情况下解密本应安全的密文。
密码学家通常忽视像填充 oracle 攻击这样的攻击,因为它们通常依赖于应用程序的行为以及用户如何与应用程序交互。但如果你没有预见到这种攻击,并且在设计和部署加密时没有将其包含在模型中,你可能会遇到一些令人惊讶的问题。
进一步阅读
本书将详细讨论加密及其各种形式,特别是现代安全密码如何运作。然而,我无法涵盖所有内容,很多令人着迷的话题我都未能涉及。例如,要学习加密的理论基础,并更深入地理解不可区分性的概念,可以阅读 1982 年由 Goldwasser 和 Micali 提出的关于语义安全的开创性论文《概率加密与如何在保持所有部分信息秘密的情况下进行心理扑克》。如果你对物理攻击和密码硬件感兴趣,Cryptographic Hardware and Embedded Systems(CHES)会议的论文集是主要参考资料。
此外,除了本章中介绍的加密类型外,还有许多其他类型的加密,包括基于属性的加密、广播加密、功能加密、基于身份的加密、消息锁定加密和代理重加密,仅举几例。有关这些主题的最新研究,请访问<wbr>eprint<wbr>.iacr<wbr>.org,这是一个密码学研究论文的电子档案。
第三章:2 随机性

随机性在加密学中无处不在:在生成密钥、加密方案,甚至在对加密系统的攻击中。如果没有随机性,加密将不可能,因为所有操作将变得可预测,从而变得不安全。
本章介绍了在加密学中的随机性概念及其应用。我们讨论了伪随机数生成器以及操作系统如何生成可靠的随机性,最后通过实际例子展示了错误的随机性如何影响安全性。
随机还是非随机?
你可能以前听过随机比特这个词,但严格来说,实际上并不存在一系列随机比特。真正随机的是产生随机比特序列的算法,或者说是过程;因此,当我们说“随机比特”时,我们实际上指的是随机生成的比特。
随机比特是什么样的?例如,8 位字符串 11010110 可能看起来比 00000000 更随机,尽管它们生成的机会是相同的(即 1/256)。11010110 看起来比 00000000 更随机,因为它具有典型的随机生成值的特征。也就是说,11010110 没有明显的模式。
当我们看到字符串 11010110 时,大脑会识别出它包含 3 个零和 5 个一,就像其他 55 个 8 位字符串(11111000、11110100、11110010 等)一样,但只有一个 8 位字符串是全零的。因为 3 个零和 5 个一的模式比 8 个零的模式更容易出现,所以我们将 11010110 识别为随机,而将 00000000 识别为非随机,即使它们并非如此。
这个例子说明了人们在识别随机性时常犯的两种错误:
将非随机性误认为随机性 认为一个物体是随机生成的,仅仅因为它看起来随机
将随机性误认为非随机性 认为偶然出现的模式是有某种原因存在的,而不是偶然的
随机看起来和真正随机之间的区别至关重要。实际上,在加密领域,非随机性常常等同于不安全。
“它是偶然发生的”这句话反映了一个特性,即在复杂系统中(在这个例子中,是遵循物理定律的宇宙,宏观层面上是确定性的,而在亚原子、量子层面上是完全随机的),可以出现特定的模式,例如字符串 00000000。根据大数法则,如果许多事件发生,有些事件看起来并不随机——例如彩票抽奖中的一系列顺序数字。许多伪科学和信仰体系实际上是将随机误认为非随机的例子。
随机性作为概率分布
任何随机化过程的特征是由概率分布决定的,概率分布提供了关于该过程随机性的所有信息。概率分布,或简称分布,列出了一个随机化过程的所有结果,并为每个结果分配一个概率。
概率是衡量事件发生可能性的指标。它表示为介于 0 和 1 之间的实数,其中 0 表示不可能,1 表示确定。例如,在抛硬币时,每一面落正面的概率是 1/2(或 0.5),而硬币立在边缘的概率接近 0。
概率分布必须包括所有可能的结果,使得所有概率的总和为 1。具体来说,如果有N个可能的事件,就有N个概率p[1],p[2],...,pN,且p[1] + p[2] + ... + pN = 1。在抛硬币的例子中,硬币正面和反面的分布各为 1/2。两者的总和为 1/2 + 1/2 = 1,因为硬币会落在它的两个面之一。
均匀分布发生在分布中的所有概率相等时,这意味着所有结果发生的可能性相同。如果有N个事件,那么每个事件的概率是 1/N。例如,如果一个 128 位密钥是均匀随机选择的——即根据均匀分布——那么 2¹²⁸个可能的密钥中的每个密钥应该有 1/2¹²⁸的概率。
相反,当分布是非均匀时,概率并不相等。一个非均匀分布的硬币抛掷被称为偏倚的,例如,正面可能以 1/4 的概率出现,反面可能以 3/4 的概率出现。
注
可以通过使用加重的骰子作弊,使得每个面出现的概率不再是 1/6;然而,硬币无法偏倚。只有当“硬币被允许反弹或旋转,而不是简单地在空中翻转”时,硬币抛掷才可能出现偏差,正如文章《你可以加重骰子,但不能偏倚硬币》中所描述的那样(<wbr>www<wbr>.stat<wbr>.berkeley<wbr>.edu<wbr>/~nolan<wbr>/Papers<wbr>/dice<wbr>.pdf)。
熵:不确定性的度量
熵是系统中不确定性或无序度的度量。熵越高,随机过程结果的不确定性越大。
我们可以计算概率分布的熵。如果你的分布由概率p[1],p[2],...,pN 组成,则其熵是所有概率与其对数的乘积的负和,如下所示:

这里的函数log是二进制对数,即以二为底的对数。与自然对数不同,二进制对数表示信息的比特,并且当概率是二的幂时,结果为整数。例如,log(1/2) = -1,log(1/4) = -2,通常情况下 log(1/2^n) = - n。(我们实际上取负和,以得到正数。)因此,使用均匀分布生成的随机 128 位密钥具有以下熵值:

如果你将 128 替换为任意整数n,那么一个均匀分布的n位字符串的熵将是n比特。
当分布是均匀时,熵最大,因为均匀分布最大化了不确定性:没有任何结果比其他结果更可能。因此,n位值的熵不能超过n比特。
同样的,当分布不是均匀时,熵会较低。考虑掷硬币的例子。公平掷硬币的熵值如下:

如果硬币的一面比另一面更可能朝上会怎么样?假设正面的概率是 1/4,反面是 3/4。(记住,所有概率的和应该为 1。)
这样一个有偏掷硬币的熵值如下:

0.81 小于公平掷硬币的 1 比特熵,这告诉我们,硬币越有偏,分布越不均匀,熵值也越低。进一步说明,如果正面朝上的概率是 1/10,那么熵值为 0.469;如果概率降到 1/100,熵值则降至 0.081。
注意
熵也可以被看作是信息的度量。例如,一个公平的掷硬币结果给你准确的 1 比特信息——正面或反面——并且你无法提前预测掷硬币的结果。在不公平的掷硬币情况下,你提前知道反面更可能出现,因此你可以预测结果。不公平掷硬币的结果为你提供了预测结果所需的信息。
随机与伪随机数生成器
加密系统需要随机性来保证安全,因此需要一个可以提供随机性的组件。这个组件的工作是当被请求时返回随机比特。为了生成这种随机性,你需要两样东西:
-
随机数生成器提供的熵源。
-
一种加密算法可以从熵源中生成高质量的随机比特。这在伪随机数生成器中得以实现。
同时使用随机和伪随机数生成器是使加密学既实用又安全的关键。让我们在深入探讨伪随机数生成器之前,简单了解一下随机数生成器的工作原理。
随机性来源于环境,而环境是模拟的、混沌的、不确定的,因此无法预测。仅依赖计算机算法无法生成随机性。在密码学中,随机性通常来自随机数生成器(RNG),这是一种软件或硬件组件,利用模拟世界中的熵来在数字系统中生成不可预测的比特。例如,一个 RNG 可能直接从温度、声学噪声、空气湍流或电静态的测量中采样比特。不幸的是,这些模拟熵源并不总是可用,而且它们的熵通常很难估计。
RNG 也可以通过从附加的传感器、I/O 设备、网络或磁盘活动、系统日志、运行中的进程以及用户活动(如按键和鼠标移动)中获取熵。此类系统和人为活动可以是一个良好的熵源,但它们可能非常脆弱,且容易受到攻击者的操控。此外,它们生成随机比特的速度较慢。
注意
量子随机数生成器(QRNG)是一种依赖于量子力学现象(如放射性衰变、光子极化或热噪声)产生的随机性的随机数生成器。这些现象不受通过方程式从当前状态推导未来状态的限制,因此是绝对意义上的随机性。然而,在实际应用中,从 QRNG 提取的原始比特可能会带有偏差,并且生成速度较慢。与前面提到的熵源类似,它们需要经过后处理才能以高速生成可靠的比特。
伪随机数生成器(PRNG)通过可靠地产生大量人工随机比特来解决生成随机性的挑战,这些比特源自少量真实的随机比特。例如,一个将鼠标移动转换为随机比特的 RNG,如果你停止移动鼠标,就会停止工作,而 PRNG 则始终在请求时返回伪随机比特。
PRNG 依赖于 RNG,但行为有所不同:RNG 从模拟源生成真实的随机比特相对较慢,采用非确定性方式,且无法保证比特的均匀分布或每个比特的高熵。相比之下,PRNG 从数字源快速生成看似随机的比特,采用确定性方式,均匀分布,并且熵足够高,适用于加密应用。实际上,PRNG 将少量不可靠的随机比特转化为适合加密应用的长时间可靠的伪随机比特流,正如图 2-1 所示。

图 2-1:RNG 从模拟源生成的比特较少且不可靠,而 PRNG 将这些比特扩展为长时间可靠的比特流。
伪随机数生成器的工作原理
PRNG 定期从 RNG 接收随机位,并使用这些随机位更新一个大型内存缓冲区,称为 熵池。熵池是 PRNG 的熵来源,就像物理环境是 RNG 的熵来源一样。当 PRNG 更新熵池时,它会将熵池中的位混合在一起,以帮助去除任何统计偏差。
为了生成伪随机位,PRNG 运行一个确定性随机位生成器(DRBG)算法,将熵池中的某些位扩展成一个更长的序列。顾名思义,DRBG 是确定性的,而非随机的:给定一个输入,你总是会得到相同的输出。PRNG 确保其 DRBG 永远不会接收到相同的输入两次,因此可以生成唯一的伪随机序列。
在其工作过程中,PRNG 执行三项操作:
**init() **初始化熵池和 PRNG 的内部状态
**刷新(R) **使用一些数据 R 更新熵池,这些数据通常来自 RNG
**下一步(N) **返回 N 个伪随机位,并更新熵池
init 操作将 PRNG 重置为一个全新的状态,重新初始化熵池为某个默认值,并初始化 PRNG 用于执行 刷新 和 下一步 操作的任何变量或内存缓冲区。
刷新 操作通常称为 重新播种,其参数 R 称为 种子。当没有可用的 RNG 时,种子可能是系统中硬编码的唯一值。通常操作系统会调用 刷新 操作,而 下一步 操作通常由应用程序调用或请求。下一步 操作运行 DRBG,并修改熵池,以确保下一次调用会生成不同的伪随机位。
安全性问题
简要谈一下伪随机数生成器(PRNG)如何解决高级安全性问题。具体来说,PRNG 应该保证 回溯抗性 和 预测抗性。回溯抗性(也叫 前向保密性)意味着先前生成的位无法恢复,而预测抗性(后向保密性)意味着未来的位应该无法预测。
为了实现回溯抗性,PRNG 应确保在通过 刷新 和 下一步 操作更新状态时所执行的变换是不可逆的。这样,如果攻击者破坏了系统并获得了熵池的值,他们就无法确定熵池的先前值或之前生成的位。为了实现预测抗性,PRNG 应定期调用 刷新 操作,使用攻击者无法得知且难以猜测的 R 值,从而防止攻击者确定熵池的未来值,即使整个熵池已被破解。(如果 R 值的列表已知,那么你需要知道 刷新 和 下一步 调用的顺序,才能重建熵池。)
PRNG Fortuna
Fortuna 是一种伪随机数生成器(PRNG),最初由 Niels Ferguson 和 Bruce Schneier 于 2003 年设计,最早应用于 Windows。Fortuna 取代了 Yarrow,这是 John Kelsey 和 Bruce Schneier 于 1998 年设计的方案,曾长期用于 macOS 和 iOS 操作系统,并且已被 Fortuna 替代。我在这里不会提供 Fortuna 的规格,也不会告诉你如何实现它,但我会尽力解释它是如何工作的。你可以在 Ferguson、Schneier 和 Kohno 的《Cryptography Engineering》(Wiley, 2010)一书的 第九章 中找到 Fortuna 的完整描述。
Fortuna 的内部内存包括以下内容:
-
三十二个熵池,P[1],P[2],...,P[32],使得 Pi 每进行 2^i 次重新种子时使用一次。
-
一个密钥 K 和一个计数器 C(都为 16 字节)。它们构成了 Fortuna 的 DRBG(确定性随机比特生成器)的内部状态。
简单来说,Fortuna 的工作原理如下:
-
init() 将 K 和 C 设为零,并清空 32 个熵池 Pi,其中 i = 1 . . . 32。
-
refresh(R) 将数据 R 添加到某个熵池中。系统选择用来生成 R 值的 RNG,并且应该定期调用 refresh。
-
next(N) 使用来自一个或多个熵池的数据更新 K,其中熵池的选择主要取决于 K 已经更新了多少次。然后,通过使用 K 作为密钥加密 C 来生成请求的 N 位。如果加密 C 还不够,Fortuna 会加密 C + 1,再加密 C + 2,以此类推,直到获得足够的位。
尽管 Fortuna 的操作看起来相当简单,但正确实现它是困难的。首先,你需要将算法的所有细节做到位——如何选择熵池,next 中使用的密码类型,如何在没有熵输入时进行处理,等等。尽管规格定义了大多数细节,但它们并未包含全面的测试套件来检查实现是否正确,这使得确保你对 Fortuna 的实现能够按预期行为运行变得困难。
即使 Fortuna 被正确实现,也可能由于算法错误以外的原因发生安全失败。例如,Fortuna 可能没有注意到 RNG 没有产生足够的随机位,因此 Fortuna 会生成质量较差的伪随机位,或者甚至完全停止生成伪随机位。
Fortuna 实现中的另一个风险在于可能将相关的 种子文件 暴露给攻击者。Fortuna 种子文件中的数据用于在 RNG(随机数生成器)不可立即使用时,通过 refresh 调用将熵传递给 Fortuna——例如,在系统重启后,且在系统的 RNG 记录任何不可预测的事件之前。然而,如果相同的种子文件被使用了两次,Fortuna 会产生相同的位序列。因此,种子文件应该在使用后被擦除,以确保它们不会被重复使用。
最后,如果两个 Fortuna 实例处于相同状态,因为它们共享一个种子文件(即熵池中的数据相同,包括C和K),那么下一个操作将在两个实例中返回相同的位。
加密与非加密伪随机数生成器
有加密和非加密伪随机数生成器。非加密伪随机数生成器设计用于产生均匀分布,适用于科学模拟或视频游戏等应用。然而,绝不能在加密应用中使用非加密伪随机数生成器,因为它们不安全;它们只关心位的概率分布质量,而不关心它们的可预测性。另一方面,加密伪随机数生成器是不可预测的,因为它们还考虑了用于生成良好分布位的底层操作的强度。
不幸的是,大多数编程语言暴露的伪随机数生成器(PRNG)——例如 libc 的 rand 和 drand48,PHP 的 rand 和 mt_rand,Python 的 random 模块,Java 的 java.util.Random 类——都是非加密的。默认使用非加密的伪随机数生成器是灾难的开始,因为它通常会被用于加密应用中,所以在生成与加密或安全应用相关的随机性时,务必使用加密伪随机数生成器。
一种流行的非加密伪随机数生成器:梅森旋转算法
梅森旋转算法 (MT) 是一种非加密的伪随机数生成器(PRNG),目前(截至本文写作时)被 PHP、Python、R、Ruby 和许多其他系统使用。它甚至(不幸地)被用于区块链钱包密钥生成器中。MT 生成均匀分布的随机位,没有统计偏差,但它是可预测的:只要知道由 MT 生成的几个位,就能猜出接下来的位是什么。
让我们深入了解梅森旋转算法不安全的原因。MT 算法比加密伪随机数生成器的算法要简单得多:它的内部状态是一个数组 S,包含 624 个 32 位的字。这个数组最初被设置为 S[1],S[2],...,S[624],然后根据这个方程演变为 S[2],...,S[625],接着是 S[3],...,S[626],依此类推:

这里,⊕ 表示按位异或(在 C 语言中为 ^),∧ 表示按位与(在 C 中为 &),∨ 表示按位或(在 C 中为 |),A 是一个函数,它将某个 32 位字 x 转换为 (x >> 1),如果 x 的最高有效位是 0,或者转换为 (x >> 1) ⊕ 0x9908b0df 否则。
在这个方程中,S的比特仅通过异或运算相互作用。运算符∧和∨从不将S的两个比特结合起来,而是将S的比特与常量 0x80000000 和 0x7fffffff 中的比特结合。这样,S[625]的任何一个比特都可以表示为S[398]、S[1]和S[2]的异或运算,而任何未来状态中的比特都可以表示为初始状态S[1]、...、S[624]中比特的异或组合。(例如,当你将S[228 + 624] = S[852]表示为S[625]、S[228]和S[229]的函数时,你可以进一步将S[625]替换为它在S[398]、S[1]和S[2]中的表示。)
因为初始状态中恰好有 624 × 32 = 19,968 个比特(或 624 个 32 位字),任何输出比特都可以表示为至多包含 19,969 项(19,968 个比特加一个常量比特)的方程。这大约是 2.5KB 的数据。反过来也是如此:初始状态中的比特可以表示为输出比特的异或组合。
线性不安全性
我们称位的异或组合为线性组合。例如,如果X、Y和Z是位,那么表达式X ⊕ Y ⊕ Z就是线性组合,而(X ∧ Y) ⊕ Z则不是,因为存在与(∧)。如果你在X ⊕ Y ⊕ Z中翻转X的某一位,那么结果也会改变,而与Y和Z的值无关。相反,如果你在(X ∧ Y) ⊕ Z中翻转X的某一位,只有当Y在相同位置的位为 1 时,结果才会改变。关键在于,线性组合是可预测的,因为你不需要知道位的具体值就能预测它们的变化如何影响结果。
为了进行比较,如果 MT 算法在密码学上是强安全的,那么它的方程应该是非线性的,并且不仅涉及单个比特,还应包括比特的与运算(乘积),如S[1]S[15]S[182]或者S[17]S[256]S[257]S[354]S[498]S[601]。尽管这些比特的线性组合最多包含 624 个变量,但非线性组合则允许多达 2⁶²⁴个变量。解决这些方程几乎是不可能的,更不用说将其写下来了。(请注意,2³⁰⁵是一个更小的数字,代表可观测宇宙的估算信息容量。)
这里的关键在于,线性变换导致简短的方程(其规模可与变量数量相当),这些方程容易求解,而非线性变换则会产生指数级的方程,其规模极大,实际上是无法求解的。因此,密码学家的任务是设计出能模拟如此复杂的非线性变换的伪随机数生成算法(PRNG),且只使用少量的简单操作。
注意
线性只是众多安全标准中的一种。尽管非线性是必要的,但仅仅有非线性并不足以使得一个伪随机数生成器(PRNG)在密码学上是安全的。
统计测试的无用性
像 TestU01、Diehard 或国家标准与技术研究院(NIST)测试套件这样的统计测试套件是一种测试伪随机位质量的方法。这些测试会从伪随机数生成器产生的伪随机位中取样(例如,1MB 的位数),计算某些模式分布的统计数据,并将结果与均匀分布所得到的典型结果进行比较。例如,一些测试会统计 1 位与 0 位的数量,或 8 位模式的分布。但统计测试与加密安全性关系不大,可能设计出一个加密弱的伪随机数生成器,使其通过任何统计测试。
当你对随机生成的数据进行统计测试时,通常会看到一堆统计指标作为结果。这些通常是p值,一种常见的统计指标。由于这些结果通常不像“通过”或“失败”那么简单,因此它们并不总是容易解释。如果你的第一次结果看起来异常,不要担心:它们可能是某些偶然偏差的结果,或者你可能测试的样本太少。为了确保你看到的结果是正常的,可以将它们与一些可靠的相同大小的样本进行比较——例如,通过以下命令使用 OpenSSL 工具包生成的样本:
$ **openssl rand `<number of bytes>`** **-out `<output file>`**
现实世界中的伪随机数生成器
让我们将注意力转向在现实世界中实现伪随机数生成器。你会在大多数平台的操作系统(OS)中找到加密伪随机数生成器,从桌面和笔记本电脑到嵌入式系统,如路由器和机顶盒,以及虚拟机、手机等。大多数这些伪随机数生成器是基于软件的,但那些纯硬件的伪随机数生成器则被操作系统上运行的应用程序使用,有时也会被其他基于加密库或应用程序上运行的伪随机数生成器使用。
接下来,我们将看看最广泛部署的伪随机数生成器:用于 Linux、Android 和许多其他基于 Unix 的系统;在 Windows 中;以及在最近的英特尔微处理器中,其伪随机数生成器是基于硬件的。
Linux 中的随机位
设备文件/dev/urandom是基于 Linux 内核的操作系统中加密伪随机数生成器(PRNG)的用户空间接口。你通常会使用它来生成可靠的随机位。因为它是一个设备文件,所以你可以通过将其当作文件读取来请求随机位。例如,以下命令使用/dev/urandom将 10MB 的随机位写入文件:
$ **dd if=/dev/urandom of=****`<output file>`** **bs=1M count=10**
错误使用/dev/urandom 的方式
你可以编写一个天真且不安全的 C 程序,如 Listing 2-1 中所示,读取随机位并寄希望于最好的结果,但那样做是一个糟糕的主意。
int random_bytes_insecure(void *buf, size_t len)
{
int fd = open("/dev/urandom", O_RDONLY);
read(fd, buf, len);
close(fd);
return 0;
}
列表 2-1: 不安全使用 /dev/urandom
这段代码不安全;它甚至没有检查<сamp class="SANS_TheSansMonoCd_W5Regular_11">open()和<сamp class="SANS_TheSansMonoCd_W5Regular_11">read()的返回值,这意味着你期望的随机缓冲区可能会被填充为零或保持不变。##### 更安全的使用方式 /dev/urandom
列表 2-2,来自 LibreSSL 库,展示了使用/dev/urandom的更安全方式。
int random_bytes_safer(void *buf, size_t len)
{
struct stat st;
size_t i;
int fd, cnt, flags;
int save_errno = errno;
start:
flags = O_RDONLY;
#ifdef O_NOFOLLOW
flags |= O_NOFOLLOW;
#endif
#ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
❶ fd = open("/dev/urandom", flags, 0);
if (fd == -1) {
if (errno == EINTR)
goto start;
goto nodevrandom;
}
#ifndef O_CLOEXEC
fcntl(fd, F_SETFD, fcntl(fd, F_GETFD) | FD_CLOEXEC);
#endif
/* Lightly verify that the device node looks sane. */
if (fstat(fd, &st) == -1 || !S_ISCHR(st.st_mode)) {
close(fd);
goto nodevrandom;
}
if (ioctl(fd, RNDGETENTCNT, &cnt) == -1) {
close(fd);
goto nodevrandom;
}
for (i = 0; i < len;) {
size_t wanted = len - i;
❷ ssize_t ret = read(fd, (char *)buf + i, wanted);
if (ret == -1) {
if (errno == EAGAIN || errno == EINTR)
continue;
close(fd);
goto nodevrandom;
}
i += ret;
}
close(fd);
if (gotdata(buf, len) == 0) {
errno = save_errno;
return 0; /* Satisfied */
}
nodevrandom:
errno = EIO;
return -1;
}
列表 2-2: 安全使用 /dev/urandom
与列表 2-1 不同,列表 2-2 进行了多个有效性检查。例如,比较对<сamp class="SANS_TheSansMonoCd_W5Regular_11">open() ❶和<сamp class="SANS_TheSansMonoCd_W5Regular_11">read() ❷的调用与列表 2-1 中的调用:更安全的代码检查这些函数的返回值,并在失败时关闭文件描述符并返回–1。
2022 年之前,/dev/urandom 和/dev/random 的区别
Linux 的伪随机数生成器(PRNG),在 Linux 内核中的drivers/char/random.c文件中定义,2022 年(从内核版本 5.17 开始)进行了重大更改。
首先,PRNG 的基本结构在旧版和新版中是相似的,它基于从各种来源(包括系统活动,如键盘、鼠标和磁盘访问)收集的熵,以及一个可以视为大数组的熵池,该数组通过哈希收集的熵数据进行填充。接下来,DRBG 负责生成伪随机数据流,当读取/dev/random或/dev/urandom,或者调用getrandom()系统调用时返回这些数据流。
历史上,在内核版本 5.17 之前,Linux 的 PRNG 行为如下:与/dev/urandom不同,/dev/random接口是阻塞的;如果内核估计 PRNG 的熵水平不足,那么/dev/random在读取时会停止返回字节(“阻塞”),直到内核估计出足够的熵水平。这并不是一个好主意。首先,熵估算器是出了名的不可靠,且容易被攻击者欺骗(这也是为什么 Fortuna 放弃了 Yarrow 的熵估算)。此外,/dev/random的估算熵会很快用尽,这可能会导致拒绝服务条件,减慢迫使等待更多熵的应用程序。结果是,在实际应用中,/dev/random不比/dev/urandom更好,反而带来了更多问题。
2022 年以来,/dev/urandom 和/dev/random 的区别
在 2022 年及以后的 Linux 内核版本(5.17 及以后),加入了几项改进。首先,在创建池内容时,SHA-1 哈希函数被 BLAKE2 替代。最大的变化是/dev/random和/dev/urandom之间相对行为的修改;甚至有人提议完全消除它们的区别。在本文写作时,在大多数平台上,这两个接口都会检测是否没有足够的熵,但是如果内核未能收集足够的熵,/dev/urandom将继续生成伪随机位,而/dev/random则会阻塞。
此外,内核的熵估算逻辑得到了极大的改进:不再认为当读取 PRNG 位时熵会减少(这是一个加密学的荒谬理论),而是内核会寻找足够的不确定性(即熵)收集完成的时刻——例如,在系统启动时。
你可以在/proc/sys/kernel/random/entropy_avail文件中读取 Linux 系统的熵值。在旧版本的内核中,这个值最大为 4,096 位,并随着伪随机数生成(PRNG)位的产生而减少。而在新内核中,这个值被限制为 256 位,因此不再减少。
Windows 中的 CryptGenRandom()函数
在 Windows 中,系统的遗留用户态接口是来自加密应用程序接口(API)的CryptGenRandom()函数。最近的 Windows 版本将CryptGenRandom()函数替换为加密 API:下一代(CNG)中的BcryptGenRandom()函数。Windows 的 PRNG 从内核模式驱动程序cng.sys(前身为ksecdd.sys)获取熵,其熵收集器在一定程度上基于 Fortuna 算法。像 Windows 中常见的那样,整个过程相对复杂。
清单 2-3 展示了一个典型的 C++调用示例,使用了带有必要检查的CryptGenRandom()函数。
int random_bytes(unsigned char *out, size_t outlen)
{
static HCRYPTPROV handle = 0; /* Only freed when the program ends */
if(!handle) {
if(!CryptAcquireContext(&handle, 0, 0, PROV_RSA_FULL,
CRYPT_VERIFYCONTEXT | CRYPT_SILENT)) {
return -1;
}
}
while(outlen > 0) {
const DWORD len = outlen > 1048576UL ? 1048576UL : outlen;
if(!CryptGenRandom(handle, len, out)) {
return -2;
}
out += len;
outlen -= len;
}
return 0;
}
清单 2-3: 使用 Windows CryptGenRandom() PRNG 接口
在调用实际的伪随机数生成器(PRNG)之前,你需要声明一个加密服务提供者(HCRYPTPROV),然后通过 CryptAcquireContext() 获取加密上下文,这会增加出错的可能性。例如,TrueCrypt 加密软件的最终版本被发现调用 CryptAcquireContext() 的方式可能会悄无声息地失败,从而导致随机性不理想而不通知用户。幸运的是,Windows 中新且更简单的 BCryptGenRandom() 接口不需要代码显式地打开句柄(或者至少使得不使用句柄的操作更为简便)。
基于硬件的 PRNG:英特尔安全密钥
到目前为止,我们只讨论了软件 PRNG,我们来看看硬件 PRNG。英特尔数字随机数生成器(Intel Digital Random Number Generator),或称英特尔安全密钥(Intel Secure Key),是 2012 年在英特尔的 Ivy Bridge 微架构中推出的硬件 PRNG。它基于 NIST 的 SP 800-90 指南,并采用高级加密标准(AES)在 CTR_DRBG 模式下工作。英特尔的 PRNG 通过 RDRAND 汇编指令进行访问,提供了一个独立于操作系统的接口,并且在原则上比软件 PRNG 更快。
软件伪随机数生成器(PRNG)尝试从不可预测的来源收集熵,而英特尔的安全密钥(Intel Secure Key)则有一个单一的熵源,该熵源提供一个由零和一组成的序列流。用硬件工程术语来说,这个熵源是一个带反馈的双差分闩锁——本质上是一个小型硬件电路,它根据热噪声波动在两种状态(0 或 1)之间跳动,频率为 3 GHz。这通常是非常可靠的。
RDRAND 汇编指令的参数是 16、32 或 64 位的寄存器,然后写入一个随机值。当被调用时,如果目标寄存器中的数据集是有效的随机值,RDRAND 会将进位标志(carry flag)设置为 1,否则设置为 0;如果你直接编写汇编代码,记得检查 CF 标志。请注意,常见编译器中的 C 内建函数不会检查 CF 标志,但会返回它的值。
注意
英特尔的 PRNG 框架提供了一条不同于 RDRAND 的汇编指令:RDSEED 汇编指令直接从熵源返回随机位,经过一些条件处理或加密处理后。这一指令的目的是能够为其他 PRNG 提供种子。
Intel Secure Key 的文档并不完全,但它是基于已知标准构建的,并且已经通过了备受推崇的公司 Cryptography Research 的审计(参见其名为“Analysis of Intel’s Ivy Bridge Digital Random Number Generator”的报告)。尽管如此,关于其安全性仍然存在一些担忧,特别是在爱德华·斯诺登揭露加密后门事件后:伪随机数生成器(PRNG)确实是破坏的完美目标。如果你对此有所担忧,但仍然希望使用 RDRAND 或 RDSEED,请将它们与其他熵源混合使用。这样可以防止在 Intel Secure Key 硬件或相关微代码中,假设的后门被有效利用,除非是在最不可能的情景下。
事物可能出错的方式
总结一下,我将展示几个随机性失败的例子。可以选择的例子数不胜数,但我挑选了四个简单到足以理解且能展示不同问题的例子。
不良的熵源
1996 年,Netscape 浏览器的 SSL 实现根据列表 2-4 中显示的伪代码计算 128 位 PRNG 种子,该代码复制自 Goldberg 和 Wagner 的页面,地址为 <wbr>www<wbr>.cs<wbr>.berkeley<wbr>.edu<wbr>/~daw<wbr>/papers<wbr>/ddj<wbr>-netscape<wbr>.html。
global variable seed;
RNG_CreateContext()
(seconds, microseconds) = time of day; /* Time elapsed since 1970 */
pid = process ID; ppid = parent process ID;
a = mklcpr(microseconds);
❶ b = mklcpr(pid + seconds + (ppid << 12));
seed = MD5(a, b); /* Derivation of a 128-bit value using the hash MD5 */
mklcpr(x) /* Not cryptographically significant; shown for completeness */
return ((0xDEECE66D * x + 0x2BBB62DC) >> 1);
MD5() /* A very good standard mixing function, source omitted */
列表 2-4:Netscape 浏览器生成 128 位 PRNG 种子的伪代码
这里的问题在于 PID 和微秒是可以猜测的值。假设你可以猜出 秒数 的值,微秒 只有 10⁶种可能的值,因此其熵为 log(10⁶),约为 20 位。进程 ID(PID)和父进程 ID(PPID)是 15 位值,因此你预计会有 15 + 15 = 30 个附加熵位。但是,通过观察 b 的计算过程 ❶,我们可以看到 3 个位的重叠导致熵只有 15 + 12 = 27 位,总熵仅为 47 位,而一个 128 位的种子应该有 128 位熵。
启动时熵不足
2012 年,研究人员扫描了互联网并收集了来自 TLS 证书和 SSH 主机的公钥。他们发现有一些系统的公钥完全相同,在某些情况下,公钥非常相似(即,RSA 密钥共享质因子)——简而言之,两个数字,n = pq 和 n′ = p′q′,其中 p = p′,而通常情况下,不同的模数值下,所有的 p 和 q 应该是不同的。
事实证明,许多设备在启动时就生成了它们的公钥,在收集足够的熵之前就已经生成,尽管它们使用了其他正常的 PRNG(通常是/dev/urandom)。不同系统中的 PRNG 生成了相同的随机位,因为它们使用了相同的熵源(例如,硬编码的种子)。
从高层次来看,相同密钥的存在是由于类似以下的密钥生成方案,伪代码如下:
prng.seed(seed)
p = prng.generate_random_prime()
q = prng.generate_random_prime()
n = p*q
如果两个系统在相同种子的情况下运行此代码,它们将生成相同的p、相同的q,因此也会生成相同的n。
不同密钥中共享质因子的存在是由于密钥生成方案,在该过程中注入了额外的熵,如下所示:
prng.seed(seed)
p = prng.generate_random_prime()
prng.add_entropy()
q = prng.generate_random_prime()
n = p*q
如果两个系统使用相同的种子运行此代码,它们将生成相同的p,但通过prng.add_entropy()注入的熵将确保生成不同的q。
共享质因子的问提在于,给定n = pq和n′ = pq′,通过计算n和n′的最大公约数(GCD),可以轻松恢复共享的p。有关详细信息,请参见 Heninger、Durumeric、Wustrow 和 Halderman 的论文《Mining Your Ps and Qs》,可以在*<wbr>factorable<wbr>.net找到。
非加密 PRNG
之前我们讨论了加密 PRNG 和非加密 PRNG 之间的区别,以及为什么后者永远不应该用于加密应用程序。可惜的是,许多系统忽略了这一点,因此我们将看看其中一个这样的例子。
流行的 MediaWiki 应用程序运行在 Wikipedia 和许多其他维基网站上。它使用随机性来生成诸如安全令牌和临时密码等内容,这些内容应该是不可预测的。不幸的是,MediaWiki 的一个现已过时的版本使用了一个非加密的 PRNG——梅森旋转算法(Mersenne Twister),来生成这些令牌和密码。以下是漏洞版 MediaWiki 源代码的一个片段;查找获取随机位的函数,并阅读注释:
/**
* Generate a hex-y looking random token for various uses.
* Could be made more cryptographically sure if someone cares.
* @return string
*/
function generateToken($salt = '') {
$token = dechex(mt_rand()).dechex(mt_rand());
return md5($token . $salt);
}
你注意到前面的代码中的mt_rand()了吗?这里,mt代表梅森旋转算法(Mersenne Twister)。在 2012 年,研究人员展示了如何利用梅森旋转算法的可预测性,基于几个安全令牌预测未来的令牌和临时密码。MediaWiki 已经修补,改用了加密 PRNG。
具有强随机性的采样错误
下一个错误展示了即使是一个强大的加密 PRNG,且具有足够的熵,也可能产生有偏的分布。聊天程序 Cryptocat 旨在提供安全通信。它使用了一个函数,试图创建一个均匀分布的十进制数字串——即 0 到 9 之间的数字。然而,单纯对随机字节取模 10 并不能产生均匀分布;当你将所有 0 到 255 之间的数字取模 10 时,你不会得到 0 到 9 之间的每个值出现的次数相等。
Cryptocat 为了解决这个问题并获得均匀分布,采取了以下措施:
Cryptocat.random = function() {
var x, o = '';
while (o.length < 16) {
x = state.getBytes(1);
if (x[0] <= 250) {
o += x[0] % 10;
}
}
return parseFloat('0.' + o)
}
那几乎是完美的。通过仅选择数字中 10 的倍数并丢弃其他数字,你应该期望数字 0 到 9 的均匀分布。不幸的是,在 if 条件中存在一个错位错误。我会把细节留给你作为练习。你应该会发现存在一个小的统计偏差,偏向索引 0(提示:<= 应该改为 <)。
进一步阅读
我刚刚只是触及了加密学中随机性的表面。关于随机性的理论还有很多值得学习的内容,包括不同的熵概念、随机性提取器,甚至是在复杂性理论中随机化和去随机化的力量。要深入了解伪随机数生成器(PRNG)及其安全性,可以阅读 Kelsey、Schneier、Wagner 和 Hall 于 1998 年发表的经典论文《Cryptanalytic Attacks on Pseudorandom Number Generators》。然后,查看你最喜欢的应用程序中 PRNG 的实现,尝试找出它们的弱点。(可以在线搜索“random generator bug”来查找大量示例。)
然而,关于随机性的讨论还没结束。我们将在本书中多次遇到它,你将发现它在构建安全系统中有着许多重要的作用。
第四章:3 密码学安全

密码学中的安全定义与一般计算机安全的定义不同。软件安全和密码学安全的主要区别在于,我们可以量化后者。不同于软件领域,在那里我们通常说应用程序要么安全,要么不安全,在密码学领域,通常可以计算破解一个密码算法所需的努力程度。而且,软件安全侧重于防止攻击者滥用程序的代码,而密码学安全的目标是让某些明确的问题变得不可能解决。
密码学问题涉及数学概念,但不涉及复杂的数学——至少在本书中不涉及。本章将介绍一些这些安全性概念以及如何将它们应用于解决实际问题。在接下来的章节中,我将讨论如何以理论上严谨和实际相关的方式量化密码安全性。我将讨论无条件安全与计算安全、比特安全与完全攻击成本、可证明安全与启发式安全、对称与非对称密钥生成等概念。我将在本章结束时,通过一些看似强大的密码学失败的现实案例来总结。
定义不可能
在第一章中,我描述了一个密码的安全性相对于攻击者的能力和目标,并认为如果在已知攻击者的能力下无法实现这些目标,则该密码是安全的。那么,在这个背景下,不可能是什么意思呢?
有两个概念定义了密码学中“不可能”的含义:无条件安全和计算安全。大致来说,无条件安全是关于理论上的不可能性,而计算安全是关于实际上的不可能性。无条件安全并没有量化安全性,因为它将密码视为要么安全,要么不安全,没有中间地带;因此,它在实践中没有用,尽管在理论密码学中起着重要作用。计算安全是衡量密码强度的更相关和实用的标准。
理论中的安全性:无条件安全
无条件安全并不是基于破解一个密码有多难,而是基于是否可以想象破解它。只有在给定无限的计算时间和内存时,密码无法被破解,这时它才是无条件安全的。即使破解一个密码需要数万亿年,这样的密码也是无条件的不安全。
例如,第一章中的一次性密码本是无条件安全的。回想一下,一次性密码本将明文 P 加密为密文 C = P ⊕ K,其中 K 是一个唯一的随机比特串,专用于每个明文。这个密码是无条件安全的,因为即使给定一个密文,并且有无限时间来尝试所有可能的密钥 K 并计算对应的明文 P,你仍然无法识别正确的 K,因为有多少个可能的 P,就有多少个可能的 K。
实践中的安全性:计算安全
与无条件安全不同,计算安全将一个密码视为安全的标准是,如果它在合理的时间内,且在合理的资源限制下(如内存、硬件、预算和能量),无法被破解。计算安全是一种量化密码或任何加密算法安全性的方法。
例如,考虑一个密码E,你知道一个明文-密文对(P,C),但不知道计算 C = E(K,P) 的 128 位密钥 K。这个密码不是无条件安全的,因为你可以通过尝试 2¹²⁸ 个可能的 128 位 K,直到找到一个满足 E(K,P) = C 的密钥来破解它。但实际上,即便每秒测试 1000 亿个密钥,也需要超过 100,000,000,000,000,000,000 年。换句话说,合理来说,这个密码是计算上安全的,因为几乎不可能破解。
我们可以用两个值来表示计算安全性:
-
t,是攻击者将要执行的操作次数的上限
-
ε (epsilon),是攻击成功概率的上限
然后我们可以说,一个加密方案是 (t, ε)-安全的,如果一个执行最多 t 次操作的攻击者——无论这些操作是什么——成功的概率不高于 ε,其中 ε 至少为 0,最多为 1。计算安全性提供了破解加密算法的难度的界限。
需要认识到 t 和 ε 只是上限:如果一个密码是 (t, ε)-安全的,那么在进行少于 t 次操作的攻击者将不会成功(成功概率为 ε)。然而,这并不意味着一个执行恰好 t 次操作的攻击者会成功,也没有提供所需的操作次数,这个次数可能远大于 t。我们说 t 是必要计算努力的下限,因为你至少需要 t 次操作才能破解安全性。
如果我们确切知道破解一个密码所需的努力量,我们就可以说 (t, ε)-安全提供了一个紧密的界限,当存在一个攻击,可以通过 ε 的概率和恰好 t 次操作来破解密码时。
例如,考虑一个具有 128 位密钥的对称加密算法。理想情况下,该加密算法应该是(t, t/2¹²⁸)-安全的,适用于任何值的t,其中 1 ≤ t ≤ 2¹²⁸。最好的攻击方式应为暴力破解(尝试所有密钥直到找到正确的)。任何更好的攻击都必须利用加密算法中的某些缺陷,因此我们努力创建那些暴力破解是最好的攻击方式的加密算法。
给定(t, t/2¹²⁸)-安全性,我们来检查三种可能攻击的成功概率:
-
在第一个情况下,t = 1,攻击者尝试一个密钥并以概率ε = 1/2¹²⁸成功。
-
在第二种情况下,t = 2¹²⁸,攻击者尝试所有 2¹²⁸个密钥,并且最终成功。因此,成功的概率ε = 1。(如果攻击者尝试所有密钥,正确的密钥必定在其中。)
-
在第三种情况下,攻击者仅尝试t = 2⁶⁴个密钥,并以概率ε = 2⁶⁴/2¹²⁸ = 2^(-64)成功。当攻击者只尝试部分密钥时,成功的概率与尝试的密钥数量成正比。
我们可以得出结论,一个具有n位密钥的加密算法,最好的安全性为(t, t/2^n)-安全,适用于任何 1 ≤ t ≤ 2^n,因为无论加密算法多么强大,对其进行暴力破解的攻击总是会成功。因此,密钥长度需要足够长,以有效抵御实际中的暴力破解攻击。
注意
在这个例子中,我们计算的是加密算法的评估次数,而不是绝对的时间或处理器时钟周期数。计算安全性与技术无关,这意味着今天是(t, ε)-安全的加密算法,明天仍将是(t, ε)-安全的——但今天被认为安全的东西,明天可能不再被认为安全。
量化安全性
当你发现一个攻击方式时,首先应弄清楚它在理论上有多高效,以及它在实际中有多可行。同样,给定一个声称安全的加密算法,你也需要知道它能承受多少工作量。为了回答这些问题,我将解释如何以比特为单位衡量密码学安全性(理论视角),以及哪些因素影响实际攻击的成本。
以比特为单位衡量安全性
当谈到计算安全性时,如果一个加密算法的成功攻击至少需要t次操作,那么它被认为是t-安全的。因此,我们通过假设成功概率ε接近 1,或者我们在实际中关心的任何概率,来避免使用不直观的(t, ε)表示法。然后我们以比特为单位表示安全性,其中“n位安全性”意味着我们需要大约 2^n次操作来破坏某个特定的安全概念。
如果你大致知道破解一个密码需要多少操作,你可以通过取操作次数的二进制对数来确定其位安全级别:如果需要 1,000,000 次操作,安全级别就是 log2,大约是 20 位(因为 1,000,000 大约等于 2²⁰)。记住,一个n位的密钥最多只能提供n位的安全性,因为一个暴力破解攻击通过所有 2^n个可能的密钥总会成功。但密钥大小并不总是与安全级别相匹配——它只提供一个上界,即最高可能的安全级别。
安全级别可能会小于密钥大小,原因有二:
-
一种攻击用比预期更少的操作破解了密码——例如,使用一种方法,通过只尝试 2^n密钥的一个子集来恢复密钥。
-
密码的安全级别故意与其密钥大小不同,正如大多数公钥算法一样。例如,具有 1,024 位私钥元素(因此具有 2,048 位模数)的 RSA 算法提供的安全性不足 128 位。
位安全性在比较密码的安全级别时很有用,但它并未提供关于攻击实际成本的足够信息。它有时是一个过于简单的抽象,因为它假设一个n位安全的密码需要 2^n次操作才能破解,无论这些操作是什么。因此,两个具有相同位安全级别的密码在实际攻击成本上可能有着巨大的差异。
假设我们有两个密码,每个密码都有 128 位的密钥和 128 位的安全性。我们必须评估每个密码 2¹²⁸次才能破解它,但第二个密码比第一个慢 100 倍。因此,评估第二个密码 2¹²⁸次的时间相当于评估第一个密码 100 × 2¹²⁸ ≈ 2^(134.64)次。如果我们按照第一个快速密码来计算,那么破解第二个密码需要 2^(134.64)次操作。如果我们按照第二个慢密码来计算,则只需要 2¹²⁸次操作。那么我们是否应该说第二个密码比第一个更强?原则上是的,但我们很少看到常见密码之间有如此百倍的性能差异。
操作定义的不一致性在比较攻击效率时带来了更多困难。有些攻击声称通过执行 2¹²⁰次某种操作而不是 2¹²⁸次密码操作来降低密码的安全性,但每种攻击类型的速度在分析中被忽略了。2¹²⁰次操作的攻击并不总是比 2¹²⁸次暴力破解攻击更快。
然而,只要操作被合理定义——即大约与评估密码的速度相同——位安全性仍然是一个有用的概念。毕竟,在现实生活中,判断安全级别是否足够所需要的只是一个数量级。
计算完整攻击成本
比特安全通过估算执行成功所需的操作数量的数量级来表示对加密算法最迅速攻击的成本。但其他因素也会影响攻击的成本,我们在估算实际安全水平时必须考虑这些因素。我将解释四个主要因素:并行性、内存、预计算和目标数量。
并行性
第一个需要考虑的因素是计算并行性——即攻击实现是否能够利用并行计算,如多核系统。
例如,考虑这两次各自执行 2⁵⁶ 次操作的攻击:
-
第一次攻击执行 2⁵⁶ 顺序依赖 操作,计算 xi [+ 1] = fi(xi),其中 x[0] 是固定的,fi 是不同的函数(i 从 1 到 2⁵⁶)。
-
第二次攻击执行 2⁵⁶ 独立 操作,计算 xi = fi(x),其中 x 是固定的,fi 是不同的函数(i 从 1 到 2⁵⁶)。因为每个 fi(x) 互相独立,所以它们可以并行执行。
这两次攻击的区别在于,第二次攻击可以并行化,而第一次攻击不能。并行处理比顺序处理要快几个数量级。例如,如果你有 2¹⁶ = 65,536 个处理器可用,你可以将并行攻击的工作负载划分为 2¹⁶ 个独立任务,每个任务执行 2⁵⁶ / 2¹⁶ = 2⁴⁰ 次操作。然而,第一次攻击无法利用多个核心,因为每个操作依赖于前一个操作的结果。因此,尽管两次攻击执行相同数量的操作,平行攻击将比顺序攻击快 65,536 倍。
注意
当 N 个核心可用时,攻击速度会加速 N 倍的算法是* 极易并行化;它们的执行时间与计算核心的数量成线性关系。
内存
在确定攻击成本时,第二个因素是内存。我们评估密码分析攻击时考虑时间和空间的使用:它们在时间上执行了多少操作,占用了多少内存或空间,如何利用这些空间,以及可用内存的速度如何?不幸的是,比特安全仅关注执行攻击所需的时间。
关于攻击如何使用空间,考虑攻击所需的内存查找次数、内存访问的速度(可能在读写操作之间有所不同)、访问数据的大小、访问模式(连续或随机内存地址)以及数据在内存中的结构是很重要的。例如,在 2021 年 Intel Xeon 8380 Ice Lake 处理器上,访问一个寄存器需要 1 个时钟周期,访问 L1 缓存(48KB)需要 5 个周期,访问 L2 缓存(1.25MB)需要 14 个周期,访问 L3 缓存(60MB)需要 63.5 个周期,而访问 DRAM 的速度最好和 L3 缓存一样快,但通常比访问 L3 缓存慢得多(具体延迟取决于多个因素)。
预计算
预计算操作只需执行一次,并可以在随后的攻击执行中重用。我们有时称预计算为攻击的离线阶段。
考虑时间-内存权衡攻击,在这种攻击中,攻击者执行一次巨大的计算,生成大型查找表,然后存储并重用这些表来执行实际的攻击。例如,针对 2G 移动加密的一次攻击花费了两个月的时间来构建 2TB 的表格,攻击者随后使用这些表格在仅仅几秒钟内破解了 2G 加密,并恢复了一个秘密会话密钥。
目标数量
最后,我们来讨论攻击的目标数量。目标数量越多,攻击面越大,攻击者能够了解到他们所追寻的密钥的信息就越多。
例如,考虑暴力破解密钥搜索:如果你目标是一个n位密钥,需要进行 2^n次尝试才能确定找到正确的密钥。如果你目标是多个n位密钥——比如一个数量M——并且对于一个单一的P,你有M个不同的密文,其中C = E(K, P)表示你想要的每个M密钥(K),那么每个密钥仍然需要 2^n次尝试来找到。但如果你只对M个密钥中的至少一个感兴趣,而不是每一个,那么平均来说,你将需要进行 2^n/M次尝试才能成功。例如,要破解一个 128 位的密钥,如果目标是 2¹⁶ = 65,536 个密钥,那么平均需要进行 2^(128 − 16) = 2¹¹²次密码评估。也就是说,攻击的成本(和速度)随着目标数量的增加而降低。
选择和评估安全级别
选择安全级别通常涉及在大多数标准加密算法和实现中选择 128 位和 256 位的安全级别。你会发现一些 64 位或 80 位的安全方案,但这些方案通常不足以在实际应用中保证安全。
从高层次来说,128 位安全意味着你需要进行大约 2¹²⁸次操作才能破解该加密系统。为了让你了解这个数字的含义,可以考虑这样一个事实:宇宙大约有 2⁸⁸纳秒的历史(1 秒有 10 亿纳秒)。由于今天的技术测试一个密钥至少需要 1 纳秒,你需要几倍宇宙年龄的时间才能成功进行一次攻击(准确地说是 2⁴⁰倍)。
那么,难道并行处理和多个目标不会显著缩短成功攻击的时间吗?并非完全如此。假设你想破解其中一个百万个目标中的任意一个,并且你有一百万个并行核心可用。这将把搜索时间从 2¹²⁸缩短到(2¹²⁸ / 2²⁰) / 2²⁰ = 2⁸⁸,这相当于“仅仅”一个宇宙的寿命。
评估安全级别时,还需要考虑技术的演变。摩尔定律认为计算效率大约每两年就会翻倍。我们可以将其视为每两年失去 1 位安全性:如果今天 1000 美元的预算可以在一小时内破解一个 40 位密钥,那么根据摩尔定律,两年后,你可以用相同的 1000 美元预算在一小时内破解一个 41 位的密钥(我在简化)。我们可以从中推测,根据摩尔定律,80 年后我们的安全性将比今天少 40 位。换句话说,80 年后,执行 2¹²⁸次操作的成本可能和今天执行 2⁸⁸次操作一样。考虑到并行处理和多个目标的因素,我们需要大约 2⁴⁸纳秒的计算时间,也就是大约三天。但这个推测并不准确,因为摩尔定律不会也不能如此大幅度地扩展。不过,你可以理解:今天看起来不可行的事情,可能在一个世纪后变得现实。
在某些情况下,低于 128 位的安全级别是可以被接受的,比如当你只需要短时间的安全保护,或者实现更高安全级别的成本会对系统的成本或可用性产生负面影响时。一个例子是付费电视系统,其中加密密钥为 48 位或 64 位。听起来可能非常低,但实际上是足够的,因为密钥每 5 到 10 秒就会刷新一次。
然而,为了确保长期安全,你应该选择 256 位安全级别或略低于此的安全级别。即使在最坏的情况下——量子计算机的出现(见第十四章)——我们也不太可能在可预见的未来破解 256 位的安全方案。256 位以上的安全性在实际应用中是没有必要的,除非作为营销手段。
正如密码学家约翰·凯尔西曾经说的:“80 位和 128 位密钥搜索之间的差距,就像是前往火星与前往半人马座阿尔法星的差距。就我看来,192 位和 256 位密钥在实际暴力破解攻击中没有实质性的区别;不可能的事就是不可能。”
实现安全性
一旦选择了一个安全级别,确保你的加密方案保持在这个级别是很重要的。换句话说,你希望有信心,而不仅仅是希望和不确定性,确保事情会按照计划进行,始终如此。
在建立对密码算法安全性的信心时,你可以依赖于数学证明,这种方法我们称之为可证明的安全性,或者依赖于未能破解算法的证据,我称之为启发式安全性(虽然它有时也被称为可能安全性)。这两种方法是互补的,没有哪一种比另一种更好,正如你将看到的那样。
可证明的安全性
可证明的安全性是指证明破解你的密码学方案至少和解决另一个已知困难的问题一样难。这样的安全证明保证了只要该困难问题依然困难,密码学就保持安全。这类证明称为归约,源自复杂性理论领域。我们说问题 X 可归约为破解某个密码,如果任何破解该密码的方法也会产生解决问题 X 的方法。这样的归约保证了只要问题 X 是困难的,密码就安全。
安全证明有两种类型,取决于你使用的假定困难问题的类型:相对于数学问题的证明和相对于密码学问题的证明。
相对于数学问题的证明
许多安全证明(例如针对公钥密码学的证明)表明,破解一个密码学方案至少和解决某个困难的数学问题一样难。我们说的是那些我们知道有解且一旦知道解就容易验证,但计算上很难找到解的问题。
注
没有真正的证据表明看似困难的数学问题实际上是困难的。事实上,证明这一点对于特定类别的问题来说是复杂性理论领域的最大挑战之一。写这篇文章时,克雷数学研究所为任何能证明这一点的人提供了 100 万美元的奖金。我在第九章中详细讨论了这个问题。
例如,考虑解决因式分解问题,这是密码学中最著名的数学问题:给定一个你知道是两个素数(n = pq)乘积的数字,找出这两个素数。例如,如果 n = 15,答案是 3 和 5。对于一个小数字来说这很容易,但随着数字大小的增加,它会变得呈指数级难。比如,如果一个数字 n 长达 3000 位(大约 900 位十进制数字)或更多,因式分解被认为实际上是不可行的。
Rivest–Shamir–Adleman (RSA) 是最著名的依赖于因式分解问题的加密方案:RSA 通过计算 C = P**^e mod n 对明文 P(看作一个大数)进行加密,其中 e 和 n = pq 是公钥。解密通过计算 P = C**^d mod n 恢复明文,其中 d 是与 e 和 n 相关的私钥。如果我们能够因式分解 n,那么就可以破解 RSA(通过从公钥恢复私钥),如果我们能获得私钥,那么就能因式分解 n(例如,参见文章 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2004<wbr>/208)。换句话说,恢复 RSA 私钥和因式分解 n 是等价的困难问题。这就是我们在可证明安全性中寻求的那种归约。然而,并不能保证恢复 RSA 明文和因式分解 n 是同样困难的,因为明文的信息并不能揭示私钥。
相对于另一个加密问题的证明
你可以将加密方案与其他加密方案进行比较,而不是与数学问题进行比较,并证明只有在能够破解第一个方案时,才能破解第二个方案。对称加密算法的安全性证明通常采用这种方法。
例如,如果你只有一个单一的置换算法,那么你可以通过将置换与各种输入类型组合,构建对称加密算法、随机比特生成器和其他加密对象,如哈希函数(如你将在第六章中看到的那样)。然后,证明表明,如果置换是安全的,那么新创建的方案也是安全的。换句话说,我们知道新创建的算法不比原始算法弱。此类证明通常通过给定对较大组件的攻击,构造对较小组件的攻击来工作——即通过展示一个归约。
当证明某个加密算法不比另一个算法弱时,主要的好处是减少了攻击面:你可以仅仅分析新加密算法的核心算法,而无需分析核心算法和组合的两部分。具体来说,如果你编写了一个使用新开发的置换和新组合的加密算法,你可以证明该组合在安全性上不弱于核心算法。因此,要破解该组合,你需要破解新的置换。
注意事项
密码学研究人员在很大程度上依赖于安全证明,无论是针对数学问题方案还是其他加密方案。但安全证明的存在并不保证加密方案是完美的,也不能作为忽视实施中更实际方面的借口。毕竟,正如密码学家拉尔斯·克努森曾经说过的,“如果它是可以证明安全的,它可能并不安全”,这意味着安全证明不应被视为绝对的安全保障。更糟糕的是,有多种原因可能导致“可证明安全”的方案最终导致安全失败。
一个问题在于“安全证明”这一短语本身。在数学中,证明是对绝对真理的展示,但在密码学中,证明仅仅是对相对真理的展示。例如,证明你的加密算法像计算离散对数一样难以破解——给定 g 和 g**^x mod n 来找到 x——保证了如果你的加密算法失败,其他许多算法也会失败,如果最坏的情况发生,没人会怪你。
另一个警告是,通常人们根据单一的安全概念来证明安全性。例如,你可能证明恢复加密算法的私钥与因式分解问题一样困难。但如果你能在没有密钥的情况下从密文中恢复明文,那么你就绕过了这个证明,而恢复密钥几乎没有意义。
但证明并不总是正确的,破解一个算法有时可能比最初想的更容易。
注意
不幸的是,很少有研究人员仔细检查安全证明,而这些证明通常长达数十页,因此使得质量控制变得复杂。尽管如此,证明不正确并不一定意味着证明的目标完全错误;如果结果是正确的,可以通过修正错误来挽救证明。
另一个重要的考虑因素是,困难的数学问题有时会比预期的更容易解决。例如,某些弱参数使得破解 RSA 加密系统变得容易。或者,数学问题在某些情况下可能很难,但在大多数情况下却并不难,正如常常发生的那样,当参考问题是新的且没有被充分理解时。这就是 1978 年梅尔克和赫尔曼的背包加密方案后来被使用格减法技术破解的原因。
最后,虽然算法的安全证明可能没问题,但算法的实现可能很脆弱。例如,攻击者可能利用侧信道信息,如功耗或执行时间,来了解算法的内部操作并破解它,从而绕过证明。或者实现者可能滥用加密方案:如果算法过于复杂,且有太多配置选项,用户或开发者出错的几率就会增加,这可能导致算法完全不安全。
启发式安全性
可证明安全性是获得对加密方案信心的好工具,但并不适用于所有类型的算法。事实上,大多数对称加密算法并没有安全性证明。例如,我们每天依赖 AES 来通过手机、笔记本电脑和台式计算机安全地通信,但 AES 并不是可证明安全的;没有证明它和某些知名问题一样难以破解。AES 无法与数学问题或其他算法关联,因为它本身就是那个难题。
在无法证明安全性适用的情况下,信任某个密码算法的唯一理由就是很多技术人员曾尝试攻破它但失败了。我们称这种安全性为启发式安全性。
我们什么时候可以确信一个密码算法是安全的?我们永远无法完全确信,但我们可以相当有信心某个算法不会被攻破,当成百上千的经验丰富的密码分析师每人都花费了数百小时尝试攻破它并发表了他们的研究成果——通常是通过攻击密码算法的简化版本(通常是操作步骤较少的版本,或是操作步骤数较少的轮,这些轮次是加密算法反复执行的操作序列,用来混合数据位)。
在分析新的加密算法时,密码分析师首先会尝试攻破一轮,然后是两轮、三轮,甚至尽可能多的轮数。安全边际则是总轮数与成功攻击的轮数之间的差值。经过多年的研究,如果某个加密算法的安全边际仍然很高,我们就可以相当有信心它是(可能)安全的。
生成密钥
如果你计划加密某些内容,你将不得不生成密钥,无论是临时的“会话密钥”(就像浏览 HTTPS 网站时生成的那种)还是长期的公钥。回想一下第二章中提到的,秘密密钥是加密安全的关键,应当随机生成,以便它们不可预测且保密。
例如,当你浏览一个 HTTPS 网站时,浏览器接收该网站的公钥,并使用它建立一个只在当前会话中有效的对称密钥,而该网站的公钥及其关联的私钥可能会有效多年。因此,攻击者最好很难找到它。但生成一个密钥并不总是像丢出足够的伪随机位那样简单。我们可以通过三种方式生成加密密钥:
-
随机地,使用伪随机数生成器(PRNG)来为密钥生成算法提供输入
-
通过密码,使用基于密码的密钥派生函数(PBKDF),它将用户提供的密码转换为密钥
-
通过密钥协商协议,即两个或更多方之间通过一系列消息交换,最终建立共享密钥的过程
目前,我将解释最简单的方法:随机生成。
对称密钥
对称密钥是由两方共享的秘密密钥,它们是最简单生成的。它们通常与所提供的安全级别相同长度:一个 128 位的密钥提供 128 位的安全性,任何 2¹²⁸ 个可能的密钥都是有效的。
要使用加密的伪随机数生成器(PRNG)生成 n 位的对称密钥,你只需向它请求 n 位伪随机比特,并将这些比特作为密钥。这就完成了。例如,你可以使用 OpenSSL 工具包通过转储伪随机字节来生成一个随机的对称密钥,如下命令所示:
$ **openssl rand -hex 16**
65a4400ea649d282b855bd2e246812c6
你的结果当然会与我的不同。
非对称密钥
与对称密钥不同,非对称密钥通常比它们提供的安全级别要长。但主要问题来自于非对称密钥比对称密钥更难生成,因为你不能仅仅从伪随机数生成器(PRNG)中获取 n 位并得到有效结果。非对称密钥不仅仅是原始的比特序列,而是代表一种特定类型的对象,比如一个具有特定属性的大数(在 RSA 中,是两个素数的乘积)。一个随机的比特串值(因此是一个随机数)不太可能具备所需的属性,因此不能成为有效的密钥。
要生成一个非对称密钥,你将伪随机比特作为种子输入到一个 密钥生成算法 中。这个算法以一个至少与预期安全级别一样长的种子值为输入,并从中构造一个私钥及其相应的公钥,确保两者都满足必要的标准。例如,RSA 的一个简单密钥生成算法会通过生成两个大致相同长度的随机素数,使用算法得到一个 n = pq 的数值。该算法会选择随机数,直到有一个是素数,因此你还需要一个算法来检测某个数字是否为素数。
为了避免手动实现密钥生成算法的麻烦,你可以使用 OpenSSL 来生成一个 4,096 位的 RSA 私钥,如下所示:
$ **openssl genrsa 4096**
Generating RSA private key, 4096 bit long modulus (2 primes)
..............................................................................
...............................++
...............................................++
e is 65537 (0x10001)
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEA3Qgm6OjMy61YVstaGawk22A9LyMXhiQUU4N8F5QZXEef2Pjq
vTtAIA1hzpK2AJsv16INpNkYcTjNmechAJ0xHraftO6cp2pZFP85dvknsMfUoe8u
btKXZiYvJwpS0fQQ4tzlDtH45Gj8sMHcwFxTO3HSIx0XV0owfJTLMzZbSE3TDlN+
JdW8d9Xd5UVB+o9gUCI8tSfnOjF2dHlLNiOhlfT4w0Rf+G35USIyUJZtOQ0Dh8M+
`--snip--`
zO/dbYtqRkMT8Ubb/0Q1IW0q8e0WnFetzkwPzAIjwZGXT0kWJu3RYj1OXbTYDr2c
xBRVC/ujoDL6O3NaqPxkWY5HJVmkyKIE5pC04RFNyaQ8+o4APyobabPMylQq5Vo5
N5L2c4mhy1/OH8fvKBRDuvCk2oZinjdoKUo8ZA5DOa4pdvIQfR+b4/4Jjsx4
-----END RSA PRIVATE KEY-----
请注意,密钥以特定格式呈现——即在 BEGIN RSA PRIVATE KEY 和 END RSA PRIVATE KEY 标记之间的 base64 编码数据。这是一个大多数系统支持的标准编码格式,可以转换为原始字节数据。开始的点序列是一种进度条,而 e 是 65537 (0x10001) 表示在加密时使用的参数(记住,RSA 通过计算 C = P**^e mod n 来加密)。
密钥保护
一旦你拥有了一个秘密密钥,你需要保持它的机密性,同时在需要时能够使用它。有三种方法可以解决这个问题:
密钥封装(使用第二个密钥加密密钥)
这种方法的问题在于,第二个密钥必须在需要解密受保护密钥时可用。在实际操作中,这第二个密钥通常是由用户在需要使用受保护密钥时提供的密码生成的。这就是安全外壳(SSH)协议的私钥通常受到保护的方式。
实时生成密码
这不需要存储加密文件,因为密钥直接来自密码。像加密货币钱包和密码管理器这样的系统通常使用这种方法。
尽管这种方法比密钥包装更直接,但它的普及程度较低,部分原因是它更容易受到弱密码的攻击。例如,假设攻击者截获了一些加密消息:如果我们使用密钥包装,攻击者首先需要获取受保护的密钥文件,该文件可能存储在用户的本地文件系统中或在密钥管理系统(KMS)中,因此不容易访问。但如果我们使用实时生成,攻击者可以通过尝试使用候选密码解密加密消息,直接搜索正确的密码。如果密码较弱,密钥就会被破解。
将密钥存储在硬件令牌(智能卡或 USB 加密狗)中
在这种方法中,我们将密钥存储在安全内存中,即使计算机被攻击,密钥也保持安全。这是最安全的密钥存储方法,但也是最昂贵且最不方便的方法,因为它要求你随身携带硬件令牌,并且有丢失的风险。智能卡和 USB 加密狗通常需要你输入密码来解锁存储在安全内存中的密钥。
注意
无论使用何种方法,交换密钥时请务必确保不要将私钥与公钥混淆,也不要通过电子邮件或源代码不小心发布私钥。(我实际上曾在 GitHub 上找到私钥。)
要测试密钥包装,请运行以下 OpenSSL 命令,并使用参数-aes128告知 OpenSSL 使用 AES-128(128 位密钥的 AES 加密算法)加密密钥:
$ **openssl genrsa -aes128 4096**
Generating RSA private key, 4096 bit long modulus (2 primes)
..........++
.............................................................................
................................................++
e is 65537 (0x10001)
Enter pass phrase:
OpenSSL 将使用请求的密码短语来加密新创建的密钥。
问题所在
加密安全可能以多种方式出错。最大风险是由于安全证明或研究透彻的协议而产生的虚假安全感,以下示例对此进行了说明。
错误的安全证明
即使是著名研究人员的安全性证明也可能是错误的。最引人注目的错误证明之一就是最优非对称加密填充(OAEP),这是一种使用 RSA 进行安全加密的方法,已在许多应用中实现。关于 OAEP 在对抗选择密文攻击者时的安全性,曾有一个错误的证明被接受为有效长达七年,直到 2001 年一位研究人员发现了其中的缺陷。不仅证明是错的,结果也是错的。后来的一项新证明显示,OAEP 对于选择密文攻击者来说几乎是安全的。现在我们只能相信新的证明,并希望它没有问题。(更多细节,请参阅 Victor Shoup 在 2001 年发表的论文《OAEP 重新考虑》)
遗留支持的快捷键
2015 年,研究人员发现一些 HTTPS 网站和 SSH 服务器支持的公钥加密使用的密钥比预期的要短——即 512 位,而不是至少 2048 位。记住,对于公钥方案,安全级别不等于密钥大小,在 HTTPS 的情况下,512 位的密钥提供约 60 位的安全级别。这些密钥在大约两周的计算时间内就能被破解,计算过程中使用了 72 个处理器的集群。许多网站都受到影响,包括美国联邦调查局(FBI)的网站。尽管最终通过为 OpenSSL 和其他软件提供修补程序修复了这一问题,但这一问题仍然是一个令人不愉快的惊喜。
进一步阅读
要了解更多关于对称加密算法的可证明安全性,请阅读海绵函数文档 (<wbr>keccak<wbr>.team<wbr>/sponge<wbr>_duplex<wbr>.html)。海绵函数在对称加密中引入了基于置换的方法,描述了如何仅使用一种置换来构造多种不同的加密函数。
关于攻击的真实成本,有一些必读材料,包括 Daniel J. Bernstein 在 2005 年发表的论文《理解暴力破解》和 Michael Wiener 在 2004 年发表的论文《密码分析攻击的完整成本》,两篇论文都可以在线免费获取。
要确定给定密钥大小的安全级别,请访问 <wbr>www<wbr>.keylength<wbr>.com。该网站展示了多个政府机构关于密钥大小的建议,以及根据公钥大小保证的安全级别的数量级。
最后,作为练习,选择一个应用程序(如安全消息传递应用程序),并识别其加密方案、密钥长度和相应的安全级别。你会常常发现一些令人惊讶的不一致性,例如一个方案提供 256 位的安全级别,而第二个方案仅提供 100 位安全级别。整个系统的安全性往往只有最弱组件的安全性那么强。
第二部分 对称加密
第五章:4 分组密码

冷战时期,美国和苏联各自开发了自己的密码。美国政府创建了数据加密标准(DES),该标准从 1979 年到 2005 年作为联邦标准使用,而苏联 KGB 则开发了 GOST 28147-89,这是一个直到 1990 年才公开的算法,至今仍在使用。2000 年,美国国家标准与技术研究院(NIST)选择了 DES 的继任者——高级加密标准(AES),该算法在比利时开发,现在广泛应用于大多数电子设备。AES、DES 和 GOST 28147-89 都是分组密码,这是一种将处理数据块的核心算法与操作模式结合起来的密码类型,后者用于处理数据块序列。
本章回顾了构成分组密码的核心算法,讨论了它们的工作模式,并解释了它们如何协同工作。还讨论了 AES 的工作原理,并以介绍一种经典的攻击工具——1970 年代的“中间人攻击”和 2000 年代流行的攻击技术——填充 Oracle 攻击作为结尾。
什么是分组密码?
一个分组密码由加密算法和解密算法组成:
-
加密算法 (E) 接收一个密钥 K 和一个明文数据块 P,并生成一个密文数据块 C。我们将加密操作写作 C = E(K, P)。
-
解密算法 (D) 是加密算法的逆操作,将密文解密还原为原始明文 P。我们将此操作写作 P = D(K, C)。
由于它们是彼此的逆操作,加密和解密算法通常涉及相似的操作。
安全目标
如果你已经跟随之前关于加密、随机性和不可区分性的讨论,那么安全分组密码的定义应该不会让你感到惊讶。我们将继续将安全性定义为类似随机的特性,可以这么说。
为了保证分组密码的安全性,它应该是一个伪随机置换(PRP),意味着只要密钥是保密的,攻击者就不应能够从任何输入计算出分组密码的输出。也就是说,只要K对攻击者而言是保密且随机的,他们应该无法知道E(K, P)的具体样式,无论给定什么样的P。
更一般来说,攻击者不应能够在块密码的输入/输出值中发现任何模式。换句话说,给定黑盒访问加密和解密函数以及某个固定且未知的密钥,应该无法将块密码与真正的随机置换区分开来。同样,攻击者也应该无法恢复安全块密码的密钥;否则,他们可以利用该密钥将块密码与随机置换区分开来。这意味着攻击者无法预测与给定密文对应的明文,即块密码所生成的密文。
块大小
块密码由两个值来表征:块大小和密钥大小。安全性依赖于这两个值。大多数块密码要么使用 64 位块,要么使用 128 位块——DES 的块为 64 位(2⁶),AES 的块为 128 位(2⁷)。在计算中,通常以 2 的幂来度量的长度简化了数据处理、存储和寻址。但为什么是 2⁶和 2⁷而不是 2⁴或 2¹⁶位呢?
块密码的块大小不宜过大,以便最小化密文长度和内存占用。块密码首先将输入数据转换为一系列块,这意味着如果块的大小为 128 位,要加密一个 16 位的消息,需要将该消息转换为一个 128 位的块,以便块密码进行处理并返回一个 128 位的密文。块越宽,这种开销就越大。处理一个 128 位的块,至少需要 128 位内存。64 位、128 位甚至 512 位的块足够小,可以适应大多数 CPU 的寄存器或通过专用硬件电路实现,从而在大多数情况下实现高效的加密。然而,较大的块(例如,几个千字节长)可能会对实现的成本和性能产生明显影响。
当密文的长度或内存占用至关重要时,可能需要使用 64 位块,因为它们产生较短的密文并消耗更少的内存。否则,128 位或更大的块更好,主要是因为现代 CPU 通常比 64 位块更高效地处理 128 位块,而且它们更安全(参见“Sweet32”攻击,见 <wbr>sweet32<wbr>.info)。特别是,CPU 可以利用指令高效地并行处理一个或多个 128 位块——例如,英特尔 CPU 中的高级矢量扩展(AVX)指令集。
字典攻击
虽然块不应过大,但也不应过小;否则,它们可能会受到字典攻击的威胁,字典攻击是针对块密码的攻击,这种攻击仅在使用较小的块时才有效。使用 16 位块时,字典攻击的工作原理如下:
- 获取与每个 16 位明文块对应的 65,536(2¹⁶)个密文。
2. 构建一个查找表——代码本——将每个密文块映射到其对应的明文块。
3. 要解密一个未知的密文块,在表中查找其对应的明文块。
使用 16 位区块时,查找表只需要 2¹⁶ × 16 = 2²⁰位的内存,约为 128 千字节。使用 32 位区块时,内存需求增加到 16GB,仍然可以管理。但是,使用 64 位区块时,你必须存储 2⁷⁰位(一个 zettabit,或者 128 exabytes),那时就不行了。因此,对于 128 位或更大区块,代码本攻击并不是问题。
如何构建区块密码
存在数百种区块密码,但只有少数几种构建方法。实际上,区块密码并不是一个巨大的算法,而是一个轮次的重复——一种单独看可能很弱,但通过数量上来补强的短序列操作。构建一个轮次的主要方法有两种:替代-置换网络(如在 AES 中)和费斯特尔方案(如在 DES 中)。在这一部分中,你将先查看当所有轮次相同的时候,如何通过一种攻击来破解,然后再了解这些技术。
区块密码的轮次
计算区块密码实际上就是计算一系列的轮次。 在区块密码中,轮次是一个基本的变换,容易指定和实现,并且重复多次以形成区块密码的算法。这种结构由一个小的组件多次重复,比起由一个巨大的算法组成的结构,更容易实现和分析。
例如,一个具有三轮的区块密码通过计算 C = R3)) 来加密一个明文,其中轮次是 R[1]、R[2] 和 R[3],P 是明文。每一轮还应该有一个逆操作,这样接收者就能够解密回明文。具体地,P = iR1)),其中 iR[1] 是 R[1] 的逆操作,以此类推。
轮函数——R[1]、R[2]等——通常是相同的算法,但它们由一个我们称之为轮密钥的值进行参数化。两个具有不同轮密钥的轮函数会表现不同,因此,如果输入相同,它们会产生不同的输出。
我们通过主密钥K使用密钥调度算法来推导轮密钥。例如,R[1]使用轮密钥K[1],R[2]使用轮密钥K[2],依此类推。
每一轮的轮密钥应该彼此不同。也就是说,并非所有的轮密钥都应该与密钥K相等;否则,所有的轮次将会是相同的,区块密码的安全性将降低,接下来我将描述这一点。
滑动攻击 和轮密钥
在分组密码中,任何一轮不应与另一轮相同,以避免滑动攻击。如图 4-1 所示,滑动攻击会寻找两个明文/密文对(P[1],C[1])和(P[2],C[2]),其中P[2] = R(P[1]),R是密码的轮次。当轮次相同的时候,两个明文之间的关系P[2] = R(P[1]),就意味着它们的密文之间有相同的关系C[2] = R(C[1])。图 4-1 展示了三个轮次,但关系C[2] = R(C[1])无论轮次数为 3、10 还是 100 都会成立。问题在于,知道某一轮的输入和输出往往有助于恢复密钥。(详情请参阅 1999 年由 Alex Biryukov 和 David Wagner 撰写的论文《高级滑动攻击》,可通过www.iacr.org/archive/eurocrypt2000/1807/18070595-new.pdf获取。)

图 4-1:针对具有相同轮次的分组密码的滑动攻击原理
使用不同的轮密钥作为参数可以确保各个轮次的行为不同,从而防止滑动攻击。
注意
使用轮密钥的一个潜在副产品和好处是防止旁路攻击,即利用密码实现过程中泄露的信息进行的攻击(例如,电磁辐射)。如果从主密钥 K到轮密钥K[i]的转换是不可逆的,那么如果攻击者找到K[i],他们就不能利用该密钥找到K。不幸的是,很少有分组密码具有单向密钥调度。AES 的密钥调度允许攻击者从任何轮密钥K[i]计算K,例如。
替代-置换网络
如果你读过关于密码学的教科书,可能会接触到“混淆”和“扩散”这两个概念。混淆意味着输入(明文和加密密钥)会经历复杂的转换,而扩散意味着这些转换对输入的每一位都依赖相同。总体来说,混淆关注的是深度,而扩散关注的是广度。在分组密码的设计中,混淆和扩散通过替代和置换操作的形式体现,我们将其结合在替代-置换网络(SPN)中。
替代通常以S 盒或替代盒的形式出现,它们是小型查找表,用来转换 4 位或 8 位的块。例如,分组密码 Serpent 的八个 S 盒中的第一个由 16 个元素(3 8 f 1 a 6 5 b e d 4 2 7 0 9 c)组成,每个元素表示一个 4 位的 nibble。这个特定的 S 盒将 4 位 nibble 0000 映射为 3(0011),将 4 位 nibble 0101(十进制为 5)映射为 6(0110),依此类推。
注意
S 盒必须小心选择以确保其密码学强度:它们应尽可能非线性(输入和输出应通过复杂的方程式相关联),且没有统计偏差(例如,翻转一个输入位应该可能影响输出位的任何位)。
替换–置换网络中的置换可以像改变位的顺序一样简单,这种方法容易实现,但并没有很好地混合位。某些密码算法使用基本的线性代数和矩阵乘法来混合位:它们执行一系列固定值(矩阵系数)的乘法操作,然后将结果相加。这种操作可以迅速在所有位之间创建依赖关系,从而确保强的扩散性。例如,块加密算法 FOX 将一个 4 字节向量 (a, b, c, d) 转换为 (a′, b′, c′, d′),我们定义如下:

在这些方程中,我们将 2 和 253 解释为二进制多项式,而不是整数;因此,我们对加法和乘法的定义与我们习惯的略有不同。例如,我们不是有 2 + 2 = 4,而是 2 + 2 = 0。无论如何,初始状态中的每个字节都会影响最终状态中的所有 4 个字节。
费斯特尔方案
在 1970 年代,IBM 工程师霍斯特·费斯特尔设计了一种块加密算法 Lucifer,其工作原理如下:
1. 将 64 位块拆分为两个 32 位的部分 L 和 R。
2. 将 L 设置为 L ⊕ F(R),其中 F 是一个替换–置换轮。
3. 交换 L 和 R 的值。
4. 返回第 2 步并重复 15 次。
5. 将 L 和 R 合并为 64 位输出块。
该结构是一个费斯特尔方案,如图 4-2 所示。左侧是前面描述的方案;右侧是一个功能等效的表示形式,其中,轮次交替执行 L = L ⊕ F(R) 和 R = R ⊕ F(L) 的操作,而不是交换 L 和 R。

图 4-2:费斯特尔方案块加密结构的两种等效形式
我已省略图 4-2 中的密钥,以简化图示,但请注意,第一个 F 使用一个轮密钥 K[1],第二个 F 使用另一个轮密钥 K[2]。在 DES 中,F函数使用一个 48 位的轮密钥,它从 56 位密钥 K 中推导出来。
在费斯特尔方案中,F函数可以是伪随机置换(PRP)或伪随机函数(PRF)。PRP 对于任何两个不同的输入都会产生不同的输出,而 PRF 则可能有 X 和 Y 的值,使得 F(X) = F(Y)。但是在费斯特尔方案中,这种差异并不重要,只要 F 是密码学上强的。
在费斯特尔结构中应该进行多少轮?嗯,DES 执行 16 轮,而 GOST 28147-89 执行 32 轮。如果F函数尽可能强大,从理论上讲,四轮就足够了,但现实中的加密算法使用更多轮来防御F中的潜在弱点。
高级加密标准
AES 是世界上使用最广泛的密码算法。在 AES 被采用之前,使用的标准密码是 DES,它的 56 位安全性极其低,还有升级版的 DES,即 Triple DES 或 3DES。尽管 3DES 提供了更高的安全级别(112 位安全性),但它的效率较低,因为为了获得 112 位安全性,密钥需要达到 168 位,并且它在软件中的速度较慢(DES 的设计初衷是为了在集成电路中运行快速,而不是在主流 CPU 上)。AES 解决了这两个问题。
NIST 在 2000 年将 AES 标准化为 DES 的替代方案,从那时起,它成为了世界上事实上的加密标准。今天,大多数商业加密产品都支持 AES,NSA 也批准了它用于保护最高机密信息。(一些国家确实更倾向于使用自己的密码算法,主要是因为它们不想使用美国的标准,但 AES 实际上比美国更具比利时特色。)
注意
AES 在成为 AES 竞赛中的 15 个候选算法之一时,曾以Rijndael(这是其发明者 Rijmen 和 Daemen 的名字合成词,发音类似于“rain-dull”)命名。该竞赛由 NIST 于 1997 年至 2000 年举行,目的是指定“一个未分类、公开披露的加密算法,能够有效保护敏感政府信息,直到下个世纪”,正如 1997 年在《联邦公报》中发布的竞赛公告所述。AES 竞赛就像是密码学家的“才艺大赛”,任何人都可以通过提交密码或破解其他参赛者的密码来参与。*
AES 内部结构
AES 处理 128 位的块,使用 128 位、192 位或 256 位的秘密密钥,其中 128 位密钥最为常见,因为它使加密稍微更快,并且对于大多数应用来说,128 位和 256 位安全性的差异并不重要。
与一些密码算法处理单个比特或 64 位字不同,AES 处理的是字节。它将 16 字节的明文视为一个二维字节数组(s = s[0],s[1],…,s[15]),如图 4-3 所示。(我们使用字母s是因为这个数组是内部状态,简称状态。)AES 通过转换这个数组的字节、列和行来生成最终的密文。

图 4-3:AES 的内部状态,作为一个 4×4 的 16 字节数组
为了转换其状态,AES 使用如图 4-4 所示的 SPN 结构,对于 128 位密钥为 10 轮,192 位密钥为 12 轮,256 位密钥为 14 轮。

图 4-4:AES 的内部操作
图 4-4 显示了 AES 轮次的四个基本模块(注意,除了最后一轮,其他所有轮次都是 SubBytes、ShiftRows、MixColumns 和 AddRoundKey 的组合):
**AddRoundKey **将轮密钥与内部状态进行 XOR 运算。
**SubBytes **根据 S-box 替换每个字节(s[0], s[1] . . . , s[15])。在本例中,S-box 是一个包含 256 个元素的查找表。
**ShiftRows **将第i行按i个位置移位,其中i的范围是 0 到 3(参见图 4-5)。
**MixColumns **对状态的四列应用相同的线性变换(即,每组具有相同灰度的单元格,如图 4-5 左侧所示)。

图 4-5:ShiftRows 在内部状态的每一行内旋转字节。
请记住,在 SPN 中,S代表替代,P代表置换。这里,替代层是 SubBytes,置换层是 ShiftRows 和 MixColumns 的组合。
如图 4-4 所示,关键调度函数 KeyExpansion 是 AES 的密钥调度算法。此扩展从 16 字节的密钥生成 11 个轮密钥(K[0], K[1], . . . , K[10]),每个轮密钥为 16 字节,使用与 SubBytes 相同的 S-box 以及 XOR 组合。KeyExpansion 的一个重要特性是,给定任何一个轮密钥,Ki,攻击者可以通过反向算法确定所有其他轮密钥以及主密钥K。从任意轮密钥获取密钥的能力降低了密码的抗侧信道攻击能力,因为攻击者可以轻松恢复一个轮密钥。
如果没有这些操作,AES 将完全不安全。每个操作以特定的方式贡献于 AES 的安全性:
-
如果没有 KeyExpansion,所有轮次将使用相同的密钥K,AES 将容易受到滑动攻击。
-
如果没有 AddRoundKey,加密将不再依赖于密钥;因此,任何人都可以在没有密钥的情况下解密任何密文。
-
SubBytes 引入了非线性操作,增加了密码学强度。没有它,AES 只会是一个可以通过高中代数(即高斯消元法)解出的线性方程系统。
-
如果没有 ShiftRows,给定列的变化将不会影响其他列,这意味着你可以通过为每列构建四个 2³²元素的代码簿来破解 AES。(请记住,在安全的分组密码中,输入中的一个比特的翻转应该影响所有输出比特。)
-
如果没有 MixColumns,字节的变化不会影响状态中的任何其他字节。选择明文攻击者可以通过存储每个字节可能值的加密值的 16 个 256 字节的查找表来解密任何密文。
注意 图 4-4 中,AES 的最后一轮不包括 MixColumns 操作。此操作被省略以节省不必要的计算:因为 MixColumns 是线性的,你可以通过一种不依赖于其值或密钥的方式在最后一轮中消除其效果。然而,我们无法在不知道状态值的情况下逆转 SubBytes,除非在 AddRoundKey 之前已知该值。
要解密一个密文,AES 通过取其逆函数来逐步恢复每个操作:SubBytes 的逆查找表会逆转 SubBytes 变换,ShiftRow 会向相反方向移动,MixColumns 的逆运算被应用(如同矩阵运算的矩阵逆),AddRoundKey 的 XOR 保持不变,因为 XOR 的逆操作还是 XOR。
AES 实践
作为练习,你可以使用 Python 的 cryptography 库来加密和解密一个数据块,方法如 清单 4-1 所示。
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from os import urandom
BLOCK_SIZE = 16
KEY_SIZE = 16
# Pick a random 16-byte key using Python's crypto PRNG.
k = urandom(KEY_SIZE)
print(f"k = {k.hex()}")
# Create an instance of AES-128.
aes = Cipher(algorithms.AES(k), modes.ECB())
aes_ecb_encryptor = aes.encryptor()
# Set plaintext p to the all-zero string.
p = bytes([0x00] * BLOCK_SIZE)
# Encrypt plaintext p to ciphertext c.
c = aes_ecb_encryptor.update(p) + aes_ecb_encryptor.finalize()
print(f"enc({p.hex()}) = {c.hex()}")
# Decrypt ciphertext c to plaintext p.
aes_ecb_decryptor = aes.decryptor()
p = aes_ecb_decryptor.update(c) + aes_ecb_decryptor.finalize()
print(f"dec({c.hex()}) = {p.hex()}")
清单 4-1:Python 中的 AES 加密和解密一个数据块
运行此脚本会产生如下类似的输出:
$ **./aes_block.py**
k = 2c6202f9a582668aa96d511862d8a279
enc(00000000000000000000000000000000) = 12b620bb5eddcde9a07523e59292a6d7
dec(12b620bb5eddcde9a07523e59292a6d7) = 00000000000000000000000000000000
你将得到不同的结果,因为每次执行时密钥都会随机化。
如何实现 AES
实际的 AES 软件与 图 4-4 中的算法工作方式不同。你不会在生产级别的 AES 代码中找到一个 SubBytes() 函数,接着是 ShiftRows() 函数,再然后是 MixColumns() 函数,因为那样效率低下。相反,快速的 AES 软件使用基于表格的实现和本地指令。
基于表格的实现
基于表格的 AES 实现将 SubBytes-ShiftRows-MixColumns 序列替换为一系列 XOR 操作和在程序中硬编码并在执行时加载到内存中的查找表。这是可能的,因为 MixColumns 等价于对四个 32 位值进行 XOR 操作,每个值都依赖于来自状态的一个字节和 SubBytes。因此,你可以构建四个包含 256 个条目的表格,每个表格对应一个字节值,并通过查找四个 32 位值并对它们进行 XOR 操作来实现 SubBytes-MixColumns 序列。
例如,OpenSSL 工具包中的基于表格的 C 实现类似于 清单 4-2。
/* Round 1: */
t0 = Te0[s0 >> 24] ^ Te1[(s1 >> 16) & 0xff] ^ Te2[(s2 >> 8) & 0xff] ^ Te3[s3 & 0xff] ^ rk[4];
t1 = Te0[s1 >> 24] ^ Te1[(s2 >> 16) & 0xff] ^ Te2[(s3 >> 8) & 0xff] ^ Te3[s0 & 0xff] ^ rk[5];
t2 = Te0[s2 >> 24] ^ Te1[(s3 >> 16) & 0xff] ^ Te2[(s0 >> 8) & 0xff] ^ Te3[s1 & 0xff] ^ rk[6];
t3 = Te0[s3 >> 24] ^ Te1[(s0 >> 16) & 0xff] ^ Te2[(s1 >> 8) & 0xff] ^ Te3[s2 & 0xff] ^ rk[7];
/* Round 2: */
s0 = Te0[t0 >> 24] ^ Te1[(t1 >> 16) & 0xff] ^ Te2[(t2 >> 8) & 0xff] ^ Te3[t3 & 0xff] ^ rk[8];
s1 = Te0[t1 >> 24] ^ Te1[(t2 >> 16) & 0xff] ^ Te2[(t3 >> 8) & 0xff] ^ Te3[t0 & 0xff] ^ rk[9];
s2 = Te0[t2 >> 24] ^ Te1[(t3 >> 16) & 0xff] ^ Te2[(t0 >> 8) & 0xff] ^ Te3[t1 & 0xff] ^ rk[10];
s3 = Te0[t3 >> 24] ^ Te1[(t0 >> 16) & 0xff] ^ Te2[(t1 >> 8) & 0xff] ^ Te3[t2 & 0xff] ^ rk[11];
`--snip--`
列表 4-2:OpenSSL 中基于表格的 AES 实现摘录
一个基本的基于表格的 AES 加密实现需要四个 4KB 的表格,因为每个表格存储 256 个 32 位值,占用 256 × 32 = 8,192 位,即 1KB。解密还需要另四个表格,因此需要额外的 4KB 存储。但有一些技巧可以将存储从 4KB 减少到 1KB,甚至更少。
可惜的是,基于表格的实现容易受到缓存时间攻击的影响,攻击者通过利用程序读取或写入缓存内存元素时的时间差异来进行攻击。访问时间会根据访问元素在缓存内存中的相对位置而变化。因此,时间差泄漏了关于访问元素的信息,进而泄漏了涉及的秘密信息。
缓存时间攻击很难避免。一个显而易见的解决方案是完全舍弃查找表,通过编写一个执行时间不依赖于输入的程序来避免这种攻击,但这样几乎不可能做到,同时还保持相同的速度。因此,芯片制造商选择了一种激进的解决方案:他们不再依赖可能存在漏洞的软件,而是依赖于硬件。
原生指令
AES 原生指令(AES-NI)解决了 AES 软件实现中缓存时间攻击的问题。要理解 AES-NI 如何工作,可以想象软件如何在硬件上运行:为了运行一个程序,微处理器将二进制代码转换为一系列指令,然后集成电路组件执行这些指令。例如,两个 32 位值之间的 MUL 汇编指令会激活微处理器中实现 32 位乘法器的晶体管。为了实现加密算法,我们通常将一系列基本操作——加法、乘法、异或等——组合在一起,然后微处理器按照预定的顺序激活其加法器、乘法器和异或电路。
AES 原生指令通过提供专门的汇编指令来计算 AES,将开发人员的工作提升到全新水平。在使用 AES-NI 时,你无需将 AES 回合编写为一系列汇编指令,只需要调用指令 AESENC,芯片会为你计算回合。原生指令允许你指示处理器执行 AES 回合,而不需要将回合编程为一系列基本操作的组合。
使用原生指令的典型 AES 汇编实现可以参考列表 4-3。
PXOR %xmm5, %xmm0
AESENC %xmm6, %xmm0
AESENC %xmm7, %xmm0
AESENC %xmm8, %xmm0
AESENC %xmm9, %xmm0
AESENC %xmm10, %xmm0
AESENC %xmm11, %xmm0
AESENC %xmm12, %xmm0
AESENC %xmm13, %xmm0
AESENC %xmm14, %xmm0
AESENCLAST %xmm15, %xmm0
列表 4-3:使用 AES 原生指令实现的 AES-128
这段代码加密了最初存储在寄存器xmm0中的 128 位明文,假设寄存器xmm5到xmm15存储了预计算的轮密钥,每条指令将其结果写入xmm0。初始的PXOR指令在计算第一轮之前执行与第一轮密钥的异或操作,最后的AESENCLAST指令执行最后一轮的方式与其他轮略有不同(MixColumns 被省略)。
注意
AES 在实现了本地指令的平台上速度大约是原来的 10 倍,正如我写这段文字时,几乎所有的笔记本、台式机和服务器微处理器,以及大多数手机和平板电脑都已经实现了这些指令。虽然 Intel 在 2008 年首次提出了 AES 指令,但这些指令也可以在 AMD 处理器中使用,除了 x86 之外的大多数架构也有等效的硬件实现 AES 的指令。例如,Armv8 指令集包含了指令AESSE(用于计算 SubBytes 和 ShiftRows)和AESMS(用于计算 MixColumns)。
在 Intel 的 Ice Lake 微架构上,AESENC指令的延迟为三个周期,反向吞吐量为半个周期,意味着调用AESENC需要三个周期才能完成,而我们可以在每个周期内发起两次新的指令调用。实际上,执行AESENC操作的微操作的内部结构使得新指令的计算可以在前一个计算完成之前开始。更重要的是,Ice Lake 架构使用了 AES 指令的向量化版本,可以同时启动多个指令。有关更多详细信息,请参阅 Nir Drucker、Shay Gueron 和 Vlad Krasnov 的文章《让 AES 重回巅峰》,文章可以在
<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2018<wbr>/392找到。为了一个接一个地加密一系列的块,完成 10 轮需要 3 × 10 = 30 个周期,或者每个字节需要 30 / 16 = 1.875 个周期。在 2 GHz 的频率下(2 × 10**⁹ 每秒周期数),这给出了大约 1GBps 的理论最大吞吐量。如果你能并行处理块,那么你就不需要在开始另一个之前完成一次完整的 AESENC 调用。在这种情况下,你可以每个周期做两次 AESENC 调用,并且每个周期获得两个结果,从而提供更高的理论吞吐量(在 2 GHz 时,最多超过 10GBps),具体取决于数据大小和操作模式。
AES 安全性
AES 的安全性是分组密码中最强的,它永远不会被破解。从根本上来说,AES 是安全的,因为所有输出比特都依赖于所有输入比特,并且这种依赖是复杂的伪随机的方式。为了实现这一点,AES 的设计者们精心选择了每个组件,基于特定的原因——MixColumns 由于其最大扩散性,SubBytes 由于其最优的非线性。这个组合使 AES 能够抵抗一类又一类的密码分析攻击。
但没有证据表明 AES 对所有可能的攻击都是免疫的。首先,我们不知道所有可能的攻击是什么,我们也不总是知道如何证明一个密码对某种特定攻击是安全的。真正增加对 AES 安全性信心的唯一方法是众包攻击:让许多有技能的人尝试破解 AES,并且理想情况下,无法成功。
经过 15 年以上的研究和数百篇研究论文,我们才刚刚触及 AES 理论安全性的表面。2011 年,密码分析师发现了一种方法,可以通过进行大约 2¹²⁶次操作来恢复 AES-128 密钥,而不是 2¹²⁸次操作,速度提升了 4 倍。但这个“攻击”需要大量的明文-密文对——大约需要 2⁸⁸比特的数据。这是一个有趣的发现,但并不是你需要担心的事情。
当你在实现和部署加密时,应该关注成千上万的事情,但 AES 的安全性并不是其中之一。对分组密码来说,最大的威胁不在于其核心算法,而在于其操作模式。如果你选择了错误的模式或误用了正确的模式,即便是像 AES 这样强大的密码也无法保护你。
操作模式
在第一章中,我解释了加密方案如何将置换与操作模式结合起来,以处理任意长度的消息。在这一节中,我将介绍分组密码使用的主要操作模式、它们的安全性和功能属性,以及如何(不)使用它们。我将从最愚蠢的那个开始:电子密码本。
电子密码本模式
最简单的块密码加密模式是电子密码本(ECB),它几乎不算是一种操作模式。ECB 处理明文块P[1]、P[2]、…、P[N],通过计算C[1] = E(K, P[1])、C[2] = E(K, P[2])等,逐块独立处理,正如图 4-6 所示。这是一种简单的操作,但也是不安全的——ECB 是不安全的,您不应使用它。

图 4-6:ECB 模式
微软的密码学家 Marsh Ray 曾说过:“大家都知道 ECB 模式不好,因为我们能看到企鹅。”他指的是一种著名的 ECB 不安全性示例,使用了 Linux 吉祥物 Tux 的图像,如图 4-7 所示。

图 4-7:原始图像(左)和 ECB 加密图像(右)
Tux 的原始图像在左侧,使用 AES 加密后的 ECB 图像(尽管底层密码不重要)在右侧。由于 ECB 将原始图像中所有相同灰度的块加密为新图像中相同灰度的新色调,因此在加密版本中很容易看到企鹅的形状;换句话说,ECB 加密生成的是颜色不同的相同图像。
清单 4-4 中的 Python 程序也显示了 ECB 的不安全性。它选择一个伪随机密钥,并加密一个包含两个空字节块的 32 字节消息p。请注意,加密产生了两个相同的块,并且使用相同的密钥和相同的明文重复加密时,又会产生相同的两个块。
#!/usr/bin/env python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from os import urandom
BLOCK_SIZE = 16
KEY_SIZE = 16
# The blocks() function splits a data string into space-separated blocks.
def blocks(data):
split = [data[i:i+BLOCK_SIZE].hex() for i in range(0, len(data), BLOCK_SIZE)]
return ' '.join(split)
k = urandom(KEY_SIZE)
print(f"k = {k.hex()}")
# Create an instance of AES-128 to encrypt and decrypt.
aes = Cipher(algorithms.AES(k), modes.ECB())
aes_ecb_encryptor = aes.encryptor()
# Set plaintext p as two blocks of zeros.
p = bytes([0x00] * 2 * BLOCK_SIZE)
# Encrypt plaintext p to ciphertext c.
c = aes_ecb_encryptor.update(p) + aes_ecb_encryptor.finalize()
print(f"enc({blocks(p)}) = {blocks(c)}")
清单 4-4:在 Python 中使用 AES 的 ECB 模式
运行此脚本将生成如下所示的密文块:
$ **./aes_ecb.py**
k = 50a0ebeff8001250e87d31d72a86e46d
enc(00000000000000000000000000000000 00000000000000000000000000000000) =
5eb4b7af094ef7aca472bbd3cd72f1ed 5eb4b7af094ef7aca472bbd3cd72f1ed
在使用 ECB 模式时,相同的密文块会向攻击者揭示相同的明文块,无论这些块是在同一密文中,还是在不同的密文中。这表明,ECB 模式下的块密码在语义上是不安全的。
ECB 的另一个问题是它只接受完整的数据块,因此如果数据块是 16 字节,比如在 AES 中,您只能加密 16 字节、32 字节、48 字节或其他 16 字节的倍数的数据块。解决这个问题有几种方法,您将在下一个模式 CBC 中看到。(我不会告诉您这些技巧如何在 ECB 中工作,因为您本不应使用 ECB。)
密码块链模式
密码块链接(CBC)与电子密码本(ECB)类似,但有一个小小的变化,这个变化带来了很大的不同:CBC 不是直接加密第 i 个块 Pi,像 Ci = E(K, Pi) 那样,而是设定 Ci = E(K, Pi ⊕ Ci [− 1]),其中 Ci [− 1] 是前一个密文块——从而将块 Ci [− 1] 和 Ci 进行链式连接。当加密第一个块 P[1] 时,由于没有前一个密文块可用,CBC 会采用一个随机初始值(IV),正如图 4-8 所示。

图 4-8: CBC 模式
CBC 模式使得每个密文块都依赖于所有前面的块,确保相同的明文块不会变成相同的密文块。随机初始值保证了,当用两个不同的初始值两次调用密码算法时,相同的明文会加密成不同的密文。
列表 4-5 演示了这两个优点。这个程序将一个全零的 32 字节消息(如列表 4-4 中的那样)进行两次 CBC 加密,并显示两个密文。加粗的行 iv = urandom(BLOCK _SIZE) 为每次新的加密选择一个新的随机 IV。
#!/usr/bin/env python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from os import urandom
BLOCK_SIZE = 16
KEY_SIZE = 16
# The blocks() function splits a data string into space-separated blocks.
def blocks(data):
split = [data[i:i+BLOCK_SIZE].hex() for i in range(0, len(data), BLOCK_SIZE)]
return ' '.join(split)
# Pick a random key.
k = urandom(KEY_SIZE)
print(f"k = {k.hex()}")
# Pick a random IV.
**iv = urandom(BLOCK_SIZE)**
print(f"iv = {iv.hex()}")
# Pick an instance of AES in CBC mode.
aes_cbc_encryptor = Cipher(algorithms.AES(k), modes.CBC(iv)).encryptor()
# Set plaintext p as two blocks of zeros.
p = bytes([0x00] * 2 * BLOCK_SIZE)
c = aes_cbc_encryptor.update(p) + aes_cbc_encryptor.finalize()
print(f"enc({blocks(p)}) = {blocks(c)}")
# Now with a different IV and the same key
**iv = urandom(BLOCK_SIZE)**
print(f"iv = {iv.hex()}")
aes_cbc_encryptor = Cipher(algorithms.AES(k), modes.CBC(iv)).encryptor()
c = aes_cbc_encryptor.update(p) + aes_cbc_encryptor.finalize()
print(f"enc({blocks(p)}) = {blocks(c)}")
列表 4-5: 使用 AES 的 CBC 模式
这两个明文是相同的(两个全零块),但加密后的块应该是不同的,正如这个执行示例所示:
$ **./aes_cbc.py**
k = 9cf0d31ad2df24f3cbbefc1e6933c872
iv = 0a75c4283b4539c094fc262aff0d17af
enc(00000000000000000000000000000000 00000000000000000000000000000000) =
370404dcab6e9ecbc3d24ca5573d2920 3b9e5d70e597db225609541f6ae9804a
iv = a6016a6698c3996be13e8739d9e793e2
enc(00000000000000000000000000000000 00000000000000000000000000000000) =
655e1bb3e74ee8cf9ec1540afd8b2204 b59db5ac28de43b25612dfd6f031087a
遗憾的是,我们常常使用常量 IV 而不是随机 IV,这会暴露出相同的明文以及以相同块开始的明文。例如,假设 CBC 将两个块的明文 P[1] || P[2] 加密成两个块的密文 C[1] || C[2]。如果 CBC 使用相同的 IV 加密 P[1] || P[2]′,其中 P[2]′ 是与 P[2] 不同的块,那么密文将变成 C[1] || C[2]′,其中 C[2]′ 与 C[2] 不同,但 C[1] 相同。因此,攻击者可以猜测两个明文的第一个块是相同的,即使他们只能看到密文。
注意
在 CBC 模式下,解密需要知道加密时使用的 IV,因此 IV 会与密文一起明文发送。
使用 CBC 时,解密通常比加密更快,因为解密可以并行进行。虽然加密新块 Pi 时需要等待前一个块 Ci [− 1],但解密一个块时,会计算 Pi = D(K, Ci) ⊕ Ci [− 1],其中不需要前一个明文块 Pi [− 1]。这意味着,只要你知道前一个密文块,就可以并行解密所有块,通常你是知道的。
CBC 模式下的消息加密
让我们回到块终止问题,看看如何处理长度不是块大小倍数的明文。例如,当块大小为 16 字节时,如何用 AES-CBC 加密 18 字节的明文?剩下的 2 个字节该怎么处理?你将看到两种广泛使用的技术来解决这个问题。第一种是填充,它使密文比明文稍长,而第二种是密文偷取,它生成与明文长度相同的密文。
消息填充
填充是一种使你能够加密任何长度的消息的技术,即使是小于一个块的消息。PKCS#7 标准和 RFC 5652 为块密码指定了填充方式,我们几乎在所有使用 CBC 的地方都使用它。
我们使用填充来扩展消息,通过向明文添加额外的字节来填充完整的块。以下是填充 16 字节块的规则:
-
如果剩余 1 个字节——例如,明文是 1 字节、17 字节或 33 字节长——则使用 15 个字节
0f(十进制为 15)填充消息。 -
如果剩余 2 个字节,使用 14 个字节
0e(十进制为 14)填充消息。 -
如果剩余 3 个字节,使用 13 个字节
0d(十进制为 13)填充消息。
如果有 15 个明文字节,并且缺少一个字节来填充块,填充会添加一个01字节。如果明文已经是 16 的倍数,即块长度,则会添加 16 个字节10(十进制为 16)。该技巧可以推广到任何块长度,最多可达到 255 字节(对于更大的块,1 字节太小,无法编码大于 255 的值)。
填充消息的解密过程如下:
1. 像解密未填充的 CBC 一样解密所有块。
2. 确保最后一个块的最后字节符合填充规则:即它们以至少一个01字节、至少两个02字节或至少三个03字节结尾,依此类推。如果填充无效——例如,最后的字节是01 02 03——则消息会被拒绝。否则,解密会去除填充字节并返回剩余的明文字节。
填充的一个缺点是,它会使密文至少增加 1 字节,最多增加一个块的长度。
密文偷取
密文偷取是我们用来加密长度不是块大小倍数的消息的另一种技巧。密文偷取比填充更复杂且不太常用,但它提供了一些好处:
-
明文可以是任意比特长度,而不仅仅是字节。例如,你可以加密一个 131 比特的消息。
-
密文的长度与明文完全相同。
-
密文盗用不容易受到填充 oracle 攻击的威胁,这种攻击有时会对带填充的 CBC 模式有效(如你将在第 83 页的“填充 Oracle 攻击”中看到的那样)。
在 CBC 模式下,密文盗用通过从前一个密文块中提取比特来扩展最后一个不完整的明文块,然后对该块进行加密。最后的不完整密文块由前一个密文块的前几个比特组成——即那些尚未附加到最后明文块上的比特。
在图 4-9 中,我们有三个块,其中最后一个块P[3]是不完整的(由零表示)。如果P[3]是 3 个字节,我们将其与前一个密文块E(K, P[2])的最后 12 个位进行异或,得到加密结果作为C[2]。最后一个密文块C[3]由E(K, P[2])的前 4 个字节组成。解密操作只是这个过程的逆操作。

图 4-9:用于 CBC 模式加密的密文盗用
密文盗用没有什么重大问题,但它不够优雅,且很难做到正确,尤其是当 NIST 的标准指定了三种不同的实现方式时(参见《特别出版物 800-38A》)。
计数器模式
为了避免密文盗用带来的问题,同时保留其好处,可以使用计数器模式(CTR)。CTR 几乎不是一种块密码模式:它将块密码转化为流密码,直接接受比特并输出比特,不需要担心“块”的概念。(我将在第五章详细讨论流密码。)
在 CTR 模式下(参见图 4-10),块密码算法不会转换明文数据。相反,它加密由计数器和随机数组成的块。计数器是一个整数,每个块都会递增。消息中的不同块不应使用相同的计数器,但不同的消息可以使用相同的计数器值序列(1, 2, 3, ……)。随机数是一个只使用一次的数字。它对于单一消息中的所有块都是相同的,但不同的消息不应使用相同的随机数。

图 4-10:CTR 模式
图 4-10 展示了在 CTR 模式下,加密操作是将明文与通过“加密”随机数N和计数器Ctr得到的流进行异或。解密过程相同,因此加密和解密都只需要加密算法。Python 脚本清单 4-6 为你提供了一个实践示例。
#!/usr/bin/env python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from os import urandom
BLOCK_SIZE = 16
KEY_SIZE = 16
# Pick a random key.
k = urandom(KEY_SIZE)
print(f"k = {k.hex()}")
# And a random nonce
# (careful with random nonces, see discussion below).
n = urandom(BLOCK_SIZE)
print(f"nonce = {n.hex()}")
# Create a 7-byte plaintext p.
p = bytes([0x00] * 7)
# Encrypt the plaintext p with AES-CTR.
aes_ctr_encryptor = Cipher(algorithms.AES(k), modes.CTR(n)).encryptor()
c = aes_ctr_encryptor.update(p) + aes_ctr_encryptor.finalize()
print(f"enc({p.hex()}) = {c.hex()}")
# Decrypt the ciphertext c.
aes_ctr_decryptor = Cipher(algorithms.AES(k), modes.CTR(n)).decryptor()
p = aes_ctr_decryptor.update(c) + aes_ctr_decryptor.finalize()
print(f"dec({c.hex()}) = {p.hex()}")
# Decrypt the ciphertext c using the encryption function.
aes_ctr_encryptor = Cipher(algorithms.AES(k), modes.CTR(n)).encryptor()
p = aes_ctr_encryptor.update(c) + aes_ctr_encryptor.finalize()
print(f"enc({c.hex()}) = {p.hex()}")
清单 4-6:在 CTR 模式下使用 AES
这个示例执行加密一个 4 字节的明文并得到一个 4 字节的密文。然后它使用加密函数解密该密文:
$ **./aes_ctr.py**
k = 130a1aa77fa58335272156421cb2a3ea
enc(00010203) = b23d284e
enc(b23d284e) = 00010203
与 CBC 中的初始值一样,加密者提供 CTR 的 nonce,并将其与密文一起明文发送。但与 CBC 的初始值不同,CTR 的 nonce 不需要是随机的;它只需要是唯一的。nonce 应该是唯一的,原因与我们不应该重用一次性密码本相同:当调用伪随机流S时,如果你用相同的 nonce 加密P[1]得到C[1] = P[1] ⊕ S,并且用相同的 nonce 加密P[2]得到C[2] = P[2] ⊕ S,那么C[1] ⊕ C[2]就会揭示出P[1] ⊕ P[2]。
只有当 nonce 足够长时,随机 nonce 才能奏效;例如,如果 nonce 是n位,那么在进行了 2*n*(/2)次加密及同样多的 nonce 后,你可能会遇到重复。64 位对于随机 nonce 来说是不够的,因为你可以预期在大约 2³²个 nonce 之后会发生重复,这个数字是不可接受的低。
如果每个新的明文都递增计数器,且计数器足够长——例如,64 位计数器——那么计数器将保证是唯一的。
CTR 的一个特别好处是,它比任何其他模式都更快。不仅如此,它是可并行化的,而且你甚至可以在不知道消息的内容之前,通过选择一个 nonce 并计算你将来与明文异或的流来开始加密。
注意
根据我们实现的 CTR 版本,我们可能会将 API 使用的 nonce 与计数器作为参数连接起来(如图 4-10 所示),或直接将计数器视为与块一样宽。
事物可能出错的地方
有两种必须了解的块密码攻击:Meet-in-the-middle 攻击,这是一种在 1970 年代发现的技术,至今仍在许多密码分析攻击中使用(不要与 man-in-the-middle 攻击混淆),以及填充 Oracle 攻击,这是一类在 2002 年由学术密码学家发现的攻击,最初被忽视,直到十年后重新被发现,并与若干脆弱应用程序一起曝光。
Meet-in-the-Middle 攻击
3DES 块密码是 1970 年代标准 DES 的升级版,使用 56 × 3 = 168 位密钥(比 DES 的 56 位密钥有所改进)。但是,3DES 的安全级别是 112 位而不是 168 位,这是由于meet-in-the-middle (MitM)攻击。
图 4-11 显示了 3DES 如何使用 DES 加密和解密函数加密一个块:首先使用密钥K[1]进行加密;然后使用密钥K[2]进行解密;最后使用密钥K[3]进行加密。如果K[1] = K[2],则前两次调用会相互抵消,3DES 简化为一个使用密钥K[3]的单一 DES。3DES 进行加密-解密-加密,而不是三次加密,以便在需要时使用新的 3DES 接口模拟 DES。

图 4-11:3DES 块密码结构
为什么使用三重 DES 而不是仅使用双重 DES——也就是说,为什么将明文 P 加密为 E(K[2], E(K[1], P))?事实证明,MitM 攻击使双重 DES 的安全性仅与单一 DES 相当。图 4-12 展示了 MitM 攻击的实际应用。

图 4-12:MitM 攻击
MitM 攻击用于攻击双重 DES 的过程如下:
1. 假设你有 P 和 C = E(K[2], E(K[1], P)),其中有两个未知的 56 位密钥,K[1] 和 K[2]。(DES 使用 56 位密钥,因此双重 DES 总共使用 112 位密钥。)你构建一个包含 2⁵⁶ 条目的密钥值表 E(K[1], P),其中 E 是 DES 加密函数,K[1] 是存储的值。
2. 对于所有 2⁵⁶ 个 K[2] 的值,计算 D(K[2], C),并检查结果值是否作为索引出现在表中(因此作为中间值,如图 4-12 中的问号所示)。
3. 如果你找到一个中间值作为表的索引,获取表中对应的 K[1] 并通过使用其他 P 和 C 对来验证所找到的 (K[1], K[2]) 是否正确。使用 K[1] 和 K[2] 对 P 进行加密,然后检查得到的密文是否为给定的 C。
该方法通过执行约 2⁵⁷ 次操作来恢复 K[1] 和 K[2],而不是执行 2¹¹² 次操作:步骤 1 对 2⁵⁶ 个块进行加密,然后步骤 2 对最多 2⁵⁶ 个块进行解密,总共进行 2⁵⁶ + 2⁵⁶ = 2⁵⁷ 次操作。你还需要存储 2⁵⁶ 个每个 15 字节的元素,或者大约 1 EB 的存储空间。虽然这需要大量存储,但有一个技巧可以让你在几乎不占用内存的情况下执行相同的攻击(正如你将在第六章中看到的)。
你可以将 MitM 攻击几乎以与双重 DES 相同的方式应用于 3DES,唯一不同的是第三阶段将遍历所有 2¹¹² 个 K[2] 和 K[3] 的值。因此,整个攻击在执行大约 2¹¹² 次操作后成功,这意味着尽管 3DES 具有 168 位密钥材料,它只获得了 112 位的安全性。
填充 Oracle 攻击
本章的结尾是 2000 年代最简单却最具破坏力的攻击之一:填充 oracle 攻击。记住,填充是通过额外字节填充明文来完成块的填充。例如,一个 111 字节的明文是六个 16 字节块,后面跟着 15 个字节。在这种情况下,形成一个完整的块填充会添加一个 01 字节。对于一个 110 字节的明文,填充会添加 2 个 02 字节。对于一个 109 字节的明文,它会添加 3 个 03 字节,依此类推,直到添加 16 个 10 字节,其中十六进制值 10 等于 16。
填充 oracle 是一个系统,它的行为取决于 CBC 加密密文的填充是否有效。你可以将它看作一个黑盒或一个 API,当收到格式不正确的密文时,它返回一个 成功 或 错误 的值。例如,你可以在远程主机上的某个服务中获得一个填充 oracle,当它接收到格式不正确的密文时,会发送错误消息。给定这样的 oracle,填充 oracle 攻击记录哪些输入具有有效填充,哪些没有,然后利用这些信息来解密选定的密文值。
假设你想解密一个密文块 C[2]。我将 X 称为你正在寻找的值,即 D(K, C[2]),P[2] 是在 CBC 模式下解密后得到的块(参见 图 4-13)。如果你选择一个随机块 C[1] 并将两个块的密文 C[1] || C[2] 发送到 oracle,只有当 C[1] ⊕ X = P[2] 以有效填充结尾时,解密才会成功——一个 01 字节、两个 02 字节、或者三个 03 字节,依此类推。

图 4-13:填充 oracle 攻击恢复 X 通过选择 C1 并检查填充的有效性。
基于这一观察,CBC 加密中的填充 oracle 攻击可以像这样解密一个块 C[2](字节以数组表示法表示:C[1][0] 是 C[1] 的第一个字节,C[1][1] 是第二个字节,以此类推,到 C[1][15],C[1] 的最后一个字节):
1. 选择一个随机块 C[1],并改变其最后一个字节,直到填充 oracle 接受该密文为有效。通常,在有效的密文中,C[1][15] ⊕ X[15] = 01,所以你将在尝试大约 128 个 C[1][15] 的值后找到 X[15]。
2. 通过将C[1][15]设置为X[15] ⊕ 02来查找值X[14],然后搜索给出正确填充的C[1][14]。当 oracle 接受密文为有效时,意味着你已经找到了C[1][14],使得C[1][14] ⊕ X[14] = 02。
3. 对所有 16 个字节重复步骤 1 和 2。
攻击平均需要对每个 16 个字节的 oracle 进行 128 次查询,总共约 2,000 次查询。(请注意,每次查询必须使用相同的初始值。)
注意
实际上,实现一个填充 oracle 攻击比我所描述的要复杂一些,因为你必须处理第 1 步中的错误猜测。一个密文可能有有效的填充,不是因为 P2 以单个01结尾,而是因为它以两个02字节或三个03字节结尾。你可以通过测试修改了更多字节的密文来管理这一点。
进一步阅读
关于分组密码有很多可以讨论的内容,无论是算法如何工作,还是如何被攻击。例如,Feistel 网络和 SPN 并不是构建分组密码的唯一方式。分组密码 IDEA 和 FOX 使用 Lai–Massey 结构,而 Threefish 使用 ARX 网络,这是加法、字轮换和异或的组合。
还有比 ECB、CBC 和 CTR 更多的模式。有些模式是民间传说中的技术,没有人使用,比如 CFB 和 OFB,而其他模式则用于特定应用,如 XTS 用于可调加密,或 GCM 用于认证加密。
我已经讨论了 Rijndael,AES 的获胜者,但在比赛中还有其他 14 种算法:CAST-256、CRYPTON、DEAL、DFC、E2、FROG、HPC、LOKI97、Magenta、MARS、RC6、SAFER+、Serpent 和 Twofish。我建议查阅它们,看看它们是如何工作的,如何设计的,如何被攻击的,以及它们的速度如何。还值得查看 NSA 的设计(Skipjack,最近的 SIMON 和 SPECK)以及更近期的“轻量级”分组密码,如 GIFT、KATAN、PRESENT 或 PRINCE。
第六章:5 流加密

对称加密可以是分组加密或流加密。回顾第四章,分组加密将明文的若干位与密钥位混合,生成相同大小的密文块,通常为 64 位或 128 位。另一方面,流加密不混合明文和密钥位;相反,它们从密钥生成伪随机位,通过将其与明文进行异或加密,类似于第一章中解释的一次性密码本。
流加密有时被排斥,因为它们在历史上比分组加密更脆弱,更容易被破解——无论是业余爱好者设计的实验性算法,还是被数百万用户使用的系统中的加密算法,包括手机、Wi-Fi 和公共交通智能卡。但幸运的是,尽管花了将近 20 年时间,我们现在知道如何设计安全的流加密算法,并信任它们来保护蓝牙连接、移动 4G 通信和 TLS 连接。
本章首先介绍流加密的工作原理,并讨论流加密的两大主要类别:有状态流加密和基于计数器的流加密。然后我们将研究硬件和软件导向的流加密算法,并查看一些不安全的加密算法(如在 GSM 移动通信中使用的 A5/1 和在旧版 TLS 中使用的 RC4)以及安全的、最先进的算法(如硬件中的 Grain-128a 和软件中的 Salsa20)。
流加密的工作原理
流加密更像是确定性随机比特生成器(DRBG),而非分组加密,因为它们生成的是伪随机比特流,而不是直接混合明文数据。
流加密与确定性随机比特生成器(DRBG)不同之处在于,DRBG 只接受一个输入值,而流加密接受两个值:一个密钥和一个随机数。密钥应当保密,通常为 128 位或 256 位。随机数不必保密,但应当对每个密钥唯一,通常为 64 位到 128 位之间。
流加密生成我们称之为密钥流的伪随机比特流。为了加密密钥流,我们将其与明文进行异或操作,然后再与密文进行异或操作以解密。图 5-1 展示了基本的流加密操作,其中SC是流加密算法,KS是密钥流,P是明文,C是密文。

图 5-1:流加密如何加密,使用一个秘密密钥 K和一个公共随机数 N
流密码计算 KS = SC(K, N),加密过程为 C = P ⊕ KS,解密过程为 P = C ⊕ KS。加密和解密函数是相同的,因为它们执行的是相同的操作——即用密钥流对位进行异或运算。因此,例如,某些加密库提供一个单一的 encrypt 函数来处理加密和解密。
流密码允许你使用密钥 K[1] 和随机数 N[1] 加密一条消息,然后使用不同的密钥 K[1] 和随机数 N[2] 加密另一条消息,或者使用密钥 K[2](不同于 K[1])和随机数 N[1]。然而,你绝不应再使用 K[1] 和 N[1] 进行加密,因为这样会重复使用相同的密钥流 KS。也就是说,你将得到第一个密文 C[1] = P[1] ⊕ KS 和第二个密文 C[2] = P[2] ⊕ KS,如果你知道 P[1],那么你可以通过 C[1] ⊕ C[2] ⊕ P[1] 来确定 P[2]。
注意
随机数 是“仅使用一次的数字”(number used only once)的缩写。在流密码的上下文中,我们有时称其为IV,即“初始化值”(initial value)。
从高层次来看,流密码分为两种类型:有状态流密码和基于计数器的流密码。有状态流密码 有一个在生成密钥流过程中不断变化的内部状态。该密码从密钥和随机数初始化状态,然后调用更新函数以更新状态值,并从状态生成一个或多个密钥流位,如图 5-2 所示。例如,RC4 是有状态的,而 Salsa20 是基于计数器的。

图 5-2:有状态流密码
基于计数器的流密码 通过一个密钥、一个随机数和一个计数器值生成密钥流块,如图 5-3 所示。与有状态流密码不同,基于计数器的流密码,如 Salsa20,在生成密钥流时不跟踪任何秘密信息,除了计数器的值。

图 5-3:基于计数器的流密码
这两种方法定义了流密码的高级架构,和核心算法如何工作无关。流密码的内部结构也分为两类,具体取决于密码的目标平台:硬件导向和软件导向。
硬件导向流密码
当密码学家谈论硬件时,他们指的是专用集成电路(ASIC)、可编程逻辑设备(PLD)和现场可编程门阵列(FPGA)。一个密码算法的硬件实现是一个电子电路,它在比特级别上实现密码学算法,且不能用于其他任何用途;换句话说,该电路是专用硬件。另一方面,密码学算法的软件实现只是告诉微处理器执行什么指令来运行该算法。这些指令作用于字节或字(byte/word),然后调用一些实现通用操作(如加法和乘法)的电子电路。软件处理的是 32 位或 64 位的字节或字,而硬件处理的是比特(bit)。最早的流密码算法处理的是比特,以节省复杂的字级操作,从而在硬件上更加高效,这是当时它们的目标平台。
流密码算法主要用于硬件实现,因为它们比块密码算法更便宜。它们需要的内存和逻辑门比块密码算法少,因此在集成电路上占用的面积更小,从而降低了制造成本。例如,按门等效数计算,这是集成电路的标准面积度量,你会发现流密码算法只需要不到 1000 个门等效;相比之下,典型的面向软件的块密码算法至少需要 10000 个门等效,使得加密比使用流密码算法时贵了一个数量级。
然而,如今块密码算法的成本不再高于流密码算法——首先,因为现在有一些硬件友好的块密码算法,它们的体积几乎和流密码一样小,其次,因为硬件成本大幅下降。然而,流密码算法通常与硬件相关联,因为它们曾是最优选择。
在下一节中,我将解释硬件流密码背后的基本机制,即反馈移位寄存器(FSR)。几乎所有硬件流密码都以某种方式依赖于 FSR,无论是 2G 手机中使用的 A5/1 密码,还是更新的 Grain-128a 密码。
注意
第一个标准块密码算法,数据加密标准(DES),是为了硬件优化的,而非软件。当美国政府在 1970 年代将 DES 标准化时,大多数目标应用都是硬件实现。因此,DES 中的 S 盒在硬件实现时非常小且计算快速,但在软件中却效率低下,这也就不足为奇了。与 DES 不同,当前的高级加密标准(AES)处理的是字节,因此在软件中比 DES 更加高效。
反馈移位寄存器
无数的流密码使用 FSR,因为它们简单且易于理解。FSR 是一个由比特组成的数组,配备了一个更新的 反馈函数,我将其表示为 f。FSR 的状态存储在数组或寄存器中,每次 更新 FSR 时,使用反馈函数来更改状态值并产生一个输出比特。
在实际应用中,FSR 的工作方式如下:如果 R[0] 是 FSR 的初始值,那么下一状态 R[1] 被定义为将 R[0] 向左移 1 位,其中离开寄存器的比特作为输出,而空缺的位置由 f(R[0]) 填充。
我们重复相同的规则来计算后续的状态值 R[2]、R[3] 等。也就是说,给定 Rt,FSR 在时刻 t 的状态,下一状态 R[t + 1] 为以下内容:

在此方程中,| 是逻辑或运算符,<< 是移位运算符,像 C 语言中使用的那样。例如,给定 8 位字符串 00001111,我们得到:

比特移位将比特向左移动,丢失最左边的比特以保持状态的比特长度,并将最右边的比特归零。FSR 的更新操作是相同的,唯一不同的是最右边的比特不被设置为 0,而是被设置为 f(Rt)。
例如,考虑一个 4 比特的 FSR,其反馈函数 f 对所有 4 比特进行异或运算。将状态初始化为以下内容:

现在将比特向左移动,其中 1 是输出,最右边的比特设置为以下内容:

现在状态变为:

下一次更新输出 1,将状态左移,并将最右边的比特设置为以下内容:

现在状态如下:

接下来的三次更新返回三个 0 比特,并给出以下状态值:

因此,在五次迭代后我们回到了初始状态 1100;从此循环中观察到的任何值更新状态五次都能回到这个初始值。我们说,给定任一值 1100、1000、0001、0011 或 0110,5 是 FSR 的 周期。因为该 FSR 的周期是 5,时钟信号钟动寄存器 10 次会得到两次相同的 5 位序列。同样,如果从 1100 开始时钟信号钟动寄存器 20 次,输出比特将是 11000110001100011000,或者四次相同的 5 位序列 11000。直观上,这种重复模式应该避免,较长的周期对安全性更好。
注意
如果你打算在流密码中使用 FSR,请避免使用周期较短的 FSR,因为它们的输出更容易预测。对于某些类型的 FSR,容易推算出它们的周期,但对于其他类型几乎不可能做到这一点。
图 5-4 展示了这个周期的结构,以及该 FSR 的其他周期,每个周期都是一个圆圈,圆圈中的点代表寄存器的一个状态。

图 5-4: FSR 的周期,其中反馈函数将 4 个位进行异或运算
实际上,这个特定的 FSR 有两个其他的周期-5 循环——{0100, 1001, 0010, 0101, 1010}和{1111, 1110, 1101, 1011, 0111}。请注意,任何给定的状态只能属于一个状态循环。在这里,我们有三个周期,每个周期包含五个状态,涵盖了 4 位寄存器的 16 个可能值中的 15 个。第 16 个可能值是 0000,正如图 5-4 所示,它是一个周期-1 循环,因为 FSR 将 0000 转换为 0000。
FSR 本质上是一个位寄存器,每次更新寄存器时都会输出一个位(寄存器最左边的位),并且有一个函数计算寄存器的新最右位。(所有其他位都会左移。)一个 FSR 的周期,从某个初始状态开始,是需要的更新次数,直到 FSR 再次进入相同的状态。如果需要N次更新才能做到这一点,那么 FSR 将一次又一次地生成相同的N个位。
线性反馈移位寄存器
线性反馈移位寄存器 (LFSRs) 是具有线性反馈函数的 FSRs——即,一个反馈函数,它是某些状态位的异或(XOR)运算,如前一节中的 4 位 FSR 示例及其反馈函数返回寄存器 4 个位的异或运算。回想一下,在密码学中,线性意味着可预测性,暗示着简单的数学结构。而且,正如你所预期的那样,正因为这种线性特性,我们可以使用线性复杂度、有限域和原始多项式等概念来分析 LFSRs——但我会跳过数学细节,只给出基本的事实。
注意
在线性代数中,我们定义一个线性变换 f 为一个满足 f(u + v) = f(u) + f(v) 的函数。如果你知道 f(u) 和 f(v),那么你就可以在不知道 u 或 v的情况下确定 f(u + v)。对于非线性函数,情况要复杂得多;你无法轻易地从* f(u) 和 f(v) 找到* f(u + v)。*
选择哪些位进行异或运算对 LFSR 的周期至关重要,从而影响其加密值。好消息是,我们知道如何选择位的位置,以确保最大周期为 2^n – 1。具体来说,我们从右至左,分别为位 1 到位n,写出多项式表达式 1 + X + X² + ... + X ^n,其中只有当第i个位置的位被用在反馈函数中的异或运算时,才包括项 X ^i。只有当该多项式是原始的,周期才是最大周期。要使多项式是原始的,它必须具备以下性质:
- 多项式必须是不可约的,这意味着它不能被因式分解——也就是说,不能写成更小的多项式的积。例如,X + X³ 不是不可约的,因为它等于(1 + X)(X + X²):

- 该多项式必须满足某些其他数学性质,这些性质无法用简单的数学概念解释,但可以通过测试轻松验证。
注意
n位 LFSR 的最大周期是 2^n – 1,而不是 2^n,因为全零状态总是自循环,永远不变。由于任何数量的零异或结果都是零,来自反馈函数的输入位将始终为零;因此,全零状态注定会保持全零。
例如,图 5-5 显示了一个 4 位 LFSR,其反馈多项式为 1 + X + X³ + X⁴,在此我们将位置 1、3 和 4 的位进行异或运算,计算出新的位,设为L[1]。然而,这个多项式不是原始的,因为它可以因式分解为(1 + X³)(1 + X)。

图 5-5:具有反馈多项式 1 + X + X3 + X4
实际上,图 5-5 中的 LFSR 的周期不是最大周期。为了证明这一点,从状态 0001 开始:

现在左移 1 位,并将新位设为 0 + 0 + 1 = 1:

再进行五次相同操作,得到以下状态值:

六次更新后的状态与初始状态相同,这表明我们处于一个周期为 6 的循环中,证明该 LFSR 的周期不是最大值 15。
现在我们来看一个具有最大周期的 LFSR,参考图 5-6 中的 LFSR。

图 5-6:一个具有反馈多项式 1 + X3 + X4,一个原始多项式,确保最大周期
该反馈多项式是一个原始多项式,描述为 1 + X³ + X⁴,你可以验证它的周期是最大值(即 15)。从初始值开始,状态按以下方式演变(从 0001 到 0010、0100、1001、0011,依此类推):

该状态遍历所有可能的值,除了 0000,并且在最终循环之前没有重复。这证明了周期是最大值,且反馈多项式是原始的。
可惜的是,使用 LFSR 作为流密码并不安全。如果n是 LFSR 的位长度,攻击者只需要n个输出位就能恢复 LFSR 的初始状态,从而确定所有之前的位并预测所有未来的位。之所以可能发生这种攻击,是因为 LFSR 是线性的,这意味着状态位之间的关系服从线性方程,解决这些方程是很简单的。你可以使用 Berlekamp–Massey 算法来解决由 LFSR 数学结构定义的方程,不仅可以找到 LFSR 的初始状态,还可以找到其反馈多项式。实际上,你甚至不需要知道 LFSR 的确切长度就能成功;你可以对所有可能的n值重复 Berlekamp–Massey 算法,直到找到正确的值。
结论是,LFSR 在密码学上较弱,因为它们是线性的。输出位和初始状态位通过简单且简短的方程关联,你可以使用高中线性代数技巧来解决这些方程。为了增强 LFSR 的安全性,我们可以加入一点非线性。
过滤 LFSR
为了减轻 LFSR 的不安全性,可以通过在返回之前将其输出位通过非线性函数进行处理来隐藏其线性特性,从而产生一个过滤 LFSR,正如图 5-7 所示。

图 5-7:一个过滤 LFSR
图 5-7 中的g函数必须是一个非线性函数——它不仅进行 XOR 操作,还结合了逻辑与或运算。例如,L[1]L[2] + L[3]L[4]是一个非线性函数(我省略了乘号,因此L[1]L[2]表示L[1] × L[2],或者使用 C 语法表示为L[1] & L[2])。
注意
你可以直接用 FSR 的位表示反馈函数,比如 L1L2 + L3L4,或者使用等效的多项式表示法 1 + XX² +* X³X⁴**。直接表示法更容易理解,但多项式表示法更适用于 FSR 属性的数学分析。除非我们关心数学属性,否则我们会坚持使用直接表示法。
滤波 LFSR 比普通 LFSR 更强,因为它们的非线性函数能阻止直接攻击。不过,像以下这样的更复杂攻击仍然能够破解系统:
代数攻击 解出从输出位推导出的非线性方程系统,其中方程中的未知数是 LFSR 状态的位。
立方体攻击 计算非线性方程的导数,将系统的次数降到 1,然后像线性系统一样高效地求解。
快速相关攻击 利用过滤函数,尽管它们是非线性的,但通常表现得像线性函数。
这里的教训,如我们在前面的例子中看到的那样,是创可贴无法修补枪伤。用稍微强一点的层来修补破损的算法并不能使整个系统变得安全。你必须从核心解决问题。
非线性 FSR
非线性 FSR(NFSR)类似于 LFSR,但它有一个非线性的反馈函数,而不是线性的。反馈函数不仅仅是按位 XOR 操作,它还可以包括按位与(AND)和或(OR)操作——这既有优点也有缺点。
非线性反馈函数的一个好处是,它们使 NFSR 在密码学上比 LFSR 更强,因为输出位依赖于初始秘密状态,并且根据指数大小的方程进行复杂的计算。LFSR 的线性函数使关系保持简单,最多有n项(N[1],N[2],...,N[n],如果N是 NFSR 的状态位)。例如,一个 4 位的 NFSR,初始秘密状态为(N[1],N[2],N[3],N[4]),反馈函数为N[1] + N[2] + N[1]N[2] + N[3]N[4],其第一个输出位为以下表达式:

第二次迭代将N[1]值替换为新的位。将第二个输出位用初始状态表示,我们得到以下方程:

这个新方程的代数次数为 3(最高次乘积的位数,这里是N[1]N[3]N[4]),而不是反馈函数的次数 2,并且它有六个项而不是四个。因此,迭代非线性函数会迅速产生无法处理的方程,因为输出的大小呈指数增长。尽管在运行 NFSR 时你永远不会计算这些方程,攻击者必须解决它们才能破解系统。
NFSR 的一个缺点是,没有高效的方法来确定 NFSR 的周期或知道其周期是否最大。对于一个 n 位的 NFSR,你需要进行接近 2^n 次试验,才能验证其周期是否最大。对于 80 位或更大位数的 NFSR,这种计算几乎是不可能的。
幸运的是,使用 NFSR 而不必担心短周期有一个技巧:你可以将 LFSR 和 NFSR 结合起来,既能确保最大周期,又能提供加密强度——这正是 Grain-128a 的工作原理。
Grain-128a
还记得在第四章中讨论的 AES 竞赛吗?流密码 Grain 是一个类似项目 eSTREAM 竞赛的产物。这个竞赛于 2008 年结束,推荐了多个流密码,其中包括四个面向硬件的密码和四个面向软件的密码。Grain 就是其中一个硬件密码,Grain-128a 是 Grain 的原始作者升级版。图 5-8 展示了 Grain-128a 的工作机制。

图 5-8:Grain-128a 的机制,带有 128 位 NFSR 和 128 位 LFSR
Grain-128a 可以说是流密码中最简单的一种,结合了一个 128 位的 LFSR、一个 128 位的 NFSR 和一个滤波函数 h。LFSR 的最大周期为 2¹²⁸ – 1,确保整个系统的周期至少为 2¹²⁸ – 1,以防 NFSR 存在潜在的短周期。同时,NFSR 和非线性滤波函数 h 增强了加密强度。
Grain-128a 采用一个 128 位的密钥和一个 96 位的 nonce。它将 128 位的密钥位复制到 NFSR 的 128 位中,将 96 位的 nonce 位复制到前 96 位 LFSR 中,剩下的 32 位用 1 填充,最后一个位填充为 0。初始化阶段会更新整个系统 256 次,然后返回第一个密钥流位。在初始化过程中,h 函数返回的位不会作为密钥流输出,而是进入 LFSR,以确保其后续状态依赖于密钥和 nonce。
Grain-128a 的 LFSR 反馈函数是

L[1]、L[2]、……、L[128] 是 LFSR 的位。这个反馈函数只从 128 位的 LFSR 中取出 6 位,但这足以得到一个原始多项式,确保最大周期。较少的位数最小化了硬件实现的成本。
这是 Grain-128a 的 NFSR 的反馈多项式(N[1],……,N[128]):

这个函数经过精心选择,以最大化其加密强度,同时最小化实现成本。它的代数度为 4,因为其项中包含最多的变量(即 N[33]N[35]N[36]N[40])。此外,g 不能通过线性函数来逼近,因为它是高度非线性的。而且,除了 g 之外,Grain-128a 还将来自 LFSR 的位与结果进行异或运算,将该结果反馈回 NFSR 的新右侧位。
过滤器函数 h 是另一个非线性函数;它从 NFSR 中提取 9 位,从 LFSR 中提取 7 位,并以一种确保良好加密属性的方式将它们组合起来。
在我写这篇文章时,Grain-128a 尚未被发现有任何已知的攻击方式,我相信它将继续保持安全。Grain-128a 被用于一些低端嵌入式系统,这些系统需要一种紧凑且快速的流密码——通常是工业专有系统——这也是为什么 Grain-128a 在开源软件社区中鲜为人知的原因。
A5/1
A5/1 是一种流密码,用于对 2G 移动通信标准中的语音通信进行加密。A5/1 标准创建于 1987 年,但直到 1990 年代末才公布,因为它是在被逆向工程后才被公开的。攻击在 2000 年代初期出现,最终 A5/1 被破解,可以对加密的通信进行实际(而非理论)解密。让我们来看一下为什么以及如何发生的。
A5/1 的机制
如图 5-9 所示,A5/1 依赖于三个 LFSR,并使用一种乍一看似乎很聪明的技巧,但实际上并不安全。

图 5-9:A5/1 加密算法
A5/1 使用 19 位、22 位和 23 位的 LFSR,其每个多项式如下:

为什么只使用 LFSR 而没有 NFSR 就能被认为是安全的呢?其中的技巧在于 A5/1 的更新机制。A5/1 的设计者并没有在每个时钟周期都更新三个 LFSR,而是增加了一个时钟规则,具体执行以下操作:
1. 检查 LFSR 1 的第 9 位、LFSR 2 的第 11 位和 LFSR 3 的第 11 位,这些被称为时钟位。这三个位中,所有位的值要么相同(1 或 0),要么有两个位的值相同。
2. 时钟更新那些时钟位与多数值相等的寄存器,值为 0 或 1。每次更新时,两个或三个 LFSR 会被时钟同步。
如果没有这个规则,A5/1 根本无法提供任何安全性,绕过这个规则足以破坏加密算法。然而,正如你将看到的,这并非易事。
注意
在 A5/1 的不规则时钟规则中,每个寄存器在每次更新时以 3/4 的概率进行时钟跳变。换句话说,至少有一个寄存器的位值与其他寄存器相同的概率为 1 - (1/2)²,其中 (1/2)**² 是另外两个寄存器的位值不同的概率。
2G 通信使用 A5/1,密钥为 64 位,nonce 为 22 位,每个新的数据帧都会改变 nonce。A5/1 的初始化机制首先将所有寄存器设置为零,然后逐位注入密钥,接着是 nonce,注入每一位后寄存器都会更新。然后,系统根据前述的不规则规则更新 100 次。
对 A5/1 的攻击恢复了系统的 64 位初始状态(19 + 22 + 23 的 LFSR 初始值),从而通过解开初始化机制,揭示了 nonce(如果它尚未已知)和密钥。这些攻击是已知明文攻击(KPA),因为部分加密数据是已知的,攻击者可以通过将密文与已知明文块进行异或运算,来确定相应的密钥流部分。
对 A5/1 的攻击主要有两种类型:
微妙攻击 利用 A5/1 的内部线性性及其简单的不规则时钟系统。
暴力攻击 仅利用 A5/1 的短密钥和帧号注入的可逆性。
让我们看看这些攻击是如何工作的。
微妙攻击
我们将研究猜测并确定微妙攻击。在这种攻击中,攻击者猜测状态的某些秘密值以确定其他值。在密码分析中,“猜测”意味着暴力破解:对于 LFSR 1 和 2 的每个可能值以及在前 11 个时钟周期中 LFSR 3 的时钟位的所有可能值,攻击通过解方程重建 LFSR 3 的位,这些方程依赖于猜测的位。当猜测正确时,攻击者就能获得 LFSR 3 的正确值。
攻击的伪代码如下:
For all 219 values of LFSR 1's initial state
For all 222 values of LFSR 2's initial state
For all 211 values of LFSR 3's clocking bit during the first 11 clocks
Reconstruct LFSR 3's initial state
Test whether guess is correct; if yes, return; else continue
相比第三章中讨论的 2⁶⁴-次尝试的暴力破解搜索,这种攻击效率如何?在最坏的情况下,当算法仅在最后一次测试时成功时,该攻击最多会进行 2¹⁹ × 2²² × 2¹¹ = 2⁵²次操作。假设前述伪代码中的最后两个操作所需的计算量与在暴力破解中测试 64 位密钥所需的计算量相当,那么这比暴力破解搜索速度快了大约 2¹²(或约 4000)倍。但这个假设正确吗?
回顾我们在第三章中关于完整攻击成本的讨论。当评估一次攻击的成本时,我们不仅需要考虑执行攻击所需的计算量,还需要考虑并行性和内存消耗。在这里,这两个问题都不成立:与任何暴力破解攻击一样,猜测并确定攻击是显著并行的(或者在N个核心上运行时,速度是N倍)且不需要比仅仅运行密码算法更多的内存。
我们的 2⁵²攻击成本估算不准确,还有另一个原因。实际上,每个 2⁵²操作(测试一个密钥候选)所需的时钟周期数是暴力破解攻击测试一个密钥所需时钟周期数的四倍。结果是,这个特定攻击的真实成本接近于 4 × 2⁵² = 2⁵⁴次操作,相比暴力破解攻击。
对 A5/1 的猜测-确定攻击可以解密加密的移动通信,但在一个专用硬件设备集群上运行时,恢复密钥需要几个小时。换句话说,它远未达到实时解密的程度。为此,我们有另一种类型的攻击。
暴力攻击
时间-内存权衡(TMTO)攻击就是对 A5/1 的暴力攻击。这种攻击不关心 A5/1 的内部结构;它只关心其状态是 64 位长。TMTO 攻击将 A5/1 视为一个黑盒,它接受一个 64 位值(状态)并输出一个 64 位值(前 64 个密钥流位)。
该攻击背后的思想是通过使用大量内存来降低暴力搜索的成本。最简单的 TMTO 类型是一种密码本攻击,其中你预先计算一个包含密钥和值对组合的 2⁶⁴个元素的表,并为每个 2⁶⁴个可能的密钥存储输出值。要使用这个预先计算的表进行攻击,只需收集 A5/1 实例的输出,然后在表中查找哪个密钥对应该输出。攻击本身很快——只需要查找一个内存中的值,但表的创建需要进行 2⁶⁴次 A5/1 计算。更糟糕的是,密码本攻击需要极其庞大的内存:2⁶⁴ × (64 + 64)比特,即 2⁶⁸字节或 256 exabyte。这相当于数十个数据中心,因此我们可以忘掉这一点。
TMTO 攻击通过在攻击的在线阶段增加计算量来减少密码本攻击的内存要求。表越小,破解密钥所需的计算量就越大。无论如何,准备表格的成本大约是 2⁶⁴次操作,但这只需要做一次。
在 2010 年,研究人员花费了约两个月的时间,通过使用图形处理单元(GPU)并并行运行 100,000 个 A5/1 实例,生成了 2TB 的表格。在这些庞大的表格的帮助下,用 A5/1 加密的电话通话几乎可以实时解密。电信运营商已采取了规避措施来减轻这一攻击,但真正的解决方案是在 3G 和 4G 移动通信标准中出现的,这些标准彻底放弃了 A5/1。
面向软件的流密码-导向流密码
软件流密码使用字节或 32 位、64 位字而不是单独的比特,这在现代 CPU 上更为高效,因为指令在相同时间内可以对字进行算术运算,效果与对比特的运算一样。软件流密码因此比硬件密码更适合用于服务器或浏览器等个人计算机上的应用,后者通过强大的通用处理器将密码作为原生软件运行。
今天,软件流密码受到了相当大的关注,原因有几个。首先,由于许多设备嵌入了强大的 CPU 且硬件变得更加便宜,因此对小型按位操作的密码需求减少了。例如,4G 移动通信标准中的两个流密码(欧洲的 SNOW3G 和中国的 ZUC)使用 32 位字而非比特,区别于较旧的 A5/1。
其次,流密码在软件中的流行度已超过了分组密码,尤其是在针对 CBC 模式分组密码的填充 oracle 攻击事件后。除此之外,流密码比分组密码更易于指定和实现:它们只是将密钥比特作为秘密输入,而不是将消息和密钥比特混合在一起。事实上,最流行的流密码之一实际上是伪装的分组密码:计数模式下的 AES(CTR)。
一种流行的软件流密码设计被 SNOW3G 和 ZUC 使用,它复制了硬件密码及其 FSR,替换了比特为字节或字。但这些并不是对密码学家最有趣的设计。截至本文撰写时,最受关注的两种设计是 RC4 和 Salsa20,尽管其中一种已经完全被攻破,但它们仍被广泛应用于多个系统中。
RC4
RC4 由 RSA 安全公司的 Ron Rivest 于 1987 年设计,并在 1994 年被逆向工程并泄露,长期以来一直是最广泛使用的流密码。RC4 被应用于无数场合,其中最著名的是第一个 Wi-Fi 加密标准 Wired Equivalent Privacy (WEP) 和用于建立 HTTPS 连接的传输层安全协议(TLS)。不幸的是,RC4 对于大多数应用而言并不够安全,包括 WEP 和 TLS。为了理解原因,我们来看看 RC4 的工作原理。
RC4 工作原理
RC4 是有史以来最简单的密码之一。它不执行任何类似加密的操作,也没有 XOR、乘法、S-box 等... 什么都没有。它仅仅是交换字节。RC4 的内部状态是一个包含 256 个字节的数组 S,最初设置为 S[0] = 0, S[1] = 1, S[2] = 2, ... , S[255] = 255,然后通过其 密钥调度算法(KSA) 从一个 n 字节的 K 中初始化,算法的实现如同在 Listing 5-1 中的 Python 代码所示。
j = 0
# Set S to the array S[0] = 0, S[1] = 1, . . . , S[255] = 255.
S = range(256)
# Iterate over i from 0 to 255.
for i in range(256):
# Compute the sum of v.
j = (j + S[i] + K[i % n]) % 256
# Swap S[i] and S[j].
S[i], S[j] = S[j], S[i]
Listing 5-1: RC4 的密钥调度算法
一旦这个算法完成,数组S仍然包含从 0 到 255 的所有字节值,但现在是随机排列的。例如,使用全零 128 位密钥时,状态S(从S[0]到S[255])变为:

然而,如果我翻转第一个密钥位并重新运行 KSA,我会得到一个完全不同、显然是随机的状态:

给定初始状态S,RC4 生成一个与明文P长度相同的密钥流KS,用来计算密文:C = P ⊕ KS。密钥流KS的字节是根据清单 5-2 中的 Python 代码,从S计算得出的,前提是P是m字节长。
i = 0
j = 0
for b in range(m):
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
KS[b] = S[(S[i] + S[j]) % 256]
清单 5-2:RC4 的密钥流生成,其中 S 是清单 5-1 中初始化的状态
在清单 5-2 中,每次迭代的for循环最多会修改 RC4 内部状态S的 2 个字节:交换值的S[i]和S[j]。也就是说,如果i = 0 且j = 4,并且如果S[0] = 56 且S[4] = 78,那么交换操作将S[0]设为 78,S[4]设为 56。如果j等于i,则S[i]不被修改。
这看起来太简单,难以保证安全,然而加密分析师花了 20 年才发现可被利用的漏洞。在这些漏洞被揭示之前,我们仅知道 RC4 在特定实现中的弱点,例如在第一个 Wi-Fi 加密标准 WEP 中。
WEP 中的 RC4
WEP,第一代 Wi-Fi 安全协议,由于协议设计和 RC4 的弱点,现在已经完全被破解。
在其 WEP 实现中,RC4 加密 802.11 帧的有效载荷数据,这些数据报(或数据包)在无线网络中传输数据。所有在同一会话中传输的有效载荷都使用相同的 40 位或 104 位秘密密钥,但在帧头中有一个假定为唯一的 3 字节随机数(它编码元数据,并位于实际有效载荷之前)。
问题在于 RC4 不支持随机数(nonce),至少在它的官方规范中没有支持,而没有随机数就无法使用流密码。WEP 的设计者通过一种变通方法解决了这一限制:他们在无线帧的头部包含了一个 24 位的随机数,并将其添加到 WEP 密钥前面,作为 RC4 的密钥使用。也就是说,如果随机数是字节N[0],N[1],N[2],而 WEP 密钥是K[0],K[1],K[2],K[3],K[4],那么实际的 RC4 密钥就是N[0],N[1],N[2],K[0],K[1],K[2],K[3],K[4]。最终效果是,40 位的密钥变成了 64 位的有效密钥,104 位的密钥变成了 128 位的有效密钥。结果是什么呢?广告中的 128 位 WEP 协议实际上最多只能提供 104 位的安全性。
但这里是 WEP 的 nonce 技巧的真正问题:
nonce 太小,只有 24 位 这意味着,如果为每个新消息随机选择一个 nonce,你必须等待大约 2^(24/2) = 2¹² 个数据包,或者几兆字节的数据流量,直到你能找到两个使用相同 nonce 和相同密钥流加密的数据包。即使 nonce 是一个从 0 到 2²⁴-1 的计数器,在发生溢出之前,也需要几千兆字节的数据量,才能让攻击者通过重复的 nonce 解密数据包。但问题更大。
以这种方式组合 nonce 和密钥有助于恢复密钥 WEP 的三个非保密 nonce 字节使攻击者能够在密钥调度算法运行三次后确定 S 的值。因为这个原因,密码分析师发现第一个密钥流字节强烈依赖于第一个秘密密钥字节——KSA 使用的第四个字节——这种偏差可以被利用来恢复秘密密钥。
利用这些弱点需要访问密文和密钥流——也就是说,已知或选择的明文。但这很容易:已知明文发生在 Wi-Fi 帧封装了带有已知头部的数据时,而选择的明文发生在攻击者注入用目标密钥加密的已知明文时。结果是,攻击在实际中有效,而不仅仅是在理论上。
在 2001 年首次出现 WEP 攻击后,研究人员发现了一些更快速的攻击方法,所需的密文更少。如今,你甚至可以找到像 aircrack-ng 这样的工具,实施整个攻击,从网络嗅探到密码分析。
WEP 的不安全性既来源于 RC4 的弱点,RC4 采用了单次使用的密钥,而不是像任何合适的流密码那样使用密钥和 nonce(一次性随机数),也来源于 WEP 设计本身的缺陷。
现在让我们来看 RC4 的第二大失败。
TLS 中的 RC4
TLS 是互联网中使用的最重要的安全协议。它以支撑 HTTPS 连接而广为人知,但也被用于保护一些虚拟专用网络(VPN)连接、电子邮件服务器、移动应用程序等。遗憾的是,TLS 长期以来一直支持 RC4。
与 WEP 不同,TLS 实现没有犯同样明显的错误,即调整 RC4 规格来使用公共 nonce。相反,TLS 只给 RC4 提供了一个独特的 128 位会话密钥,这意味着它比 WEP 稍微不那么容易破解。
TLS 中的弱点仅仅源于 RC4 及其不可原谅的缺陷:统计偏差,或称非随机性,这一点我们知道对于流密码来说是完全不可接受的。例如,RC4 生成的第二个密钥流字节是零的概率为 1/128,而理想情况下它的概率应该是 1/256(回忆一下,一个字节可以取 0 到 255 之间的 256 个值;因此,真正随机的字节零出现的概率应该是 1/256)。更离奇的是,大多数专家直到 2013 年依然相信 RC4,尽管其统计偏差自 2001 年以来就已为人所知。
RC4 已知的统计偏差本应足以让我们彻底放弃这种密码算法,即使我们不知道如何利用这些偏差来危及实际应用。在 TLS 中,RC4 的缺陷直到 2011 年才被公开利用,但据称 NSA 在此之前就已经利用 RC4 的弱点来攻破 TLS 的 RC4 连接。
结果表明,不仅 RC4 的第二个密钥流字节有偏差,前 256 个字节也都有偏差。2011 年,研究人员发现其中一个字节为零的概率等于 1/256 + c/256²,其中常数c的值介于 0.24 和 1.34 之间。不仅仅是字节零,其他字节值也存在类似的偏差。RC4 令人惊讶之处在于,它在很多非加密伪随机数生成器都能成功的地方失败了——也就是说,它无法生成均匀分布的伪随机字节(即每个 256 个字节中,每个字节都有 1/256 的概率出现)。
RC4 在 TLS 中的缺陷甚至可以在最弱的攻击模型——选择密文攻击中被利用:你收集密文并寻找明文,而不是密钥。但有一个警告:你需要许多密文,并使用不同的密钥多次加密相同的明文。我们有时将这种攻击模型称为广播模型,因为它类似于将相同的消息广播给多个接收者。
假设你想要解密明文 P 的明文字节P[1],并且通过拦截同一消息的不同密文,获取了多个密文字节。这样,你将得到四个密文的第一个字节C¹、C²、C³、C⁴,以及四个密钥流KS¹、KS²、KS³、KS⁴,使得:

由于 RC4 的偏差,密钥流字节KS[1]i*(四个实例中的第一个字节)更可能是零,而不是其他任何字节值。因此,*C*[1]*i字节更可能等于P[1],而不是任何其他值。为了确定P[1],给定C[1]^i字节,只需计算每个字节值出现的次数,并返回出现频率最高的字节作为P[1]。然而,由于统计偏差非常小,你需要数百万个值才能以一定的准确性得到正确的结果。
攻击概括为恢复多个明文字节并利用多个偏差值(这里为零)。算法变得稍微复杂了一些。但是,这种攻击很难实施,因为它需要收集许多加密同一明文但使用不同密钥的密文。例如,该攻击无法破解使用 RC4 的所有 TLS 保护连接,因为您需要欺骗服务器将相同明文加密给许多不同的接收者,或者多次使用不同密钥加密给同一接收者。
Salsa20
Salsa20 是一种简单的、面向软件的密码算法,专为现代 CPU 优化,已在许多协议和库中实现,以及其变体 ChaCha。其设计者,受人尊敬的密码学家 Daniel J. Bernstein,于 2005 年将 Salsa20 提交给 eSTREAM 竞赛,并在 eSTREAM 的软件组合中赢得了一席之地。Salsa20 的简单性和速度使其在开发者中非常流行。
Salsa20 是基于计数器的流密码 —— 它通过重复处理每个块的计数器来生成密钥流。如图 5-10 所示,Salsa20 核心 算法使用密钥(K)、一次性数(N)和计数器值(Ctr)来转换一个 512 位块。然后 Salsa20 将结果添加到块的原始值中以生成 密钥流块。(如果算法直接将核心的排列作为输出返回,Salsa20 将是完全不安全的,因为它可以被反向操作。最终加上初始秘密状态 K || N || Ctr 使得密钥到密钥流块的转换是不可逆的。)

图 5-10:Salsa20 用于 512 位明文块的加密方案
使用 Quarter-Round 函数
Salsa20 的核心置换使用称为 quarter-round(QR)的函数,它将四个 32 位字(a、b、c 和 d)转换如下:

我们按自上而下的顺序计算这四行,意味着 b 的新值取决于 a 和 d,c 的新值取决于 a 和 b 的新值(因此也取决于 d),依此类推。
<<< 操作是按指定位数进行逐字左旋转,可以是 1 到 31 之间的任何值(对于 32 位字)。例如,<<< 8 将一个字的位向左旋转八个位置,如下例所示:

转换 Salsa20 的 512 位状态
Salsa20 的核心置换将 512 位的内部状态转换为一个 4×4 的 32 位字数组。图 5-11 展示了初始状态,使用了一个由八个字(256 位)组成的密钥、两个字(64 位)的随机数、两个字(64 位)的计数器以及四个固定常数字(128 位),这些常数对于每次加密/解密和所有块都是相同的。

图 5-11:Salsa20 状态的初始化
为了转换初始的 512 位状态,Salsa20 首先独立地对四列应用 QR 变换(称为 列轮),然后独立地对四行应用变换(行轮),正如 图 5-12 所示。列轮/行轮序列就是 双轮。Salsa20 重复进行 10 次双轮,总共 20 轮,这就是 Salsa20 中 20 的由来。

图 5-12:通过 Salsa20 的四分之一轮(QR)函数转换的列和行
列轮通过以下方式转换四列:

行轮通过以下方式转换行:

在列轮中,每个 QR 以从上到下的顺序接受 xi 参数,而行轮的 QR 以对角线上的词作为第一个参数(如 图 5-12 右侧数组所示),而不是来自第一列的词。
评估 Salsa20
清单 5-3 展示了当使用全零密钥(00 字节)和全一随机数(ff 字节)初始化时,Salsa20 第一个和第二个块的初始状态。这两个状态仅在计数器中有 1 位的差异,计数器部分用粗体显示:具体来说,第一个块为 0,第二个块为 1。
61707865 00000000 00000000 00000000 61707865 00000000 00000000 00000000
00000000 3320646e ffffffff ffffffff 00000000 3320646e ffffffff ffffffff
**00000000** 00000000 79622d32 00000000 **00000001** 00000000 79622d32 00000000
00000000 00000000 00000000 6b206574 00000000 00000000 00000000 6b206574
清单 5-3:使用全零密钥和全一随机数时 Salsa20 第一个和第二个块的初始状态
然而,尽管只有 1 位的差异,经过 10 次双轮变换后的内部状态彼此完全不同,正如 清单 5-4 所示。
e98680bc f730ba7a 38663ce0 5f376d93 1ba4d492 c14270c3 9fb05306 ff808c64
85683b75 a56ca873 26501592 64144b6d b49a4100 f5d8fbbd 614234a0 e20663d1
6dcb46fd 58178f93 8cf54cfe cfdc27d7 12e1e116 6a61bc8f 86f01bcb 2efead4a
68bbe09e 17b403a1 38aa1f27 54323fe0 77775a13 d17b99d5 eb773f5b 2c3a5e7d
清单 5-4:经过 10 次 Salsa20 双轮变换后的 清单 5-3 状态
但请记住,即使密钥流块中的字值看起来随机,这也远不能保证安全性。RC4 的输出看起来是随机的,但它有明显的偏差。幸运的是,Salsa20 比 RC4 更加安全,并且没有统计偏差。然而,请记住,即使密钥流在统计上与完全随机字节无法区分,这也不足以实现密码学安全。##### 学习差分密码分析
为了证明 Salsa20 比 RC4 更安全,让我们来看看差分密码分析的基础知识,它研究的是状态之间的差异,而不是它们的实际值。例如,Listing 5-3 中的两个初始状态在计数器中的第 1 位或 Salsa20 状态数组中的词 x[8] 有 1 位的不同。下表显示了这两个状态之间的按位差异:

这两个状态之间的差异实际上是这两个状态的 XOR 结果。加粗的 1 位对应于这两个状态之间的 1 位差异。在这两个状态的 XOR 结果中,任何非零位都表示差异。
为了查看 Salsa20 的核心算法在初始状态下如何迅速传播变化,让我们观察两种状态在多轮迭代过程中的差异。在一轮之后,差异会传播到第一列中的两个其他单词:

在两轮之后,差异进一步传播到已经包含差异的行,除了第二行。此时状态之间的差异较为稀疏;在一个字中的比特变化不多:

在三轮之后,状态之间的差异变得更加密集,尽管许多零位显示出许多比特位置仍然未受初始差异的影响:

在四轮之后,差异对于人类观察者看起来是随机的,从统计学角度来看,它们几乎也是随机的:

仅仅经过四轮,一次差异就会传播到 512 位状态的大部分位。在密码学中,我们称之为完全扩散。
差异不仅会传播到所有状态,而且传播的方式符合复杂的方程式,使得未来的差异难以预测,因为高度的非线性关系驱动了状态的演变,这得益于异或(XOR)、加法和旋转的混合。如果我们只使用 XOR,虽然差异依然会传播,但过程将是线性的,因此不安全。
攻击 Salsa20/8
Salsa20 默认执行 20 轮,但我们有时使用仅 12 轮的版本,称为 Salsa20/12,以提高速度。尽管 Salsa20/12 比 Salsa20 少了八轮,但根据最新的研究进展,实际上它与 20 轮版本一样可靠。即使是 Salsa20/8,只有八轮,也被认为只是理论上较弱,但在实践中与 Salsa20 一样稳固。
理论上,打破 Salsa20 应该需要 2²⁵⁶次操作,因为它使用了 256 位密钥。如果能够通过少于 2²⁵⁶次的操作恢复密钥,则该加密算法在理论上是破解的。Salsa20/8 恰好就是这种情况。
对 Salsa20/8 的攻击(发布于 2008 年的论文《拉丁舞的新特性:Salsa、ChaCha 和 Rumba 的分析》,我作为共同作者之一,并因此获得了 Daniel J. Bernstein 的密码分析奖)利用了 Salsa 核心算法在四轮后的统计偏差,从而恢复了八轮 Salsa20 的密钥。实际上,这大多是一个理论攻击:我们估计其复杂度为 2²⁵¹次核心函数操作——这是不可行的,就像计算 2¹⁰⁰次操作或更多一样不现实,但比打破预期的 2²⁵⁶复杂度要容易一些。
该攻击不仅利用了 Salsa20/8 前四轮的偏差,还利用了最后四轮的一个特性:已知 nonce,N,和计数器,Ctr(参见图 5-10),要将密钥流逆向计算回初始状态所需的唯一值就是密钥,K。但是正如图 5-13 所示,如果你仅知道部分K,你可以将计算逆转到第四轮,并观察到一些中间状态的位——包括偏差位!你只有在正确猜出部分密钥时,才会观察到偏差;因此,偏差就作为一个指示,表明你已经找到了正确的密钥。

图 5-13:Salsa20/8 攻击原理
在实际的 Salsa20/8 攻击中,为了确定正确的猜测,我们需要猜测密钥的 220 个位,并且需要 2³¹对具有相同特定 nonce 差异的密钥流块。一旦筛选出正确的 220 个位,我们就能通过暴力破解 36 个位。暴力破解需要 2³⁶次操作,这一计算量比 2²²⁰ × 2³¹ = 2²⁵¹次试验还要小,而后者是找到 220 个位以完成攻击第一部分所需的次数。
可能出错的地方
可惜,流密码中可能会出现许多问题,从脆弱、不安全的设计到实现不当的强加密算法。我将在接下来的章节中探讨每一类潜在问题。
Nonce 重用
流密码中最常见的失败发生在使用相同的密钥多次重用 nonce 时。这会产生相同的密钥流,从而使加密被破解——例如,通过将两个密文进行异或操作;然后,密钥流消失,你将得到两个明文的异或结果。
一个真实的例子是早期版本的 Microsoft Word 和 Excel,它们为每个文档使用了唯一的 nonce,但修改文档并不会改变 nonce。因此,人们可以使用旧版本文档的明文和加密文本来解密后来加密的版本。如果微软犯下这个错误,你可以想象问题会有多大。
某些在 2010 年代设计的流密码尝试通过构建“抗滥用”构造来减轻 nonce 重用的风险,或者即使 nonce 被使用两次也能保持安全的密码。然而,达到这一安全级别会带来性能上的惩罚,正如你在 第八章 中看到的 SIV 模式。
损坏的 RC4 实现
尽管 RC4 本身非常脆弱,但如果你盲目优化其实现,它可能会变得更脆弱。例如,考虑一下 2007 年的 Underhanded C 竞赛中的一个参赛作品,这是一个非正式的竞赛,程序员编写看似无害的代码,实际上却包含恶意功能。
其工作原理如下。实现 RC4 算法中行 swap(S[i], S[j]) 的一种简单方法是执行以下操作,如 Python 代码所示:
buf = S[i]
S[i] = S[j]
S[j] = buf
这种交换两个变量的方法是可行的,但你需要创建一个新的变量 buf。为了避免这种情况,程序员通常使用以下 XOR-交换 技巧来交换变量 x 和 y 的值:
x = x ⊕ y
y = x ⊕ y
x = x ⊕ y
这个方法之所以有效,是因为第二行将 y 设置为 x ⊕ y ⊕ y = x,第三行将 x 设置为 x ⊕ y ⊕ x ⊕ y ⊕ y = y。使用这个技巧来实现 RC4 会得到 清单 5-5 中的实现(改编自 David Wagner 和 Philippe Biondi 提交到 2007 年 Underhanded C 竞赛的程序,并在线访问于 <wbr>www<wbr>.underhanded<wbr>-c<wbr>.org<wbr>/<wbr>_page<wbr>_id<wbr>_16<wbr>.html)。
#define TOBYTE(x) (x) & 255
#define SWAP(x,y) do {x^=y; y^=x; x^=y;} while (0)
static unsigned char S[256];
static int i=0, j=0;
void init(char *passphrase) {
int passlen = strlen(passphrase);
for (i=0; i<256; i++)
S[i] = i;
for (i=0; i<256; i++) {
j = TOBYTE(j + S[TOBYTE(i)] + passphrase[j % passlen]);
SWAP(S[TOBYTE(i)], S[j]);
}
i = 0; j = 0;
}
unsigned char encrypt_one_byte(unsigned char c) {
int k;
i = TOBYTE(i+1);
j = TOBYTE(j + S[i]);
SWAP(S[i], S[j]);
k = TOBYTE(S[i] + S[j]);
return c ^ S[k];
}
列表 5-5:由于使用了异或交换,RC4 的 C 语言实现不正确
你能发现异或交换的问题吗?
当 i = j 时,情况就变得糟糕。与其保持状态不变,异或交换会将 S[i] 设置为 S[i] ⊕ S[i] = 0。实际上,每当 i 等于 j 时,状态中的一个字节就会被置为零,无论是在密钥调度还是加密过程中,最终会导致一个全零的状态,从而生成一个全零的密钥流。例如,在处理 68KB 数据后,256 字节状态中的大多数字节都变为零,输出的密钥流如下所示:

这里的教训是,不要过度优化你的加密实现。在密码学中,清晰和信心始终优于性能。
嵌入硬件的弱密码
当一个加密系统无法保持安全时,有些系统会迅速通过远程静默更新受影响的软件(如网页应用程序)或通过发布新版本并提示用户升级(如移动应用程序)来响应。其他系统则没那么幸运,需要坚持使用被破坏的加密系统一段时间,才能升级到安全版本,某些卫星电话就是这种情况。
在 2000 年代初期,美国和欧洲的电信标准化机构(TIA 和 ETSI)共同开发了两项卫星电话通信标准。卫星电话类似于移动电话,区别在于它们的信号通过卫星传输,而非地面基站。其优点是,只要有卫星覆盖,几乎可以在全球任何地方使用。缺点是价格、质量、延迟,以及,事实证明,安全性。
GMR-1 和 GMR-2 是大多数商业供应商(如 Thuraya 和 Inmarsat)采用的两种卫星电话标准。它们都包含流密码用于加密语音通信。GMR-1 的密码设计偏向硬件,结合了四个 LFSR,类似于 A5/2——一种故意不安全的密码,用于面向非西方国家的 2G 移动标准。GMR-2 的密码设计偏向软件,使用了 8 字节的状态和 S 盒。两种流密码都不安全,只能防御业余攻击者,无法抵御国家级机构的攻击。
这个故事提醒我们,流密码曾经比块密码更容易被破解,而且更容易被破坏。为什么?因为如果你故意设计一个弱的流密码,当你发现漏洞时,你依然可以把问题归咎于流密码本身的弱点,并否认任何恶意意图。
进一步阅读
要了解更多关于流密码的信息,可以从 eSTREAM 竞赛的档案开始,访问 <wbr>www<wbr>.ecrypt<wbr>.eu<wbr>.org<wbr>/stream<wbr>/project<wbr>.html,在这里你可以找到数百篇关于流密码的论文,其中包括 30 多个候选算法的详细信息以及许多攻击。最有趣的攻击之一是相关性攻击、代数攻击和立方体攻击。特别参考 Nicolas Courtois 和 Willi Meier 的研究,了解前两种攻击类型,以及 Itai Dinur 和 Adi Shamir 的立方体攻击研究。
要了解更多关于 RC4 攻击的信息,可以查阅 2001 年 Scott Fluhrer、Itsik Mantin 和 Adi Shamir(FMS)提出的攻击,以及 2013 年关于“RC4 在 TLS 中的安全性”的研究文章。
Salsa20 的遗产同样值得关注。流密码 ChaCha 与 Salsa20 相似,但其核心置换略有不同,后来这一置换被用于哈希函数 BLAKE,正如你在第六章中看到的那样。这些算法都利用了 Salsa20 的软件实现技术,采用并行指令,正如在 <wbr>cr<wbr>.yp<wbr>.to<wbr>/snuffle<wbr>.html 中所讨论的那样。
第七章:6 哈希函数

哈希函数——如 SHA-256、SHA3 和 BLAKE3——组成了密码学家的瑞士军刀:它们被用于数字签名、公钥加密、完整性验证、消息认证、密码保护、密钥协议以及许多其他加密协议。
无论你是在加密电子邮件、在手机上发送信息、连接到 HTTPS 网站,还是通过虚拟私人网络(VPN)或安全外壳(SSH)连接到远程机器,哈希函数都会在幕后发挥作用。
哈希函数是迄今为止最通用和最普遍的所有加密算法。它们的应用包括:云存储系统使用它们来识别相同的文件并检测修改过的文件;Git 版本控制系统使用它们来识别仓库中的文件;终端检测和响应(EDR)系统使用它们来检测修改过的文件;基于网络的入侵检测系统(NIDS)使用哈希值来检测通过网络的已知恶意数据;法医分析师使用哈希值来证明数字化文物未被修改;比特币在其工作量证明系统中使用哈希函数——还有更多应用。
与流密码不同,流密码从短输入生成长输出,哈希函数则从长输入生成短输出,称为 哈希值 或 摘要(见图 6-1)。

图 6-1:哈希函数的输入和输出
本章围绕两个主要话题展开。首先是安全性:一个哈希函数安全意味着什么?为此,我介绍了两个关键概念——碰撞抗性和原像抗性。第二个话题则围绕哈希函数的构建展开。我解释了现代哈希函数使用的高级技术,并回顾了最常见的哈希函数的内部结构:SHA-1、SHA-2、SHA-3 和 BLAKE2。最后,你将看到如果误用,安全哈希函数也可能表现得不安全。
注意
不要将加密哈希函数与 非加密 哈希函数混淆。你在数据结构中使用非加密哈希函数,如哈希表,或用于检测偶然的错误,它们根本不提供任何安全性。例如,循环冗余检验(CRC)是非加密哈希,你用它来检测文件的意外修改。
安全哈希函数
哈希函数的安全性概念与我们迄今为止讨论的有所不同。加密算法通过保护数据的机密性,确保明文数据无法被读取,而哈希函数则通过保护数据的完整性,确保数据——无论是明文还是加密——未被篡改。如果哈希函数是安全的,那么两组不同的数据应该始终具有不同的哈希值。因此,文件的哈希值可以作为其唯一标识符。
考虑哈希函数最常见的应用:数字签名,或简称签名。在使用数字签名时,应用程序处理的是要签名消息的哈希值,而不是消息本身,正如图 6-2 所示。

图 6-2:数字签名方案中的哈希函数,其中哈希值充当消息的代理
哈希值充当消息的标识符。如果消息中哪怕只有一个位被改变,消息的哈希值将完全不同。因此,哈希函数有助于确保消息没有被篡改。签名消息的哈希值与签名消息本身同样安全,而签名一个较短的哈希值,比如 256 位,比签名一个可能非常大的消息要快得多。事实上,大多数签名算法仅在像哈希值这样的短输入上工作。
不可预测性 再一次
哈希函数的密码学强度来源于其输出的不可预测性。以下是 256 位的十六进制值;你可以使用 NIST 标准哈希函数 SHA-256,输入 ASCII 字母a、b和c来计算这些哈希。尽管a、b和c的差异仅为 1 或 2 个位(a是位序列 01100001,b是 01100010,c是 01100011),但它们的哈希值却完全不同:

仅凭这三个哈希值,无法预测d的 SHA-256 哈希值或其任何位,因为安全哈希函数的哈希值是不可预测的。一个安全的哈希函数应该像一个黑箱,每次接收到输入时返回一个随机字符串。
安全哈希函数的通用理论定义是,它的行为类似于一个真正的随机函数(有时称为随机神谕)。具体来说,一个安全哈希函数不应该具有任何随机函数不具备的属性或模式。这个定义对理论工作者有帮助,但在实际应用中,我们需要更具体的概念,即预影像抗性和碰撞抗性。
预影像抗性
一个给定哈希值H的预影像是任何一个消息M,使得 Hash(M) = H。预影像抗性描述了这样的安全保证:给定一个随机的哈希值,攻击者永远无法找到该哈希值的预影像。实际上,你有时可以将哈希函数称为单向函数,因为你可以从消息得到它的哈希值,但不能反过来。
事实上,即使拥有无限的计算能力,你也无法反转哈希函数。例如,假设我使用 SHA-256 哈希函数对某个消息进行哈希,得到这个 256 位的哈希值:

即使拥有无限的时间和计算能力,你也永远无法确定我选择的那个消息来生成这个特定的哈希值,因为有很多消息会哈希到相同的值。因此,你会找到一些消息,它们会生成这个哈希值(可能包括我选择的那个),但你无法确定我用的具体消息。你将获得无条件的安全性。
例如,一个 256 位的哈希值有 2²⁵⁶ 种可能的值(这是实际使用的哈希函数的典型长度),但比如说,1,024 位消息有更多的值(即,2^(1,024) 种可能的值)。因此,可以推导出,平均而言,每个可能的 256 位哈希值会有 2^(1,024) / 2²⁵⁶ = 2^(1,024 – 256) = 2⁷⁶⁸ 个 1,024 位的预影像。
实际上,你必须确保几乎不可能找到任何与给定哈希值对应的消息,而不仅仅是被用来生成该哈希值的消息,这正是预影像抗性所代表的含义。具体来说,你可以讨论第一预影像抗性和第二预影像抗性。第一预影像抗性(或称预影像抗性)描述了在这种情况下,几乎不可能找到与给定哈希值对应的消息。另一方面,第二预影像抗性描述的是,给定一个消息M[1],几乎不可能找到另一个消息M[2],使得M[1] 和 M[2] 的哈希值相同。
预影像的代价
给定一个哈希函数和一个哈希值,你可以通过尝试不同的消息来搜索第一个预影像,直到找到与目标哈希匹配的那个。列表 6-1 显示了如何使用类似于 solve_preimage() 的算法来实现这一点。
solve_preimage(H) {
repeat {
M = random_message()
if Hash(M) == H then return M
}
}
清单 6-1:安全哈希函数的最佳逆像搜索算法
在这里,random_message()生成一个随机消息(比如,一个随机的 1024 位值)。如果哈希的位长n足够大,solve_preimage()几乎永远不会完成,因为它在找到逆像之前,平均需要 2^n次尝试。这在处理n = 256 时是个无望的局面,像现代哈希函数 SHA-256 和 BLAKE2 就是如此。 ##### 为什么第二个逆像抵抗力较弱
如果你能够找到第一个逆像,那么你也能找到第二个逆像(对于同一个哈希函数)。作为证明,如果算法solve_preimage()返回给定哈希值的逆像,使用清单 6-2 中的算法找到某个消息M的第二个逆像。
solve_second_preimage(M) {
H = Hash(M)
return solve_preimage(H)
}
清单 6-2:如果能够找到第一个逆像,如何找到第二个逆像
你将通过将第二个逆像问题视为一个逆像问题并应用逆像攻击来找到第二个逆像。因此,任何抗第二个逆像的哈希函数也是抗逆像的。(如果不是这样,它也不会对第二个逆像具有抵抗力,参见前面的solve_second_preimage()算法。)换句话说,找到第二个逆像的最佳攻击几乎与找到第一个逆像的最佳攻击相同,除非哈希函数存在某些缺陷,允许更高效的攻击。另请注意,逆像搜索攻击本质上类似于对分组密码或流密码的密钥恢复攻击,不同之处在于加密情况下有一个已知大小的确切解。
抗碰撞性
无论你选择哪种哈希函数,碰撞是不可避免的,因为抽屉原理指出,如果你有m个洞和n只鸽子要放进这些洞中,并且n大于m,那么至少有一个洞必须包含超过一只鸽子。
注意
你可以将抽屉原理推广到其他物品和容器。例如,美国宪法中的任何 27 个单词序列至少包含两个以相同字母开头的单词。在哈希函数的世界里,洞是哈希值,而鸽子是消息。因为你知道可能的消息数量远大于哈希值的数量,所以碰撞必定会存在。
然而,尽管碰撞是不可避免的,但要考虑一个哈希函数是抗碰撞的,碰撞应该很难找到——换句话说,攻击者不应该能够找到两个不同的消息,它们的哈希值相同。
碰撞抗性与第二预像抗性有关:如果你能为一个哈希函数找到第二预像,你也能找到碰撞,正如列表 6-3 所示。
solve_collision() {
M = random_message()
return (M, solve_second_preimage(M))
}
列表 6-3:天真碰撞搜索算法
也就是说,任何碰撞抗性的哈希也是第二预像抗性的。如果不是这样,就会有一个高效的解第二预像算法可以用来破解碰撞抗性。
如何寻找碰撞
寻找碰撞比寻找预像更快:它大约需要 2n*(/2) 次操作,而不是 2^n,这是由于生日攻击,其核心思想如下:给定 N 个消息和同样数量的哈希值,你可以通过考虑每一对哈希值(数量级大约为 N²)来产生 N × (N* – 1) / 2 个潜在的碰撞。之所以叫做生日攻击,是因为它通常用生日悖论来说明,生日悖论指出一组 23 个人中,恰好有两个人生日相同的概率接近 1/2——这并不是悖论,只是对许多人来说是一个惊讶。
注意
N × (N – 1) / 2 是两个不同消息的对数,你除以 2 是因为你将 (M1, M2) 和 (M2, M1) 视为同一对,顺序不重要。
作比较,在预像搜索的情况下,N 个消息仅能得到 N 个候选预像,而相同的 N 个消息大约会得到 N² 个潜在的碰撞。通过 N² 代替 N,你可以说找到解决方案的机会是二次增多的。搜索的复杂度相应降低:要找到一个碰撞,使用 2n*(/2) 条消息,而不是 2^n*。
天真生日攻击
下面是使用生日攻击寻找碰撞的最简单方法:
1. 计算 2n*(/2) 个消息的 2n*(/2) 哈希值,并将所有消息/哈希对存储在一个列表中。
2. 按哈希值对列表进行排序,将相同的哈希值放在一起。
3. 搜索排序后的列表,找到两个连续的条目,它们具有相同的哈希值。
不幸的是,这种方法需要大量内存(足以存储 2n*(/2) 个消息/哈希对),而排序大量元素会减慢搜索速度,平均需要大约 n2^(n/2)* 次操作,使用快速排序算法如快速排序。
低内存碰撞搜索与 Rho 方法
Rho 方法是一种寻找碰撞的算法,不同于天真生日攻击,它只需要少量内存。它的工作原理如下:
-
给定一个具有n位哈希值的哈希函数,选择一个随机的哈希值(H[1]),并定义 H[1] = H ′[1]。
-
计算 H[2] = Hash(H[1]) 和 H ′[2] = Hash(Hash(H ′[1]))。在第一种情况下应用哈希函数一次,而在第二种情况下应用哈希函数两次。
-
迭代该过程并计算 Hi [+ 1] = Hash(Hi), H ′i [+ 1] = Hash(Hash (H ′i)),对i值递增,直到达到 i 使得 Hi [+ 1] = H ′i [+ 1]。
图 6-3 帮助可视化该攻击,其中从H[1]到H[2]的箭头表示 H[2] = Hash(H[1])。观察到H的序列最终进入一个循环,也叫做周期,其形状类似希腊字母 rho(ρ)。该周期从H[5]开始,且由碰撞 Hash(H[4]) = Hash(H[10]) = H[5]所特征化。这里的关键观察是,找到一个碰撞,你只需要找到这样的一个周期。Rho 方法允许攻击者检测到周期的位置,因此能够找到碰撞。

图 6-3:Rho 哈希函数的结构,其中每个箭头表示哈希函数的评估。开始于 H5 的周期对应于一个碰撞, Hash(H4) = Hash(H10) = H5。
基于 Rho 方法的先进碰撞查找技术通过先检测循环的起始点,再找到碰撞,而不需要在内存中存储大量值或排序一个长列表。成功找到碰撞大约需要 2n*(/2)次操作。实际上,图 6-3 中的哈希值要远少于一个具有 256 位或更多摘要的实际函数。平均来说,循环和尾部(从H[1]到H[5]的部分,在图 6-3 中展示)各自包括大约 2n*(/2)个哈希值,其中n是哈希值的位长度。因此,找到碰撞至少需要进行 2n*(/2) + 2n*(/2)次哈希评估。### 如何构建哈希函数
在 1980 年代,加密学者意识到,哈希消息最简单的方法是将其分割成若干块,并使用相似的算法逐块处理。这个策略即为迭代哈希,它有两种主要形式:
-
使用压缩函数进行迭代哈希,该函数将输入转换为更小的输出,正如图 6-4 所示。这种技术也叫做Merkle–Damgård结构,以加密学者拉尔夫·梅尔克尔和伊万·丹麦戈德命名,他们首次描述了这种结构。
-
使用将输入转换为相同大小输出的函数进行迭代哈希,使得任何两个不同的输入都会产生两个不同的输出(即排列)。这样的函数称为海绵函数。

图 6-4:使用名为 Compress 的压缩函数的 Merkle–Damgård 结构
我们现在将讨论这些结构如何工作以及压缩函数在实践中的表现。
基于压缩的哈希函数
从 1980 年代到 2010 年代开发的所有哈希函数都基于 Merkle–Damgård(M–D)结构:MD4、MD5、SHA-1 以及 SHA-2 系列,还有不太知名的 RIPEMD 和 Whirlpool 哈希函数。虽然 M–D 结构并不完美,但它简单且已证明对许多应用足够安全。
注意
在 MD4、MD5 和 RIPEMD 中,MD代表消息摘要,而非Merkle–Damgård。
为了对消息进行哈希,M–D 结构将消息分成相同大小的块,并使用压缩函数将这些块与内部状态混合,如图 6-4 所示。这里,H[0]是内部状态的初始值(记作 IV),H[1]、H[2]等是链值,最终的内部状态值就是消息的哈希值。
消息块通常是 512 位或 1,024 位,但原则上可以是任意大小。无论块的长度如何,它对特定的哈希函数是固定的。例如,SHA-256 使用 512 位块,SHA-512 使用 1,024 位块。
填充块
如果你想要哈希一个无法分割为完整块的消息会发生什么?例如,如果块是 512 位,那么一个 520 位的消息将包含一个 512 位块和 8 位。此时,M–D 构造会按照以下方式形成最后一个块:取出剩下的比特块(在我们的例子中为 8 位),附加 1 位,然后附加 0 位,最后附加原始消息的长度,长度以固定比特数编码。这个填充技巧保证了任何两个不同的消息都会给出不同的块序列,从而得到不同的哈希值。
例如,如果你使用 SHA-256 对 8 位字符串 10101010 进行哈希,该哈希函数使用 512 位消息块,则第一个也是唯一一个块的位表示如下:

这里,消息位是前 8 位(10101010),而填充位是其后所有位(以斜体显示)。块末尾的1000(带下划线)是消息的长度,即 8 的二进制编码(最多 32 位)。因此,填充产生了一个由单个 512 位块组成的 512 位消息,准备好由 SHA-256 的压缩函数处理。
安全性保证
Merkle–Damgård 构造将一个安全的压缩函数(其输入为小且固定长度)转变为一个安全的哈希函数,该哈希函数能够接受任意长度的输入。如果一个压缩函数是抗原像攻击和抗碰撞攻击的,那么基于 M–D 构造构建的哈希函数同样也具备这两种抗性。之所以如此,是因为我们可以将针对 M–D 哈希的任何成功的原像攻击转化为对压缩函数的成功原像攻击,正如 Merkle 和 Damgård 在他们 1989 年的论文中所展示的那样(请参见本章的“进一步阅读”部分)。碰撞的情况也是如此:攻击者无法打破哈希函数的抗碰撞性,除非打破了底层压缩函数的抗碰撞性;因此,后者的安全性保证了哈希函数的安全性。
请注意,反过来的论点并不成立,因为压缩函数的碰撞不一定会导致哈希的碰撞。对于链式值 X 和 Y,它们都不同于 H[0],Compress(X, M[1]) 和 Compress(Y, M[2]) 之间的任意碰撞不会导致哈希碰撞,因为你无法将碰撞“插入”到哈希的迭代链中——除非其中一个链值恰好是 X,另一个是 Y,但这种情况发生的可能性很小。
寻找多重碰撞
多重碰撞发生在三条或更多消息哈希到相同的值时。例如,三元组(X, Y, Z),使得Hash(X) = Hash(Y) = Hash(Z),就是一个3-碰撞。理想情况下,多重碰撞应该比单一碰撞更难找到,但有一个简单的技巧可以几乎以与单次碰撞相同的成本找到它们。其工作原理如下:
1. 找到一个碰撞 Compress(H[0], M[1.1]) = Compress(H[0], M[1.2]) = H[1]。这只是一个 2-碰撞,即两条消息哈希到相同的值。
2. 以 H[1] 作为起始链值,找到第二次碰撞:Compress (H[1], M[2.1]) = Compress(H[1], M[2.2]) = H[2]。现在你得到了一个 4-碰撞,四条消息哈希到相同的值 H[2]:M[1.1] || M[2.1],M[1.1] || M[2.2],M[1.2] || M[2.1],以及 M[1.2] || M[2.2]。
3. 重复找到 N 次碰撞,你将得到 2^N 条消息,每条包含 N 个块,哈希到相同的值——即 2^N 碰撞,代价“仅仅”约为 N2^N 次哈希计算。
实际上,这个技巧并不是特别实用,因为它首先需要找到一个基本的 2-碰撞。
构建压缩函数
所有在实际哈希函数中使用的压缩函数,如 SHA-256 和 BLAKE2,都基于块密码,因为这是构建压缩函数最简单的方法。图 6-5 展示了基于块密码的最常见压缩函数,即 Davies–Meyer 构造。

图 6-5: Davies–Meyer 构造。深色三角形显示了块密码密钥的输入位置。
给定消息块 Mi 和前一个链值 Hi [– 1],Davies–Meyer 压缩函数使用一个块密码 E 来计算新的链值,公式如下:

消息块 Mi 作为块密码的密钥,链值 Hi [– 1] 作为其明文块。只要块密码是安全的,结果的压缩函数也将是安全的,并且抵抗碰撞和前像攻击。如果没有前一个链值的异或操作(⊕ Hi [– 1]),Davies–Meyer 就不安全,因为你可以反转它,通过块密码的解密函数从新的链值回到前一个链值。
注意
Davies–Meyer 构造有一个令人惊讶的性质:你可以找到 固定点,或链值,在应用给定消息块的压缩函数后保持不变。只需将 H[i –] 1 = D(M[i], 0) 作为链值,其中 D 是与 E 对应的解密函数。因此,新的链值 H[i] 等于原始的 H[i –] 1:
你得到 H[i] = H[i –] 1 是因为将零解密值输入加密函数会得到零——因此 E(M[i], D(M[i], 0)) = 0——只留下 ⊕ H[i –] 1 部分,这部分出现在压缩函数输出的表达式中。你可以为基于 Davies–Meyer 构造的 SHA-2 函数的压缩函数找到固定点。例如,幸运的是,固定点并不是安全风险。
除了 Davies–Meyer 之外,还有许多基于分组密码的压缩函数,例如在图 6-6 中展示的函数。

图 6-6:其他安全的基于分组密码的压缩函数构造
这些方法较少被使用,因为它们更为复杂,或者需要消息块的长度与链值相同。
基于置换的哈希函数
经过数十年的研究,密码学家已掌握了关于基于分组密码的哈希技术的所有知识。然而,难道没有更简单的哈希方法吗?为什么要使用一个需要密钥的分组密码算法,而哈希函数本身不需要密钥?为什么不构建一个带有固定密钥的分组密码的哈希函数,或者一个简单的置换算法?
那些更简单的哈希函数是海绵函数,它们使用单一的置换来代替压缩函数和分组密码(见图 6-7)。海绵函数不使用分组密码来混合消息位与内部状态,而是直接进行异或操作。海绵函数不仅比 Merkle–Damgård 函数简单,而且更加多功能。你会发现它们不仅作为哈希函数使用,还作为确定性随机比特生成器、流密码、伪随机函数(见第七章)和认证密码(见第八章)等使用。最著名的海绵函数是 Keccak,也被称为 SHA-3。

图 6-7:海绵构造
海绵函数的工作原理如下:
1. 它将第一个消息块 M[1] 与 H[0](内部状态的预定义初始值,例如全零字符串)进行异或运算。所有消息块的大小相同且小于内部状态的大小。
2. 一个置换 P 将内部状态转变为另一个相同大小的值。
3. 它将消息块 M[2] 与 P 进行异或运算,然后重复对消息块 M[3]、M[4] 等进行同样的操作。这是 吸收阶段。
4. 在注入所有消息块之后,再次应用 P 并从状态中提取一块比特以形成哈希。如果需要更长的哈希,再次应用 P 并提取一块比特。这是 挤压阶段。
海绵函数的安全性取决于其内部状态的长度和块的长度。如果消息块的长度为r位,内部状态的长度为w位,则有c = w – r 位是消息块无法修改的内部状态部分。c的值就是海绵函数的容量,而海绵函数所保证的安全级别是c/2。例如,要实现 256 位安全性,并且使用 64 位消息块,内部状态的长度至少应为w = 2 × 256 + 64 = 576 位。安全级别还取决于哈希值的长度n。因此,碰撞攻击的复杂度是 2n*(/2)与 2c*(/2)之间的较小值,而第二原像攻击的复杂度是 2^n与 2*c*(/2)之间的较小值。
为了保证安全,置换P应表现得像一个随机置换,既没有统计偏差,也没有数学结构可以让攻击者预测输出结果。与基于压缩函数的哈希相似,海绵函数也对消息进行填充,但填充过程更简单,因为它不需要包括消息的长度。最后一位消息位后面紧跟一个 1 位,然后是必要数量的零位。
SHA 哈希函数系列
安全哈希算法(SHA) 哈希函数是由 NIST 定义的标准,供美国非军事联邦政府机构使用。它们被视为全球标准,只有少数非美国政府出于主权原因选择自己的哈希算法(如中国的 SM3、俄罗斯的 Streebog 和乌克兰的 Kupyna),而非因为不信任 SHA 的安全性。美国的 SHA 算法比非美国的算法经过了更多的密码分析审查。
注意
消息摘要算法 5 (MD5) 是 1992 年到 2005 年间最流行的哈希函数,直到它在 2005 年左右被攻破,许多应用程序开始转向使用 SHA 系列哈希函数。MD5 处理 512 位的消息块,并更新一个 128 位的内部状态以生成 128 位哈希,因此最好的情况提供 128 位的原像安全性和 64 位的碰撞安全性。1996 年,密码分析师就 MD5 压缩函数的碰撞问题发出警告,但直到 2005 年,中国的密码分析师团队才发现如何计算出完整 MD5 哈希的碰撞。直到现在,找到 MD5 的碰撞只需要几秒钟,但一些系统仍然使用或支持 MD5,通常是出于向后兼容的原因。
SHA-1
SHA-1 标准源自 NSA 原始 SHA-0 哈希函数的失败。1993 年,NIST 标准化了 SHA-0 哈希算法,但 1995 年 NSA 发布了 SHA-1,以修复 SHA-0 中未发现的安全问题。当 1998 年两位研究人员发现如何在约 2⁶⁰次操作内找到 SHA-0 的碰撞,而不是预期的 SHA-0 和 SHA-1 这样的 160 位哈希函数所需的 2⁸⁰次操作时,修正的原因变得明确。后来,攻击将复杂度降低到约 2³³次操作,导致 SHA-0 在不到一个小时内就发生了实际碰撞。
SHA-1 内部结构
SHA-1 结合了 Merkle-Damgård 哈希函数和基于专门设计的块密码的 Davies-Meyer 压缩函数。也就是说,SHA-1 通过对 512 位消息块(M)进行以下操作的迭代来工作:

在这里,使用加号(+)而非⊕(XOR)是有意为之。E(M, H)和 H 被视为 32 位整数的数组,两个处于相同位置的词相加:E(M, H)的第一个 32 位词与 H 的第一个 32 位词相加,依此类推。H 的初始值对任何消息都是固定的,随后 H 根据之前的公式进行修改,处理完所有块后,H 的最终值作为消息的哈希值返回。
一旦使用消息块作为密钥、当前 160 位链接值作为明文块运行块密码,160 位的结果就会被视为五个 32 位词的数组,其中每个词与初始 H 值中的对应 32 位词相加。
清单 6-4 展示了 SHA-1 的压缩函数,SHA1-compress():
SHA1-compress(H, M) {
(a0, b0, c0, d0, e0) = H // Parsing H as five 32-bit big-endian words
(a, b, c, d, e) = **SHA1-blockcipher**(a0, b0, c0, d0, e0, M)
return (a + a0, b + b0, c + c0, d + d0, e + e0)
}
清单 6-4:SHA-1 的压缩函数
SHA-1 的块密码 SHA1-blockcipher(),以粗体显示,接受一个 512 位的消息块 M 作为密钥,并通过迭代 80 步的短操作序列来转换五个 32 位词(a、b、c、d 和 e),通过这些步骤将词 a 替换为五个词的组合。然后,它会像移位寄存器一样移动数组中的其他词。清单 6-5 以伪代码描述了这些操作,其中 K[i] 是与轮次相关的常量。
SHA1-blockcipher(a, b, c, d, e, M) {
W = **expand**(M)
for i = 0 to 79 {
new = (a <<< 5) + **f**(i, b, c, d) + e + K[i] + W[i]
(a, b, c, d, e) = (new, a, b >>> 2, c, d)
}
return (a, b, c, d, e)
}
清单 6-5:SHA-1 的块密码
清单 6-5 中的< samp class="SANS_TheSansMonoCd_W5Regular_11">expand()函数通过将W的前 16 个字设置为M,并将后续的字设置为先前字的 XOR 组合,左移 1 位,生成一个包含 80 个 32 位字的数组W。清单 6-6 显示了相应的伪代码。
expand(M) {
// The 512-bit M is seen as an array of sixteen 32-bit words.
W = empty array of eighty 32-bit words
for i = 0 to 79 {
if i < 16 then W[i] = M[i]
else
W[i] = (W[i – 3] ⊕ W[i – 8] ⊕ W[i – 14] ⊕ W[i – 16]) <<< 1
}
return W
}
清单 6-6:SHA-1 的 expand() 函数
清单 6-6 中的<<< 1操作是 SHA-1 和 SHA-0 函数之间唯一的区别。
最后,清单 6-7 展示了在SHA1-blockcipher()中的f()函数,这是一系列基本的按位逻辑运算(布尔函数),依赖于轮次编号。
f(i, b, c, d) {
if i < 20 then return ((b & c) ⊕ (~b & d))
if i < 40 then return (b ⊕ c ⊕ d)
if i < 60 then return ((b & c) ⊕ (b & d) ⊕ (c & d))
if i < 80 then return (b ⊕ c ⊕ d)
}
清单 6-7:SHA-1 的 f() 函数
清单 6-7 中的第二和第四个布尔函数简单地将三个输入字进行 XOR 操作,这是一个线性操作。相反,第一个和第三个函数使用非线性&运算符(逻辑与)来防止差分密码分析,这种分析利用了按位差异的可预测传播。如果没有&运算符(换句话说,如果f()始终是b ⊕ c ⊕ d,例如),SHA-1 将容易被破解,通过追踪其内部状态中的模式。
SHA-1 的攻击
尽管比 SHA-0 更强大,SHA-1 仍然不安全,这也是为什么自 2014 年以来,Chrome 浏览器将使用 SHA-1 的 HTTPS 连接标记为不安全。尽管其 160 位哈希值应提供 80 位的碰撞抗性,但在 2005 年,研究人员发现了 SHA-1 的弱点,并估计找到碰撞需要大约 2⁶³次计算(如果算法完美无缺,则需要 2⁸⁰次计算)。真正的 SHA-1 碰撞直到 12 年后才出现,当时经过多年的研究,密码分析师通过与谷歌研究人员的联合工作,展示了两个碰撞的 PDF 文档(参见*<wbr>shattered<wbr>.io)。
你不应该使用 SHA-1。大多数网页浏览器现在标记 SHA-1 为不安全,NIST 也不再推荐 SHA-1。应使用 SHA-2、SHA-3、BLAKE2 或 BLAKE3 代替。
SHA-2
SHA-2 是 SHA-1 的继任者,由 NSA 设计并在 2002 年由 NIST 标准化。SHA-2 是一个包含四种哈希函数的家族:SHA-224、SHA-256、SHA-384 和 SHA-512(其中 SHA-256 和 SHA-512 是两种主要算法)。这些三位数表示每个哈希值的位长度。
开发 SHA-2 的初衷是为了生成更长的哈希值,从而提供比 SHA-1 更高的安全性。然而,SHA-1 和 SHA-2 的结构非常相似。所有 SHA-2 实例也都使用 Merkle–Damgård 结构,并且其压缩函数与 SHA-1 的非常相似,但具有更强的非线性和差异传播特性。
SHA-256
SHA-256 是最常见的 SHA-2 版本。虽然 SHA-1 有 160 位的链值,但 SHA-256 具有 256 位的链值,由八个 32 位字组成。SHA-1 和 SHA-256 都使用 512 位的消息块,但 SHA-1 执行 80 轮,而 SHA-256 执行 64 轮,使用 expand256() 函数将 16 字的消息块扩展为 64 字的消息块,如 列表 6-8 所示。
expand256(M) {
// The 512-bit M is seen as an array of sixteen 32-bit words.
W = empty array of sixty-four 32-bit words
for i = 0 to 63 {
if i < 16 then W[i] = M[i]
else {
// The ">>" shifts instead of a ">>>" rotates and is not a typo.
s0 = (W[i – 15] >>> 7) ⊕ (W[i – 15] >>> 18) ⊕ (W[i – 15] >> 3)
s1 = (W[i – 2] >>> 17) ⊕ (W[i – 2] >>> 19) ⊕ (W[i – 2] >> 10)
W[i] = W[i – 16] + s0 + W[i – 7] + s1
}
}
return W
}
列表 6-8:SHA-256 的 expand256() 函数
请注意,SHA-2 的 expand256() 消息扩展比 SHA-1 的 expand() 在 列表 6-6 中要复杂得多,后者仅执行 XOR 和 1 位旋转。SHA-256 的压缩函数的主要循环也比 SHA-1 更复杂,每次迭代执行 26 次算术运算,而 SHA-1 只执行 11 次。再次说明,这些操作包括 XOR、逻辑与运算和字旋转。更高的复杂性使得 SHA-256 对差分密码分析更具抵抗力。
其他 SHA-2 算法
SHA-2 家族包括 SHA-224,它在算法上与 SHA-256 相同,唯一的不同是它的初始值由一组不同的八个 32 位字组成,并且其哈希值长度为 224 位,而不是 256 位,取的是最终链值的前 224 位。
SHA-2 家族还包括 SHA-512 和 SHA-384 算法。SHA-512 与 SHA-256 类似,不同之处在于它使用的是 64 位字而不是 32 位字。因此,它使用 512 位的链值(八个 64 位字)并处理 1024 位的消息块(十六个 64 位字),并且进行 80 轮而非 64 轮。除非需要处理更宽的字长,压缩函数几乎与 SHA-256 相同(例如,SHA-512 包括操作a >>> 34,但如果是 SHA-256 的 32 位字,这个操作就没有意义)。SHA-384 与 SHA-512 的关系就像 SHA-224 与 SHA-256 的关系一样——它们使用相同的算法,只是初始值不同,最终哈希被截断为 384 位。
从安全性角度来看,所有四个版本的 SHA-2 迄今为止都履行了其承诺:SHA-256 保证了 256 位的前像抗性,SHA-512 保证了大约 256 位的碰撞抗性,等等。不过,仍然没有确凿的证据证明 SHA-2 的算法是安全的;我们讨论的只是可能的安全性。
尽管如此,在对 MD5 和 SHA-1 进行实际攻击之后,研究人员和 NIST 开始担心 SHA-2 的长期安全性,因为它与 SHA-1 有相似之处,许多人认为 SHA-2 迟早也会遭遇攻击。直到我写这篇文章时,我们还没有看到 SHA-2 被成功攻击过。尽管如此,NIST 已制定了备选方案:SHA-3。
SHA-3 竞赛
NIST 于 2007 年宣布启动 SHA-3 竞赛(NIST 哈希函数竞赛的正式名称),该竞赛开始时要求提交符合一些基本要求的方案:哈希算法必须至少与 SHA-2 一样安全和快速,并且其功能应当至少与 SHA-2 相当。SHA-3 候选算法还不能与 SHA-1 和 SHA-2 太相似,以免受到可能攻破 SHA-1 及 SHA-2 的攻击。到 2008 年,NIST 已收到来自全球的 64 份提交,包括来自大学和大型企业(如 BT、IBM、微软、高通和索尼等)的方案。在这 64 份提交中,有 51 份符合要求并进入了竞赛的第一轮。
在竞赛的最初几周,密码分析师们无情地攻击了所有提交的方案。2009 年 7 月,NIST 公布了 14 个第二轮候选算法。在经过 15 个月的分析和评估这些候选算法的性能后,NIST 选出了五个最终候选算法:
BLAKE 是一种增强版的 Merkle–Damgård 哈希算法,其压缩函数基于区块密码,而该区块密码又基于流密码 ChaCha 的核心函数,这些函数包括加法链、异或操作和字位旋转。BLAKE 是由一个来自瑞士和英国的学术研究团队设计的,其中包括我在攻读博士学位时的工作。
Grøstl 是一种增强型 Merkle–Damgård 哈希,其压缩函数使用基于 AES 块加密的两个置换(或固定密钥块密码)。Grøstl 由来自丹麦和奥地利的七位学术研究人员设计。
JH 是一种经过调整的海绵函数构造,其中消息块在置换前后都被注入,而不仅仅是在置换之前。该置换还执行类似于替代-置换块密码的操作(请参见 第四章)。JH 由一位来自新加坡大学的密码学家设计。
Keccak 是一种海绵函数,其置换仅执行按位操作。Keccak 由来自比利时和意大利的一家半导体公司工作的四位密码学家设计,其中包括 AES 的两位设计者之一。
Skein 是一种基于与 Merkle–Damgård 不同工作模式的哈希函数,其压缩函数基于一种新型的块密码,仅使用整数加法、异或运算和字轮转。Skein 由来自学术界和工业界的八位密码学家设计,其中大部分人来自美国,包括著名的 Bruce Schneier。
在对五个入围算法进行广泛分析后,NIST 宣布了最终的获胜者:Keccak。NIST 的报告称 Keccak 具有“优雅的设计、大的安全余量、良好的整体性能、在硬件中的出色效率以及灵活性”。让我们看看 Keccak 是如何工作的。
Keccak (SHA-3)
NIST 选择 Keccak 的原因之一是它与 SHA-1 和 SHA-2 完全不同。首先,它是一种海绵函数。Keccak 的核心算法是一个 1,600 位状态的置换,接收 1,152、1,088、832 或 576 位的数据块,分别生成 224、256、384 或 512 位的哈希值——与 SHA-2 哈希函数生成的四种长度相同。但与 SHA-2 不同,SHA-3 使用一个核心算法,而不是两个算法来处理所有四种哈希长度。
另一个原因是 Keccak 不仅仅是一个哈希函数。SHA-3 标准文档 FIPS 202 定义了四个哈希函数——SHA3-224、SHA3-256、SHA3-384 和 SHA3-512——以及两个算法,分别为 SHAKE128 和 SHAKE256。(SHAKE 代表 基于 Keccak 的安全哈希算法)。这两个算法是 可扩展输出函数(XOFs),即可以生成可变长度的哈希值的哈希函数,甚至可以生成非常长的哈希值。数字 128 和 256 表示每个算法的安全级别。
FIPS 202 标准本身很冗长且难以解析,但你可以找到开源实现,这些实现速度较快,并且使得算法易于理解。例如,由 Markku-Juhani O. Saarinen 编写的 tiny_sha3 (<wbr>github<wbr>.com<wbr>/mjosaarinen<wbr>/tiny<wbr>_sha3) 通过 19 行 C 代码解释了 Keccak 的核心算法,部分内容如在 Listing 6-9 中所示。
static void sha3_keccakf(uint64_t st[25], int rounds)
{
(⊕)
for (r = 0; r < rounds; r++) {
❶ // Theta
for (i = 0; i < 5; i++)
bc[i] = st[i] ^ st[i + 5] ^ st[i + 10] ^ st[i + 15] ^ st[i + 20];
for (i = 0; i < 5; i++) {
t = bc[(i + 4) % 5] ^ ROTL64(bc[(i + 1) % 5], 1);
for (j = 0; j < 25; j += 5)
st[j + i] ^= t;
}
❷ // Rho Pi
t = st[1];
for (i = 0; i < 24; i++) {
j = keccakf_piln[i];
bc[0] = st[j];
st[j] = ROTL64(t, keccakf_rotc[i]);
t = bc[0];
}
❸ // Chi
for (j = 0; j < 25; j += 5) {
for (i = 0; i < 5; i++)
bc[i] = st[j + i];
for (i = 0; i < 5; i++)
st[j + i] ^= (~bc[(i + 1) % 5]) & bc[(i + 2) % 5];
}
❹ // Iota
st[0] ^= keccakf_rndc[r];
}
(⊕)
}
列表 6-9:tiny_sha3 实现
tiny_sha3 程序实现了 Keccak 的置换 P,这是一个可逆变换,作用于一个视为 25 个 64 位字数组的 1,600 位状态。该代码迭代执行一系列轮次,每轮包括四个主要步骤:
1. Theta ❶ 包括对 64 位字的异或运算,或者是字的 1 位旋转值(ROTL64(w, 1) 操作将字 w 左旋转 1 位)。
2. Rho Pi ❷ 包括对 64 位字进行常量旋转,常量硬编码在 keccakf_rotc[] 数组中。
3. Chi ❸ 包括更多的异或运算,还包括 64 位字之间的逻辑与(& 操作)。这些与运算是 Keccak 中唯一的非线性操作,它们带来了密码学的强度。
4. Iota ❹ 包括与一个 64 位常量的异或运算,该常量被硬编码在 keccakf _rndc[] 中。
这些操作为 SHA-3 提供了一个强大的置换算法,避免了任何偏差或可利用的结构。SHA-3 是经过十多年研究的产物,数百名技术娴熟的密码分析师未能攻破它。它不太可能在短期内被破解。
BLAKE2 和 BLAKE3 哈希函数
安全性可能是最重要的,但速度排在第二位。我见过很多情况,开发者不会从 MD5 切换到 SHA-1,仅仅因为 MD5 更快,或者从 SHA-1 切换到 SHA-2,因为 SHA-2 比 SHA-1 慢得多。不幸的是,SHA-3 的速度并没有超过 SHA-2,而由于 SHA-2 仍然是安全的,所以升级到 SHA-3 的动力较少。那么,我们如何在比 SHA-1 和 SHA-2 更快的同时,还能实现更高的安全性呢?答案就是哈希函数 BLAKE2,它是在 SHA-3 竞赛之后发布的。
注意
完全公开:我与 Samuel Neves、Zooko Wilcox-O’Hearn 和 Christian Winnerlein 一起设计了 BLAKE2。
BLAKE2 的设计思想如下:
-
它应该至少与 SHA-3 同样安全,甚至更强。
-
它应该比所有之前的哈希标准更快,包括 MD5。
-
它应该适合用于现代应用,并能够哈希大量数据,无论是作为少量的大消息还是许多小消息,无论是否使用秘密密钥。
-
它应该适用于支持多核系统上的并行计算以及单核内指令级并行的现代 CPU。
工程过程的结果是一对主要的哈希函数:
-
BLAKE2b(或简称 BLAKE2),为 64 位平台优化,生成的摘要长度从 1 到 64 字节不等。
-
BLAKE2s 经过优化,适用于 8 到 32 位平台,生成的摘要长度从 1 到 32 字节不等。
每个函数都有一个并行变体,可以利用多个 CPU 核心。BLAKE2b 的并行版本 BLAKE2bp 在四个核心上运行,而 BLAKE2sp 在八个核心上运行。前者在现代服务器和笔记本 CPU 上速度最快,可以在笔记本 CPU 上接近 2Gbps 的速度进行哈希。BLAKE2 的速度和特性使其成为最受欢迎的非 NIST 标准哈希函数。BLAKE2 被广泛应用于各种软件,并且已集成到 OpenSSL 和 Sodium 等主要密码学库中。BLAKE2 还是互联网工程任务组(IETF)的标准,详见 RFC 7693。
注意
你可以在<wbr>blake2<wbr>.net找到 BLAKE2 的规格和参考代码,并且可以从<wbr>github<wbr>.com/BLAKE2下载优化过的代码和库。参考代码还提供了 BLAKE2X,这是 BLAKE2 的一个扩展,能够生成任意长度的哈希值。
如图 6-8 所示,BLAKE2 的压缩函数是 Davies–Meyer 构造的一个变体,它将参数作为额外输入——即一个计数器(确保每个压缩函数的行为像一个不同的函数)和一个标志(指示压缩函数是否在处理最后一个消息块,以增强安全性)。

图 6-8:BLAKE2 的压缩函数。块密码处理后,状态的两半进行异或操作。
BLAKE2 的压缩函数中的块密码基于流密码 ChaCha,ChaCha 本身是 Salsa20 流密码的一个变体,Salsa20 在第五章中有讨论。在这个块密码中,BLAKE2b 的核心操作由以下一系列操作组成,它使用两个消息字Mi 和Mj,将四个 64 位字的状态转化:

BLAKE2s 的核心操作类似,但它使用 32 位字而非 64 位字(因此使用不同的旋转值)。
最后但同样重要的是,BLAKE3 是 BLAKE2 的一个更具可并行性、更简单、更通用且更快的版本,首次在 2020 年的现实世界密码学大会上发布。BLAKE3 由 Jack O’Connor、Samuel Neves、Zooko Wilcox-O’Hearn 和我自己设计,凭借其不可否认的优势,BLAKE3 迅速成为最受欢迎的哈希函数之一。更多详细信息,请参见<wbr>github<wbr>.com<wbr>/BLAKE3<wbr>-team<wbr>/BLAKE3。
事情如何可能出错
尽管哈希函数表面上看似简单,但在错误的地方或以错误的方式使用时,可能会导致重大的安全问题——例如,在需要检查文件完整性的应用程序中,使用弱校验算法(如 CRC)而不是加密哈希函数来验证通过网络传输的数据的完整性。然而,这种弱点与其他弱点相比,显得微不足道,后者甚至可能完全破坏看似安全的哈希函数。你将看到两个失败的例子:第一个适用于 SHA-1 和 SHA-2,但不适用于 BLAKE2 或 SHA-3,而第二个适用于这四个函数中的所有。
长度扩展攻击
图 6-9 展示了长度扩展攻击,这是 Merkle–Damgård 构造的主要威胁。

图 6-9:长度扩展攻击
基本上,如果你知道某个未知消息M的Hash(M),其中M由块M[1]和M[2](填充后)组成,你可以确定任何块M[3]的Hash(M[1] || M[2] || M[3])。因为M[1] || M[2]的哈希值是紧跟在M[2]之后的链式值,所以即使你不知道被哈希的数据,也可以将另一个块M[3]添加到已哈希的消息中。更重要的是,这个技巧可以推广到未知消息中任意数量的块(此处的M[1] || M[2])或后缀中的块(M*[3])。
长度扩展攻击不会影响哈希函数的大多数应用,但如果在哈希函数使用上过于创新,可能会危及安全。不幸的是,尽管 NSA 设计了这些 SHA-2 哈希函数,并且 NIST 对其进行了标准化,而且两者都清楚这一缺陷的存在,但 SHA-2 哈希函数仍然容易受到长度扩展攻击。这一缺陷本可以通过简单地改变最后一个压缩函数的调用方式来避免(例如,通过在先前的调用中传递 0 位,而在最后一个调用中传递 1 位作为额外的参数)。这就是 BLAKE2 所做的。
欺骗存储证明协议
云计算应用程序在存储证明协议中使用哈希函数——即协议中,服务器(云服务提供商)向客户端(云存储服务用户)证明服务器确实存储了它应当代表客户端存储的文件。
2007 年,Ramakrishna Kotla、Lorenzo Alvisi 和 Mike Dahlin 的论文《SafeStore: A Durable and Practical Storage System》([https://www.cs.utexas.edu/lorenzo/papers/p129-kotla.pdf](https://www.cs.utexas.edu/lorenzo/papers/p129-kotla.pdf))提出了一种存储证明协议,用于验证某个文件M*的存储,具体如下:
1. 客户端选择一个随机值C作为挑战。
2. 服务器计算Hash(M || C)作为响应并将结果发送给客户端。
- 客户端也计算Hash(M || C)并检查它是否与从服务器接收到的值匹配。
本文的前提是服务器不应该能够欺骗客户端,因为如果服务器不知道M,它就无法猜测Hash(M || C)。但这里有一个陷阱:实际上,Hash是一个迭代哈希,它会逐块处理输入,并在每个块之间计算中间的链接值。例如,如果Hash是 SHA-256,且M的长度为 512 位(SHA-256 中一个块的大小),服务器就可以作弊。怎么做呢?服务器第一次接收到M时,它计算 H[1] = Compress(H[0], M[1]),其中 H[0] 是 SHA-256 的初始值,M 是 512 位的输入。然后,它将 H[1] 记录在内存中,并丢弃M,此时它不再存储M。
当客户端发送一个随机值 C 时,服务器计算 Compress(H[1], C),并将填充添加到 C 以填满完整的块,然后将结果返回为Hash(M || C)。客户端接着会认为,由于服务器返回了正确的Hash(M || C)值,它持有完整的消息——然而,正如你所看到的,它可能并不持有完整消息。
这个技巧适用于 SHA-1、SHA-2、SHA-3 以及 BLAKE2。解决方案很简单:请求Hash(C || M)而不是Hash(M || C)。
进一步阅读
想了解更多关于哈希函数的信息,可以阅读 1980 年代和 1990 年代的经典文献:比如 Ralph Merkle 的《单向哈希函数与 DES》和 Ivan Damgård 的《哈希函数的设计原则》。还可以阅读由 Bart Preneel、René Govaerts 和 Joos Vandewalle 合著的《基于块密码的哈希函数:一种综合方法》。
关于碰撞搜索的更多信息,可以参考 1997 年由 Paul van Oorschot 和 Michael Wiener 撰写的论文《并行碰撞搜索及其密码分析应用》。如果你想了解支撑前像抗性、碰撞抗性以及长度扩展攻击的理论安全概念,可以查找indifferentiability(不可区分性)。
有关哈希函数的最新研究,可以参考 SHA-3 竞赛的档案,其中包含所有不同的算法以及它们是如何被破解的。你可以在 SHA-3 Zoo 找到许多相关的参考资料,网址是 <wbr>ehash<wbr>.iaik<wbr>.tugraz<wbr>.at<wbr>/wiki<wbr>/The<wbr>_SHA<wbr>-3<wbr>_Zoo<wbr>.html,也可以访问 NIST 的页面,网址是 <wbr>csrc<wbr>.nist<wbr>.gov<wbr>/projects<wbr>/hash<wbr>-functions<wbr>/sha<wbr>-3<wbr>-project。
关于 SHA-3 的获胜者 Keccak 和海绵函数的更多信息,可以查看* <wbr>keccak<wbr>.team<wbr>/sponge<wbr>_duplex<wbr>.html*,这是 Keccak 设计者的官方网站。
最后,你可以查阅这两个关于弱哈希函数的真实攻击实例:
-
国家级恶意软件 Flame 利用 MD5 碰撞制造了一个伪造证书,看起来像是一个合法的软件。
-
Xbox 游戏主机使用了一种弱的块密码(称为 TEA)来构建哈希函数,这被利用来黑客攻击主机并在其上运行任意代码。
第八章:7 带密钥的哈希

第六章中的哈希函数接收消息并返回其哈希值——通常是一个 256 位或 512 位的短字符串。任何人都可以计算消息的哈希值,并验证某条消息是否哈希到特定的值。然而,当你希望只有特定的人能够计算哈希时,你需要使用带有密钥的哈希函数进行哈希处理。
带密钥的哈希是两种加密算法的基础:消息认证码(MACs),用于验证消息并保护其完整性,和 伪随机函数(PRFs),用于生成看似随机的哈希大小值。我们将在本章的第一节中探讨 MAC 和 PRF 的相似之处,然后回顾 MAC 和 PRF 的工作原理。一些 MAC 和 PRF 基于哈希函数,一些基于分组密码,还有一些是原创设计。最后,我们将讨论针对本来安全的 MAC 的攻击示例。
消息认证码
MAC 通过生成一个值 T = MAC(K, M) 来保护消息的完整性和真实性,这个值被称为消息 M 的身份验证标签(通常令人困惑地称为 M 的 MAC)。就像你知道加密算法的密钥就能解密消息一样,如果你知道 MAC 的密钥,你也可以验证消息是否没有被篡改。
例如,假设 Alex 和 Bill 共享一个密钥 K,并且 Alex 将消息 M 及其身份验证标签 T = MAC(K, M) 发送给 Bill。在收到消息及其身份验证标签后,Bill 重新计算 MAC(K, M) 并检查它是否等于收到的身份验证标签。因为只有 Alex 才能计算出这个值,所以 Bill 知道消息在传输过程中没有被损坏(确认其完整性),无论是偶然的还是恶意的,同时也知道是 Alex 发送了这条消息(确认其真实性)。
安全通信中的 MAC
安全通信系统通常将加密算法和 MAC 结合使用,以保护消息的机密性、完整性和真实性。例如,互联网协议安全(IPsec)、SSH 和 TLS 协议会为每个传输的网络数据包生成一个 MAC。
并非所有通信系统都使用 MAC。 有时,身份验证标签可能会给每个数据包增加不可接受的开销,通常在 64 到 128 位之间。例如,旧的 GSM 移动通信标准加密了语音通话的数据包,但并未对其进行身份验证。攻击者可以修改加密后的音频信号,而接收者不会察觉。
伪造和选择消息攻击
一个 MAC(消息认证码)安全意味着什么?首先,和加密算法一样,秘密密钥应该保持机密。如果一个 MAC 是安全的,攻击者不应该在不知道密钥的情况下伪造某个消息的标签。我们将伪造的消息/标签对称为伪造,而恢复密钥是伪造攻击这一更广泛类别的特定案例。安全性概念认为,伪造应该是不可能被发现的,这就是不可伪造性。此外,也应该不可能从标签列表中恢复秘密密钥,否则攻击者可以使用密钥伪造标签。
攻击者能做什么来破坏一个 MAC?换句话说,攻击模型是什么?最基本的攻击模型是已知消息攻击,它被动地收集消息及其关联的标签(例如,通过监听网络)。但真实的攻击者往往可以发起主动攻击,因为他们可以选择要认证的消息,从而获得他们想要的消息的 MAC。因此,标准模型是选择消息攻击,其中攻击者可以为自己选择的消息获取标签。
重放攻击
MAC 并不免疫于重放攻击。例如,如果你在监听 Alex 和 Bill 的通信,你可以捕获 Alex 发送给 Bill 的消息及其标签,并后来再次将其发送给 Bill,伪装成 Alex。为了防止这种重放攻击,协议会在每个消息中包含一个消息编号。每个新消息的编号递增,并与消息一起通过 MAC 进行认证。接收方会按顺序收到编号为 1、2、3、4 等的消息。因此,如果攻击者尝试再次发送消息 1,接收方会注意到这条消息的顺序错乱,从而发现它可能是之前消息 1 的重放。
伪随机函数
一个伪随机函数(PRF)使用秘密密钥返回PRF(K, M),其输出看起来是随机的。由于密钥是秘密的,攻击者无法预测输出值。
不像 MAC,PRF 不是用来独立使用的,而是作为加密算法或协议的一部分。例如,你可以使用 PRF 在 Feistel 结构中创建分组密码——请参见第四章《如何构造分组密码》。密钥派生方案使用 PRF 从主密钥或密码生成加密密钥,而身份验证方案使用 PRF 从随机挑战生成响应。(基本上,服务器发送一个随机挑战消息 M,客户端则通过 PRF(K, M) 在响应中返回,以证明它知道 K。)5G 电信标准使用 PRF 来验证 SIM 卡和服务提供商,类似的 PRF 生成在通话过程中使用的加密密钥和 MAC 密钥。TLS 协议使用 PRF 从主秘密和会话特定的随机值生成密钥材料。甚至在 Python 语言内置的非加密函数 hash() 中,也有一个 PRF 用于比较对象。
PRF 安全性
为了保证安全,伪随机函数(PRF)应该没有任何模式,使其输出与真正的随机值有所区别。一个不知道密钥的攻击者,K,不应该能够区分 PRF(K, M) 的输出和随机值。换句话说,攻击者应该没有任何手段知道自己是在与一个 PRF 算法交互,还是与一个随机函数交互。这个安全概念的学术术语是“无法区分于随机函数”。(想了解更多关于 PRF 理论基础的内容,请参见 Goldreich 的《密码学基础》第一卷,第 3.6 节。)
PRF 比 MAC 更强
PRF 和 MAC 都是带密钥的哈希函数,但 PRF 本质上比 MAC 更强,因为 MAC 的安全要求较弱。你会认为一个 MAC 安全,如果攻击者无法伪造标签——也就是说,如果他们不能猜测 MAC 的输出——而一个 PRF 只有在其输出是不可区分的随机字符串时才是安全的。如果你不能区分一个 PRF 的输出和随机字符串,这意味着它们的值无法被猜测;换句话说,任何安全的 PRF 也是一个安全的 MAC。
然而,反过来说,一个安全的 MAC 不一定是一个安全的 PRF。例如,假设你从一个安全的 PRF,PRF1,开始,并希望从中构建第二个 PRF,PRF2,像这样:

因为PRF2的输出被定义为PRF1的输出后跟一个 0 位,它看起来不像真正的随机字符串,你可以通过最后那个 0 位来区分其输出。因此,PRF2不是一个安全的 PRF。然而,由于PRF1是安全的,PRF2仍然可以作为一个安全的 MAC。如果你能够伪造一个标签,T = PRF2(K, M),对于某些M,那么你也能伪造PRF1的标签,而你知道这本身是不可能的,因为 PRF1 是一个安全的 MAC。因此,PRF2是一个带密钥的哈希,它是一个安全的 MAC,但不是一个安全的 PRF。
但是别担心:你不会在实际应用中找到这样的 MAC 构造。事实上,许多已经部署或标准化的 MAC 也都是安全的 PRF,且常常同时作为两者使用。例如,TLS 使用 HMAC-SHA-256 算法既作为 MAC,也作为 PRF。
如何从未加密的哈希生成带密钥的哈希
在密码学的历史中,MAC(消息认证码)和 PRF(伪随机函数)很少是从零开始设计的,而是通常基于现有算法构建,通常是哈希函数或块密码。看起来很明显,你可以通过给(未加密的)哈希函数提供一个密钥和消息来生成一个带密钥的哈希函数,但这其实说起来容易做起来难。
秘密前缀构造
我们将要讨论的第一个技术,秘密前缀构造,通过将密钥附加到消息前面,并返回Hash(K || M),将普通哈希函数转换为带密钥的哈希。当哈希函数容易受到长度扩展攻击(参见第六章中的“长度扩展攻击”)时,以及当哈希支持不同长度的密钥时,这种方法是 insecure 的。
对长度扩展攻击的安全性问题
回想一下第六章中提到的,SHA-2 系列的哈希函数允许攻击者在给定消息的较短版本的哈希值时,计算出部分未知消息的哈希值。用正式术语来说,长度扩展攻击使得攻击者可以在只知道Hash(K || M[1])的情况下,计算出Hash(K || M[1] || M[2]),而不知道M[1]或K。这些函数使得攻击者可以免费伪造有效的 MAC 标签,因为他们不应该仅凭M[1]的 MAC 就能猜测出M[1] || M[2]的 MAC。这个事实使得当与 SHA-256 或 SHA-512 一起使用时,秘密前缀构造作为 MAC 和 PRF 是 insecure 的。这是 Merkle–Damgård 结构允许长度扩展攻击的一个弱点,而 SHA-3 的所有候选者都没有这个问题。抵御长度扩展攻击是 SHA-3 提交的必要条件(参见第六章)。
不同密钥长度的安全性问题
当允许使用不同长度的密钥时,密文前缀构造也是不安全的。例如,如果密钥 K 是 24 位的十六进制字符串 123abc,且 M 是 def00,那么 Hash() 处理的值是 K || M = 123abcdef00。如果 K 是 16 位的字符串 123a,且 M 是 bcdef000,那么 Hash() 处理的值也是 K || M = 123abcdef00。因此,密文前缀构造 Hash(K || M) 的结果对于两个密钥来说是相同的。
这个问题与底层哈希函数无关;你可以通过将密钥的长度与密钥和消息一起哈希来修复它,例如,将密钥的位长编码为 16 位整数 L,然后哈希 Hash(L || K || M)。然而,现代哈希函数如 BLAKE2 和 SHA-3 已经包含了一个密钥模式,避免了这些问题,并且能生成安全的 PRF 和安全的 MAC。
密文后缀构造
与在密文前缀结构中将密钥哈希到消息之前不同,您可以将其 哈希在后面。这正是 密文后缀结构 的工作方式,通过构建一个由哈希函数构成的伪随机函数(PRF),如 Hash(M || K)。
将密钥放在末尾会产生很大的不同。针对密文前缀 MAC 的长度扩展攻击无法对密文后缀起作用。对密文后缀 MAC 应用长度扩展会得到 Hash(M[1] || K || M[2]),而不是 Hash(M[1] || K),但这并不是一个有效的攻击,因为 Hash(M[1] || K || M[2]) 不是一个有效的密文后缀 MAC;密钥需要在末尾。
然而,密文后缀构造对于另一类攻击更为脆弱。假设你有一个哈希冲突 Hash(M[1]) = Hash(M[2]),其中 M[1] 和 M[2] 是两个不同的消息,可能具有不同的大小。在像 SHA-256 这样的 M-D 哈希函数中,这意味着 Hash(M[1] || P[1] || K) 和 Hash(M[2] || P[2] || K) 也将相等,其中 P[1] 和 P[2] 是为了完成块而添加的填充数据。在处理 M[1] || P[1] 后,哈希函数的状态与处理 M[2] || P[2] 后的状态相同。将 K 添加到每个实例中会保持状态的相等,从而导致冲突,无论 K 的值如何。
为了利用这一特性,攻击者:
1. 找到两个冲突的消息,M[1] 和 M[2]。
2. 请求 M[1] 的 MAC 标签,Hash(M[1] || K)
3. 猜测 Hash(M[2] || K) 相同,从而伪造一个有效的标签,破坏 MAC 的安全性。
HMAC 构造
基于哈希的 MAC(HMAC)结构允许你从哈希函数构建一个 MAC,这比使用秘密前缀或秘密后缀更安全。只要底层哈希是抗碰撞的,HMAC 就能产生一个安全的伪随机函数(PRF),即使不是这种情况,只要哈希的压缩函数是 PRF,HMAC 仍能产生一个安全的 PRF。IPsec、SSH 和 TLS 等安全通信协议都使用了 HMAC。(你可以在 NIST FIPS 198-1 标准和 RFC 2104 中找到 HMAC 规范。)
HMAC 使用哈希函数哈希来计算 MAC 标签,如图 7-1 所示,并根据以下表达式进行:

值opad(外填充)是十六进制字符串5c5c5c. . .5c,其长度与哈希的块大小相同。密钥K通常比一个块要短,并且用00字节填充并与opad进行异或操作。例如,如果K是 1 字节的字符串00,那么K ⊕ opad = opad。(如果K是任何长度的全零字符串,直到块的长度,也是如此。)K ⊕ opad是由外部调用哈希处理的第一个块——即方程中最左边的哈希,或图 7-1 中的底部哈希。

图 7-1:基于压缩函数的哈希函数的 HMAC 基于哈希的 MAC 结构
值ipad(内填充)是一个字符串(363636 . . . 36),其长度与哈希的块大小相同,并以 00 字节补充。得到的块是首先由哈希的内层调用处理的——即方程式中最右侧的哈希,或图 7-1 中的顶部哈希。
注意
信封法是一种比秘密前缀或秘密后缀更安全的构造。它可以表示为 Hash(K || M || K),一种三明治 MAC,但从理论上讲,它不如 HMAC 安全。
如果使用 SHA-256 作为哈希函数,则称 HMAC 实例为 HMAC-SHA-256。更一般地,称使用哈希函数Hash的 HMAC 实例为 HMAC-Hash。这意味着如果有人要求你使用 HMAC,你应该始终问:“使用哪种哈希函数?”
一种针对基于哈希的 MAC 的通用攻击
有一种攻击方法可以针对所有基于迭代哈希函数的 MAC 进行攻击。回想一下在“秘密后缀构造”中对第 143 页中的攻击,我们使用哈希碰撞来获取 MAC 的碰撞。你可以使用相同的策略来攻击秘密前缀 MAC 或 HMAC,尽管后果不那么严重。
图 7-2 展示了秘密前缀 MAC 哈希(K || M)。

图 7-2:基于哈希的 MAC 的通用伪造攻击原理
如果摘要是n位,你可以找到两个消息,M[1]和M[2],使得Hash(K || M[1]) = Hash(K || M[2]),通过向持有密钥的系统请求大约 2n*(/2)个 MAC 标签。(回想一下第六章中的生日攻击。)如果哈希函数支持长度扩展,像 SHA-256 那样,你可以使用M[1]和M[2]通过选择一些任意数据M[3]来伪造 MAC,然后查询 MAC 预言机得到Hash(K* || M[1] || M[3]),这就是消息M[1] || M[3]的 MAC。结果证明,这也是消息M[2] || M[3]的 MAC,因为M[1]和M[3]以及M[2]和M[3]的哈希内部状态是相同的。你已经成功伪造了一个 MAC 标签。(随着n增长到例如 128 位时,这项工作会变得不可行。)
即使哈希函数不容易受到长度扩展攻击,这个攻击仍然有效,它同样适用于 HMAC。这是因为所需的只是哈希函数内部的碰撞,而不一定是在整个哈希值中的碰撞。攻击的成本取决于链值的大小和 MAC 的长度:如果一个 MAC 的链值是 512 位而它的标签是 128 位,2⁶⁴的计算将找到一个 MAC 碰撞,但可能不会在内部状态中找到碰撞,因为找到这样的碰撞平均需要 2^(512/2) = 2²⁵⁶次操作。
如何从块密码创建带密钥的哈希
许多哈希函数中的压缩函数是建立在块密码上的(参见第六章)。例如,HMAC-SHA-256 PRF 是对 SHA-256 压缩函数的一系列调用,SHA-256 压缩函数本身就是一个块密码,重复执行一系列的轮次。换句话说,HMAC-SHA-256 是一个块密码,嵌套在一个压缩函数内,再嵌套在哈希函数中,最后构建成 HMAC。那么,为什么不直接使用块密码,而要构建这样一个层级结构呢?
基于密码的 MAC(CMAC)就是这样的一个结构:它只使用一个块密码,如 AES,来创建一个 MAC。虽然比 HMAC 不那么流行,但 CMAC 已经被应用于许多系统中,包括互联网密钥交换(IKE)协议,它是 IPsec 套件的一部分。例如,IKE 使用 AES-CMAC-PRF-128 结构作为核心算法(或基于 AES 的 CMAC,输出为 128 位)。CMAC 在 RFC 4493 中有规范。
破解 CBC-MAC
CMAC 是在 2005 年设计的,作为CBC-MAC的改进版本,CBC-MAC 是一种较简单的基于块密码的 MAC,源自密码块链接(CBC)密码工作模式(参见第四章中的“工作模式”)。
CBC-MAC,CMAC 的前身,十分简单:要计算消息 M 的标签,给定一个块密码 E,将 M 在 CBC 模式下使用全零初始值(IV)加密,然后丢弃除最后一个密文块外的所有块。也就是说,计算
C[1] = E(K, M[1]), C[2] = E(K, M[2] ⊕ C[1]), C[3] = E(K, M[3] ⊕ C[2])
对 M 的每个块进行同样的操作,仅保留最后一个 Ci —— 即 M 的 CBC-MAC 标签 —— 简单,并且容易被攻击。
为了理解为什么 CBC-MAC 不安全,考虑单块消息 M[1] 的 CBC-MAC 标签,T[1] = E(K, M[1]),以及另一块单块消息 M[2] 的标签,T[2] = E(K, M[2])。给定这两个配对,(M[1], T[1]) 和 (M[2], T[2]),你可以推导出 T[2] 也是两块消息 M[1] || (M[2] ⊕ T[1]) 的标签。如果你将 CBC-MAC 应用于 M[1] || (M[2] ⊕ T[1]) 并计算 C[1] = E(K, M[1]) = T[1],然后 C[2] = E(K, (M[2] ⊕ T[1]) ⊕ T[1]) = E(K, M[2]) = T[2],你就能从两个消息/标签配对中创建出第三个消息/标签配对,而无需知道密钥。也就是说,你可以伪造 CBC-MAC 标签,从而破坏 CBC-MAC 的安全性。
修复 CBC-MAC
CMAC 通过使用与前面的块不同的密钥来处理最后一个块,从而修复了 CBC-MAC。为此,CMAC 首先从主密钥 K 中派生出两个密钥 K[1] 和 K[2],使得 K、K[1] 和 K[2] 是不同的。CMAC 使用 K[1] 或 K[2] 来处理最后一个块,而前面的块使用 K。
为了确定 K[1] 和 K[2],CMAC 首先计算一个临时值 L = E(0, K),其中 0 作为块密码的密钥,K 作为明文块。然后,CMAC 将 K[1] 设置为 (L << 1),如果 L 的最高有效位(MSB)为 0;否则,将 K[1] 设置为 (L << 1) ⊕ 87,如果 L 的 MSB 为 1。(十六进制数 87,即十进制的 135,因其在数据块大小为 128 位时的数学性质而被选择;如果数据块大小不是 128 位,则需要选择不同的值。)密钥 K[2] 被设置为 (K[1] << 1),如果 K[1] 的 MSB 为 0;否则,K[2] = (K[1] << 1) ⊕ 87。
给定 K[1] 和 K[2],CMAC 的工作方式与 CBC-MAC 相似,唯一不同的是最后一块。如果最终消息块 Mn 恰好等于一个块的大小,CMAC 将返回 E(K, Mn ⊕ Cn [− 1] ⊕ K[1]) 作为标签,如图 7-3 所示。

图 7-3:当消息是一个完整块序列时,基于 CMAC 块密码的 MAC 构造
如果 Mn 的位数少于一个块,CMAC 会使用一个 1 位和零填充它,并返回 E(K, Mn ⊕ Cn [− 1] ⊕ K[2]) 作为标签,如图 7-4 所示。

图 7-4:当消息的最后一个块必须用 1 位和零填充以填满一个块时,基于 CMAC 块密码的 MAC 构造
第一个案例仅使用K[1],第二个案例仅使用K[2],但两者都使用主密钥K来处理在最后一个消息块之前的消息块。
与 CBC 加密模式不同,CMAC 不接受 IV(初始化向量)作为参数,并且是确定性的:对于同一个密钥,CMAC 始终为给定的消息M返回相同的标签,因为CMAC(M)的计算不是随机化的——这没问题,因为与加密不同,MAC 计算不需要随机化即可保证安全,这也避免了必须选择随机 IV 的负担。### 专用 MAC 设计
你已经看到如何回收哈希函数和块密码算法来构建 PRF,只要它们底层的哈希或密码算法是安全的,PRF 也会是安全的。像 HMAC 和 CMAC 这样的方案只是简单地结合现有的哈希函数或块密码算法来生成一个安全的 PRF 或 MAC。重用现有的算法虽然方便,但这是否是最有效的方法呢?
直观上,PRF(伪随机函数)和 MAC(消息认证码)相比无密钥哈希函数在安全性上所需的工作量更少——它们使用了一个秘密密钥,防止攻击者篡改算法,因为攻击者没有密钥。此外,PRF 和 MAC 仅暴露一个短标签给攻击者,而不像块密码算法那样暴露与消息同样长度的密文。因此,PRF 和 MAC 不需要哈希函数或块密码算法的最大计算能力——这就是专用设计的意义所在,即专门为作为 PRF 或 MAC 而设计的算法。
接下来的部分将重点介绍两种这样的算法:Poly1305 和 SipHash。我将解释它们的设计原理以及为什么它们(很可能)是安全的。
Poly1305
Poly1305 算法(发音为poly-thirteen-o-five)是由 Daniel J. Bernstein 于 2005 年设计的(他也是第五章中的 Salsa20 流密码和启发 BLAKE 及 BLAKE2 哈希函数的 ChaCha 密码的创造者)。Poly1305 经过优化,能够在现代 CPU 上实现超快的速度,截止目前,它已经成为 TLS 1.3 和 OpenSSH 等多种应用程序中支持的算法之一。与 Salsa20 不同,Poly1305 的设计基于上世纪 70 年代的技术——即通用哈希函数和 Wegman–Carter 构造方法。
通用哈希函数
Poly1305 MAC 使用了通用哈希函数。这种哈希函数比加密哈希函数要弱,但速度更快。例如,通用哈希函数不必具备抗碰撞性。
类似于伪随机函数(PRF),通用哈希由一个秘密密钥参数化:给定一个消息 M 和密钥 K,我们用 UH(K, M) 表示通用哈希函数 UH 的输出。通用哈希函数只有一个安全要求:对于任意两个消息 M[1] 和 M[2],以及一个随机密钥 K,UH(K, M[1]) 等于 UH(K, M[2]) 的概率必须是微不足道的。与 PRF 不同,通用哈希不需要是伪随机的;只是不能有一对 (M[1],M[2]) 对,能够在多个不同的密钥下生成相同的哈希值。由于它们的安全要求更容易满足,通用哈希函数需要的运算较少,比 PRF 快得多。
然而,你只能将通用哈希用作 MAC 来验证最多一个消息。例如,考虑 Poly1305 中使用的通用 多项式评估 哈希。(更多关于这一概念的信息,请参见 Edgar Gilbert、Jessie MacWilliams 和 Neil Sloane 在 1974 年发表的开创性文章《检测欺骗的编码》)。这种多项式评估哈希是由一个质数 p 参数化的,它接受一个由两个数字 R 和 K 组成的密钥(范围为 [1, p))以及一个包含 n 个块的消息 M(M[1],M[2],. . . ,Mn)。然后,你可以按照以下方式计算通用哈希的输出:

在此方程中,加号(+)表示正整数的加法,K^i 是 K 的 i 次方,"mod p" 表示结果对 p 取模(即结果除以 p 的余数;例如,12 mod 10 = 2,10 mod 10 = 0,8 mod 10 = 8,依此类推)。
因为你希望哈希操作尽可能快速,基于通用哈希的 MAC 通常使用 128 位的消息块和一个略大于 2¹²⁸ 的质数 p,例如 2¹²⁸ + 51。128 位宽度通过高效使用常见 CPU 的 32 位和 64 位算术单元,允许非常快速的实现。
安全限制
通用哈希有一个弱点:由于通用哈希只能安全地验证一个消息,攻击者可以通过请求两个消息的标签来突破之前的多项式评估 MAC。具体来说,他们可以请求一个所有块都为零的消息的标签——M[1] = M[2] = . . . = 0——从而得到标签 UH(R, K, 0) = R,进而找出秘密值 R。或者,他们可以请求一个消息的标签,其中 M[1] = 1 且 M[2] = M[3] = . . . = 0(其标签为 T = R + K),这样他们就可以通过从 T 中减去 R 来找出 K。现在攻击者知道了整个密钥(R,K),并且能够伪造任何消息的 MAC。
幸运的是,存在一种方法可以将单消息安全性提升到多消息安全性。
Wegman–Carter MAC
使用通用哈希函数对多个消息进行身份验证的技巧得益于 IBM 研究员 Mark Wegman 和 Lawrence Carter 以及他们 1981 年发表的论文《新哈希函数及其在身份验证和集合相等中的应用》。Wegman–Carter 结构通过使用两个密钥,K[1]和K[2],以及通用哈希函数和伪随机函数(PRF)来构建 MAC,返回结果为:

其中N是一个随机数,必须针对每个密钥K[2]使用最多一次,而PRF的输出与通用哈希函数UH的输出大小相同。通过将这两个值相加,PRF的强伪随机输出掩盖了UH的加密弱点。你可以将其视为对通用哈希结果的加密,其中 PRF 充当流密码,并通过使得使用相同密钥K[1]可以验证多个消息,来防止之前的攻击。
总结一下,Wegman–Carter 结构UH(K[1], M) + PRF(K[2], N)在假设以下条件的情况下,提供了一个安全的 MAC:
-
UH是一个安全的通用哈希。
-
PRF是一个安全的伪随机函数。
-
每个随机数N只针对每个密钥K[2]使用一次。
-
UH和PRF的输出值足够长,确保具有足够的安全性。
现在让我们看看 Poly1305 如何利用 Wegman–Carter 结构来构建一个安全且快速的 MAC。
Poly1305-AES
Poly1305 最初被提议为 Poly1305-AES,结合了 Poly1305 通用哈希和 AES 块密码。Poly1305-AES 比基于 HMAC 的 MAC,甚至比 CMAC 还要快,因为它只计算一个 AES 块,并通过一系列简单的算术运算并行处理消息。
给定 128 位的K[1]、K[2]和N,以及消息M,Poly1305-AES 返回以下结果:

模 2¹²⁸的约简确保结果适合 128 位。Poly1305 将消息M解析为一系列 128 位的块(M[1],M[2],...,Mn),并将每个块的最高有效位附加一个第 129 位,使得所有块的长度为 129 位。(如果最后一个块小于 16 字节,它会先填充一个 1 位,接着是 0 位,然后再添加最后的第 129 位。)接着,Poly1305 计算该多项式,得到以下结果:

这个表达式的结果是一个最多 129 位长的整数。当你将其与 128 位的值AES(K[2], N)相加时,结果会模 2¹²⁸约简,生成一个 128 位的 MAC。
注意
我曾经描述过 Wegman–Carter 使用 PRF,但 AES 不是 PRF,它是伪随机置换(PRP)。然而,这不影响结果,因为 Wegman–Carter 结构在 PRP 和 PRF 上都能很好地工作。这是因为如果你给定一个函数,它既可以是 PRF 也可以是 PRP,单从函数的输出值无法判断它是 PRF 还是 PRP。换句话说,区分 PRP 和 PRF 在计算上是困难的。
Poly1305-AES 的安全分析(参见 <wbr>cr<wbr>.yp<wbr>.to<wbr>/mac<wbr>/poly1305<wbr>-20050329<wbr>.pdf 中的 "Poly1305-AES 消息认证码")表明,只要 AES 是一个安全的分组密码,并且所有实现都正确,Poly1305-AES 就是 128 位安全的——这与任何加密算法一样。
你可以将 Poly1305 通用哈希与 AES 之外的其他算法结合使用。例如,Poly1305 被与流密码 ChaCha 一起使用(参见 RFC 7539,"ChaCha20 和 Poly1305 用于 IETF 协议")。毫无疑问,Poly1305 将继续在需要快速 MAC 的地方得到应用。
尽管 Poly1305 既快速又安全,但它也有一些缺点。首先,它的多项式评估实现起来较为复杂,特别是对于那些不熟悉相关数学概念的人来说尤为困难。(参见 <wbr>github<wbr>.com<wbr>/floodyberry<wbr>/poly1305<wbr>-donna 的示例。)其次,单独使用时,它只对一个消息是安全的,除非你使用 Wegman-Carter 构造。但是在这种情况下,它需要使用一个 nonce,并且如果 nonce 被重复使用,算法将变得不安全。最后,Poly1305 对长消息进行了优化,但如果仅处理小消息(比如少于 128 字节),它就显得有些过度。在这种情况下,SipHash 是一个不错的解决方案。
SipHash
我与 Dan Bernstein 一起在 2012 年设计了 SipHash,最初是为了解决一个非加密问题:哈希表的拒绝服务攻击。哈希表是一种数据结构,特别用于编程语言中高效地存储元素。在 SipHash 之前,哈希表依赖于非加密的密钥哈希函数,这些哈希函数经常能轻易找到冲突,这可以被利用来减慢系统速度并执行拒绝服务攻击。我们观察到伪随机函数(PRF)可以解决这个问题,于是着手设计了 SipHash,一个适用于哈希表的 PRF。由于哈希表主要处理短输入,SipHash 对短消息进行了优化。SipHash 是一个完整的 PRF 和 MAC,在大多数输入较短的场景下表现出色。
SipHash 使用了一个技巧,使它比基本的海绵函数更安全:它不是在置换前仅对消息块进行一次异或运算,而是在置换前后都对消息块进行异或运算,正如 图 7-5 所示。SipHash 的 128 位密钥被视为两个 64 位字,K[1] 和 K[2],它们异或成一个 256 位的固定初始状态,这个状态又被视为四个 64 位字。接下来,密钥被丢弃,计算 SipHash 就简化为通过一个叫做 SipRound 的核心函数进行迭代,然后将消息块异或到四字内部状态中。最后,SipHash 通过将四个状态字异或在一起,返回一个 64 位标签。

图 7-5:SipHash-2-4 处理一个 15 字节的消息(一个块, M1,8 字节,和一个块, M2,7 字节,再加 1 字节填充)
SipRound 函数使用一堆 XOR 运算以及加法和字旋转来使函数安全。 SipRound 通过从上到下执行以下操作来转换四个 64 位字(a、b、c、d)的状态。左侧和右侧的操作是独立的,可以并行执行:

这里,a += b 是 a = a + b 的简写,b <<< = 13 是 b = b <<< 13 的简写(64 位字 b 左旋 13 位)。
这些对 64 位字的简单操作几乎是您需要实现以计算 SipHash 的全部内容——尽管您不必自己实现它。 您可以在大多数语言中找到现成的实现,如 C、Go、Java、JavaScript、Python 和 Rust。
注意
我写了 SipHash-x-y 作为 SipHash 版本,意味着在每个消息块注入之间进行 x 个 SipRounds,然后进行 y 轮。更多轮次需要更多操作,这会减慢操作速度,但也会增加安全性。 默认版本是 SipHash-2-4(简称为 SipHash),迄今为止它已经抵抗了密码分析。 请注意,我还定义了 SipHash128,SipHash 的一个生成 128 位标签的版本。
许多系统,如 Rust 语言、OpenBSD 操作系统和比特币区块链,内部使用 SipHash。Linux 内核也使用 SipHash,并且还使用 HalfSipHash,“SipHash 的不安全的年轻表亲”,一个具有 64 位密钥和 32 位输出的较小版本(参见<wbr>docs<wbr>.kernel<wbr>.org<wbr>/security<wbr>/siphash<wbr>.html)。### 事情可能出错的地方
像密码和无密钥哈希函数一样,纸面上安全的 MAC 和 PRF 在实际环境中使用时可能会受到攻击。 让我们讨论两个例子。
MAC 验证的时序攻击
侧信道攻击 针对的是加密算法的实现而不是算法本身。特别是 时序攻击 利用算法的执行时间来确定秘密信息,如密钥、明文和秘密随机值。变时间字符串比较不仅在 MAC 验证中引入漏洞,还在许多其他加密和安全功能中引入漏洞。
当远程系统在依赖于标签值的时间内验证标签时,MAC(消息认证码)可能会受到定时攻击的影响,从而允许攻击者通过尝试多个错误的标签来确定正确的消息标签,最终找出完成时间最长的那个。当服务器逐字节按顺序比较正确的标签和错误标签,直到字节不同时,问题就出现了。例如,列表 7-1 中的 Python 代码逐字节按可变时间比较两个字符串:如果第一个字节不同,函数仅经过一次比较就返回;如果字符串 x 和 y 相同,函数会根据字符串的长度进行 n 次比较。
def compare_mac(x, y, n):
if len(x) != len(y):
return False
if len(x) != n:
return False
for i in range(n):
if x[i] != y[i]:
return False
return True
列表 7-1:比较两个 n-字节字符串,采用可变时间
为了演示 compare_mac() 函数的漏洞,我们将编写一个程序,测量 100,000 次调用 compare_mac() 的执行时间,首先是相同的 16 字符 x 和 y 值,然后是第三个字节不同的 x 和 y 值。后者的比较应该花费明显更少的时间,因为 compare_mac() 比较的字节比相同的 x 和 y 少,正如列表 7-2 所示。
from time import time
MAC1 = 'abcdefghijklmnop'
MAC2 = 'abXdefghijklmnop'
TRIALS = 100000
def compare_mac(x, y, n):
if len(x) != len(y):
return False
if len(x) != n:
return False
for i in range(n):
if x[i] != y[i]:
return False
return True
# Each call to compare_mac() will look at all 16 characters.
start = time()
for i in range(TRIALS):
compare_mac(MAC1, MAC1, len(MAC1))
end = time()
print("%0.5f" % (end-start))
# Each call to compare_mac() will look at three characters.
start = time()
for i in range(TRIALS):
compare_mac(MAC1, MAC2, len(MAC1))
end = time()
print("%0.5f" % (end-start))
列表 7-2:执行 compare_mac() 时测量时间差异,来自列表 7-1
在搭载 ARM M1 芯片的 MacBook Pro 上,列表 7-2 中的程序执行时间分别约为 67 毫秒和 26 毫秒。这个差异足够大,可以帮助识别算法内部发生了什么。现在,将差异转移到字符串中的其他偏移位置,你将会观察到不同偏移位置的执行时间不同。如果MAC1是正确的 MAC 标签,而MAC2是攻击者尝试的标签,你就能轻松识别第一个差异的位置,这个位置表示正确猜测的字节数。
如果执行时间不依赖于某个秘密时序,那么定时攻击将无法生效,这也是实现者努力编写恒定时间实现的原因——即无论输入的秘密值是什么,代码完成所需的时间都完全相同。例如,列表 7-3 中的 C 函数以恒定时间比较两个size字节的缓冲区:临时变量result只有在两个缓冲区之间存在差异时才为非零。
int cmp_const(const void *a, const void *b, const size_t size)
{
const unsigned char *_a = (const unsigned char *) a;
const unsigned char *_b = (const unsigned char *) b;
unsigned char result = 0;
size_t i;
for (i = 0; i < size; i++) {
result |= _a[i] ^ _b[i];
}
return result; /* Returns 0 if *a and *b are equal, nonzero otherwise */
}
列表 7-3:用于更安全的 MAC 验证的恒定时间比较两个缓冲区 #### 当海绵泄漏时
基于置换的算法,如 SHA-3 和 SipHash,简单、易于实现,且具有紧凑的实现,但在面对恢复系统状态快照的旁路攻击时,它们比较脆弱。例如,如果一个进程能够随时读取 RAM 和寄存器的值,或者读取内存的核心转储,攻击者就能确定 SHA-3 在 MAC 模式下的内部状态,或 SipHash 的内部状态,然后计算置换的逆操作来恢复初始的秘密状态。之后,他们可以伪造任何消息的标签,从而破坏 MAC 的安全性。
幸运的是,这种攻击对基于压缩函数的 MAC(如 HMAC-SHA-256 和带密钥的 BLAKE2)无效,因为攻击者需要在密钥使用的精确时刻捕获内存快照。结果是,如果你处于一个可能泄露进程部分内存的环境中,你可以使用基于不可逆变换压缩函数的 MAC,而不是基于置换的 MAC。
进一步阅读
受人尊敬的 HMAC 值得更多的关注,比我在这里所能提到的要多得多,特别是它的广泛应用和最终因与弱哈希函数结合而导致的衰退背后的思维过程。我推荐 1996 年由 Mihir Bellare、Ran Canetti 和 Hugo Krawczyk 撰写的论文《消息认证的哈希函数键控》,该论文介绍了 HMAC 及其同类 NMAC,还有 2006 年 Bellare 撰写的后续论文《NMAC 和 HMAC 的新证明:无需碰撞抗性的安全性》,该论文证明了 HMAC 不需要碰撞抗性的哈希函数,只需要具有压缩函数的伪随机函数(PRF)的哈希函数。在进攻方面,Pierre-Alain Fouque、Gaëtan Leurent 和 Phong Nguyen 在 2007 年的论文《HMAC/NMAC-MD4 和 NMAC-MD5 的完整密钥恢复攻击》中展示了如何攻击基于脆弱哈希函数(如 MD4 或 MD5)构建的 HMAC 和 NMAC。(HMAC-MD5 和 HMAC-SHA-1 并未完全崩溃,但风险已经足够高。)
Wegman–Carter MACs 也值得更多关注,无论是从实际应用的角度,还是从其底层理论的角度。Wegman 和 Carter 的开创性论文可以在 <wbr>cr<wbr>.yp<wbr>.to<wbr>/bib<wbr>/entries<wbr>.html 中找到。其他前沿设计包括 UMAC 和 VMAC,它们是处理长消息时速度最快的 MAC 之一。
本章没有讨论的另一种 MAC 是 Pelican,它使用经过简化的 AES 区块密码(将完整区块密码的 10 轮减少到 4 轮)来验证消息块,在一种简易结构中进行认证,具体描述可以参考 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2005<wbr>/088。不过,Pelican 更多的是一个好奇心驱动的研究,实际上很少被使用。
最后但同样重要的是,如果你有兴趣寻找密码学软件中的漏洞,可以关注 CBC-MAC 的使用,或者关注由于 HMAC 处理任意大小的密钥而导致的弱点——如果 K 太长,就将 Hash(K) 作为密钥,而不是 K,从而使得 K 和 Hash(K) 成为 等效密钥。或者,你也可以关注那些应该使用 MAC 却没有使用的系统——这种情况很常见。
在 第八章 中,我们将结合 MAC 和密码学算法来保护消息的真实性、完整性和保密性。我们也会在没有使用 MAC 的情况下实现这一目标,得益于认证密码,它将基础密码和 MAC 的功能结合在一起,每次加密时都会返回一个标签。
第九章:8 认证加密

本章讲解的是一种算法,它不仅保护消息的机密性,还保护其真实性。回想一下第七章,消息认证码(MAC)通过创建一个标签(类似于签名)来保护消息的真实性。像 MAC 一样,本章中的认证加密(AE)算法不仅生成认证标签,还会加密消息。换句话说,单一的 AE 算法提供了常规密码和 MAC 的功能。
结合密码学和消息认证码(MAC)可以实现不同级别的认证加密,正如你在本章中将学到的那样。我们将回顾几种将 MAC 与密码学结合的方式,讨论哪些方法是最安全的,并探索既生成密文又生成认证标签的密码算法。然后,我们将介绍四种重要的认证加密算法:三种基于分组密码的构造,重点讨论广泛使用的高级加密标准(AES-GCM)模式,以及一种仅使用置换算法的密码。
使用 MAC 的认证加密
图 8-1 展示了三种将 MAC 和密码结合来同时加密和认证明文的方法:加密与 MAC、MAC-再加密和加密-再 MAC。

图 8-1:密码与 MAC 的组合
这些组合方法的区别在于应用加密和生成认证标签的顺序。选择特定的 MAC 或密码算法并不重要,只要每种算法本身是安全的,并且 MAC 和密码算法使用不同的密钥。
在加密与 MAC 组合中,明文被加密并且认证标签直接从明文生成,因此这两步操作(加密和认证)是独立的,你可以并行计算它们。在 MAC-再加密方案中,你首先从明文生成标签,然后将明文和 MAC 一起加密。在加密-再 MAC 方法中,你首先加密明文,然后从密文中生成标签。让我们看看哪种方法可能是最安全的。
加密与 MAC 方法
加密与 MAC方法分别计算密文和 MAC 标签。给定明文P,发送方计算密文C = E(K[1], P),其中E是加密算法,C是得到的密文。你从明文计算认证标签T,即T = MAC(K[2], P)。这两步操作是独立的,因此可以并行计算。
一旦你生成了密文和认证标签,发送方将两者传输给预期的接收方。当接收方收到 C 和 T 时,他们通过计算 P = D(K[1], C) 解密 C 以获得明文 P。接下来,他们使用解密后的明文计算 MAC(K[2], P),并将结果与接收到的 T 进行比较。如果 C 或 T 被损坏,验证将失败,消息将被视为无效。
从理论上讲,加密并附加 MAC 是最不安全的 MAC 和密码组合,因为即使是一个安全的 MAC 也可能泄露 P 的信息,使得 P 更容易被恢复。因为使用 MAC 的目的是为了使标签不可伪造,并且标签不一定是随机的,明文 (P) 的认证标签 (T) 即使在 MAC 被认为是安全的情况下,也可能泄露信息!(如果 MAC 是伪随机函数,标签就不会泄露任何关于 P 的信息。)
尽管相对较弱,许多系统仍然继续支持加密并附加 MAC,包括安全传输层协议 SSH,其中每个加密数据包 C 后面跟随标签 T = MAC(K, N || P),其中 N 是一个 32 位的序列号,每个数据包递增。在实践中,由于使用了像 HMAC-SHA-256 这样的强大 MAC 算法,它们不会泄露 P 的信息,因而加密并附加 MAC 在 SSH 中被证明足够安全。输入以下命令
$ **ssh -Q mac**
查看 OpenSSH 软件支持的 MAC 列表。
MAC-然后加密组合
MAC-然后加密 组合通过首先计算认证标签 T = MAC(K[2], P) 来保护消息 P。接下来,根据 C = E(K[1], P || T),它将明文和标签一起加密生成密文。
一旦这些步骤完成,发送方只传输 C,它包含了加密的明文和标签。接收方收到后,通过计算 P || T = D(K[1], C) 解密 C 以获得明文和标签 T。接下来,接收方通过根据 MAC(K[2], P) 直接从明文计算标签来验证接收到的标签 T,以确认计算出的标签与标签 T 相等。
与加密并附加 MAC 类似,在使用 MAC-然后加密时,接收方必须在确定是否接收到损坏的数据包之前先解密 C —— 这个过程可能会让接收方看到潜在损坏的明文。尽管如此,MAC-然后加密比加密并附加 MAC 更安全,因为它隐藏了明文的认证标签,从而防止标签泄露明文的信息。
TLS 协议已使用 MAC-然后加密多年,但 TLS 1.3 将 MAC-然后加密替换为经过认证的密码(有关 TLS 1.3 的更多信息,请参见第十三章)。
加密-然后 MAC 组合
encrypt-then-MAC 组合将两个值发送给接收方:由 C = E(K[1], P) 产生的密文和基于密文的标签 T = MAC(K[2], C)。接收方使用 MAC(K[2], C) 计算标签,并验证它是否等于接收到的 T。如果值相等,则计算明文为 P = D(K[1], C);如果不相等,则丢弃密文。
这种方法的一个优点是接收方只需要计算 MAC 来检测消息是否损坏,这意味着不需要解密损坏的密文。此外,攻击者不能发送 C 和 T 的对给接收方解密,除非他们已经破解了 MAC,这使得攻击者更难将恶意数据传输给接收方。
这种特性组合使得 encrypt-then-MAC 比 encrypt-and-MAC 和 MAC-then-encrypt 方法更强大。这也是广泛使用的 IPsec 安全通信协议套件采用这种方法来保护数据包(例如,在 VPN 隧道内)的原因之一。
注意,SSH 和 TLS 并没有使用 encrypt-then-MAC,因为在创建 SSH 和 TLS 时,其他方法已经足够有效——这并不是因为理论上的弱点不存在,而是因为不良特性不一定会变成实际的漏洞。
认证密码算法
认证密码算法 是密码和 MAC 组合的替代方案。它们像普通的密码算法,只不过它们会在返回密文的同时还返回一个认证标签。
你可以将认证密码加密表示为 AE(K, P) = (C, T)。术语 AE 代表 认证加密,它基于密钥 (K) 和明文 (P),返回密文 (C) 和生成的认证标签 (T)。换句话说,单一的认证密码算法完成了密码和 MAC 组合的工作,使得它更简洁、更快速,并且通常更安全。
你可以将认证密码解密表示为 AD(K, C, T) = P。这里,AD 代表 认证解密,它根据密文 (C)、标签 (T) 和密钥 (K) 返回明文 (P)。如果标签验证失败,AD 会返回错误,防止接收方处理可能被伪造的明文。同样,如果 AD 返回明文,说明它是由知道秘密密钥的某人或某物加密的。
认证密码的基本安全要求很简单:其认证强度应该与 MAC 的强度一样,这意味着应该不可能伪造一个密文和标签对 (C, T),使得解密函数 AD 接受并解密它。
就保密性而言,一个认证密码比一个基础密码从根本上更强大,因为持有秘密密钥的系统只有在认证标签有效时才会解密密文。如果标签无效,明文会被丢弃。这一特性防止了攻击者进行选择密文查询攻击,即他们创建密文并请求相应的明文。
带关联数据的认证加密
密码学家将关联数据定义为通过认证密码处理的任何数据,使得这些数据经过认证(通过认证标签)但不被加密。默认情况下,所有输入到认证密码的数据都是被加密和认证的。
假设你想要验证一条消息,包括它的未加密部分,但不加密整个消息——也就是说,你想验证和传输数据,除了加密的消息之外。例如,如果一个密码算法处理一个由头部和有效负载组成的网络数据包,你可能会选择加密有效负载来隐藏实际传输的数据,但不加密头部,因为头部包含将数据包传递给最终接收者所需的信息。与此同时,你可能仍然希望验证头部数据,以确保它是从预期的发送者收到的。
为了实现这些目标,密码学家创造了带关联数据的认证加密(AEAD)的概念。AEAD 算法允许你将明文数据附加到密文中,以一种方式使得如果明文数据被篡改,认证标签将无法通过验证,密文也无法解密。这些明文数据必须以安全的方式进行编码和序列化,以防止对其内容的歧义解释。
你可以将 AEAD 操作写成AEAD(K, P, A) = (C, A, T)。给定一个密钥(K)、明文(P)和关联数据(A),AEAD 返回密文、未加密的关联数据A和认证标签。AEAD 保持未加密的关联数据不变,密文是明文的加密。认证标签依赖于P和A,并且只有当C和A都没有被修改时,认证标签才会被验证为有效。
由于认证标签依赖于A,你可以通过ADAD(K, C, A, T) = (P, A)来计算带关联数据的解密。解密需要密钥、密文、关联数据和标签,以便计算明文和关联数据,如果C或A被破坏,解密将失败。
使用 AEAD 时,你可以将A或P留空。如果关联数据A为空,AEAD 就变成了一个普通的认证密码;如果P为空,那它就只是一个 MAC。
注意
截至本文写作时,AEAD 是当前认证加密的标准。由于几乎所有当前使用的认证密码都支持关联数据,在本书中提到的认证密码,除非特别说明,都是指 AEAD。当讨论 AEAD 的加密和解密操作时,我将分别称之为 AE 和 AD。*
可预测性和随机数
回想一下第一章中提到的,为了保证安全,加密方案必须是不可预测的,并且在重复加密相同明文时返回不同的密文——否则,攻击者就能确定是否加密了相同的明文。为了不可预测性,分组密码和流密码会给密码算法提供一个额外的参数:初始值(IV)或随机数——这是一个只能使用一次的数字。认证密码也使用了同样的技巧。因此,你可以将认证加密表示为AE(K, P, A, N),其中N是随机数。加密操作需要选择一个从未与相同密钥一起使用过的随机数。
与分组密码和流密码一样,使用认证密码进行解密时需要使用加密时所用的随机数,以确保解密操作正确。因此,你可以将解密表示为AD(K, C, A, T, N) = (P, A),其中N是用于生成C和T的随机数。
良好认证密码的标准
自 2000 年代初以来,研究人员一直在努力定义什么才是一个好的认证密码,但答案仍然难以捉摸。由于 AEAD 有许多不同角色的输入,其安全性比仅加密消息的基本密码更难定义。例如,研究文章《随机数被注意到:AEAD 再探》,可在<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2019<wbr>/624查看,提出了一个基于随机数加密的理论框架。尽管如此,在本节中,我将总结评估认证密码的安全性、性能和功能时需要考虑的最重要标准。
安全性
衡量认证密码强度的最重要标准是其保护数据机密性的能力(即明文的保密性),以及通信的真实性和完整性(例如,MAC 检测损坏消息的能力)。认证密码必须在这两个领域都具备竞争力:其机密性必须与最强密码相当,而其真实性必须与最好的 MAC 相当。换句话说,如果你去掉 AEAD 中的认证部分,你应该得到一个安全的密码,而如果去掉加密部分,你应该得到一个强大的 MAC。
认证密码强度的另一个衡量标准是它在面对重复的随机数时的脆弱性。例如,如果随机数被重复使用,攻击者是否能够解密密文或识别明文之间的差异?
研究人员称这种强度的概念为滥用抗性,并设计了滥用抗性的认证密码,来评估重复使用随机数的影响,并尝试确定在这种攻击面前,机密性、真实性或两者是否会受到破坏,以及加密数据可能泄露哪些信息。
性能
和每种加密算法一样,认证密码的吞吐量是通过每秒处理的比特数来衡量的。这一速度取决于密码算法执行的操作数量以及认证功能的额外开销。认证密码的额外安全特性会带来性能损失。然而,密码性能的衡量标准不仅仅是纯粹的速度,还包括并行性、结构以及密码是否支持流式处理。我们来更深入地了解这些概念。
密码的并行性是衡量其在不等待前一个数据块处理完成的情况下同时处理多个数据块的能力。基于块密码的设计可以轻松实现并行化,当每个数据块可以独立于其他数据块进行处理时。例如,第四章中的 CTR 块密码模式是可并行的,而 CBC 加密模式则不可并行,因为块是相互链式的。
认证密码的内部结构是另一个重要的性能标准。主要有两种结构类型:单层和双层。在双层结构中(例如广泛使用的 AES-GCM),一个算法处理明文,然后第二个算法处理结果。通常,第一个层是加密层,第二个层是认证层。但正如你可能预料到的那样,双层结构使得实现更加复杂,并且通常会导致计算速度变慢。
当认证密码能够逐块处理消息并丢弃已处理的块时,它就是可流式处理(也称为在线密码)。相反,不能流式处理的密码必须存储整个消息,通常是因为它们需要对数据进行两次连续的处理:一次从头到尾,另一次从尾到头,利用第一次处理得到的数据。
由于可能的高内存需求,某些应用程序无法使用不可流式处理的密码。例如,一台路由器可以接收加密的数据块,解密它,然后返回明文数据块,然后再继续解密后续的消息块,尽管接收解密消息的接收者仍需验证解密数据流末尾发送的认证标签。
其他特性
功能标准是指密码或其实现的特性,这些特性与安全性或性能无直接关系。例如,一些认证密码只允许关联数据位于待加密数据之前(因为它们需要访问关联数据以开始加密)。其他的则要求关联数据位于待加密数据之后,或者支持在任何位置(即使是明文块之间)插入关联数据。最后这种情况是最理想的,因为它允许用户在任何可能的情况下保护数据,但这也是最难以安全设计的:更多的功能往往带来更多的复杂性和潜在漏洞。
另一个需要考虑的功能标准是,你是否可以使用相同的核心算法进行加密和解密。例如,许多认证密码基于 AES 区块密码,它规定使用两种相似的算法来加密和解密数据块。如第四章中所述,CBC 区块密码模式需要两种算法,而 CTR 模式只需要加密算法。同样,认证密码可能不需要两种算法。虽然实现加密和解密算法的额外成本不会影响大多数软件,但在低成本专用硬件上,往往会有所体现,在这种硬件上,实施成本通常以逻辑门或加密占用的硅面积来衡量。
AES-GCM 认证密码标准
AES-GCM 是最广泛使用的认证密码。AES-GCM 基于 AES 算法,而 Galois 计数器模式(GCM)的工作方式本质上是对 CTR 模式的一种调整,加入了一个小巧高效的组件来计算认证标签。在我写这篇文章时,AES-GCM 已成为 NIST 标准(SP 800-38D),是 NSA Suite B 的一部分,并且被 IETF 认可,用于安全网络协议 IPsec、SSH 以及 TLS 1.2 和 1.3。
注意
尽管 GCM 可以与任何区块密码一起使用,但你可能只会看到它与 AES 一起使用。
GCM 内部机制
图 8-2 展示了 AES-GCM 的工作原理:由一个秘密密钥 (K) 参数化的 AES 实例将一个由随机数 (N) 与计数器(从 1 开始,接着递增为 2、3 等)连接起来组成的块进行转换,然后将结果与明文块进行异或操作,得到密文块。到目前为止,与 CTR 模式相比,这没有什么新意。

图 8-2:AES-GCM 模式,应用于一个关联数据块, A1,以及两个明文块, P1 和 P2。圆圈中的乘号表示由 H,从 K派生出的认证密钥进行多项式乘法。
密文块随后通过异或和乘法的组合进行混合(正如你接下来会看到的)。你可以把 AES-GCM 看作是做了 (1) 在 CTR 模式下的加密和 (2) 对密文块进行 MAC 操作。因此,AES-GCM 本质上是一种加密-再进行 MAC 构造,其中 AES-CTR 使用 128 位密钥 (K) 和 96 位随机数 (N) 进行加密。
为了验证密文的真实性,GCM 使用了 Wegman–Carter MAC(见 第七章),它将值 AES(K, N || 0) 与通用哈希函数 GHASH 的输出进行异或。在 图 8-2 中,GHASH 对应于操作序列 “⊗[H]”,然后与 len(A) || len(C) 进行异或,或与 A(关联数据)的比特长度以及 C(密文)的比特长度进行异或。
因此,你可以将认证标签的值表示为 T = GHASH (H, A, C) ⊕ AES(K, N || 0),其中 H 是 哈希密钥,或 认证密钥。这个密钥被确定为 H = AES(K, 0),即等于由一系列空字节组成的块的加密(为了清晰起见,我没有在 图 8-2 中包含这一步)。
注意
在 GCM 中,GHASH 并不直接使用 K ,以确保如果 GHASH 的密钥被泄露,主密钥 K 仍然保持秘密。给定 K,你可以通过计算 AES(K, 0) 来得到* H,但你无法从这个值恢复 K,因为 K* 在这里作为 AES 的密钥。*
GHASH 使用 多项式表示法 将每个密文块与认证密钥 H 相乘。这种多项式乘法的使用使得 GHASH 在硬件和软件中都非常快,因为许多常见微处理器都提供了一个特殊的多项式乘法指令(CLMUL,用于无进位乘法)。
可惜,GHASH 远非理想。首先,它的速度并不理想。即使使用了 CLMUL 指令,加密明文的 AES-CTR 层仍然比 GHASH MAC 更快。其次,正确实现 GHASH 非常麻烦。事实上,即便是 OpenSSL 项目中经验丰富的开发人员——世界上最广泛使用的加密软件——也曾在实现 AES-GCM 的 GHASH 时犯错。有一个提交在 gcm_ghash_clmul 函数中存在一个 bug,允许攻击者伪造 AES-GCM 的有效 MAC。 (幸运的是,英特尔工程师在 bug 进入下一版 OpenSSL 发布之前发现了这个错误。)
GCM 安全性
AES-GCM 的最大弱点是它在面对随机数重复时的脆弱性。如果你使用相同的随机数 N 加密两个不同的消息,那么观察这两个密文的攻击者可以确定它们各自明文的异或值。攻击者还可以恢复出认证密钥 H,并用它伪造任何密文、关联数据或它们的组合的标签。
查看 AES-GCM 计算背后的基本代数(见 图 8-2)有助于澄清这种脆弱性。你可以将标签 (T) 计算为 T = GHASH (H, A, C) ⊕ AES(K, N || 0),其中 GHASH 是一个具有线性相关输入输出的通用哈希函数。
如果你用相同的随机数 N 计算两个标签 T[1] 和 T[2],那么 AES 部分将会消失。如果你有两个标签,T[1] = GHASH(H, A[1], C[1]) ⊕ AES(K, N || 0) 和 T[2] = GHASH(H, A[2], C[2]) ⊕ AES(K, N || 0),将它们进行异或操作,结果如下:

如果你两次使用相同的随机数,那么攻击者可以恢复出 GHASH(H, A[1], C[1]) ⊕ GHASH(H, A[2], C[2]),其中 A[1]、C[1]、A[2] 和 C[2] 是已知的。GHASH 的线性特性允许攻击者恢复出 H。
2016 年,研究人员扫描了互联网,查找通过 HTTPS 服务器暴露的 AES-GCM 实例,试图寻找存在重复随机数的系统(参见研究文章 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2016<wbr>/475)。他们发现了 184 台存在重复随机数的服务器,其中 23 台总是使用全零字符串作为随机数。
GCM 效率
GCM 模式的一个优点是 GCM 加密和解密过程中的每个块可以独立处理,从而可以将它们的计算并行化。然而,GMAC 计算是无法并行化的,因为它必须从密文的开始到结束计算,一旦 GHASH 处理完任何关联数据后才可以计算。这种无法并行化的特性意味着,任何先接收明文然后接收关联数据的系统都必须等待所有关联数据被读取并哈希处理之后,才能对第一个密文块进行哈希处理。
然而,GCM 是可流式处理的:由于其两层计算可以流水线处理,因此无需在计算 GHASH 之前存储所有密文块,因为 GHASH 会在每个块被加密时处理它。换句话说,P[1] 会加密为 C[1],然后 GHASH 在 P[2] 加密为 C[2] 的同时处理 C[1],之后 P[1] 和 C[1] 就不再需要,以此类推。
OCB 认证加密模式
偏移代码本(OCB)首次开发于 2001 年,早于 GCM,并且像 GCM 一样,它从块加密算法生成一个认证的密码文本,尽管它做得更快、更简单。由于在 2021 年之前,OCB 的使用受专利保护,并且需要从发明者那里获得许可,因此它尚未广泛采用。现在,OCB 对任何人免费开放,任何应用程序都可以使用。
与 GCM 不同,OCB 将加密和认证融合为一个处理层,并使用一个密钥。没有单独的认证层,因此 OCB 提供的认证几乎是免费的,并且执行的块加密调用几乎与非认证加密模式一样多——OCB 几乎与 ECB 模式一样简单(参见 第四章),只不过它更安全。
OCB 内部结构
图 8-3 显示了 OCB 的工作原理:它将每个明文块 P 加密为一个密文块 C = E(K, P ⊕ O) ⊕ O,其中 E 是一个块加密函数。在这里,O(偏移量)是一个值,它依赖于密钥和每处理一个新块时递增的随机数。

图 8-3:OCB 在处理两个明文块且没有关联数据时的加密过程
为了生成认证标签,OCB 首先对明文块进行异或运算,计算 S = P[1] ⊕ P[2] ⊕ P[3] ⊕ . . . 然后,标签为 T = E(K, S ⊕ O^),其中 O^ 是通过处理最后一个明文块时的偏移量计算出的偏移值。
像 AES-GCM 一样,OCB 也支持将关联数据作为一系列块,即 A[1]、A[2] 等。当 OCB 加密的消息包含关联数据时,按照公式计算认证标签。

在这里,OCB 定义了与用于加密 P 的偏移量不同的偏移量。
与 GCM 和加密后再 MAC 不同,后者通过将密文块组合形成标签,OCB 通过组合明文数据来计算认证标签。这种方法没有问题,且 OCB 得到了坚实的安全证明。
注意
有关如何实现 OCB 的更多信息,请参见 RFC 7253 或 2011 年 Ted Krovetz 和 Phillip Rogaway 撰写的论文《认证加密模式的软件性能》,该文介绍了最新和最好的 OCB 版本,即 OCB3。有关 OCB 的更多细节,请参见 FAQ: <wbr>web<wbr>.cs<wbr>.ucdavis<wbr>.edu<wbr>/rogaway<wbr>/ocb<wbr>/ocb<wbr>-faq<wbr>.htm。
OCB 安全性
OCB 对重复的 nonce(随机数)比 GCM 稍微不那么脆弱。如果你使用同一个 nonce 两次,攻击者可以看到两个密文后,发现例如第一个消息的第三个明文块与第二个消息的第三个明文块相同。使用 GCM 时,攻击者不仅能发现重复项,还能找出相同位置块之间的异或差异。因此,重复 nonce 对 GCM 的影响比 OCB 更严重。
与 GCM 一样,重复的 nonce 会危及 OCB 的真实性,尽管影响较小。例如,攻击者可以将用 OCB 认证的两条消息中的块组合起来,生成一个与原始两条消息之一相同的加密消息,并且具有相同的校验和和标签,但攻击者无法像在 GCM 中那样恢复秘密密钥。
2023 年,密码学家发现,使用非常短的 nonce(6 位)的 OCB3 会显著降低其安全性;请参阅这篇文章:* <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2023<wbr>/326。请注意,OCB2 版本已被破解,详情请参见这篇文章: <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2019<wbr>/311<wbr>.pdf*。
OCB 效率
OCB 和 GCM 的效率大致相同。像 GCM 一样,OCB 也是可以并行处理和流式传输的。在原始效率方面,GCM 和 OCB 对底层块加密算法(通常是 AES)调用次数差不多,但 OCB 比 GCM 略快,因为它只需要对明文进行异或运算,而不是像相对昂贵的 GHASH 计算那样执行额外的操作。(在早期的英特尔微处理器中,AES-GCM 的速度曾比 AES-OCB 慢三倍以上,因为 AES 和 GHASH 指令必须争夺 CPU 资源,无法并行执行。)
OCB 和 GCM 实现之间的一个重要区别是,OCB 需要块密码的加密和解密函数来进行加密和解密,这增加了硬件实现的成本,尤其是在加密组件的硅片资源有限的情况下。相比之下,GCM 只使用加密函数来进行加密和解密。
SIV 认证密码模式
合成初始化向量(SIV) 是一种通常与 AES 一起使用的认证密码模式。与 GCM 和 OCB 不同,即使使用相同的随机数两次,SIV 依然是安全的:攻击者如果获取到使用相同随机数加密的两个密文,最多只能知道是否对相同的明文进行过两次加密。与 GCM 或 OCB 不同,攻击者无法判断两个消息的第一个块是否相同,因为用于加密的随机数是通过给定的随机数和明文的组合来计算的。
SIV 构造的规范比 GCM 更为通用。与 GCM 的 GHASH 详细内部实现不同,SIV 只告诉你如何将一个密码 (E) 和一个伪随机函数 (PRF) 结合起来得到一个认证密码:首先计算标签 T = PRF(K[1], N || P),然后计算密文 C = E(K[2], T, P),其中 T 作为 E 的随机数。因此,SIV 需要两个密钥 (K[1] 和 K[2]) 以及一个随机数 (N)。
SIV 的主要限制是它不可流式处理:在计算 T 后,它必须将整个明文 P 保存在内存中。换句话说,要用 SIV 加密一个 100GB 的明文,你必须先存储这 100GB,以便 SIV 加密可以读取它。
文档 RFC 5297 基于 2006 年 Phillip Rogaway 和 Thomas Shrimpton 发表的论文《确定性认证加密》,指定 SIV 使用 CMAC-AES(一个基于 AES 的 MAC 构造)作为伪随机函数(PRF),并使用 AES-CTR 作为密码算法。2015 年,提出了一种更高效的 SIV 版本——GCM-SIV,它结合了 GCM 的快速 GHASH 函数和 SIV 模式,并且几乎与 GCM 一样快。然而,与原始的 SIV 一样,GCM-SIV 也不可流式处理。(更多信息请参见 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2015<wbr>/102。)
基于置换的 AEAD
现在介绍一种完全不同的认证密码构建方法:我们将不围绕像 AES 这样的块密码构建操作模式,而是看一种围绕置换构建模式的密码。置换简单地将输入转换为大小相同的输出,可逆且不使用密钥,这是最简单的组件。更好的是,最终得到的 AEAD 不仅快速、可证明安全,而且比 GCM 和 OCB 更能抵抗重用随机数攻击。
图 8-4 展示了基于置换的 AEAD 是如何工作的:从一个固定的初始状态 H[0] 开始,你将密钥 K 和随机数 N 依次与内部状态进行异或操作,以获得一个与原始状态相同大小的新内部状态。然后,你通过置换 P 转换新的状态,并获得状态的新值。接下来,你将第一个明文块 P[1] 与状态进行异或操作,并将得到的结果作为第一个密文块 C[1],其中 P[1] 和 C[1] 大小相同,但都比状态短。

图 8-4:基于置换的认证加密算法
要加密第二个块,通过P转换状态,将下一个明文块 P[2] 与当前状态进行异或操作,并将结果作为 C[2]。然后对所有明文块进行迭代,并在最后一次调用 P 后,从内部状态中提取位作为认证标签 T,如图 8-4 右侧所示。
注意
你可以根据图 8-4 中的模式来支持关联数据,但过程稍微复杂一些,因此我将跳过其描述。
设计安全的基于置换的认证加密算法有一定要求。首先,只能对状态的一部分进行异或操作:这一部分越大,成功攻击者对内部状态的控制越多,密码的安全性就越低。实际上,所有的安全性都依赖于内部状态的保密性。
此外,你必须正确地填充块并添加额外的位,以确保任何两个不同的消息会产生不同的结果。举个反例,如果最后一个明文块小于完整块,它不应该仅仅填充 0;否则,一个例如 2 字节的明文块(0000)会变成一个完整的明文块(0000 . . . 0000),同样,3 字节的块(000000)也会变成完整的明文块。结果,你会得到两个消息相同的标签,尽管它们的大小不同。
如果在这样的基于置换的加密算法中重用随机数,则影响不像在 GCM 或 OCB 中那么严重——认证标签的强度不会受到影响。如果重复使用随机数,攻击者只能知道两个加密消息是否以相同的值开始,并且了解这个公共值或前缀的长度。例如,尽管使用相同的随机数加密两个六块消息 ABCXYZ 和 ABCDYZ(这里每个字母表示一个块)可能会产生两个密文 JKLTUV 和 JKLMNO,它们有相同的前缀,攻击者也无法得知这两个明文共享相同的最后两个块(YZ)。
从性能角度来看,基于置换的密码提供了单层操作、流式处理和使用单核算法进行加密和解密的优势。然而,它们不像 GCM 或 OCB 那样可以并行化,因为对P的新调用需要等待先前的调用完成。
注意
如果你有冲动选择自己喜欢的置换并制作自己的认证密码,请不要这样做。你可能会犯错,最终得到一个不安全的密码。阅读经验丰富的密码学家为诸如 Keyak(源自 Keccak 的算法)、Duplex 构造和 deck 函数等算法编写的规范,访问 <wbr>keccak<wbr>.team。你会发现,基于置换的密码比它们最初看起来的要复杂得多。
如何出错
认证密码的攻击面比哈希函数或分组密码大,因为它们不仅要实现保密性和真实性。它们需要多个不同的输入值,并且必须在输入仅包含关联数据且没有加密数据、极大明文或不同密钥大小的情况下保持安全。它们还必须对所有随机数值在攻击者收集大量消息/标签对时保持安全,并且在一定程度上能够抵御随机数重复的意外情况。
这要求很高,即使是 AES-GCM 也有几个不完美之处。
AES-GCM 和弱哈希密钥
AES-GCM 的一个弱点在于其认证算法 GHASH:某些哈希密钥 H 的值极大地简化了针对 GCM 认证机制的攻击。具体而言,如果 H 的值属于所有 128 位字符串的某些特定数学定义的子群,攻击者可能通过重新排列之前消息的块来猜测某些消息的有效认证标签。
为了理解这个弱点,我们来看一下 GHASH 是如何工作的。
GHASH 内部结构
正如你在图 8-2 中看到的,GHASH 从一个 128 位的值 H 开始,初始值设置为 AES(K, 0),然后反复计算:

从 X[0] = 0 开始,处理密文块 C[1]、C[2] 等。GHASH 返回最终的 Xi,用于计算最终标签。
假设为了简便,所有 Ci 值都等于 1,那么对于任何 I,你都会得到以下结果:

接下来,从我们的第一个方程计算出 X[1],结果为:

将 X[0] 替换为 0,C[1] 替换为 1,得到如下结果:

由于⊗在⊕上的分配律,我们将 X[1] 替换为 H,将 C[2] 替换为 1,然后计算下一个值 X[2],结果为:

其中 H ² 是 H 的平方,或 H ⊗ H。
现在,通过将 X[2] 用于其推导,你得出 X[3],并得到以下结果:

接下来,你推导出 X[4] 为 X[4] = H ⁴ ⊕ H ³ ⊕ H ² ⊕ H,以此类推,最终得到最后的 Xi 为:

请记住,你将所有块 Ci 设置为 1。如果这些值是任意的,你将得到以下结果:

GHASH 然后将消息的长度与最后的 Xn 异或,将结果乘以 H,并将此值与 AES(K, N || 0) 异或以创建最终的认证标签 T。
问题出现的地方
从这里开始可能会出什么问题?我们首先来看最简单的情况:
-
如果 H = 0,则无论 Ci 的值如何,Xn 都为 0,因此与消息无关。也就是说,如果 H 为 0,则所有消息的认证标签将相同。
-
如果 H = 1,则标签仅仅是密文块的异或,重新排列密文块将得到相同的认证标签。
由于 0 和 1 只是 H 的 2¹²⁸ 种可能值中的两种,因此它们出现的概率仅为 2/2¹²⁸ = 1/2¹²⁷。但也有其他弱值—即所有当 H 被提升到 i 次方时,属于短周期的 H 的所有值。例如,H 的值 10d04d25f93556e69f58ce2f8d035a4 属于长度为 5 的周期,因为它满足 H⁵ = H,因此任何 e 为 5 的倍数时,H ^e = H(即第五次方周期的定义)。因此,在前面的最终 GHASH 值 Xn 表达式中,交换块 Cn(与 H 相乘)和块 Cn – [4](与 H⁵ 相乘)不会改变认证标签,这就等于伪造攻击。攻击者可能利用这一特性构造一个新的消息及其有效标签,而无需知道密钥,这应该是不可能的,对于一个安全的认证密码。
前面的示例基于长度为 5 的周期,但存在许多更长的周期,因此有许多 H 的值比应有的更弱。结果是,在极不可能的情况下,如果 H 属于一个短周期的值,并且攻击者可以伪造任意数量的认证标签,除非他们知道 H 或 K,否则他们无法确定 H 的周期长度。虽然攻击者无法利用这个漏洞,但你可以通过仔细选择用于模减的多项式来避免此问题。
注意
有关此攻击的更多细节,请阅读 Markku-Juhani O. Saarinen 的《GCM、GHASH 和其他多项式 MAC 和哈希的循环攻击》,可以在 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2011<wbr>/202 上查阅。
AES-GCM 和小标签
实际上,AES-GCM 通常返回 128 位标签,但它可以生成任意长度的标签。不幸的是,当使用较短的标签时,伪造的概率显著增加。
使用 128 位标签时,攻击者试图伪造标签的成功概率应为 1/2¹²⁸,因为有 2¹²⁸种可能的 128 位标签。(通常,对于一个n位标签,成功的概率应为 1/2^n,其中 2^n 是n位标签的可能值的数量。)但是,当使用较短的标签时,由于 GCM 结构中的弱点,伪造的概率远高于 1/2^n,这些弱点超出了本讨论的范围。例如,32 位标签允许一个攻击者在知道某个 2MB 消息的认证标签后,以 1/2¹⁶的几率成功,而不是 1/2³²。
通常,对于n位标签,伪造的概率不是 1/2^n,而是 2m/2n,其中 2^m 是成功的攻击者观察到标签的最长消息的块数。例如,如果你使用 48 位标签并处理 4GB 的消息(或 2²⁸个每个 16 字节的块),那么伪造的概率是 2²⁸/2⁴⁸ = 1/2²⁰,大约相当于百万分之一的几率。这在密码学中算是一个相对较高的几率。(有关此攻击的更多信息,请参见 Niels Ferguson 在 2005 年发表的论文《GCM 中的认证弱点》)
进一步阅读
要了解更多关于认证密码的信息,请访问 CAESAR 主页,认证加密竞赛:安全性、适用性和鲁棒性:competitions.cr.yp.to/caesar.html。CAESAR 于 2012 年开始,是一场类似于 AES 和 SHA-3 竞赛的加密竞赛,尽管它并不是由 NIST 组织的。
CAESAR 竞赛吸引了大量创新设计:从类似 OCB 的模式到基于置换的模式(如 NORX 和 Keyak),以及完全原创的算法(如 AEZ 或 AEGIS)。2019 年,CAESAR 竞赛结束,选出了七个算法组成的组合,分为三类,你可以在其官方网站上找到:competitions.cr.yp.to/caesar-submissions.html。
另一个可以找到认证加密算法的竞争项目是 NIST 的轻量级密码学项目。该项目从 2017 年到 2023 年,旨在标准化优化资源受限环境(如嵌入式平台的内存、处理器大小等)使用的算法。该竞争的获胜者是 Ascon,这是一类基于置换的算法,详细信息可在其官方网站上查看,网址是<wbr>ascon<wbr>.iaik<wbr>.tugraz<wbr>.at。要了解其他候选算法以及在项目研讨会上展示的工作,请访问<wbr>csrc<wbr>.nist<wbr>.gov<wbr>/Projects<wbr>/lightweight<wbr>-cryptography。
本章中,我们重点讨论了 GCM,但在实际应用中,其他几种模式也被使用。具体来说,带 CBC-MAC 的计数器模式(CCM)和 EAX 模式在 2000 年代初与 GCM 竞争标准化,尽管最终选用了 GCM,但其竞争者也在一些应用中被使用。例如,CCM 被用于 WPA2 Wi-Fi 加密协议。考虑阅读这些密码的规格,并审视它们相对的安全性和性能优势。
这就是我们关于对称密钥密码学的讨论的总结!你已经了解了分组密码、流密码、(带密钥的)哈希函数,以及现在的认证密码——所有与对称密钥或根本不使用密钥的主要密码学组件。在我们转向非对称密码学之前,第九章将重点介绍计算机科学和数学,为非对称方案(如 RSA(第十章)和 Diffie–Hellman(第十一章))提供背景。
第三部分 非对称加密
第十章:9 难题

困难的计算问题是现代密码学的基石。这些问题即使是最好的算法,也无法在太阳燃尽之前找到解决方案。
在 1970 年代,对困难问题的严格研究催生了一个新的科学领域——计算复杂性理论,它对密码学及许多其他领域(包括经济学、物理学和生物学)产生了深远影响。在本章中,你将学习理解密码学安全基础所必需的复杂性理论概念工具。我还将介绍公钥方案背后的难题,例如 RSA 加密和 Diffie-Hellman 密钥交换。我会涉及一些深奥的概念,但我会最小化技术细节,仅触及表面。尽管如此,我希望你能够看到密码学如何利用计算复杂性理论最大化安全保障的美妙之处。
计算困难性
计算问题是一个通过足够的计算可以回答的问题——例如,“217 是质数吗?”或“incomprehensibilities中有多少个i?”第一个问题是决策问题,因为它的答案只有“是”或“否”,而第二个问题则是搜索问题。
计算困难性是指那些没有可以在合理时间内运行的算法的计算问题。这类问题也被称为难以处理的问题。计算困难性与所使用的计算设备类型无关,无论是通用中央处理单元(CPU)、图形处理单元(GPU)、集成电路,还是机械图灵机。事实上,计算复杂性理论的初步发现之一是,所有计算模型都是等效的。如果某个计算设备能高效解决一个问题,任何其他设备通过将算法移植到其他设备的语言上也能高效解决该问题——量子计算机是个例外,我们将在第十四章讨论它。 因此,在讨论计算困难性时,我无需指定底层计算设备或硬件;我们只需要讨论算法。
为了评估计算困难性,首先需要一种方法来衡量算法的复杂性,或者说其运行时间。然后你将运行时间分类为困难或容易。
运行时间
算法的计算复杂度是其执行的操作次数的近似值,作为输入大小的函数。你可以用比特数或作为输入的元素个数来衡量大小。例如,参考 Listing 9-1 中的算法,它是用伪代码编写的。该算法在一个包含n个元素的数组中查找值x,并返回其索引位置,如果没有找到x,则返回-1。
search(x, array, n) {
for i from 0 to n - 1 {
if (array[i] == x) {
return i;
}
}
return -1;
}
Listing 9-1: 一个复杂度与数组长度线性相关的简单搜索算法 n
该算法使用一个for循环在数组中查找特定值x,它对变量i的值进行迭代,从 0 开始。它检查数组中位置i的值是否等于x。如果是,则返回位置i。否则,它会增加i并尝试下一个位置,直到达到n – 1,即数组中的最后一个位置,此时它返回-1。
对于这种算法,你将复杂度定义为for循环的迭代次数:最好的情况为 1(如果x等于array[0]),最坏的情况为n(如果x等于array[n - 1],或者如果x在array中找不到),平均情况下为n/2(如果x在n个数组元素中是均匀随机分布的)。对于一个大小是原数组 10 倍的数组,算法的速度会变慢 10 倍。因此,复杂度与n成正比,也就是n的“线性”复杂度。与输入大小成线性关系的复杂度被认为是快速的,不像指数级复杂度那样。虽然在这个例子中,处理更大输入值的速度变慢,但计算成本不会指数级地膨胀,而是与表格大小成正比。
然而,许多有用的算法比这更慢,且其复杂度高于线性。教科书中的例子是排序算法:给定一个随机顺序的n个值,最坏情况下你需要n × log n 次基本操作来排序该列表,这种复杂度有时被称为线性对数复杂度。由于n × log n 增长速度快于n,排序的速度减慢的比n增长得更快。然而,这种排序算法仍然属于实用计算的范畴,即在合理时间内可以完成的计算。
通常不合理的是输入大小呈指数增长的复杂度。到了一定程度,即使对于相对较小的输入长度,你也会遇到不可行的上限。以密码分析中最简单的例子为例:暴力破解密钥。回想一下第一章中提到的,给定一个明文 P 和密文 C = E(K, P),恢复一个 n 位对称密钥最多需要 2^n 次尝试,因为有 2^n 个可能的密钥——这是一个复杂度呈指数增长的例子。一个具有指数复杂度的问题实际上是无法解决的,因为随着 n 增长,计算量会迅速变得无法承受。
你可能会反驳说我们这里在比较橙子和苹果:在清单 9-1 中的 search() 函数中,我们计算了 if (array[i] == x) 操作的次数,而密钥恢复计算的是加密操作的次数,每次加密的速度比单次 == 比较慢上千倍。这个看似的不一致,如果你比较两个复杂度非常相似的算法,可能会有所不同,但大多数情况下它并不重要,因为操作次数的影响比单个操作的成本更大。此外,复杂度估算忽略了常数因子:当我们说一个算法的时间复杂度是 n³(即立方复杂度)时,它可能实际上需要 41 × n³ 次操作,甚至是 12,345 × n³ 次操作——但随着 n 增长,常数因子的影响变得不重要,甚至可以忽略。复杂度分析关注的是输入大小对理论难度的影响;它不关心你计算机上需要多少个 CPU 周期。
你通常可以使用 O() 符号(大 O)来表示复杂度。例如,O(n³) 表示复杂度增长不会超过 n³,忽略潜在的常数因子。O() 表示算法复杂度的上界。O(1) 表示一个算法运行在常数时间内——即运行时间与输入长度无关。例如,通过查看整数的最低有效位(LSB)并返回“偶数”如果为零,返回“奇数”否则,来确定整数的奇偶性,这个算法无论整数的长度如何,都会以相同的成本完成相同的操作。
要查看线性、二次和指数时间复杂度之间的差异,可以查看 图 9-1 中 O(n)(线性)、O(n²)(二次)和 O(2^n)(指数)复杂度的增长情况。

图 9-1:从最快到最慢增长的指数、二次和线性复杂度
指数复杂度意味着问题几乎不可能解决,线性复杂度意味着解决方案是可行的,而二次复杂度介于两者之间。
多项式时间与超多项式时间
二次 O(n²) 复杂度(图 9-1 中的中间曲线)是更广泛的多项式复杂度类 O(n**^k) 的特例,其中 k 是一些固定的数值,比如 3、2.373、7/10 或者 17 的平方根。多项式时间算法在复杂度理论和密码学中非常重要,因为它们是实际可行性的定义。当一个算法在多项式时间内运行,简称polytime,即使输入很大,它也能在合理的时间内完成。这就是为什么对于复杂度理论家和密码学家来说,多项式时间就是“高效”的代名词。
相反,你可以将运行在超多项式时间下的算法视为不切实际的——即在O(f(n)), 其中 f(n) 是任何比任何多项式增长更快的函数——不切实际。我说的是超多项式时间,而不仅仅是指数时间,因为在多项式复杂度和著名的指数复杂度 O(2^n) 之间,还有一些复杂度,例如 O(n(log()*n*^)),正如图 9-2 所示。

图 9-2:2 n、 nlog(n)以及 n² 函数,从最快到最慢增长
O(n²) 或 O(n³) 可能是高效的,但 O(n^(99,999,999,999)) 显然不是。换句话说,只要指数不太大,多项式时间在实际中是快速的。幸运的是,所有实际问题中找到的多项式时间算法都有较小的指数。例如,O(n^(2.373)) 是理论上已知的最佳 n × n 矩阵乘法算法的时间复杂度,但这个算法在实际中从未使用。2002 年,突破性的多项式时间确定性算法首次用于识别 n 位素数,初始复杂度为 O(n¹²),但后来改进为 O(n⁶)。因此,多项式时间可能不是算法实际时间的完美定义,但它是我们目前最好的定义。
扩展来看,你可以认为无法通过多项式时间算法解决的问题是不切实际的,或者是困难的。例如,对于一个简单的密钥搜索,除非加密算法被某种方式破解,否则无法突破 O(2^n) 的复杂度。
注意
指数复杂度 O(2^n) 并不是你能遇到的最糟糕的复杂度。有些复杂度增长得更快,因此使得算法的计算速度更慢——例如,复杂度 O(n^n) 或 指数阶乘 O(n(f()*n ^(– 1))**),其中对于任何 x,函数 f 在此递归地定义为 f(x) = x*(f()*x *^(– 1))**。实际上,你几乎不可能遇到具有如此荒谬复杂度的算法。
你知道,对于一个蛮力密钥搜索算法,无法突破 O(2^n) 的复杂度(只要加密算法是安全的),但你并不总是能知道解决一般计算问题的最快方法。复杂度理论的大部分研究都集中在证明解决给定问题的算法的复杂度界限。为了让他们的工作更轻松,复杂度理论家将计算问题按解决它们所需的努力分成了不同的组,或者说是类。
复杂度类
在数学中,类是具有某些相似属性的一组对象。例如,所有在时间 O(n²) 内可以解决的计算问题,复杂度理论家简化地称为 TIME(n²),就构成了一个类。同样,TIME(n³) 是在时间 O(n³) 内可解决的问题类,TIME(2^n) 是在时间 O(2^n) 内可解决的问题类,以此类推。正如超级计算机可以计算任何笔记本电脑能计算的内容一样,任何在 O(n²) 内可解的问题也可以在 O(n³) 内解决。因此,TIME(n²) 类中的任何问题也属于 TIME(n³) 类,而这两个类也都属于 TIME(n⁴) 类,以此类推。所有类 TIME(n**^k), 对于所有常数 k 的并集是 P,表示多项式时间。
如果你编写过计算机程序,你会知道看似快速的算法仍然可能通过耗尽所有内存资源而导致系统崩溃。在选择算法时,你不仅要考虑它的时间复杂度,还要考虑它使用的内存量,或者说它的空间复杂度。这尤其重要,因为一次内存访问通常比 CPU 中的基本算术操作慢几个数量级。
正式地,你将算法的内存消耗定义为其输入长度n的函数,方法与时间复杂度的定义相同。使用f(n)位内存能够解决的问题类是SPACE(f(n))。例如,SPACE(n³)是使用大约n³位内存可以解决的问题类。就像P是所有TIME(n**k*)的并集一样,所有**SPACE**(*n**k)问题的并集是PSPACE。
虽然内存越少越好,但多项式数量级的内存并不一定意味着一个算法是实用的。以暴力穷举密钥搜索为例,虽然它几乎不消耗内存,但却极其缓慢。更一般地说,即使一个算法只使用很少的内存,它也可能需要永远运行。
任何能够在时间f(n)内解决的问题,最多需要f(n)的内存,因此TIME(f(n))包含在SPACE(f(n))中。在时间f(n)内,你可以写入最多f(n)位数据,不能再多,因为写入(或读取)1 位被假设为需要一个单位的时间;因此,任何TIME(f(n))中的问题不能使用超过f(n)的空间。因此,P是PSPACE的子集。
非确定性多项式时间
NP,即非确定性多项式时间,是第二重要的复杂度类,仅次于P类(所有多项式时间算法类)。
NP是决策问题的类,对于这些问题,你可以在多项式时间内验证一个解——也就是说,高效地验证——尽管该解可能很难找到。所谓的验证是指,给定一个潜在解,你可以运行某个多项式时间的算法来检查你是否找到了一个实际的解。例如,判断是否存在一个密钥K,使得P和C在对称加密系统E下满足C = E(K, P)的问题属于NP类。这是因为,给定一个候选密钥K[0],你可以通过验证E(K[0], P)是否等于C来检查K[0]是否是正确的密钥。你不能在多项式时间内找到潜在密钥(如果存在的话),但你可以检查一个密钥是否正确。
现在来看一个反例:已知密文攻击怎么样?这次,你只能得到一些E(K, P)的值,其中P是随机的未知明文。如果你不知道P是什么,那么就无法验证一个潜在的密钥K[0]是否正确。换句话说,在已知密文攻击下,密钥恢复问题不在NP类中(更不用说在P类中了),因为你无法将其表示为一个决策问题。
另一个不在NP中的问题是验证一个问题无解。验证一个解是否正确归结为计算某个算法,将候选解作为输入,然后检查返回值。然而,要验证没有解,你可能需要遍历所有可能的输入。如果输入数量是指数级的,你将无法高效地证明没有解存在。对于NP类中最难的问题——所谓的NP-完全问题来说,证明没有解是很困难的。
NP-完全问题
NP-完全问题是NP类中最难的决策问题;你无法在多项式时间内解决这些问题的最坏情况实例。正如复杂性理论学者在 1970 年代发展NP-完全性理论时发现的那样,NP中最难的问题本质上是同样困难的。通过展示你可以将任何NP-完全问题的有效解决方案转化为另一个NP-完全问题的有效解决方案,证明了这一点。换句话说,如果你能够高效地解决任何NP-完全问题,你就能解决所有问题,包括NP中的所有问题。这是怎么回事呢?
NP- 完全问题有不同的表现形式,但从数学角度来看它们本质上是相似的。实际上,你可以将任何NP-完全问题转化为另一个NP-完全问题,且解决第二个问题的能力意味着能够解决第一个问题。记住,NP包含的是决策问题,而不是搜索问题。你可以高效地将一个能够解决搜索问题的算法转化为能够解决相应决策问题的算法,尽管反方向并不总是可能的。幸运的是,对于 NP-完全问题来说,这种转化是可行的,这也解释了为什么人们常常将这两者混淆。
以下是一些NP-完全问题的例子:
旅行商问题 给定一组地图上的点(如城市)以及每个点之间的距离,并给定一个最大距离 x,决定是否存在一条路径能够访问每个点,并且总距离小于 x。(注意,你可以以基本相同的复杂度找到这样一条路径,作为决策问题,但判断路径是否最优并不在NP中,因为你不能高效地验证一个解的正确性。)
团问题 给定一个数字 x 和一个图(一个由边连接的节点集合,如图 9-3 所示),判断是否存在一个最多包含 x 个节点的集合,这些节点之间都是相互连接的。

图 9-3:包含四个节点的团体的图形。给定大小的团体(所有节点都互相连接)的查找问题是 NP-完全问题。
背包问题给定两个数字,x和y,以及一组物品,每个物品都有已知的价值和重量,决定是否存在一组物品,使得总价值至少为x,并且总重量不超过y。
你可以在许多地方找到这样的NP-完全问题,从调度问题(给定一些优先级和持续时间的任务,以及一个或多个处理器,按照优先级将任务分配给处理器,同时最小化总执行时间)到约束满足问题(确定满足一组数学约束的值,如逻辑方程)。甚至某些视频游戏的获胜任务也被证明是NP-难题(包括俄罗斯方块、超级马里奥、口袋妖怪和糖果传奇等著名游戏)。例如,文章《经典任天堂游戏是(计算上)困难的》探讨了“可达性决策问题”,以确定从某个特定起点到达目标点的可能性(* <wbr>arxiv<wbr>.org<wbr>/abs<wbr>/1203<wbr>.1895 *)。
其中一些视频游戏问题至少与NP-完全问题一样困难,称为NP-难。当一个(不一定是可判定的)问题至少与NP-完全(决策)问题一样困难,并且任何解决该问题的方法都可以高效地用于解决NP-完全问题时,该问题被称为NP-难问题。
NP-完全问题必须是可判定问题;也就是说,问题的答案必须是“是”或“否”。因此,严格来说,计算解决方案“最佳”值的问题不能是NP-完全问题,但可以是NP-难题。例如,考虑旅行商问题:问题“是否存在一条路径,经过所有点,且距离小于X?”是NP-完全问题,而“找到一条更快的路径,经过所有点”是NP-难题。
请注意,并非所有NP-难问题的实例都难以解决。你可能能够高效地解决某些实例,因为它们很小或具有特定结构。例如,考虑图 9-3 中的图形。你可以快速找到其中的团体,也就是顶部的四个连接节点——尽管前述的团体查找问题是NP-难题,但这里并没有什么难度。NP-难并不意味着给定问题的所有实例都很难,而是随着问题规模的增长,其中一些实例变得更难。这就是为什么密码学家更关心那些在平均情况下很难解决的问题,而不仅仅是最坏情况下的问题。
P 与 NP 问题
如果你能够在多项式时间内解决最难的NP问题,那么你就能够在多项式时间内解决所有的NP问题,因此NP将等于P。这样的等式听起来荒谬:难道不是有些问题,解法很容易验证,但却很难找到吗?例如,难道不显而易见,指数时间的暴力破解是恢复对称密码密钥最快的方法,因此这个问题不可能属于P吗?
尽管听起来疯狂,但至今没有人证明P与NP不同,尽管有 100 万美元的奖金。克雷数学研究所将奖励任何证明P ≠ NP或P = NP的人。这个问题,称为P与NP问题,被著名的复杂性理论学家 Scott Aaronson 称为“人类曾经提出的最深刻的问题之一”。想一想:如果P等于NP,那么任何容易验证的解法也会很容易找到。所有实践中使用的加密技术都将不再安全,因为你可以高效地恢复对称密钥并反转哈希函数。
但别惊慌:大多数复杂性理论学家认为P不等于NP,因此P是NP的一个严格子集,正如图 9-4 所示,NP-完全问题是NP的另一个子集,与P没有交集。换句话说,看起来困难的问题实际上确实是困难的,只是很难用数学证明这一点。虽然证明P = NP只需要为一个NP-完全问题提供一个多项式时间算法,但证明不存在这样的算法则要困难得多。尽管如此,这并没有阻止数学家们提出一些简单的证明,虽然通常显然是错误的,但却往往很有趣;例如,可以参考“P 与 NP 页面”(* www.win.tue.nl/~wscor/woeginger/P-versus-NP.htm *)。

图 9-4: 类别 NP 和 P 以及 NP-完全问题的集合
如果我们几乎可以确定NP中存在困难问题,那么如何利用它们来构建强大的、可证明安全的加密呢?想象一下,如果我们能证明破解某些密码是NP-难的,因此,只要P不等于NP,该密码就是不可破解的。但现实令人失望:NP-完全问题的搜索版本已证明在加密中难以使用,因为正是这种使它们在最坏情况下变得困难的结构,有时会在加密中出现特定的情况,从而使得它们变得容易。相反,密码学通常依赖于那些可能不是NP-难的问题。
因式分解问题
因式分解问题的任务是,给定一个大数N = p × q,找出质数p和q。广泛使用的 RSA 算法正是基于因式分解的困难:RSA 加密和签名方案是安全的,因为因式分解是一个困难的问题。在我们了解 RSA 如何在第十章中利用因式分解问题之前,我想说服你,关于该问题的判定版本(“N是否有一个小于k且不等于 1 的因子?”)确实是困难的,但可能不是NP-完全的。
首先,一些基本的数学知识。质数不能被任何其他数字整除,除了它本身和 1。例如,3、7 和 11 是质数;而 4(即 2 × 2)、6(2 × 3)和 12(2 × 2 × 3)则不是。数论的一个基本定理表明,你可以将任何整数唯一地表示为质数的乘积,这种表示方式称为该数的因式分解。例如,123,456 的因式分解是 2⁶ × 3 × 643,1,234,567 的因式分解是 127 × 9,721,等等。任何整数都有一个唯一的因式分解,或者说,唯一的方式将其表示为质数的乘积。多项式时间质数测试算法使我们能够高效地测试一个给定的数字是否是质数,或者一个给定的因式分解是否仅包含质数。然而,从一个数字得到它的质因数,则是另一个问题。
因式分解大数
那么如何从一个数字得到它的因式分解——也就是将它分解成质数的乘积呢?分解一个数字N的最基本方法是尝试用所有比它小的数字去除,直到找到一个数字x,它能整除N。然后尝试用下一个数字x + 1 去除N,以此类推。最终你会得到N的因子列表。那么,这种方法的时间复杂度是多少呢?首先,记住我们将复杂度表示为输入长度的函数。数字N的比特长度是n = log[2] N。根据对数的定义,这意味着N = 2^n。因为所有小于N/2 的数字都是N的可能因子,因此我们需要尝试大约N/2 = 2^n/2 个值。因此,我们的朴素因式分解算法的复杂度是O(2^n),忽略了O()符号中的 1/2 系数。
虽然许多数字通过首先找到任何小因子(2、3、5 等)并且迭代地分解其他非质数因子很容易,但在这里我们关注的是形如N = p × q的数字,其中p和q较大,这在密码学中很常见。
让我们更聪明一点:因为我们不需要测试所有小于N/2 的数字,而只需要测试小于N平方根的质数,因此我们可以从小于N平方根的质数开始。如果N不是质数,那么它至少有一个小于它平方根√N的因子。这是因为如果N的两个因子p和q都大于√N,那么它们的乘积将大于√N × √N = N,这显然是不可能的。例如,如果N = 100,它的因子p和q不可能都大于 10,因为那样它们的乘积会大于 100。p或q必须小于或等于√N。
那么,测试所有小于√N的质数的复杂度是多少呢?质数定理指出,小于N的质数大约有N/log N个。因此,小于√N的质数大约有√N/log √N个。将这个值用n = log[2] N表示,我们得到大约 2n*(/2 + 1)/n个可能的质因子,因此复杂度为O(2n*(/2)/n),因为√N = 2n*(/2),而 1/log √N* = 1/(n/2) = 2/n。这比测试所有质数要快,但仍然非常慢——对于一个 256 位的数字来说,大约需要 2¹²⁰次操作。这是一个相当不切实际的计算量。不过,我们可以做得更好。
最快的分解算法是通用数域筛法(GNFS),我在这里不做描述,因为它需要引入几个高级数学概念。GNFS 的复杂度粗略估算为 exp(1.91 × n^(1/3) (log n)^(2/3)),其中n = log[2] N是N的位长,exp(. . .)只是指数函数e ^x的另一种表示方式,其中e是大约等于 2.718 的指数常数。然而,确切估算 GNFS 在特定数字大小下的实际复杂度是困难的。因此,我们必须依赖启发式复杂度估算,显示随着n值的增加,安全性如何提升。例如:
-
分解一个1,024 位的数字,该数字有两个大约为 500 位的素因子,需要进行大约 2⁷⁰次基本操作。
-
分解一个2,048 位的数字,该数字有两个大约为 1,000 位的素因子,需要进行大约 2⁹⁰次基本操作——比分解 1,024 位数字慢约一百万倍。
你可以估算,达到 128 位安全性至少需要 4,096 位。请对这些值持保留态度,因为研究人员对这些估算并不总是达成一致。以下实验结果揭示了分解的实际成本:
-
2005 年,在大约 18 个月的计算之后——得益于一个由 80 个处理器组成的计算集群,总计算量相当于单个处理器上 75 年的计算——一组研究人员分解了一个663 位(200 位十进制数字)数字。
-
2009 年,在大约两年的时间里,使用数百个处理器,总计算量相当于单个处理器上约 2,000 年的计算,另一个研究小组分解了一个768 位(232 位十进制数字)数字。
-
2020 年,在几个月的计算之后,使用了数万个处理器和一台超级计算机,总计算量相当于单个处理器上约 2,700 年的计算,另一个团队分解了一个829 位(250 位十进制数字)数字。
如你所见,研究人员分解的数字比实际应用中的数字要短,实际应用中的数字至少是 1,024 位,通常超过 2,048 位。就我所写的内容来看,目前没有人报告过分解 1,024 位数字的情况,但许多人猜测像 NSA 这样资金充裕的组织可能已经做到了。
总结来说,你应该将 1,024 位的 RSA 视为不安全的,并使用至少 2,048 位的 RSA,但最好使用 4,096 位的 RSA 以确保更高的安全性。
分解问题可能不是 NP 难的
我们不知道如何高效地分解大数,这表明分解问题不属于P类。然而,分解问题的决策版本显然属于NP类,因为给定一个因式分解结果,我们可以通过检查所有因子是否为质数来验证答案,感谢前述的质数测试算法,并且当这些因子相乘时,它们的乘积等于预期的数值。例如,要检查 3 × 5 是否是 15 的因式分解,只需确认 3 和 5 都是质数,并且 3 乘以 5 等于 15。
所以我们有一个NP类中的问题,看起来很困难,但它真的有NP最困难的问题那么难吗?换句话说,分解问题的决策版本是否是NP-完全的,因此,分解问题是否是NP-困难的?剧透:可能不是。
目前没有数学证明表明分解问题不是NP-困难的,但我们有一些间接证据。首先,所有已知的NP-困难问题在NP类中可能有一个解、多个解或没有解。而分解问题始终只有一个解。此外,分解问题具有数学结构,使得 GNFS 算法能够显著超越朴素算法,而NP-困难问题并不具备这样的结构。使用量子计算机解决分解问题将变得简单,量子计算机是一种利用量子力学现象运行各种算法的计算模型,具有高效分解大数的能力(不是因为它能更快地运行算法,而是因为它能运行专门用于分解大数的量子算法)。然而,这种量子计算机目前尚不存在——而且可能永远也不会存在。不管怎样,人们认为量子计算机在解决NP-困难问题上是无用的,因为它的速度不会比经典计算机快(见第十四章)。
理论上,分解问题可能比解决NP-困难问题稍微简单一些,但就密码学而言,它足够困难,而且比NP-困难问题更可靠。事实上,基于分解问题构建密码系统比基于NP-完全问题的搜索版本更容易,因为很难确切知道破解基于此类问题的密码系统到底有多难——换句话说,你能获得多少位的安全性。如前所述,这与NP关心最坏情况的困难度,而密码学家们关注的是平均情况的困难度相关。
分解问题是你可以在密码学中使用的多个难度假设之一,难度假设是指某个问题在计算上是困难的。你可以在证明打破密码系统安全性至少和解决该问题一样困难时使用这个假设。另一个你可以用作难度假设的问题是离散对数问题(DLP),它实际上是一个问题族。
离散对数问题
离散对数问题(DLP)早于因数分解问题出现在密码学的官方历史中。尽管 RSA 算法出现在 1977 年,但另一个密码学突破——Diffie–Hellman 密钥交换协议(见第十一章)大约在一年前就已出现,其安全性依赖于 DLP 的难解性。与因数分解问题类似,DLP 也涉及大数,但它比因数分解问题更复杂,需要更多的数学知识。让我们从在离散对数背景下引入群的数学概念开始。
群
在数学中,群是一个元素的集合(通常是数字),这些元素根据某些明确定义的规则相互关联。群的一个例子是非零整数模素数p的集合(即 1 到p–1 之间的数字),其群运算是模乘法。我们将这样的群记作(Zp^, ×),或者在群运算明确为乘法时简写为Zp^。
对于p = 5,得到群Z[5]^* = {1, 2, 3, 4}。在群Z[5]^中,运算是模 5 进行的;因此,3 × 4 ≠ 12,而是 3 × 4 = 2,因为 12 mod 5 = 2。你仍然可以使用常规整数乘法中使用的符号(×)。你还可以使用指数表示法来表示群元素与自身的乘法模p,这是密码学中常见的运算。例如,在Z[5]^中,2³ = 2 × 2 × 2 = 3,而不是 8,因为 8 mod 5 = 3。
要成为一个群,数学集合及其运算必须具备以下特征,这些特征被称为群公理:
封闭性 对于任何两个群元素x和y,x × y也在该群中。在Z[5]^*中,2 × 3 = 1(因为 6 = 1 mod 5),2 × 4 = 3,等等。
结合性 对于任何群元素x、y、z,(x × y) × z = x × (y × z)。在Z[5]^*中,(2 × 3) × 4 = 1 × 4 = 2 × (3 × 4) = 2 × 2 = 4。
单位元存在 群中包含一个元素e,使得e × x = x × e = x。这样的元素称为单位元。在任何Zp^*中,单位元是 1。
逆元存在 对群中的任何x,存在一个y使得x × y = y × x = e。在Z[5]^*中,2 的逆元是 3,3 的逆元是 2,而 4 是它自身的逆元,因为 4 × 4 = 16 = 1 mod 5。
此外,如果群是交换的或阿贝尔的,即对任何群元素x和y都有x × y = y × x,那么该群是交换群。对于任何整数乘法群Zp*,这一点也成立。特别地,**Z**[5]*是交换的:3 × 4 = 4 × 3,2 × 3 = 3 × 2,依此类推。
如果存在至少一个元素 g,使得它的幂次(g¹、g²、g³ 等)对 p 取模后能够覆盖所有不同的群元素,那么该群就是循环的。此时,元素 g 被称为该群的生成元。Z[5]^* 是循环群,并且有两个生成元,2 和 3,因为 2¹ = 2,2² = 4,2³ = 3,2⁴ = 1,3¹ = 3,3² = 4,3³ = 2,3⁴ = 1。
请注意,我这里使用的是乘法作为群运算符,但你也可以使用其他运算符来构造群。例如,最简单的群是所有整数的集合,包括正整数和负整数,以加法作为群运算。让我们检查加法是否满足群公理,按以下顺序:x + y 是整数,如果 x 和 y 是整数(封闭性);(x + y) + z = x + (y + z),对任意 x、y 和 z 都成立(结合性);零是单位元素;群中任何数字 x 的逆元素是 –x,因为 x + (–x) = 0,对任何整数 x 都成立。然而,一个很大的不同是,这个整数群是无限大的,而在密码学中,你只会处理有限群,即包含有限个元素的群,这是出于实现的考虑。通常,你会使用群 Zp^,其中 p 是成千上万比特长的(也就是说,如果 p 是 m 比特长的,则该群包含大约 2^m* 个数字)。
困难之处
最初在密码学中使用的离散对数问题是寻找 y,使得 g**^y = x,已知生成元 g 位于某个群 Zp^* 中,其中 p 是素数,已知群元素 y。我们通常以加法而非乘法表示 DLP,如椭圆曲线群中的情况。在这种情况下,问题是找到乘法因子 k,使得 k × P = Q,其中已知点 P 和 Q。这被称为椭圆曲线离散对数问题(ECDLP)。
DLP 是离散的,因为你处理的是可数的整数,而不是不可数的实数,并且它是对数问题,因为你要找的是以 g 为底的 x 的对数。例如,256 以二为底的对数是 8,因为 2⁸ = 256。
因式分解的难度与离散对数问题差不多,因此它们的安全性也相似。事实上,解决 DLP 的算法与因式分解整数的算法有相似之处,处理 n 比特的难以因式分解的数字与在 n 比特群中求离散对数的安全级别差不多。与因式分解一样,DLP 并不是 NP-困难问题。(有一些群,在这些群中,DLP 更容易解决,但它们在密码学中不会使用,至少在需要 DLP 难度的地方不会使用。)
事情如何出错
40 多年后,我们仍然不知道如何有效地分解大数或解决离散对数问题。在没有数学证明的情况下,总是可以猜测它们有一天会被破解。但我们也没有证明 P ≠ NP,因此你可以猜测 P 可能等于 NP;然而,根据专家的看法,这个惊讶的可能性不大。目前大多数公共密钥加密系统依赖于分解(RSA)或 DLP(迪菲-赫尔曼,ElGamal,椭圆曲线密码学)。虽然数学可能不会失败,但现实世界的问题和人为错误可能会悄悄地潜入。
当分解变得容易时
分解大数并不总是困难的。例如,考虑以下 1,024 位的数字 N:

对于在 RSA 加密或签名方案中使用的 1,024 位数字,其中 N = pq,我们预期最佳的分解算法大约需要 2⁷⁰ 次操作,正如我们之前讨论的那样。但你可以使用 SageMath 这个基于 Python 的数学软件,在几秒钟内分解这个示例数字。在我的 2023 年款 MacBook 上,使用 SageMath 的 factor() 函数,不到一秒钟就找到了以下的分解:

对,我作弊了。这个数字不是 N = pq 的形式,因为它不仅有两个大素数因子,而是有五个,包括一些非常小的因子,这使得它很容易分解。首先,你通过尝试一个预先计算好的素数列表中的小素数,识别出 2⁸⁰⁰ × 641 × 6,700,417 这一部分,剩下的就是一个 192 位的数字,比一个有两个大因子的 1,024 位数字更容易分解。
分解不仅仅在 n 有小素数因子的情况下容易,N 或其因子 p 和 q 也有特定形式时也可能容易——例如,当 N = pq,并且 p 和 q 都接近某个 2^b 时,当 N = pq 且 p 或 q 的某些位已知时,或者当 N 的形式是 N = p**r**q**s 且 r 大于 log p 时。然而,详细阐述这些弱点的原因对于本书来说过于技术化。
结果是,RSA 加密和签名算法(见 第十章)需要使用 N = pq 的值,其中 p 和 q 必须精心选择,以避免 N 的简单分解,这可能会导致安全灾难。
小的难题并不难
计算上困难的问题,甚至是指数时间的算法,当它们足够小的时候也能变得实用。对称加密可能在某种意义上是安全的,因为没有比 2^n 次暴力破解更快的攻击,但如果密钥长度是 n = 32,你将在几分钟内破解这个加密。这听起来很显然,你可能会认为没有人会天真到使用小密钥,但实际上,确实有很多原因可能导致这种情况。以下是两个真实的故事。
假设你是一个对加密一无所知的开发者,但你有一些 API 可以用 RSA 进行加密,而且被要求使用 128 位安全性进行加密。你会选择什么样的 RSA 密钥大小?我见过真实案例中使用 128 位 RSA,或者基于 128 位数字N = pq的 RSA。然而,虽然对一个长度达到几千位的N进行因式分解几乎是不可能的,但对 128 位数字进行因式分解却很容易。使用 SageMath 软件,列表 9-2 中的命令几乎可以瞬间完成。
sage: p = random_prime(2**64)
sage: print(p)
6822485253121677229
sage: q = random_prime(2**64)
sage: print(q)
17596998848870549923
Sage: N = p*q
sage: factor(N)
6822485253121677229 * 17596998848870549923
列表 9-2:通过选择两个随机质数生成 RSA 模数,并即时进行因式分解
列表 9-2 显示了你可以轻松地将一个 128 位数字随机作为两个 64 位质数的乘积进行因式分解,且这一过程在典型的笔记本电脑上几乎瞬间完成。然而,如果我选择使用 1,024 位质数,通过命令p = random _prime(2**1024),则命令factor(p*q)将永远无法完成,至少在我有生之年是如此。
公平地说,现有的工具并不能有效防止天真的使用不安全的短参数。例如,OpenSSL 工具包曾允许你生成短至 31 位的 RSA 密钥,且没有任何警告;这些短密钥是完全不安全的,如列表 9-3 所示。OpenSSL 后来已修复,在其 2023 年 2 月发布的版本 1.1.1t 中,如果你请求一个小于 512 位的密钥,会返回“密钥大小太小”的错误。
$ **openssl genrsa 31**
Generating RSA private key, 31 bit long modulus
.+++++++++++++++++++++++++++
.+++++++++++++++++++++++++++
e is 65537 (0x10001)
-----BEGIN RSA PRIVATE KEY-----
MCsCAQACBHHqFuUCAwEAAQIEP6zEJQIDANATAgMAjCcCAwCSBwICTGsCAhpp
-----END RSA PRIVATE KEY-----
列表 9-3:使用旧版本的 OpenSSL 工具包生成不安全的 RSA 私钥
在审查密码学时,你不仅要检查所使用的算法类型,还要检查它们的参数以及它们的秘密值的长度。然而,正如你将在接下来的故事中看到的那样,今天足够安全的东西明天可能就不再安全了。
2015 年,研究人员发现许多 HTTPS 服务器和邮件服务器支持一个旧版本的不安全的 Diffie–Hellman 密钥交换协议。具体来说,底层的 TLS 实现支持在一个由一个 512 位质数p定义的组Zp^*中使用 Diffie–Hellman,其中离散对数问题已经不再是不可计算的。
服务器不仅支持了一个弱算法,攻击者还可以通过在客户端会话中注入恶意流量,迫使一个无害的客户端使用这个算法。更糟糕的是,对于攻击者来说,攻击的最大部分可以一次性执行并循环使用,以攻击多个客户端。在对特定群体进行大约一周的计算后,Zp^*,破解不同用户的单独会话仅需 70 秒。
如果安全协议被一个弱化的算法削弱,那么它是毫无价值的;如果一个可靠的算法被弱参数破坏,那么它也是无用的。在密码学中,你应该始终注意细节。
有关此故事的更多细节,请查看研究文章《不完美的前向保密:Diffie–Hellman 在实践中的失败》 (weakdh.org/imperfect-forward-secrecy-ccs15.pdf)。
进一步阅读
我鼓励你深入探讨计算理论中的基础内容,尤其是在可计算性(什么函数可以计算?)和复杂性(以什么代价?)的背景下,以及它们如何与密码学相关。我主要讲解了P和NP类问题,但对于密码学家来说,还有许多其他类和有趣的研究点。我强烈推荐 Scott Aaronson 的《自德谟克里特以来的量子计算》(剑桥大学出版社,2013 年)。这本书大部分内容讲的是量子计算,但其前几章巧妙地介绍了复杂性理论和密码学。
在密码学研究文献中,你会找到其他困难的计算问题。我将在后续章节中提到这些问题,但这里有一些例子,展示了密码学家利用的各种问题:
-
Diffie–Hellman 问题(给定 g**^x 和 g ^y,求 g**^(xy)) 是离散对数问题的一种变体,广泛应用于密钥协议中。
-
格问题,如最短向量问题(SVP)、短整数解问题(SIS)以及带错误学习问题(LWE),根据其参数的不同,可能是NP-困难的。
-
编码问题依赖于解码错误纠正码的困难性,通常信息不足,并且自 1970 年代末以来就一直是研究的课题。这些问题也可能是NP-困难的。
-
多变量问题是指求解非线性方程组的问题,可能是NP-困难的,但它们未能提供可靠的密码系统,因为困难版本的规模过大且速度过慢,且实际应用的版本被发现不安全。
在第十章中,我们将继续探讨困难问题,特别是因式分解及其主要变体——RSA 问题。
第十一章:10 RSA

1977 年,Rivest–Shamir–Adleman (RSA) 加密系统的出现彻底革新了密码学,它是第一个公钥加密方案。与经典的对称密钥加密方案使用相同的密钥来加密和解密消息不同,公钥加密(或非对称加密)使用两把密钥:公钥,任何想要为你加密消息的人都可以使用;私钥,用于解密通过公钥加密的消息。这种魔法是 RSA 成为真正突破的原因,40 年后,它依然是公钥加密的典范,并且是互联网安全的核心工具。(在 RSA 出现的前一年,Whitfield Diffie 和 Martin Hellman 提出了公钥密码学的概念,但他们的方案只能在公钥设置中执行密钥分发。)
RSA 通过创建一个陷门置换来工作,这个函数将数字x转换为范围内的数字y,使得使用公钥计算从x到y变得容易,但除非知道私钥——也就是陷门,否则从y到x的计算几乎是不可能的。(可以将x看作是明文,将y看作是密文。)
除了加密,RSA 还可以用来构建数字签名,其中只有私钥的拥有者才能签署消息,而公钥则允许任何人验证签名的有效性。
本章将讲解 RSA 陷门置换是如何工作的,以及为什么仅靠这种置换不足以构建安全的加密和签名。我还将讨论 RSA 相对于因式分解问题的安全性(见第九章)、如何安全实现 RSA 以及如何攻击 RSA。
我们从解释 RSA 背后的基本数学概念开始。
RSA 背后的数学原理
在处理消息时,无论是加密还是签名,RSA 首先会从该消息中创建一个大数字,并通过大数字之间的乘法运算对其进行处理。因此,要理解 RSA 是如何工作的,你需要了解它操作的那些大数字是什么,以及这些数字上的乘法是如何进行的。
要加密或签名,RSA 将一个介于 1 和n - 1 之间的正整数进行变换,其中n是一个称为模数的大数字。当这些数字相乘时,会得到另一个符合这些标准的数字。这些数字组成一个群,你可以将其表示为Zn^,并称之为模n*的乘法群。(参见第九章第 189 页中“群”的数学定义。)
例如,考虑模 4 的整数群 Z[4]^。回想一下第九章,一个群必须包含单位元素(即 1),并且群中的每个数字 x 必须有一个逆元 y,使得 x × y = 1。你如何确定构成 Z[4]^ 的集合?根据定义,你知道 0 不属于 Z[4]^,因为任何数字与 0 相乘都不可能得到 1,因此 0 没有逆元。数字 1 属于 Z[4]^,因为 1 × 1 = 1,所以 1 是它自己的逆元。然而,数字 2 不属于该群,因为无法通过将 2 与 Z[4]^* 的另一个元素相乘得到 1(注意,2 与 4 不是互质的,因为 4 和 2 有公因子 2)。数字 3 属于 Z[4]^,因为它是 Z[4]^ 中的逆元。因此,Z[4]^* = {1, 3}。
现在考虑Z[5]^,即模 5 的整数乘法群。如同第九章中所述,Z[5]^ = {1, 2, 3, 4}。实际上,由于 5 是质数,1、2、3 和 4 都与 5 互质,因此 Z[5]^* 的集合包含了它们。我们来验证一下:2 × 3 mod 5 = 1;因此,2 是 3 的逆元,而 3 是 2 的逆元;注意,4 是它自己的逆元,因为 4 × 4 mod 5 = 1;最后,1 在该群中依然是它自己的逆元。
当 n 不是质数时,要找出群 Zn^* 中的元素个数,可以使用欧拉函数,我们将其写作 φ(n),其中 φ 代表希腊字母 phi。这个函数给出与 n 互质的元素个数,即 Zn^* 中的元素个数。通常,如果 n 是质数的积,即 n = p[1] × p[2] × … × pm,那么群 Zn^* 中的元素个数如下:

RSA 仅处理 n 是两个大质数的积的数字,通常表示为 n = pq。相关的群 Zn^* 包含 φ(n) = (p – 1)(q – 1) 个元素。通过展开这个表达式,你可以得到等价的定义 φ(n) = n – p – q + 1,或者 φ(n) = (n + 1) – (p + q),它更直观地表示了 φ(n) 相对于 n 的值。
RSA 陷门置换
RSA 陷门置换是基于 RSA 的加密和签名的核心算法。给定模数 n 和一个数字 e,你可以称之为公钥指数,RSA 陷门置换将集合 Zn^* 中的一个数字 x 转换为数字 y = x**^e mod n。换句话说,它计算的结果是 x 自乘 e – 1 次后对 n 取模,然后返回该结果。当你使用 RSA 陷门置换进行加密时,模数 n 和指数 e 组成了 RSA 公钥。
为了从 y 中恢复出 x,你可以使用另一个数字 d 来计算以下公式:

因为 d 是让你能够解密的陷阱门,它是 RSA 密钥对中私钥的一部分,这意味着它应该始终保密。数字 d 也叫做 秘密指数。
当然,d 并不是任意一个数字;它是这样一个数字,使得 e 乘以 d 等于 1,因此有 x**^(ed) mod n = x,对任意 x 都成立。更精确地说,你必须满足 ed mod φ(n) = 1,才能得到 x**^(ed) = x¹ = x 并正确解密消息。请注意,这里计算的是模 φ(n) 而不是模 n,因为指数像是 Zn^* 元素的 索引,而不是这些元素本身。由于 Zn^* 有 φ(n) 个元素,索引必须小于 φ(n)。
数字 φ(n) 对 RSA 的安全性至关重要。实际上,求解 RSA 模数 n 的 φ(n) 相当于破解 RSA,因为你可以通过计算 e 的逆元轻松从 φ(n) 和 e 推导出秘密指数 d。因此,p 和 q 也应保持机密,因为知道 p 或 q 可以通过计算 (p – 1)(q – 1) = φ(n) 来得出 φ(n)。
注意
φ(n) 被称为Z[n]^* 的阶;阶是群体的重要特征,对其他公钥系统(如 Diffie–Hellman 和椭圆曲线密码学)至关重要。*
RSA 密钥生成与安全性
密钥生成 是创建 RSA 密钥对的过程,即一个公钥(模数 n 和公钥指数 e)及其私钥(秘密指数 d)。由于数字 p 和 q(使得 n = pq)以及顺序 φ(n) 也应保持机密,因此它们通常作为私钥的一部分。
为了生成 RSA 密钥对,首先选择两个随机素数 p 和 q,然后从中计算 φ(n)。接着将 d 计算为 e 的逆元。Listing 10-1 演示了如何使用 SageMath(一个开源的类似 Python 的环境,包含许多数学包)来实现这一过程,SageMath 的网址是 (<wbr>www<wbr>.sagemath<wbr>.org)。
sage: **p = random_prime(2³²); p**
1103222539
sage: **q = random_prime(2³²); q**
17870599
sage: **n = p*q; n**
19715247602230861
sage: **phi = (p-1)*(q-1); phi**
19715246481137724
sage: **e = random_prime(phi); e**
13771927877214701
sage: **d = xgcd(e, phi)[1]; d = mod(d, phi)**
11417851791646385
sage: **mod(d*e, phi)**
1
Listing 10-1:使用 SageMath 生成 RSA 参数
这里你使用 random_prime() 函数来选择小于给定参数的随机素数 p 和 q。接下来,你将 p 和 q 相乘得到模数 n 和 φ(n),其中 φ(n) 是 phi 变量。然后,你通过选择一个小于 phi 的随机素数生成随机公钥指数 e,以确保 e 在模 phi 下有逆元。
你使用 Sage 的 xgcd() 函数来生成相关的私钥指数 d。这个函数使用扩展欧几里得算法,给定两个数字 a 和 b,计算出 s 和 t,使得 as + bt = GCD(a, b)。最后,你检查 ed mod φ(n) = 1,以确保 d 正确地反转了 RSA 置换。
注意
我在清单 10-1 中使用了一个 64 位模数 n ,以避免产生多页输出,但在实际应用中,为了确保安全,RSA 模数应至少为 2,048 位。
现在你可以应用陷门置换,正如清单 10-2 所示。
sage: **x = 1234567**
sage: **y = power_mod(x, e, n); y**
17129109575774132
sage: **power_mod(y, d, n)**
1234567
清单 10-2:计算 RSA 陷门置换的正反运算
你将整数 1,234,567 赋值给x,然后使用 power_mod(x, e, n) 函数,即指数模 n 运算,或者以方程形式表示的 x**^e mod n,来计算 y。计算出 y = x**^e mod n 后,你使用陷门 d 计算 y**^d mod n 来返回原始的 x。
没有陷门 d,要找到 x 有多难?一个能够分解大数的攻击者,可以通过恢复 p 和 q,然后计算 φ(n) 来从 e 计算出 d,从而破解 RSA。RSA 还有一个风险,攻击者可以从 x**^e mod n,即 e 次方根模 n 计算出 x,而不必分解 n。虽然这两个风险看起来密切相关,但我们不能确定它们是否等价。
假设因式分解和求 e 次方根的难度相当,RSA 的安全性依赖于三个因素:n 的大小、p 和 q 的选择,以及如何使用陷门置换。如果 n 太小,就可以在合理的时间内进行因式分解,从而揭示私钥。为了安全起见,n 至少应为 2048 位(大约 90 位的安全等级,需要约 2⁹⁰ 次运算),但最好是 4096 位(大约 128 位的安全等级)。p 和 q 的值应为不相关的随机质数,且大小相似。如果它们太小或太接近,就更容易从 n 中确定它们的值。最后,不应直接使用 RSA 陷门置换进行加密或签名,稍后我会讨论这个问题。
使用 RSA 加密
通常,RSA 与对称加密方案结合使用,其中 RSA 加密一个对称密钥,该密钥用于使用对称密码(如 AES-GCM)加密消息。但使用 RSA 加密消息或对称密钥比简单地将目标转换为数字 x 并计算 x**^e mod n 要复杂得多。
在以下小节中,我将解释为什么直接应用 RSA 陷门置换是不安全的,以及基于 RSA 的强加密是如何工作的。
教科书版 RSA 加密的易变性
短语 教科书版 RSA 加密 描述了简单的 RSA 加密方案,其中指数运算的数字仅包含你想要加密的消息。例如,为了加密字符串 RSA,你首先将其转换为一个数字——例如,通过将每个字母的 ASCII 编码连接起来作为字节:R(字节 52)、S(字节 53)和 A(字节 41)。转换成十进制后,结果字节串 525341 等于 5,395,265,接下来你可以通过计算 5,395,265^e mod n 来加密它。如果没有私钥,理论上是无法解密该消息的。
然而,教科书版 RSA 加密是确定性的:如果你两次加密相同的明文,你将得到相同的密文。而且还有一个更大的问题:给定两个教科书版 RSA 密文 y[1] = x[1]^e mod n 和 y[2] = x[2]^e mod n,你可以通过将这两个密文相乘,推导出 x[1] × x[2] 的密文:

结果是 (x[1] × x[2])^e mod n,即消息 x[1] × x[2] mod n 的密文。攻击者可以通过从两个 RSA 密文中创建一个新的有效密文,从而危及加密的安全性,使他们能够推断出原始消息的信息。这种弱点使教材 RSA 加密变得可变。(如果你知道 x[1] 和 x[2],你也可以计算 (x[1] × x[2])^e mod n,但如果你只知道 y[1] 和 y[2],你不应能将密文相乘并得到相乘的明文的密文。)
另一个简单的教材 RSA 加密问题是“特殊”消息的存在。无论 n 和 e 是什么,1^e = 1。因此,消息 1 在加密过程中保持不变。教材 RSA 还有许多其他问题,但你将通过使用强 RSA 加密方法来避免这些问题。
带 OAEP 的强 RSA 加密
为了使 RSA 密文不可伪造,加密过程中指数化的数字应将消息数据与额外的数据(称为填充)结合起来,正如图 10-1 所示。

图 10-1:使用 RSA 加密对称密钥 K,使用 (n, e) 作为公钥
以这种方式使用 RSA 加密的标准方法是使用最优非对称加密填充(OAEP),这种组合通常被称为 RSA-OAEP。该方案涉及创建一个与模数相同大小的比特串,通过在应用 RSA 函数之前用额外的数据和随机性填充消息。
注意
如 PKCS#1 标准和 NIST 的特别出版物 800-56B 所示,OAEP 被称为 RSAES-OAEP。OAEP 改进了早期的方法 PKCS#1 v1.5,这是 RSA 发布的一系列公钥密码标准(PKCS)中的第一个。它的安全性明显低于 OAEP,但在 OAEP 引入后,仍在许多系统中使用。
安全性
OAEP 使用伪随机数生成器(PRNG)确保密文的不可区分性和不可伪造性,通过使加密具有概率性。只要 RSA 函数和 PRNG 安全,且哈希函数不太弱,它已被证明是安全的。在使用 RSA 加密时,应该使用 OAEP,而不是其前身标准 PKCS#1 v1.5。
加密
使用 RSA 的 OAEP 模式加密需要一个消息(如对称密钥K)、一个伪随机数生成器(PRNG)和两个哈希函数。为了创建密文,使用给定模数n长度的m字节(即 8m位,因此n小于 2⁸^m)。要加密K,将编码后的消息构造为
M = H || 00 . . . 00 || 01 || K
其中H是由 OAEP 方案定义的h字节常量,后跟必要数量的00字节和一个01字节。图 10-2 展示了如何处理这个编码后的消息M。

图 10-2:使用 RSA-OAEP 加密对称密钥, K,其中 H 是一个固定参数, R 是随机位
你生成一个h字节的随机字符串R,并设置M = M ⊕ Hash1(R),其中Hash1(R)的长度与M相同。接下来,设置R = R ⊕ Hash2(M),其中Hash2(M)的长度与R相同。现在,使用这些新的M和R值来形成一个m字节的字符串P = 00 || M || R,其长度与模数n相同,且你可以将其转换为一个小于n的整数。这次转换得到的数字是x,然后你可以使用它来计算 RSA 函数x**^e mod n以得到密文。
要解密密文y,首先计算x = y**^d mod n,然后从中恢复出M和R的最终值。接下来,通过计算M ⊕ Hash1(R ⊕ Hash2(M))来恢复M的初始值。最后,验证M的格式是否为H || 00 . . . 00 || 01 || K,其中h字节的H后跟若干00字节,接着是一个01字节。
在实际应用中,参数 m 和 h(分别是模数的长度和Hash2输出的长度)通常设置为 m = 256 字节(适用于 2048 位 RSA)和 h = 32(使用 SHA-256 作为 Hash2)。这意味着 m – h – 1 = 223 字节用于 M,其中最多有 m – 2h – 2 = 190 字节可以用于 K("-2" 是由于 M 中的 01 字节分隔符)。然后,Hash1 的哈希值由 m – h – 1 = 223 字节组成,这比任何常见哈希函数的哈希值要长。为了构建具有如此不同寻常输出长度的哈希,RSA 标准规范定义了一种 掩码生成函数 技术,用于创建从任何哈希函数返回任意大小哈希值的哈希函数。另一种方法是使用可扩展输出函数(XOF),例如 SHAKE 或 BLAKE3,尽管这与标准规范不同。
使用 RSA 签名
数字签名可以证明与特定数字签名相关的私钥持有者签署了某个消息,通常是为了支持其内容。因为只有私钥持有者知道私有指数 d,其他任何人都无法根据某个值 x 计算出签名 y = x**^d mod n,但每个人都可以通过公共指数 e 验证 y**^e mod n = x。原则上,可以使用该验证的签名作为证据,证明私钥持有者签署了某个特定消息;这就是所谓的 不可否认性。
很容易将 RSA 签名视为加密的逆过程,但它们并不相同。使用 RSA 签名并不等于使用私钥加密。加密提供了保密性,而数字签名则有助于防止伪造。这种区别的最明显例子是,签名方案可以泄露签名消息的部分信息,因为消息本身并不保密。例如,揭示部分消息内容的方案可以是一个安全的签名方案,但却不是一个安全的加密方案。
由于必要的处理开销,公钥加密只能处理短消息,通常是秘密密钥而非实际消息。然而,签名方案可以通过使用其哈希值 Hash(M) 作为代理来处理任意大小的消息,而且它可以是确定性且安全的。与 RSA-OAEP 类似,基于 RSA 的签名方案可以使用填充方案,但也可以使用 RSA 模数允许的最大消息空间。
教科书中的 RSA 签名
一个教科书式的 RSA 签名是通过直接计算 y = x**^d mod n 来签名消息 x 的方法,其中 x 可以是 1 到 n – 1 之间的任何数字。像教科书加密一样,教科书 RSA 签名方法简单易于指定和实现,但在面对多种攻击时却不安全。一个这样的攻击涉及到一个简单的伪造:由于 1^d mod n = 1 和 (n – 1)^d mod n = n – 1,不管私钥 d 的值如何,攻击者可以伪造 1 或 n – 1 的签名,而无需知道 d。
更令人担忧的是盲化攻击。例如,假设你想让第三方在你知道他们绝不会故意签署的某个消息 M 上签名。为了发起这种攻击,首先找出某个值 R,使得 R^eM mod n 是一个你受害者愿意签署的消息。接着,你说服他们签署该消息并展示给你他们的签名,该签名等于 S = (R**e**M*)*d mod n,即消息的 d 次方。得到这个签名后,你可以通过一些简单的计算推导出 M 的签名,也就是 M**^d。
因为你可以将 S 写成 (R e**M*)*d = R**(ed)**M**d,并且因为 R**^(ed) = R(根据定义),所以你有 S = (R**e**M*)*d = RM**^d。为了得到签名 M**^d,你可以按照以下方式将 S 除以 R:

这通常是一种实用且强大的攻击方式。
PSS 签名标准
RSA 概率签名方案(PSS) 就像 OAEP 对于 RSA 加密一样,是对 RSA 签名的改进。它的设计目的是通过增加填充数据来使消息签名更加安全。
图 10-3 展示了 PSS 是如何将比模数更窄的消息与一些随机和固定的比特组合,然后再通过 RSA 加密这些填充后的结果。

图 10-3:使用 RSA 和 PSS 标准签名消息 M,其中(n, d)是私钥
像所有公钥签名方案一样,PSS 是基于消息的哈希值进行操作,而不是直接在消息本身上签名。签名Hash(M) 只有在哈希函数是抗碰撞的情况下才是安全的。这样,你就可以签名任何长度的消息,因为在对消息进行哈希后,无论消息的原始长度如何,你都能得到一个相同固定长度的哈希值。
为什么不通过对 Hash(M) 应用 OAEP 加密来签名呢?不幸的是,你不能这样做。虽然 OAEP 类似于 PSS,但已被证明仅在加密中是安全的,而不是用于签名。
类似于 OAEP,PSS 也需要一个伪随机数生成器(PRNG)和两个哈希函数。一个,Hash1,是一个具有标准输出长度的典型哈希,如 SHA-256。另一个,Hash2,是一个宽输出哈希,类似于 OAEP 的 Hash2。与 OAEP 一样,PSS 可以使用掩码生成函数(MGF)构建这样的哈希。
PSS 签名过程如下(其中 h 是 Hash1 的输出长度):
1. 使用 PRNG 选择一个 r 字节的随机字符串 R。
2. 形成编码消息 M′ = 0000000000000000 || Hash1(M) || R,长度为 h + r + 8 字节(开头有 8 个零字节)。
3. 计算 h 字节字符串 H = Hash1(M′)。
4. 设置 L = 00. . .00 || 01 || R,或者是一个由 00 字节组成的序列,后跟一个 01 字节和 R,并且有足够数量的 00 字节,使得 L 的长度为 m – h – 1 字节(模数 m 的字节宽度减去哈希长度 h 再减去 1)。
5. 设置 L = L ⊕ Hash2(H),用新值替换之前的 L。
6. 将 m 字节字符串 P = L || H || BC 转换为小于 n 的数字 x。这里,字节 BC 是附加在 H 后的固定值。
7. 给定刚得到的 x 值,计算 RSA 函数 x**^d mod n 来获得签名。
8. 验证签名时,给定消息 M,计算 Hash1(M),然后使用公钥指数 e 反转 RSA 函数并从签名中恢复 L、H,然后 M′,并在每个步骤中检查填充的正确性。
在实践中,随机字符串 R(在 RSA-PSS 标准中称为 salt t)通常与哈希值的长度相同。例如,如果你使用 n = 2,048 位和 SHA-256 作为哈希函数,则值 L 为 m – h – 1 = 256 – 32 – 1 = 223 字节,随机字符串 R 通常为 32 字节。
与 OAEP 一样,PSS 是可证明安全的,已标准化,并且在许多加密软件工具和库中都有实现,包括 OpenSSL 和 Go 语言的加密模块。与 OAEP 一样,它看起来复杂且容易出现实现错误和处理不当的边界情况。与 RSA 加密不同,PSS 在签名方面有一个更简单的替代方案:不需要 PRNG,只有一个哈希函数,并且没有填充。
全域哈希签名
全域哈希(FDH) 是你能想象的最简单的签名方案。要实现它,将字节字符串 Hash(M) 转换为数字 x,然后创建签名 y = x**^d mod n,如图 10-4 所示。

图 10-4:使用 RSA 和全域哈希技术对消息进行签名
签名验证也很简单。给定一个签名为数字y,计算x = y**^e mod n,然后将结果与Hash(M)进行比较。这个过程简单、确定且安全。那么,为什么还要去研究 PSS 的复杂性呢?
PSS 是在 1996 年,FDH 之后发布的,它的安全性证明比 FDH 更令人信服。具体来说,它的证明提供了比 FDH 证明略高的安全保障,并且其随机性的使用加强了该证明。
这些更强的理论保证是许多加密学家偏好 PSS 而非 FDH 的主要原因,但今天使用 PSS 的大多数应用程序可以切换到 FDH,而不会有明显的安全损失。然而,在某些情况下,使用 PSS 而非 FDH 是可行的,因为 PSS 的随机性能保护其免受一些对其实现的攻击,例如我们将在“如何出错”一章中讨论的故障攻击,详见第 211 页。
无论如何,RSA 签名(PSS 或 FDH)的使用越来越少。基于椭圆曲线的签名,如 ECDSA 和 EdDSA,已变得更为流行,尤其是因为它们计算签名的速度更快(尽管 RSA 在签名验证时通常更快)。
RSA 实现
我希望你永远不需要从零实现 RSA。如果有人要求你这样做,尽量跑得快一点,并质疑那个要求你这么做的人是否理智。加密学家和工程师们花了几十年时间,才开发出既快速、足够安全又理想中没有严重漏洞的 RSA 实现,所以重新发明 RSA 并不明智。即使有了所有文档,完成这项艰巨任务也需要几个月的时间。
通常,在软件中使用 RSA 时,你会使用一个库或 API,这些库或 API 提供执行 RSA 操作所需的功能。例如,Go 语言在其crypto包中有以下功能(来自 <wbr>go<wbr>.dev<wbr>/src<wbr>/crypto<wbr>/rsa<wbr>/rsa<wbr>.go):
func EncryptOAEP(hash hash.Hash, random io.Reader, pub *PublicKey, msg []byte, label []byte) (out []byte, err error)
函数EncryptOAEP()接受一个哈希函数、一个伪随机数生成器(PRNG)、一个公钥、一个消息和一个标签(OAEP 的可选参数),并返回一个密文和错误代码。当你调用EncryptOAEP()时,它会调用encrypt()来计算给定填充数据的 RSA 功能,正如清单 10-3 所示。
func encrypt(c *big.Int, pub *PublicKey, m *big.Int) *big.Int {
e := big.NewInt(int64(pub.E))
c.Exp(m, e, pub.N)
return c
}
清单 10-3:Go 语言加密库中核心 RSA 加密功能的实现
这里的主要操作是c.Exp(m, e, pub.N),它将消息m提升到指数e的幂次,模pub.N运算,并将结果赋值给变量c。
如果你选择自己实现 RSA,而不是使用现成的库函数,务必依赖现有的大数库,这是一组允许你定义并计算大数(数值位数达到几千位)上进行算术运算的函数和类型。例如,你可以在 C 语言中使用 GNU 多重精度(GMP)算术库,或者在 Go 语言中使用big包。(相信我,你不想自己实现大数算术运算。)
即使你在实现 RSA 时只使用库函数,也要确保理解其内部实现原理,这样你才能评估风险,判断其是否符合应用的安全需求。
快速指数运算算法
指数运算是将x提升到e次方的操作,计算x**^e mod n时需要使用。当处理大数时,比如 RSA,如果实现不当,这个操作可能非常慢。如何高效地计算指数运算呢?
计算x**^e mod n的简单方法需要e – 1 次乘法,如清单 10-4 中的伪代码算法所示。
expModNaive(x, e, n) {
y = x
for i = 1 to e – 1 {
y = y * x mod n
}
return y
}
清单 10-4:一种朴素的指数运算伪代码算法,将 x 提升到指数 e 模 n
这个算法简单但效率极低。你可以通过对中间值y进行平方,而不是相乘,直到达到正确的值,从而以指数级速度获得相同的结果。这种方法族称为平方-乘法、平方指数法或二进制指数法。
假设你想计算 3^(65,537) mod 36,567,232,109,354,321。例如,数字 65,537 是大多数 RSA 实现中的公钥指数。你可以将数字 3 乘以自身 65,536 次,或者你可以通过知道 65,537 可以写成 2¹⁶ + 1,来使用一系列平方操作来解决这个问题。基本上,你首先初始化一个变量y = 3,然后进行以下的平方(y²)运算:
-
设置y = y² mod n(现在y = 3² mod n)。
-
设置y = y² mod n(现在y = (3²)² mod n = 3⁴ mod n)。
-
设置y = y² mod n(现在y = (3⁴)² = 3⁸ mod n)。
4. 设定 y = y² mod n(现在 y = (3⁸)² = 3¹⁶ mod n)。
5. 设定 y = y² mod n(现在 y = (3¹⁶)² = 3³² mod n)。
继续进行,直到 y = 3^(65,536),通过进行 16 次平方运算。
为了得到最终结果,你需要返回 3 × y mod n = 3^(65,537) mod n = 26,652,909,283,612,267。换句话说,你只用了 17 次乘法运算来计算结果,而朴素方法需要 65,536 次乘法运算。
更一般地,平方和乘法方法通过逐个扫描指数的比特,计算每个指数比特的平方来加倍指数值,并且对于每个遇到值为 1 的比特,乘以原始数值。在上述例子中,指数 65,537 的二进制表示是 10000000000000001,你为每个新比特进行了平方运算,并且只在第一个和最后一个比特时才乘以原始数值 3。
列表 10-5 展示了如何将 x**^e mod n 作为一个通用算法来计算,当指数 e 包含比特 em – [1]em – [2] . . . e[1]e[0] 时,其中 e[0] 是最低有效位。
expMod(x, e, n) {
y = x
for i = m – 1 to 0 {
y = y * y mod n
if ei == 1 then
y = y * x mod n
}
return y
}
列表 10-5:伪代码中的快速指数算法,将 x 提升到 e 的幂次 modulo n
expMod() 算法的运行时间是 O(m),而朴素算法的运行时间是 O(2^m),其中 m 是指数的比特长度。这里,O() 是在 第九章 中介绍的渐进复杂度符号。
所有严肃的系统都会实现这种最简单的平方和乘法方法的某个变体。一个变体是 滑动窗口 方法,它考虑比特块而不是单个比特来执行给定的乘法操作。例如,查看 Go 语言中的函数 expNN(),其源代码可以在 <wbr>go<wbr>.dev<wbr>/src<wbr>/math<wbr>/big<wbr>/nat<wbr>.go 中找到。
这些平方和乘法指数算法有多安全?不幸的是,加速过程的技巧通常会导致对某些攻击的脆弱性增加。
这些算法的弱点源于指数运算高度依赖于指数值的事实。在列表 10-5 中的if操作会根据指数的位是 0 还是 1 而采取不同的分支。如果某个位是 1,那么for循环的一个迭代会比为 0 时慢,攻击者通过监控 RSA 操作的执行时间,可以利用这个时间差恢复私有指数。这就是所谓的时序攻击。对硬件的攻击可以通过监控设备的功耗来区分 1 位和 0 位,并观察哪些迭代执行额外的乘法,从而揭示私有指数中哪些位是 1。
根据平台的不同,信息通道可能不是执行时间。例如,您可能通过测量设备的功耗并观察哪些迭代执行额外的乘法来区分指数中的 1 位和 0 位,从而揭示私有指数位为 1 的部分。这是一种功耗分析攻击。很少有开源加密库能够有效防御这些类型的攻击。
较小的指数以实现更快的公钥操作
因为 RSA 计算本质上是指数运算,所以其性能取决于涉及数字的大小,特别是指数。较小的指数需要更少的乘法,因此可以使指数运算更快。
原则上,公共指数e可以是介于 3 和φ(n) - 1 之间的任何值,只要e与φ(n)互质。但实际上,你会发现只有小的e值,而且大多数情况下,e = 65,537 是由于加密和签名验证速度的考虑。例如,Microsoft Windows CryptoAPI 仅支持适合 32 位整数的公共指数。e越大,计算x**^e mod n的速度就越慢。
与公共指数的大小不同,私有指数d的大小不可避免地接近n,使得解密比加密慢得多,签名比验证慢得多。因为d是秘密的,它必须是不可预测的,因此不能限制为一个小值。例如,如果e固定为 65,537,则相应的d通常与模数n的数量级相同,如果n是 2048 位长,d将接近 2^(2,048)。
正如在“快速指数算法”中讨论的那样,在第 206 页,将一个数提升到 65,537 次方需要 17 次乘法,而将一个数提升到某个 2048 位数的次方需要大约 3000 次乘法。
你可以通过使用 OpenSSL 工具包来估算 RSA 的速度。例如,列表 10-6 展示了在配备 M2 芯片组的 MacBook 上进行 512 位、1,024 位、2,048 位和 4,096 位 RSA 操作的结果:
Doing 1024 bits private rsa sign ops for 10s: 119019 1024 bits private RSA sign ops in 9.93s
Doing 1024 bits public rsa verify ops for 10s: 2573979 1024 bits public RSA verify ops in 9.93s
Doing 2048 bits private rsa sign ops for 10s: 19184 2048 bits private RSA sign ops in 9.93s
Doing 2048 bits public rsa verify ops for 10s: 769696 2048 bits public RSA verify ops in 9.93s
Doing 4096 bits private rsa sign ops for 10s: 3005 4096 bits private RSA sign ops in 9.92s
Doing 4096 bits public rsa verify ops for 10s: 206367 4096 bits public RSA verify ops in 9.91s
sign verify sign/s verify/s
rsa 1024 bits 0.000083s 0.000004s 11985.8 259212.4
rsa 2048 bits 0.000518s 0.000013s 1931.9 77512.2
rsa 4096 bits 0.003301s 0.000048s 302.9 20824.1
`--snip--`
列表 10-6:使用 OpenSSL 工具包(版本 3.2.0)进行 RSA 操作的示例基准测试
为了了解验证相较于签名生成的速度慢多少,可以计算验证时间与签名时间的比率。列表 10-6 中的基准测试显示,对于 1,024 位、2,048 位和 4,096 位模数,我得到的验证与签名速度比大约分别为 21.6、40.1 和 68.7。随着模数大小的增加,这一差距会变大,因为e操作所需的乘法次数对于模数大小保持不变(例如,当e = 65,537 时为 17 次操作),而私钥操作则需要更多的乘法,因为d也相应增大。
如果小指数如此有优势,为什么不使用 65,537 而选择像 3 这样的数呢?实际上,当使用 OAEP、PSS 或 FDH 等安全方案实现 RSA 时,使用 3 作为指数是可以的,甚至更快。然而,加密学家避免这么做,因为当e = 3 时,某些不安全的方案可能会导致特定类型的数学攻击。数字 65,537 足够大,可以避免这种低指数攻击,而且它只有两个非零位,这在计算x^(65,537)时可以减少计算时间。对于数学家来说,65,537 还是一个特殊的数字:它是第四个费马数,或者是形如

因为它等于 2¹⁶ + 1,其中 16 = 2⁴,但这对大多数加密工程师来说只是一个与主题无关的好奇心。 #### 中国剩余定理
加速解密和签名生成(即计算y**^d mod n)的最常见技巧是中国剩余定理(CRT),它能让 RSA 速度提高约四倍。
CRT 通过计算两个模p和模q的指数,而不是单一的模n指数,从而加速解密。由于p和q远小于n,因此执行两个“小”指数运算比执行一个“大”指数运算要快。
中国剩余定理并不特定于 RSA。它是一个通用的算术结果,简单形式为:如果 n = n[1]n[2]n[3] . . . ,其中 nis 是两两互质的(即 GCD(ni, nj) = 1 对于任何不同的 i 和 j),那么就可以从 x mod n[1],x mod n[2],x mod n[3],. . . 的值中计算出 x mod n。例如,假设你有 n = 1,155,这是素因数 3 × 5 × 7 × 11 的乘积,并且你想要确定 x,使得 x mod 3 = 2,x mod 5 = 1,x mod 7 = 6,x mod 11 = 8。(我随意选择了 2、1、6 和 8。)使用中国剩余定理来找到 x,计算 P(n[1]) + P(n[2]) + . . .,其中 P(ni) 定义如下:

请注意,第二项 n/ni 等于除这个 ni 外所有因子的乘积。
要将这个公式应用于本例并恢复 x mod 1155,计算 P(3),P(5),P(7),和 P(11);然后将它们加起来,得到如下表达式:

在这里,我刚刚应用了前面定义的P(ni)。(计算每个数字的数学原理很简单,但我不会在这里详细说明。)然后,你可以将这个表达式简化为[770 + 231 + 1980 + 1680] mod n = 41。由于 41 是我为这个示例选择的数字,所以你得到了正确的结果。
将中国剩余定理应用于 RSA 比前面的示例更简单,因为每个 n 只有两个因子(即 p 和 q)。给定一个密文 y 需要解密时,代替计算 y**^d mod n,使用中国剩余定理计算 xp = y**^s mod p,其中 s = d mod (p – 1),并且 xq = y**^t mod q,其中 t = d mod (q – 1)。将这两个表达式结合起来并计算 x,得到如下:

就是这样。这比单次指数运算要快,即使使用了平方乘法技巧,因为涉及大量乘法的运算是在模 p 和 q 上进行的,而这两个数字的大小是 n 的一半,而指数运算的复杂度比线性增长还要快。当你将数字的大小加倍时,你不仅仅是将指数运算的成本加倍,它增长得更快。
注意
在最终操作中,你可以提前计算两个数字 q × (1/q mod p) 和 p × (1/p mod q*),这意味着你只需要计算两个乘法和一次模 n 的加法来找到 x。
不幸的是,这些技术存在一个安全警告。
出错的方式
比 RSA 方案本身更优雅的是一系列攻击,这些攻击之所以有效,要么是因为实现过程泄露(或者可以被设计为泄露)了关于内部信息,要么是因为 RSA 被不安全地使用。我将在接下来的章节中讨论两种经典的这类攻击示例。
Bellcore 攻击与 RSA-CRT
RSA 上的 Bellcore 攻击是 RSA 历史上最重要的攻击之一。它最早于 1996 年被发现,之所以突出,是因为它利用了 RSA 对 故障注入(故意使算法一部分发生故障并可能产生错误结果)的脆弱性。例如,攻击者可以通过突然改变电压供应或向芯片的某个特定部位发射激光脉冲,暂时扰乱硬件电路或嵌入式系统。然后,攻击者可以通过观察结果的影响,利用算法内部故障的结果。通过将正确结果与故障结果进行比较,可以提供关于算法内部值的信息,包括秘密值。同样,尝试验证结果是否有效也可能泄露可被利用的信息。
Bellcore 攻击就是这样一种故障攻击。它适用于使用中国剩余定理且是确定性的 RSA 签名方案——这意味着它适用于 FDH,但不适用于 PSS,后者是概率性的。
要理解 Bellcore 攻击,请回顾上一节的内容,在使用中国剩余定理时,通过计算以下内容,你得到的结果等于 x**^d mod n,其中 xp = y**^s mod p,xq = y**^t mod q:

现在假设攻击者在计算 xq 时诱发故障,使得你得到某个不正确的值,该值与实际的 xq 不同。我们将这个不正确的值称为 xq′,最终得到的结果称为 x′。攻击者随后可以将不正确的签名 x′ 从正确签名 x 中减去,从而因式分解 n,结果如下:

因此,x – x′ 的值是 p 的倍数,所以 p 是 x – x′ 的约数。因为 p 也是 n 的约数,所以 n 和 x – x′ 的最大公约数为 p,GCD(x – x′, n) = p。然后你可以计算 q = n/p 和 d,从而完全破解 RSA 签名。
这种攻击的变种在你只知道消息已签名而不知道正确签名的情况下有效。还有一种类似的故障攻击针对模数值,而不是针对中国剩余定理(CRT)值的计算,但我在这里不打算详细讨论。
共享私有指数或模数
现在我将向你展示为什么你的公钥不应与他人的模数 n 相同。
不同系统或不同人的私钥应该有不同的私有指数 d,即使它们使用不同的模数。或者你也可以尝试自己设置 d 值来解密为其他实体加密的消息,直到你找到一个与之共享相同 d 的密钥。类似地,不同的密钥对应该有不同的 n 模数值,即使它们有不同的 d,因为 p 和 q 通常是私钥的一部分。因此,如果我与别人共享相同的 n,从而拥有相同的 p 和 q,我就可以利用 p 和 q 从你的公钥 e 计算出你的私钥。
假设你知道你自己的私有指数 d[1] 和另一个与你共享相同模数 n 的人的公有指数 e[2],但不知道其因子 p 和 q。那么,你如何从你自己的私有指数 d[1] 计算出 p 和 q,进而找到另一个人的私有指数 d[2] 呢?这个解决方案有点技术性,但非常优雅。
记住,d 和 e 满足 ed = kφ(n) + 1,其中 φ(n) 是秘密的,可能揭示出 p 和 q。你不知道 k 或 φ(n),但你可以计算出 kφ(n) = ed – 1。
你可以用 kφ(n) 做什么呢?根据 欧拉定理,对于与 n 互质的任何数字 a,都有 a(φ()*n*^) = 1 mod n。因此,模 n 下,你可以得到以下结果:

因为 kφ(n) 是一个偶数,你可以将其写成 2^s**t 的形式,其中 s 和 t 是某些数字。也就是说,你可以将 a**k*(φ()n*) = 1 mod n 表示为 x² = 1 mod n,其中 x 是从 kφ(n) 计算出来的。你可以称这种 x 为 单位根。
关键的观察是,x² = 1 mod n 等价于 x² – 1 = (x – 1)(x + 1) 整除 n。换句话说,x – 1 或 x + 1 必须与 n 有共同因子,这样就能得到 n 的因式分解。
清单 10-7 展示了该方法的 Python 实现,在此实现中,为了简化,我使用了小的 64 位数字来从 n 和 d 中找出因子 p 和 q。
from math import gcd
n = 36567232109354321
e = 13771927877214701
d = 15417970063428857
❶ kphi = d*e - 1
t = kphi
❷ while t % 2 == 0:
t = divmod(t, 2)[0]
❸ a = 2
while a < 100:
❹ k = t
while k < kphi:
x = pow(a, k, n)
❺ if x != 1 and x != (n - 1) and pow(x, 2, n) == 1:
❻ p = gcd(x - 1, n)
break
k = k*2
a = a + 2
q = n//p
❼ assert (p*q) == n
print('p = ', p)
print('q = ', q)
清单 10-7:一个计算质因数的 Python 程序 p 和 q 从私有指数 d
该程序通过找到数字 t 使得 kφ(n) = 2^s**t,来从 e 和 d ❶ 确定 kφ(n),其中 s ❷ 是某个数。然后,它寻找 a 和 k 使得 (a**^k)² = 1 mod n ❸,以 t 作为 k 的起点 ❹。当这个条件得到满足 ❺,你就找到了一个解。接下来,它确定因子 p ❻ 并验证 ❼ pq 的值是否等于 n 的值。最后,它打印出结果值 p 和 q:
p = 2046223079
q = 17870599
该程序正确地返回了两个因子。
进一步阅读
RSA 值得专门写一本书。我不得不省略了许多重要且有趣的主题,比如丹尼尔·布莱辛巴赫(Daniel Bleichenbacher)对 OAEP 前身(标准 PKCS#1 v1.5)进行的填充 oracle 攻击,或者曼格(Manger)对 OAEP 的填充 oracle 攻击,这两种攻击在精神上与第四章中描述的块加密的填充 oracle 攻击类似。还有迈克尔·维纳(Michael Wiener)针对具有低私钥指数的 RSA 攻击,以及使用科珀史密斯(Coppersmith)方法攻击具有小指数的 RSA,并且这些攻击可能也存在不安全的填充。
要查看与侧信道攻击及其防御相关的研究成果,请访问自 1999 年以来举行的 CHES 研讨会论文集,网址为<wbr>ches<wbr>.iacr<wbr>.org。在撰写本章时,最有用的参考之一是丹·博内(Dan Boneh)的《RSA 密码系统二十年攻击回顾》(Twenty Years of Attacks on the RSA Cryptosystem),这篇综述回顾并解释了针对 RSA 的最重要攻击。关于时间攻击的参考文献,必读的是比利·鲍勃·布鲁姆利(Billy Bob Brumley)和丹·博内的论文《远程时间攻击是可行的》(Remote Timing Attacks Are Practical),它不仅在分析上做出了贡献,而且在实验上也非常重要。要深入了解故障攻击,请阅读丹·博内、理查德·德米洛(Richard DeMillo)和理查德·利普顿(Richard Lipton)合著的《消除密码计算中的错误的重要性》(On the Importance of Eliminating Errors in Cryptographic Computations)的 Bellcore 攻击论文完整版。
学习 RSA 实现工作原理的最佳方法,虽然有时痛苦且令人沮丧,就是审查广泛使用的实现的源代码。例如,查看 OpenSSL、NSS(Mozilla Firefox 浏览器使用的库)、Crypto++或其他流行软件中 RSA 及其底层大数算术的实现,研究它们的算术运算实现以及它们对时间攻击和故障攻击的防御。
第十二章:11 迪菲–赫尔曼

1976 年 11 月,斯坦福大学的研究人员惠特菲尔德·迪菲和马丁·赫尔曼发表了一篇名为《密码学的新方向》的研究论文,彻底改变了密码学的面貌。他们的论文提出了公钥加密和签名的概念,尽管他们实际上并没有这些方案;他们仅提出了一个他们称之为公钥密码系统的协议,这种协议允许双方通过交换对窃听者可见的信息来建立共享的秘密。这就是现在所称的迪菲–赫尔曼(DH)协议。在迪菲–赫尔曼之前,建立共享秘密需要繁琐的程序,例如手动交换密封的信封。
一旦通信双方通过 DH 协议建立了共享的秘密值,他们就可以利用该秘密值建立一个安全通道,通过将秘密转化为一个或多个对称密钥,然后用这些密钥加密和验证随后的通信。因此,DH 协议及其变种被称为密钥协商协议。
在本章的第一部分,你将了解迪菲–赫尔曼协议的数学基础,包括 DH 依赖的计算问题,这些问题使得它的魔力得以实现。接下来,你将学习可以用来创建安全通道的不同版本的迪菲–赫尔曼协议。最后,由于迪菲–赫尔曼方案只有在其参数选择得当时才是安全的,你将看到一些迪菲–赫尔曼可能失败的场景。
注意
迪菲和赫尔曼因发明了公钥密码学和数字签名,于 2015 年荣获了声望极高的图灵奖,但其他人也应当获得认可。1974 年,当时还是计算机科学本科生的拉尔夫·梅尔克提出了公钥密码学的概念,并引入了梅尔克谜题。大约在同一时期,英国政府通信总部(GCHQ),即英国版的美国国家安全局(NSA)的研究人员发现了里维斯特–沙米尔–阿德尔曼(RSA)和迪菲–赫尔曼密钥协商的原理,尽管这一事实直到几十年后才被解密。
迪菲–赫尔曼函数
要理解 DH 密钥协商协议,你必须理解其核心操作,即DH 函数。迪菲和赫尔曼最初定义了 DH 函数,要求其与Zp^群一起工作,该群由模素数 p 的非零整数构成(通常用p表示,见第九章)。另一个公共参数是基数或生成元,即g*。
DH 函数涉及两个私有值,由两方从群体 Zp^* 中随机选择,我们将其写作 a 和 b。私有值 a 与公有值 A = g**^a mod p 相关,或者说 g 的 a 次方对 p 取模。这个 A 会通过一个对窃听者可见的消息发送给另一方。与 b 相关的公有值是 B = g**^b mod p,它会发送给 a 的拥有者。因此,攻击者可以得知 A 和 B。
DH 的工作原理是将一个公有值与另一个私有值结合,使得两者的结果相同:A**^b = (g**a*)*b = g**^(ab) 和 B ^a = (g**b*)*a = g**^(ba) = g**^(ab)。得到的值 g**^(ab) 是 共享密钥;然后,你将其传递给 密钥派生函数 (KDF) 以生成一个或多个共享的对称密钥。KDF 是一种哈希函数,它返回一个看起来随机的字符串,其长度等于所需的密钥长度。
就这样。像许多伟大的科学发现(重力、相对论、量子计算或 RSA)一样,Diffie–Hellman 方法事后看相对简单。
然而,Diffie–Hellman 的简单性可能会令人误解。首先,它并不是任何素数 p 或基数 g 都能奏效。一些 g 的值会将共享密钥 g**^(ab) 限制在一个较小的子集范围内,而你可能期望共享密钥的值范围与 Zp^* 中的元素数量相当,因此应该有尽可能多的共享密钥值。为了确保最高的安全性,安全的 DH 参数应与一个素数 p 一起工作,使得 (p – 1)/2 也是素数。这样的 安全素数 可以确保群体中没有小的子群,这些子群可能使 DH 更容易被破解。使用安全素数时,DH 可以与 Zp^* 中的任何元素一起工作,除了 1 和 p – 1;特别地,g = 2 会使计算稍微加快。不过,生成一个安全素数 p 比生成一个完全随机的素数需要更多时间。
例如,OpenSSL 工具包中的 dhparam 命令仅生成安全的 DH 参数,但算法内置的额外检查会显著增加执行时间,如示例 11-1 所示。
$ **time openssl dhparam 2048**
Generating DH parameters, 2048 bit long safe prime, generator 2
This is going to take a long time
`--snip--`
-----BEGIN DH PARAMETERS-----
MIIBCAKCAQEAoSIbyA9e844q7V89rcoEV8vd/l2svwhIIjG9EPwWWr7FkfYhYkU9
fRNttmilGCTfxc9EDf+4dzw+AbRBc6oOL9gxUoPnOd1/G/YDYgyplF5M3xeswqea
SD+B7628pWTaCZGKZham7vmiN8azGeaYAucckTkjVWceHVIVXe5fvU74k7+C2wKk
iiyMFm8th2zm9W/shiKNV2+SsHtD6r3ZC2/hfu7XdOI4iT6ise83YicU/cRaDmK6
zgBKn3SlCjwL4M3+m1J+Vh0UFz/nWTJ1IWAVC+aoLK8upqRgApOgHkVqzP/CgwBw
XAOE8ncQqroJ0mUSB5eLqfpAvyBWpkrwQwIBAg==
-----END DH PARAMETERS-----
openssl dhparam 2048 7.46s user 0.10s system 99% cpu 7.593 total
示例 11-1:测量生成 2,048 位 Diffie–Hellman 参数的执行时间(使用 OpenSSL 工具包)
使用 OpenSSL 工具包生成 DH 参数大约需要 8 秒(常见的生成时间为 30 秒,甚至超过 1 分钟)。
为了进行比较,示例 11-2 显示了在相同系统上生成相同大小的 RSA 参数(即两个素数,p 和 q,每个素数的大小是 DH 所用 p 的一半)所需的时间。
$ **time openssl genrsa 2048**
Generating RSA private key, 2048 bit long modulus
...................................................+++
.............................................................+++
e is 65537 (0x10001)
-----BEGIN RSA PRIVATE KEY-----
`--snip--`
-----END RSA PRIVATE KEY-----
openssl genrsa 2048 0.16s user 0.01s system 95% cpu 0.171 total
示例 11-2:生成 2,048 位 RSA 参数并测量执行时间
生成 DH 参数的时间大约是生成相同安全级别的 RSA 参数的 50 倍,主要是由于在生成 DH 参数时对素数施加了额外的约束。### Diffie–Hellman 问题
DH 协议的安全性依赖于计算问题的难度,尤其是离散对数问题(DLP)的难度,详见第九章。你可以通过从公有值 g**^a 中恢复私有值 a 来破解 DH,这实际上是解决一个 DLP 实例。但当我们使用 DH 来计算共享秘密时,我们不仅关心离散对数问题。我们还关心两个 DH 特有的问题。
计算问题
计算 Diffie–Hellman (CDH) 问题是给定公有值 g ^a 和 g ^b,在不知道私有值 a 和 b 的情况下,计算共享秘密 g ^(ab)。其动机是确保即使窃听者捕获了 g ^a 和 g ^b,他们也无法确定共享秘密 g ^(ab)。
如果你能够解决 DLP,那么你也可以解决 CDH;也就是说,如果给定 g ^a 和 g ^b 能确定 a 和 b,那么你就能计算出 g ^(ab)。换句话说,DLP 至少和 CDH 一样难。但你不能确定 CDH 是否至少和 DLP 一样难,这会使得这两个问题同样困难。换句话说,DLP 对 CDH 就像因式分解问题对 RSA 问题一样。(回想一下,因式分解可以解决 RSA 问题,但不一定能反过来解决。)
Diffie–Hellman 与 RSA 还有另一个相似之处,即 DH 在给定模数大小时提供与 RSA 相似的安全级别。例如,具有 2,048 位素数 p 的 DH 协议提供大约与 2,048 位模数的 RSA 相当的 90 位安全性。实际上,破解 CDH 的最快方法是使用 数域筛法 算法来解决离散对数问题(DLP),这种方法与通用数域筛法(GNFS)相似,但不完全相同,GNFS 是通过对 RSA 的模数进行因式分解来破解 RSA 的方法。
可判定问题
当你需要比 CDH 的难度更强的假设时,就需要引入 可判定 Diffie–Hellman (DDH) 问题。给定 g ^a、g ^b,以及一个值,它要么是 g ^(ab),要么是 g ^c(其中 c 是某个随机数,每个值的概率为 1/2),DDH 问题的目标是判断 g ^(ab)(对应于 g ^a 和 g ^b 的共享秘密)是否被选择。
在以下情况下,依赖 DDH 而非 CDH 是有意义的:假设攻击者仅能计算给定 g ^a 和 g ^b 的 2,048 位值中的前 32 位 g ^(ab)。虽然 CDH 仍然没有被攻破,因为 32 位可能不足以完全恢复 g ^(ab),但攻击者已经从中学到了一些关于共享密钥的信息,这可能允许他们破坏应用程序的安全性。
为了确保攻击者无法得知共享密钥 g ^(ab) 的任何信息,该值需要与一个随机群元素 不可区分,就像加密方案在密文与随机字符串不可区分时是安全的。这就是说,攻击者不应该能够确定给定的数字是 g ^(ab) 还是 g**^c(其中 c 是某个随机值),给定 g**^a 和 g**^b。判定性 Diffie–Hellman 假设 假设没有攻击者可以有效地解决 DDH 问题。
如果 DDH 很困难,那么 CDH 也是如此,你无法得知 g ^(ab) 的任何信息。所以,如果你能解决 CDH,那么你也能解决 DDH:给定一个三元组 (g ^a, g ^b, x),你将能够从 g**^a 和 g ^b 中推导出 g ^(ab) 并检查结果是否等于给定的 x。
结论是,DDH 从根本上比 CDH 更简单(值得注意的是,DDH 在 Zp^* 上并不困难,与 CDH 相反),然而,DDH 难度是密码学中的一个重要假设,并且是最研究的假设之一。
Diffie–Hellman 的变体
有时,密码学家设计新的方案,并证明它们至少像解决某些困难问题一样困难。但这些困难问题并不总是 CDH 或 DDH,可能是它们的变体。我们希望能够证明,破解一个密码系统的难度和解决 CDH 或 DDH 的难度一样大,但对于一些先进的密码学机制,这并不总是可能的,通常是因为这些方案涉及比基本的 Diffie–Hellman 协议更复杂的操作。
例如,在一个类似 DH 的问题中,给定 g**^a,攻击者试图计算 g(1/)*a,其中 1/a* 是群中 a 的逆元(通常是 Zp^,其中 p 是某个质数)。在另一个问题中,攻击者可能通过随机的 a 和 b 区分对 (g^a*, *g^b) 和对 (g^a, g(1/)*a)。最后,在 双重 Diffie–Hellman 问题 中,给定 *ga*、*g**b* 和 g**^c,攻击者试图计算两个值 g**^(ab) 和 g**^(ac)。有时这些 DH 变体的难度与 CDH 或 DDH 一样困难,有时它们则更容易——因此提供的安全保证较低。作为练习,试着找出这些问题的难度与 CDH 和 DDH 难度之间的联系。(双重 Diffie–Hellman 实际上与 CDH 同样困难,但这并不容易证明!)
密钥协议
Diffie–Hellman 问题旨在构建安全的密钥协商协议,确保两个或多个在网络上通信的各方通过共享秘密来加密通信。这些各方将这个秘密转化为一个或多个 会话密钥—对称密钥,在会话期间加密和认证交换的信息。
在研究实际的 DH 协议之前,你应该了解一个密钥协商协议安全的因素,以及更简单的协议是如何工作的。我们将从一个常见的密钥协商协议开始,这个协议不依赖于 DH。
非 DH 密钥协商
为了让大家了解一个密钥协商协议是如何工作的,以及它如何确保安全,我们来看看 4G 和 5G 电信标准中用于在 SIM 卡和电信运营商之间建立通信的协议:认证密钥协商 (AKA)。它不使用 Diffie–Hellman 函数,而是仅使用对称密钥操作。图 11-1 详细描述了该协议是如何工作的。

图 11-1:4G 和 5G 电信中的 AKA 协议
在此协议描述中,SIM 卡有一个秘密密钥 K,该密钥由运营商知晓。运营商通过选择一个随机值 R 开始会话,然后基于两个伪随机函数 PRF0 和 PRF1 计算出两个值 SK 和 V[1]。接下来,运营商向 SIM 卡发送包含 R 和 V[1] 的消息,这些值对攻击者是可见的。一旦 SIM 卡获取到 R,它便可以使用 PRF0 来计算 SK,并且确实计算出了这个值。会话中的两方最终得到一个共享密钥 SK,攻击者无法仅通过查看双方交换的消息,甚至通过修改它们或注入新的消息来确定该密钥。SIM 卡通过重新计算 V[1],使用 PRF1、K 和 R,并检查计算出的 V[1] 是否与运营商发送的 V[1] 匹配,从而验证它正在与运营商通信。然后,SIM 卡使用新的函数 PRF2 和 K、R 作为输入计算一个验证值 V[2],并将 V[2] 发送给运营商。运营商通过计算 V[2] 并检查计算出的值是否与接收到的 V[2] 匹配来验证 SIM 卡是否知道 K。
在我描述的协议中,有一种方法可以通过重放攻击欺骗 SIM 卡。本质上,如果攻击者捕获了一对 (R, V[1]),他们可以将其发送到 SIM 卡,并欺骗 SIM 卡相信该对数据来自一个合法的运营商,该运营商知道 K。为了防止这种攻击,协议包含了额外的检查,以确保同一 R 不会被重用。
如果K被泄露,就会出现问题。例如,一个泄露了K的攻击者可以进行中间人攻击,监听所有明文通信。这样的攻击者可能在两个方之间发送消息,同时冒充合法的 SIM 卡运营商和 SIM 卡。即使K在某次通信时并没有被泄露,攻击者也可以记录通信和密钥协议期间交换的任何消息,并在找到K时使用捕获的R值解密这些通信。攻击者可以通过此方式确定过去的会话密钥,并用它们解密已录制的流量——在这种情况下,协议没有提供前向保密性。
密钥协议的攻击模型
密钥协议没有单一的安全定义,且没有考虑攻击模型和安全目标的上下文情况下,任何密钥协议都不可能完全安全。例如,你可以认为之前的 4G/5G 协议是安全的,因为被动攻击者无法找到会话密钥,但它也不安全,因为一旦密钥K泄露,这会危及所有之前和之后的通信。
密钥协议中有不同的安全概念,以及三个主要的攻击模型,这些模型取决于协议泄露的信息。从最弱到最强,这些模型是网络攻击者、数据泄漏和突破:
网络攻击者 该攻击者观察两个合法方之间交换的消息,并且可以记录、修改、丢弃或注入消息。为了防范此类攻击者,密钥协议必须确保不会泄漏任何关于已建立共享秘密的信息。
数据泄漏 在这个模型中,攻击者通过一次或多次协议执行获得会话密钥和所有临时秘密(例如电信协议示例中的SK),但不会获得长期秘密(如该协议中的K)。
突破(或篡改) 在这个模型中,攻击者获知一个或多个方的长期密钥。一旦发生突破,安全性就无法再保证,因为攻击者可以在后续的协议会话中冒充其中一个或两个方,因为这是唯一可以标识一个方的信息(至少在理论上如此,因为在实践中像 IP 白名单这样的机制可以降低冒充的风险)。尽管如此,攻击者不应该能够从发生在获取密钥之前的会话中恢复秘密。
现在我们已经了解了攻击模型并看到了攻击者可能采取的行动,让我们来探讨一下安全目标——即协议应该提供的安全保障。你可以设计一个密钥协议来满足多个安全目标。这里描述了四个最相关的目标,按从简单到复杂的顺序排列:
认证 协议应该允许互相认证,即每一方都能认证另一方。AKA 是指协议认证双方。
密钥控制 任何一方都不应能够选择最终的共享密钥或强迫其属于特定子集。前面讨论过的 4G/5G 密钥协商协议缺乏这一特性,因为运营商选择了完全决定最终共享密钥的R值。
前向保密性 即使所有长期密钥都被泄露,攻击者也应该无法从协议的先前执行中计算出共享密钥,即使他们记录了所有先前的执行或能够注入或修改先前执行中的消息。前向保密,或称为前向安全协议,保证即使你必须将设备及其密钥交给某个机构,他们也无法解密你之前加密的通讯。(4G/5G 密钥协商协议不提供前向保密性。)
抵抗密钥泄露冒充(KCI) KCI 发生在攻击者泄露某一方的长期密钥,并利用该密钥冒充另一方。例如,4G/5G 密钥协商协议允许轻易的密钥泄露冒充,因为双方共享相同的密钥K。理想的密钥协商协议应该防止这种攻击。
性能
为了有用,密钥协商协议应该既高效又安全。在考虑一个密钥协商协议的效率时,你应该考虑多个因素,包括交换的消息数量、消息的长度、实现协议的计算工作量以及是否可以进行预计算以节省时间。协议通常在交换较少、较短的消息时更高效,如果能够最小化交互性,使得双方无需等待收到消息再发送下一个消息,那是最理想的。你通常可以通过协议的持续时间来衡量其效率,以往返时间为标准,即发送消息并接收响应所需的时间。
往返时间通常是协议延迟的主要原因,但双方需要执行的计算量也很重要;所需计算越少,能进行的预计算越多,效果越好。例如,4G/5G 密钥协商协议交换两条每条几百比特的消息,且必须按照特定顺序发送。你可以使用预计算来节省时间,因为运营商可以提前选择多个R值;预计算与SK、V[1]、V[2]的匹配值,并将它们都存储在数据库中。在这种情况下,预计算的优点是减少了长期密钥的暴露。
Diffie–Hellman 协议
Diffie–Hellman 函数是大多数已部署的公钥协商协议的核心——例如 TLS 和 SSH。但并没有单一的 Diffie–Hellman 协议,而是有多种方法可以使用 DH 函数来建立共享密钥。我们将在接下来的章节中回顾三种协议。在每个讨论中,我将坚持使用常见的加密占位符名称,称两方为 Alice 和 Bob,攻击者为 Eve。我将写 g 作为用于算术运算的群生成元,这是一个在 Alice、Bob 和 Eve 之间预先固定并已知的值。
匿名 Diffie–Hellman
匿名 Diffie–Hellman 是最简单的 Diffie–Hellman 协议。它之所以匿名,是因为没有认证机制;参与者没有任何加密身份,双方都无法验证对方的身份,也没有持有长期密钥。Alice 无法证明自己是 Alice,Bob 也是如此。
在匿名 Diffie–Hellman 中,每一方选择一个随机值(a 代表 Alice,b 代表 Bob)作为私钥,并将对应的公钥发送给对方。图 11-2 详细展示了这一过程。

图 11-2:匿名 Diffie–Hellman 协议
Alice 使用她的指数 a 和群基 g 计算 A = g**^a,并将其发送给 Bob。Bob 收到 A 后计算 A**^b,这等同于 (g**a*)*b。Bob 现在获得值 g**^(ab),并根据他的随机指数 b 和值 g 计算 B。然后他将 B 发送给 Alice,Alice 使用该值计算 g**^(ab)。Alice 和 Bob 在进行相似的操作后,最终得到相同的值 g**^(ab),这些操作涉及将 g 和接收到的值分别提升到各自私有指数的幂。一个简单的协议,只能抵抗最懒的攻击者。
攻击者可以通过中间人攻击摧毁匿名 Diffie–Hellman 协议。网络攻击者只需要拦截消息,并假装是 Bob(对 Alice),又假装是 Alice(对 Bob),如图 11-3 所示。

图 11-3:中间人攻击匿名 Diffie–Hellman 协议
如同之前的交换过程,Alice 和 Bob 各自选择随机指数,a 和 b。Alice 现在计算并发送 A,但 Eve 拦截并丢弃了该消息。然后,Eve 选择一个随机指数 c,计算 C = g**^c 并发送给 Bob。由于该协议没有认证机制,Bob 以为他收到了来自 Alice 的 C,并继续计算 g**^(bc)。接着,Bob 计算 B 并将该值发送给 Alice,但 Eve 再次拦截并丢弃了消息。Eve 现在计算 g**^(bc),选择一个新的指数 d,计算 g**^(ad),从 g**^d 计算出 D,并将 D 发送给 Alice。然后,Alice 也计算 g**^(ad)。
由于这种攻击,攻击者 Eve 与 Alice 共享一个秘密 (g**^(ad)),并与 Bob 共享另一个秘密 (g**^(bc)),而 Alice 和 Bob 认为他们共享的是一个彼此之间的秘密。在协议执行完成后,Alice 从 g**^(ad) 推导出对称密钥来加密发送给 Bob 的数据,但 Eve 拦截了加密消息,解密后使用另一个由 g**^(bc) 推导出的密钥重新加密并发送给 Bob——可能还会修改明文。所有这些操作都是在 Alice 和 Bob 不知情的情况下进行的;他们注定会失败。
为了防止这种攻击,你需要一种认证双方身份的方法,这样 Alice 就可以证明她是真正的 Alice,Bob 也能证明他是真正的 Bob。幸运的是,确实有办法做到这一点。
认证的 Diffie–Hellman
认证的 Diffie–Hellman 解决了可能影响匿名 DH 的中间人攻击问题。认证的 DH 为双方提供了私钥和公钥,从而使得 Alice 和 Bob 可以对他们的消息进行签名,以防止 Eve 代表他们发送消息。在这里,签名不是通过 DH 函数计算的,而是通过公钥签名方案(如 RSA-PSS)计算的。因此,要成功地代表 Alice 发送消息,攻击者需要伪造一个有效的签名,而在安全的签名方案下,这是不可能的。图 11-4 展示了认证 DH 的工作原理。

图 11-4:认证的 Diffie–Hellman 协议
第一行中的标签 Alice (privA, pubB) 表示 Alice 持有她自己的私钥 privA,以及 Bob 的公钥 pubB。这个 priv/pub 密钥对被称为 长期密钥,因为它是预先固定的,并且在协议的连续执行中保持不变。Alice 可以使用她的密钥对 privA/pubA 与除 Bob 以外的其他方进行通信,只要他们知道 pubA(他们怎么知道这个是另一个问题,也是密码学中最难解决的操作问题之一)。这些长期私钥应该保密,而公钥则被认为是攻击者已知的。
Alice 和 Bob 首先选择随机指数 a 和 b,就像在匿名 DH 中一样。然后,Alice 基于她的签名函数 sign、她的私钥 privA 和 A 计算 A 和一个签名 sigA。现在,Alice 将 A 和 sigA 发送给 Bob,后者使用 Alice 的公钥 pubA 来验证 sigA。如果签名无效,Bob 知道该消息不是来自 Alice,并会丢弃 A。
如果签名是正确的,Bob 会根据 A 和他自己的随机指数 b 计算 g**^(ab)。然后,他通过 sign 函数、自己的私钥 privB 和 B 的组合计算 B 和他的签名 sigB。接着,他将 B 和 sigB 发送给 Alice,后者尝试用 Bob 的公钥 pubB 来验证 sigB。如果 Bob 的签名成功验证,Alice 才会计算 g**^(ab)。
防止网络攻击者的安全性
认证 DH 对网络攻击者是安全的,因为他们无法从共享秘密 g**^(ab) 中获取任何信息,因为他们忽略了 DH 指数。认证 DH 还提供前向保密性:即使攻击者在某个时刻破坏了任何一方,正如之前讨论的 泄漏 攻击模型那样,他们将获得私有签名密钥,但无法获得任何临时 DH 指数;因此,他们无法得知任何先前共享秘密的值。
DH 的认证变体只提供了对 密钥控制 的部分保护。Alice 不能构造特殊的 a 值来限制共享秘密 g**^(ab) 的选择,因为她还不知道 g**^b,而 g**^b 对结果的影响与 a 一样大。(一个例外是如果 Alice 选择 a = 0,在这种情况下无论 b 取什么值,g**^(ab) 都会等于 1。协议因此应该拒绝 0,尽管实际实现可能不会这么做。)然而,Bob 可以尝试多个 b 值,直到找到一个“适合他的”值;例如,对于某些值,使得 g**^(ab) 具有某些属性,如其前 16 位为 1。
你可以通过在 Alice 发送她的 g**^a 之前,先从 Bob 发送 Hash(g**^b) 到 Alice 作为第一个消息,来消除 Bob 对秘密值的控制。我将留给你分析这个修改并理解为什么它有效(新发送的消息是 Bob 公钥的承诺)。
认证 DH 还有其他局限性。例如,Eve 可以通过记录之前的 A 和 sigA 值并将其重放给 Bob 来伪装成 Alice。Bob 错误地认为他正在与 Alice 共享秘密,尽管 Eve 无法得知该秘密,因为她不知道 Alice 的秘密 a。因此,她无法从 Bob 发送的 B 计算出 B**^a。
你可以通过添加 密钥确认 程序来消除这个风险,其中 Alice 和 Bob 互相证明他们拥有共享秘密。例如,Alice 和 Bob 可以通过分别发送 Hash(pubA || pubB || g**^(ab)) 和 Hash(pubB || pubA || g**^(ab)) 来执行密钥确认,其中 Hash 是某个哈希函数。双方可以通过重新计算结果来验证这些哈希值的正确性。公钥 pubA || pubB 和 pubB || pubA 的不同顺序确保了 Alice 和 Bob 发送不同的值,且攻击者不能通过复制 Bob 的哈希值来伪装成 Alice。
防止数据泄露的安全性
经过身份验证的 DH 在面对数据泄露攻击者时的脆弱性更为令人关注。在此类攻击中,攻击者获取了临时、短期秘密(即指数 a 和 b)的值,并利用这些信息冒充其中一方通信者。如果 Eve 获取了指数 a 的值以及发送给 Bob 的 sigA 的值,她就可以发起协议的新执行并冒充 Alice,正如图 11-5 所示。

图 11-5:对经过身份验证的 Diffie–Hellman 协议的冒充攻击
在这种攻击场景中,Eve 获取了 a 的值,并重放了相应的 A 及其签名 sigA,假装自己是 Alice。Bob 验证签名并根据 A 计算 g**^(ab),然后发送 B 和 sigB,Eve 接着利用窃取的 a 来计算 g**^(ab),从而两者达成了共享秘密。此时,Bob 认为他在与 Alice 进行通信。
你可以通过将长期密钥整合到共享秘密计算中,来保护经过身份验证的 DH 免受临时秘密泄露的风险,这样就无法在不知道长期秘密的情况下确定共享秘密。
Menezes–Qu–Vanstone
Menezes–Qu–Vanstone (MQV) 协议是 DH 基础协议历史上的一个里程碑。MQV 于 1998 年设计,获得批准用于保护大多数关键资产,当时 NSA 将其纳入其 Suite B,后者是为了保护机密信息而设计的一组算法。(NSA 最终放弃了 MQV,原因据说是其未被广泛使用。稍后我会讨论原因。)
MQV 是经过加强的 Diffie–Hellman。它比经过身份验证的 DH 更安全,并且改善了经过身份验证的 DH 的性能特性。特别是,MQV 允许用户发送仅有的两条消息,彼此独立,顺序任意。用户还可以发送比经过身份验证的 DH 更短的消息,而且不需要发送显式的签名或验证消息。换句话说,你不需要在 Diffie–Hellman 函数之外使用签名方案。
与经过身份验证的 DH 相同,在 MQV 中,Alice 和 Bob 各自持有一个长期私钥以及对方的长期公钥。不同之处在于,MQV 密钥不是签名密钥:它们由一个私有指数 x 和一个公有值 g**^x 组成。图 11-6 展示了 MQV 协议的操作。

图 11-6:MQV 协议
x和y分别是 Alice 和 Bob 的长期私钥,X和Y是他们的公钥。Bob 和 Alice 从各自的私钥和对方的公钥开始,公钥是g的私钥幂。每人选择一个随机的指数,然后 Alice 计算A并将其发送给 Bob,接着 Bob 计算B并将其发送给 Alice。一旦 Alice 得到 Bob 的短期公钥B,她将其与自己的长期私钥x、短期私钥a以及 Bob 的长期公钥Y结合,计算出结果(B × Y**B*)*a ^+ ^(xA),如图 11-6 所示。展开这个表达式,你可以得到以下结果:

与此同时,Bob 计算(A × X**A*)*b ^+ ^(yB)的结果,你可以验证它等于 Alice 计算出的值:

Alice 和 Bob 都得到相同的值,g(*b* ^+ (yB)*()()^a* ^+ *(xA)*),这表明 Alice 和 Bob 共享相同的密钥。
与认证 DH 不同,通过单纯泄露短期密钥并不能破解 MQV。知道a或b并不能让攻击者确定最终的共享密钥,因为他们需要长期私钥才能计算出来。
在最强的攻击模型——破坏模型中,如果长期密钥被破坏,会发生什么?如果 Eve 破坏了 Alice 的长期私钥x,那么先前建立的共享密钥仍然是安全的,因为它们的计算过程也涉及了 Alice 的短期私钥。
然而,MQV 并不能提供完美的前向保密性,原因是存在以下攻击。例如,假设 Eve 拦截了 Alice 的A消息,并将其替换为她自己选择的a值,使得A = g**^a。与此同时,Bob 向 Alice 发送B(Eve 记录了B的值),并计算共享密钥。如果 Eve 后来获取了 Alice 的长期私钥x,她就能确定 Bob 在此会话中计算出的密钥。这破坏了前向保密性,因为 Eve 已经恢复了先前执行协议时的共享密钥。然而,在实践中,你可以通过一个密钥确认步骤来消除这一风险,Alice 和 Bob 可以意识到他们没有共享相同的密钥,并且在派生任何会话密钥之前终止协议。
尽管 MQV 具有优雅性和安全性,但在实践中却很少使用,原因有几个。它曾经受到专利的限制,阻碍了其广泛采用。与此同时,正确实现 MQV 比看起来要困难得多。事实上,考虑到它增加的复杂性,MQV 的安全性优势通常被认为相较于简单的认证 DH 较低。
事情如何出错
Diffie–Hellman 协议可能以各种方式失败,下面的章节将突出显示一些实践中常见的情况。
没有对共享密钥进行哈希处理
我曾提到过,结束 DH 会话交换的共享密钥(我们示例中的 g**^(ab))被作为输入用于派生会话密钥,但它本身并不是密钥。而它不应该是密钥。对称密钥应该看起来是随机的,每一位应该有相同的概率是 0 或 1。但是 g**^(ab) 并不是一个随机字符串;它是某个数学群中的随机元素,其位可能会偏向 0 或 1。一个随机的群元素与一个随机的位字符串是不同的。
比如说,假设你在使用乘法群Z[13]^* = {1, 2, 3, . . . , 12},并且使用 g = 2 作为该群的生成元,这意味着 g**i*遍历所有**Z**[13]中的值,i 取 1, 2, . . . 12:g¹ = 2,g² = 4,g³ = 8,g⁴ = 3,依此类推。如果 g的指数是随机的,你将得到Z[13]*中的一个随机元素,但将**Z**[13]元素编码为 4 位字符串时,不会是均匀随机的:并非所有位都有相同的概率是 0 或 1。在Z[13]^中,七个值的最高有效位是 0(群中从 1 到 7 的数字),但只有五个值的最高有效位是 1(从 8 到 12)。也就是说,这个位是 0 的概率是 7/12 ≈ 0.58,而随机位理想情况下应该是 0 的概率为 0.5。此外,4 位序列 1101、1110 和 1111 永远不会出现。
为了避免从 DH 共享密钥派生的会话密钥存在这种偏差,可以使用加密哈希函数,如 BLAKE3 或 SHA-3,或者更好的是,使用密钥派生函数(KDF)。KDF 构造的一个例子是 HKDF,或基于 HMAC 的 KDF(如 RFC 5869 中所规定),但如今 BLAKE2 和 SHA-3 都有专门的模式来充当 KDF。
TLS 1.0 中的匿名 Diffie–Hellman
TLS 协议是 HTTPS 安全网站以及其他许多协议背后的安全机制,比如使用简单邮件传输协议(SMTP)的电子邮件传输。TLS 采用多个参数,包括它将使用的 Diffie–Hellman 协议的类型。出于向后兼容的原因,TLS 从 1.0 版本到 1.2 版本支持匿名 DH(即没有任何服务器认证),但在 1.3 版本中不再支持。由于 DH 仅对被动攻击者具有安全性,它可能会给人一种错误的安全感。
TLS 的原始文档描述了该协议的风险 (<wbr>www<wbr>.rfc<wbr>-editor<wbr>.org<wbr>/rfc<wbr>/rfc2246):
完全匿名的连接仅能防止被动窃听。除非使用独立的防篡改通道来验证完成的消息未被攻击者替换,否则在存在主动中间人攻击风险的环境中,需要服务器认证。
不安全的组参数
在 2016 年 1 月,OpenSSL 工具包的维护者修复了一个高严重性的漏洞(CVE-2016-0701),该漏洞允许攻击者利用不安全的 Diffie-Hellman 参数。漏洞的根本原因是 OpenSSL 允许用户使用不安全的 DH 组参数(即不安全的素数p),而不是在执行任何算术操作之前抛出错误并完全中止协议。
实质上,OpenSSL 接受了一个素数p,其乘法群Zp^(所有 DH 操作发生的地方)包含小子群。正如你在本章开头所学到的,密码学协议中较大群体内的小子群的存在是非常不利的,因为它将共享的密钥限制在一个比使用整个群Zp^更小的值集上。更糟糕的是,攻击者可以构造一个 DH 指数x,当它与受害者的公钥g**^y结合时,泄露私钥y的部分信息,最终揭示其完整内容。
尽管实际漏洞源自 2016 年,但该攻击所采用的原理可以追溯到 1997 年 Chae Hoon Lim 和 Pil Joong Lee 的论文《使用素数阶子群的离散对数方案的密钥恢复攻击》。修复此漏洞的方法很简单:当接受素数p作为群模数时,协议必须通过验证(p – 1) / 2 是否为素数来检查p是否为安全素数,从而确保群Zp^*没有小子群,攻击者无法利用此漏洞进行攻击。
进一步阅读
你可以通过阅读多个标准和官方出版物,深入了解 DH 密钥协商协议,包括 ANSI X9.42、RFC 2631 和 RFC 5114、IEEE 1363 和 NIST SP 800-56A。这些文献作为参考,确保互操作性并为群参数提供建议。
要了解更多关于高级 DH 协议(如 MQV 及其相关协议 HMQV 和 OAKE 等)及其安全性概念(包括未知密钥共享攻击和群表示攻击),请阅读 Hugo Krawczyk 的 2005 年文章《HMQV:一种高效的安全 Diffie-Hellman 协议》(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2005<wbr>/176)和 Andrew C. Yao 与 Yunlei Zhao 的 2011 年文章《一种新的隐式认证 Diffie-Hellman 协议家族》(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2011<wbr>/035)。这些文章以不同于本章的方式表达 Diffie-Hellman 操作。例如,它们将共享密钥表示为xP而不是g**^x。通常,你会发现乘法被加法取代,指数运算被乘法取代,因为这些协议通常不是定义在整数群上,而是定义在椭圆曲线上,正如你在第十二章中将学到的。
第十三章:12 椭圆曲线

1985 年引入的椭圆曲线密码学(ECC)彻底改变了公钥密码学。它比 RSA 和经典的 Diffie–Hellman 等替代方案更强大、更高效:256 位密钥的 ECC 比 4096 位密钥的 RSA 更强大。但它也更复杂。
像 RSA 一样,ECC 主要由大数的乘法运算组成,但它通过在数学曲线上组合点来进行这些运算,这条曲线被称为椭圆曲线(顺便说一下,这与椭圆没有任何关系)。更复杂的是,椭圆曲线有许多类型——简单和复杂、高效和低效、安全和不安全,这取决于使用场景。
ECC 直到 2000 年代初才被标准化机构采纳,直到更晚的时间才在主流密码软件中出现:OpenSSL 在 2005 年增加了对 ECC 的支持,OpenSSH 安全连接工具则等到了 2011 年。如今,你会在大多数 HTTPS 连接中找到 ECC,在手机中,以及像比特币和以太坊这样的区块链平台中。实际上,椭圆曲线使得常见的公钥密码学操作,如加密、签名和密钥协商,比其经典版本更加高效。大多数依赖离散对数问题(DLP)的密码学应用,当基于其椭圆曲线对应物 ECDLP 时,也能正常工作,其中有一个显著的例外:安全远程密码协议(SRP)。
本章重点介绍了椭圆曲线密码学(ECC)的应用,并讨论了在何时以及为什么要使用 ECC 而不是 RSA 或经典的 Diffie–Hellman 算法,以及如何选择适合您应用的椭圆曲线。
什么是椭圆曲线?
椭圆曲线是平面上的一条曲线——由具有 x 和 y 坐标的点组成。曲线的方程定义了所有属于该曲线的点。例如,曲线y = 3 是一条纵坐标为 3 的水平直线,形式为y = ax + b且固定常数a和b的曲线是直线,x² + y² = 1 是一个半径为 1、以原点为中心的圆等等。不管是哪种类型的曲线,曲线上的点都是满足该曲线方程的(x, y)坐标对。
在密码学中,椭圆曲线通常有一种形式的方程 y² = x³ + ax + b(Weierstrass 形式),其中常数a和b定义了曲线的形状。例如,图 12-1 显示了满足方程y² = x³ - 4x的椭圆曲线。

图 12-1:一个椭圆曲线,其方程为 y2 = x3 – 4x, 显示在实数范围内
该图展示了所有使得 x 在 –3 和 4 之间的点,无论是位于曲线左侧(看起来像圆形的部分)还是右侧(看起来像抛物线的部分)。所有这些点的 (x, y) 坐标都满足曲线方程 y² = x³ – 4x。例如,当 x = 0 时,y² = x³ – 4x = 0³ – 4 × 0 = 0;因此,y = 0 是一个解,点 (0, 0) 属于该曲线。同样,如果 x = 2,方程的解是 y = 0,意味着点 (2, 0) 属于该曲线。
注意
在这一章中,我将重点讨论最简单、最常见类型的椭圆曲线——其方程形式为 y² =* x³ +* ax + b——但其他椭圆曲线有不同形式的方程。例如,Edwards 曲线的方程形式为 x² +* y² = 1 +* dx²y²**。密码学家有时使用 Edwards 曲线(例如,在 Ed25519 方案中)。
在使用椭圆曲线进行密码学时,区分属于曲线的点和其他点至关重要,因为曲线外的点通常会带来安全风险。然而,曲线的方程并不总是有解,至少在自然数平面中没有解。例如,要求解横坐标 x = 1 的点,你需要解 y² = x³ – 4x,得到 y² = x³ – 4x = 1³ – 4 × 1,结果为 –3。但 y² = –3 没有解,因为没有任何数字使得 y² = –3。在复数范围内存在解(√3i),但在实际应用中,椭圆曲线密码学只使用自然数(ECC 使用的是模素数下的整数,通常是直接运算或通过多项式)。因为 x = 1 时曲线的方程没有解,曲线在该位置上没有点,如图 12-1 所示。
如果你尝试求解 x = –1,你会得到方程 y² = –1 + 4 = 3,它有两个解(y = √3 ≈ 1.73 和 y = –√3 ≈ –1.73),即 3 的平方根及其负值。这两个值的平方都是 3,且图 12-1 中有两个点,x = –1。更一般地,曲线对于所有满足其方程的点,相对于 x 轴是对称的(所有形如y² = x³ + ax + b的椭圆曲线也是如此,y = 0 除外)。
整数上的椭圆曲线
这里有一点不同:椭圆曲线加密中的曲线实际上并不像图 12-1 那样。它们既不是曲线,也不是椭圆。它们更像是图 12-2,这是一组点的云,而不是一条曲线。这里到底发生了什么?

图 12-2:方程 y2 = x3 – 4x 在 Z191上,模 191 的整数集合
图 12-1 和图 12-2 基于相同的曲线方程,y² = x³ – 4x,但它们分别展示了与不同数字集合相关的曲线点:图 12-1 展示的是实数集合上的曲线点,其中包含负数、小数等。例如,作为一条连续曲线,它显示了在x = 2.0、x = 2.1、x = 2.00002 等位置的点。而图 12-2 则只展示满足此方程的整数,排除了小数。具体来说,图 12-2 展示了曲线y² = x³ – 4x在模 191的整数集合下的情况:0, 1, 2, 3,一直到 190。你可以用Z[191]表示这个数字集合。(这里没有特别的意义,除了 191 是一个素数。我选择了一个小的数字,以避免图表上的点太多。)因此,图 12-2 中的点的 x 和 y 坐标都是模 191 的整数,并且满足方程y² = x³ – 4x。例如,当x = 2 时,y² = 0,y = 0 是一个有效的解。这意味着点(2, 0)属于该曲线。
如果x = 3,你得到方程y² = 27 – 12 = 15,它在Z[191]中有两个解,46 和 145。实际上,46² = 2,116,2,116 mod 191 = 15,145² = 21,025,21,025 mod 191 = 15。因此,点(3, 46)和(3, 145)都属于该曲线,并在图 12-2 中出现(左侧被圈起来的两个点)。
注意
图 12-2 考虑了从集合 Z191 = {0, 1, 2, . . . , 190} 中选取的点,这个集合包括零。这与我们在讨论 RSA 和 Diffie-Hellman 时提到的表示为 Z[p]^(带星号上标)的群不同。之所以有所不同,是因为你将同时进行数的乘法和加法,因此需要确保数的集合包括加法的恒等元素(即 0,使得 x + 0 = x 对于每个 x ∈ Z191)。
另外,每个数字 x 在加法下都有一个逆元素,记作 –x,使得 x + (–x) = 0。例如,100 在 Z191 中的逆元素是 91,因为 100 + 91 mod 191 = 0。这样的数集,其中加法和乘法都是可能的,并且每个元素 x 都有加法逆元素(记作 –x),并且除了零元素外,每个元素都有乘法逆元素(记作 1 / x),叫做一个域。当一个域包含有限个元素时,就像 Z191 和所有用于椭圆曲线加密的域一样,它是一个有限域*。
加法法则
现在你知道椭圆曲线上的点是满足曲线方程 y² = x³ + ax + b 的坐标对 (x, y),我们来看一下如何使用 加法法则 来加椭圆曲线上的点。
加法运算两个点
假设你想在椭圆曲线上加两个点,P 和 Q,得到一个新点 R,它是这两个点的和。理解点加法的最简单方法是通过几何规则确定 R = P + Q 在曲线上相对于 P 和 Q 的位置:画出连接 P 和 Q 的直线,找到与该直线相交的曲线上的另一个点,R 是该点相对于 x 轴的对称点。例如,在 图 12-3 中,连接 P 和 Q 的直线与曲线相交于 P 和 Q 之间的第三个点,点 P + Q 具有相同的 x 坐标,但 y 坐标是其逆值。

图 12-3:椭圆曲线点加法几何规则的一般情况
这个几何规则很简单,但它并不会直接给出点 R 的坐标。你可以使用公式 xR = m² – xP – xQ 和 yR = m(xP – xR) – yP 计算点 R 的坐标 (xR, yR),其中 m = (yQ – yP) / (xQ – xP) 是连接 P 和 Q 的直线的斜率。
不幸的是,这些公式和图 12-3 中的画线技巧并不总是有效。例如,如果 P = Q,你就无法在两个点之间画线(因为只有一条),如果 Q = –P,则直线不会再次与曲线相交,因此没有曲线上的点可供对称。我们将在接下来的章节中探讨这些情况。
加点及其负点
点 P = (xP, yP) 的负点是点 –P = (xP, –yP),这是围绕 x 轴对称的点。对于任何 P,你可以说 P + (–P) = O,其中 O 是 无穷远点。如图 12-4 所示,P 和 –P 之间的直线延伸到无穷远,并且永远不与曲线相交。无穷远点对椭圆曲线来说,类似于零对整数的意义,只不过它是一个“虚拟”点,你无法在特定位置定位它,因为它位于无穷远处。

图 12-4:加法规则——点及其负点的相加,或 P + (–P) = O, 当两点之间的直线永远不与曲线相交时
点的加倍
当 P = Q(即 P 和 Q 在同一位置)时,P 和 Q 的加法等同于计算 P + P,即 2P。这种加法操作称为 加倍。
要找到结果 R = 2P 的坐标,你不能使用上一节中的几何规则,因为你不能在 P 和 P 之间画一条直线。相反,你需要画出在 P 处与曲线相切的直线,然后 2P 是这条直线与曲线交点的对称点,如图 12-5 所示。

图 12-5:加倍点的几何规则——也就是说, P + P
用于确定 R = P + P 坐标(xR, yR)的公式与用于计算两个不同点 P 和 Q 的公式略有不同。基本公式为 xR = m² – xP – xQ 和 yR = m(xP – xR) – yP,其中 m 的值为 (3xP² + a) / 2yP,a 是曲线的参数,如 y² = x³ + ax + b 中所示。
点的乘法
要通过给定的整数 k 来乘以椭圆曲线上的点,你可以通过将 P 加到自身 k – 1 次来确定点 kP。换句话说,2P = P + P,3P = P + P + P,依此类推。要获得 kP 的 x 和 y 坐标,重复将 P 加到自身并应用前述的加法法则。
然而,要高效地计算 kP,通过应用加法法则将 P 加到自身 k – 1 次的朴素方法远非最优。例如,如果 k 很大(例如,2²⁵⁶ 级别),像在基于椭圆曲线的加密方案中出现的那样,那么计算 k – 1 次加法几乎是不可行的。
通过采用第十章《快速指数算法》中的技巧来计算 x**^e mod n,你可以获得指数级的加速。不同之处在于,你是通过整数乘法而非计算指数来进行运算。但你可以将这个方法改为加倍并相加,在这种情况下,乘法变成加法,平方变成加倍。
例如,使用朴素方法计算 8P 需要七次加法,而使用三次加法可以计算 8P,首先计算 P[2] = P + P,然后 P[4] = P[2] + P[2],最后 P[4] + P[4] = 8P。这是最简单的情况,当乘数是 2 的幂次方时,例如 8 = 2³。否则,例如,要计算 10P,步骤如下:观察到 10 的二进制表示是 1010,从最重要的(“最左”)位开始,按如下方式计算结果 R:
位 1 首先设置 R = P。
位 0 加倍,设置 R = 2R = 2P。
位 1 加倍并相加,设置 R = 2R + P = 2(2P) + P = 5P。
位 0 加倍,设置 R = 2R = 2(5P) = 10P。
椭圆曲线群
你不仅可以将椭圆曲线上的点相加,还可以使用它们来形成一个群结构。根据群的定义(参见第九章中的“群”),如果点 P 和 Q 属于给定的曲线,那么 P + Q 也属于该曲线。
此外,由于加法是结合律的,你有 (P + Q) + R = P + (Q + R) 对于任何点 P, Q, 和 R。在椭圆曲线点的群体中,我们称单位元为无限远点,并用 O 来表示,使得 P + O = P 对于任何 P。每个点 P = (xP, yP) 都有一个逆元素,–P = (xP , –yP),使得 P + (–P) = O。
在实践中,大多数基于椭圆曲线的加密系统使用的是 x 和 y 坐标,这些数字是模一个素数 p 的数(换句话说,这些数字属于有限域 Zp)。正如 RSA 的安全性依赖于所使用的数字大小一样,基于椭圆曲线的加密系统的安全性也依赖于曲线上的点的数量。那么,你如何知道椭圆曲线上的点的数量,或者它的基数呢?其实,这取决于曲线和 p 的值。
注意
素域并不是椭圆曲线密码学中唯一使用的域。还有 二进制域,它是由两个元素域(包括 0 和 1)的扩展,其元素表示为具有二进制系数的多项式。与一般目的的微处理器相比,二进制域上的算术运算通常更容易在硬件逻辑电路中高效实现。
曲线 Zp 上大约有 p 个点,但你可以使用 Schoof 算法精确计算点的数量,该算法用于计算有限域上的椭圆曲线的点数。你会发现这个算法已经内置在 SageMath 中。例如,清单 12-1 使用 Schoof 算法计算 Z[191] 上曲线 y² = x³ – 4x 的点数,参见图 12-1。
sage: **Z = Zmod(191)**
sage: **E = EllipticCurve(Z, (-4,0))**
sage: **E.cardinality()**
192
清单 12-1:计算曲线的基数或点数
在清单 12-1 中,首先定义变量 Z 为模 191 的整数集合;然后定义变量 E 为系数为 -4 和 0 的椭圆曲线 Z 上的曲线。最后,你计算曲线的点数,即它的 基数、群阶 或简称 阶。此计数包括无穷远点 O。
ECDLP 问题
第九章介绍了离散对数问题(DLP):给定一个基数 g,找出一个数 y,使得 x = g ^y mod p,其中 p 是某个大素数。椭圆曲线密码学有类似的问题,给定基点 P,找出数 k,使得点 Q = kP。这就是椭圆曲线离散对数问题。与数字不同,椭圆曲线的问题作用于点,并使用乘法代替指数运算。
几乎所有的椭圆曲线密码学都基于 ECDLP 问题,像 DLP 一样,ECDLP 被认为是困难的,并且自 1985 年引入以来经受了密码分析。与经典 DLP 的一个重要区别是,ECDLP 允许你使用更小的数字,同时仍然享有类似的安全级别。
通常,当参数 p 是 n 位时,ECC 可以提供约 n/2 位的安全性。例如,使用一个模 256 位 p 的椭圆曲线可以提供约 128 位的安全性。为了比较,使用 DLP 或 RSA 达到类似的安全性水平,需要使用几千位的数字。使用更小的数字进行 ECC 运算是它通常比 RSA 或经典的 Diffie–Hellman 更快的原因之一。
解决 ECDLP 的一种方法是找到两个输出之间的碰撞,c[1]P + d[1]Q 和 c[2]P + d[2]Q。这些方程中的点 P 和 Q 是这样的:Q = kP,其中 k 是一个未知数,而 c[1]、d[1]、c[2] 和 d[2]* 是你需要找到 k 的数字。
正如在 第六章 中的哈希函数一样,碰撞发生在两个不同的输入产生相同输出时。因此,要解决 ECDLP,你需要找到以下条件成立的点:

假设你已经找到了四个系数,接下来你要恢复 k。为此,将 Q 替换为 kP 的值,你会得到以下结果:

这告诉你,当对曲线上的点数取模时,(c[1] + d[1]k) 等于 (c[2] + d[2]k),这并不神秘。
从中,你可以推导出以下内容:

然后,你找到了 k,也就是 ECDLP 的解。当然,这只是大致的框架——细节更复杂且更有趣,特别是在如何恢复系数 c[1]、c[2]、d[1]、d[2] 的方式上。
在实际应用中,椭圆曲线的范围至少为 256 位数字,这使得通过寻找碰撞来攻击椭圆曲线加密变得不切实际,因为这样做需要最多 2¹²⁸ 次操作(这就是在 256 位数字上找到碰撞的成本——详见 第六章)。
椭圆曲线上的 Diffie–Hellman 密钥协商
回想一下 第十一章 中提到的经典 Diffie–Hellman(DH)密钥协商协议,两个参与方通过交换非秘密值来建立共享密钥。给定某个固定数 g,爱丽丝选择一个秘密随机数 a,计算 A = g**^a,并将 A 发送给鲍勃。然后鲍勃选择一个秘密随机数 b,并将 B = g**^b 发送给爱丽丝。然后,两人将各自的秘密密钥与对方的公钥结合,计算出相同的 A**^b = B**^a = g**^(ab)。
椭圆曲线版的 DH,椭圆曲线 Diffie–Hellman(ECDH),与经典 DH 相同,只是符号不同。在 ECC 的情况下,给定某个固定点 G,爱丽丝选择一个秘密随机数 a,计算 A = aG(点 G 乘以整数 a),并将 A 发送给鲍勃。鲍勃选择一个秘密随机数 b,计算点 B = bG,并将其发送给爱丽丝。然后,两人都计算相同的共享密钥,aB = bA = abG。
ECDH 与 ECDLP 问题的关系,就像 DH 与 DLP 的关系一样:只要 ECDLP 问题足够困难,它就安全。因此,你可以将依赖于 DLP 的 DH 协议调整为使用椭圆曲线,并依赖 ECDLP 作为难度假设。例如,认证 DH 和 Menezes–Qu–Vanstone (MQV)协议,在与椭圆曲线一起使用时也会是安全的。事实上,MQV 最初就是定义为在椭圆曲线上工作的。
椭圆曲线签名
用于椭圆曲线签名(ECC)签名的主要标准算法是椭圆曲线数字签名算法(ECDSA)。该算法已在许多应用中取代了 RSA 签名和经典的 DSA 签名。ECDSA 是 NIST 的标准,支持在 TLS 和 SSH 协议中,并且是许多区块链平台(包括比特币和以太坊)中的主要签名算法。
与所有签名方案一样,ECDSA 包括一个签名生成算法,签名者使用它来创建一个使用私钥生成的签名,以及一个验证算法,验证者使用它根据签名者的公钥检查签名的正确性。签名者持有一个数字,d,作为私钥,而验证者持有公钥,P = dG。两者都事先知道使用的椭圆曲线、其阶数(n,曲线中的点数)以及基点G的坐标。
ECDSA 签名生成
要对消息进行签名,签名者首先使用加密哈希函数(如 SHA-256 或 BLAKE2)对消息进行哈希处理,生成一个哈希值,h,你可以将其理解为一个介于 0 和n – 1 之间的数字。接下来,签名者选择一个介于 1 和n – 1 之间的随机数,k,并计算kG,得到一个坐标为(x, y)的点。签名者现在设定r = x mod n,并计算s = (h + rd) / k mod n,然后使用这些值作为签名(r, s)。
签名的长度取决于你使用的坐标长度。例如,当你使用一个坐标为 256 位数字的曲线时,r和s都是 256 位长,从而得到一个 512 位长的签名。
ECDSA 签名验证
ECDSA 验证算法使用签名者的公钥来验证签名的有效性。
要验证一个 ECDSA 签名(r, s)和消息的哈希值,h,验证者首先计算w = 1 / s,即签名中s的逆,等于k / (h + rd) mod n,因为s被定义为s = (h + rd) / k。接下来,验证者用h乘以w来找到u,按照以下公式:

然后,验证者用w乘以r来找到v:

给定u和v,验证者根据以下公式计算点Q:

这里,P是签名者的公钥,等于dG,只有当Q的 x 坐标等于签名中的r值时,验证者才接受该签名。
这个过程之所以有效,是因为最后一步,你通过用公钥P的实际值dG替代它来计算点Q:

当你用u和v的实际值替换它们时,你将得到以下结果:

这告诉你 (u + vd) 等于在签名生成过程中选择的值 k,并且 uG + vdG 等于点 kG。换句话说,验证算法成功地计算出了点 kG,这是在签名生成过程中计算的相同点。一旦验证者确认 kG 的 x 坐标等于接收到的 r,验证就完成;否则,签名会被拒绝为无效。
ECDSA 与 RSA 签名
虽然一些人认为椭圆曲线加密学是 RSA 公钥加密的替代方案,但 ECC 和 RSA 并没有太多共同之处。你只在加密和签名时使用 RSA,而 ECC 是一系列算法,你可以用它来执行加密、生成签名、进行密钥协议,并提供高级加密功能,如基于身份的加密(一种使用从个人标识符(如电子邮件地址)派生的加密密钥的加密方式)。
在比较 ECDSA 和 RSA 签名时,回想一下在 RSA 签名中,签名者使用他们的私钥 d 来计算签名,公式为 y = x**^d mod n,其中 x 是要签名的数据,y 是签名。验证过程使用公钥 e 来确认 y**^e mod n 是否等于 x——这个过程显然比 ECDSA 的过程要简单。
RSA 的验证过程通常比 ECC 的签名验证更快,因为它使用较小的公钥 e。RSA 签名的验证过程仅包括对 y^(65,537) mod n 的指数运算。
ECC 相对于 RSA 有两个主要优势:更短的签名和更快的签名速度。由于 ECC 使用的是较短的数字,它比 RSA 生成的签名更短(只有几百位,而不是几千位),如果需要存储或传输大量签名,这是一个明显的优势。使用 ECDSA 进行签名也比使用 RSA 更快,因为 ECDSA 使用的是更小的数字,其计算开销更小。例如,列表 12-2 显示 ECDSA 在签名速度上比 RSA 快大约 200 倍,验证速度与 RSA 相当(基准测试在 Apple M2 处理器上进行)。请注意,在这个例子中,ECDSA 签名也比 RSA 签名更短,因为它们是 512 位(由两个 256 位元素组成),而不是 4,096 位。
$ **openssl speed ecdsap256 rsa4096**
sign verify sign/s verify/s
rsa 4096 bits 0.003297s 0.000048s 303.3 20933.4
sign verify sign/s verify/s
256 bit ecdsa (nistp256) 0.0000s 0.0000s 63709.1 20979.8
列表 12-2:比较 4,096 位 RSA 签名与 256 位 ECDSA 签名的速度
比较这些不同大小的签名性能是公平的,因为它们提供了相似的安全性水平。然而,实际上,许多系统使用 2,048 位的 RSA 签名,这比 256 位的 ECDSA 安全性低几个数量级。由于其较小的模数大小,2,048 位 RSA 在验证时比 256 位 ECDSA 更快,但在签名时仍然较慢,正如 列表 12-3 所示。
$ **openssl speed rsa2048**
sign verify sign/s verify/s
rsa 2048 bits 0.000520s 0.000013s 1923.7 77221.1
列表 12-3:2,048 位 RSA 签名的速度
除非签名验证至关重要且你不关心签名速度,否则应优先选择 ECDSA,例如在一次签名、多个验证的场景下(例如,当一个 Windows 可执行应用程序被签名一次,然后被所有执行它的系统验证)。
EdDSA 和 Ed25519
ECDSA 在 1990 年代初作为椭圆曲线版本的 DSA(数字签名算法)引入,该算法是 NSA 的数字签名标准,1991 年由 NIST 标准化。但在 1989 年,加密学家 Claus-Peter Schnorr 提出了所谓的 Schnorr 签名方案,这是一种比(EC)DSA 更简单、更高效的算法,也适用于椭圆曲线。然而,Schnorr 申请了限制其签名方案使用的专利,阻碍了其广泛采用和标准化。
在 Schnorr 的专利于 2008 年到期后,加密学家 Daniel J. Bernstein、Niels Duif、Tanja Lange、Peter Schwabe 和 Bo-Yin Yang 在 Schnorr 的思想基础上构建了Edwards 曲线 DSA(EdDSA),这是一种仅适用于椭圆曲线的签名方案,具有以下优点:
-
比 ECDSA 更简单
-
比 ECDSA 更快,签名和验证速度均优
-
确定性(而 ECDSA 和 Schnorr 签名使用随机数进行签名),消除了与随机性缺陷相关的风险
让我们看看一般的 EdDSA 是如何工作的,以及它的特定实例 Ed25519 为何如此吸引人。为了简化说明,我将提供这些方案的简化概述。有关这些方案的完整细节和背后的原理,请参阅 2011 年的论文《高速高安全性签名》和 RFC 8032《Edwards 曲线数字签名算法(EdDSA)》。
请注意,我使用的符号类似于原文中的符号,可能与本书之前使用的符号有所不同。
EdDSA 签名
像任何签名方案一样,使用 EdDSA 签名需要私钥和消息,但与 ECDSA 不同,私钥是一个随机字节串,而不是随机(标量)数值。你通过对私钥字符串进行哈希运算来推导出实际的私有标量。这带来了几个安全优势,包括使应用程序更容易通过简单地从伪随机数生成器(PRNG)中导出原始字节来选择随机密钥,而不必确保密钥格式正确且不弱。它还帮助从密钥和消息中有效地派生一个随机数,从而替代了像 ECDSA 中那样使用每个签名的随机值。
签名过程如下所示,从 256 位私钥k和任意大小的消息M,以及给定的基点B:
1. 计算a || h = Hash(k),对于生成 512 位值的哈希函数,其中前 256 位形成私有标量a,后 256 位形成字符串h。
2. 将公钥定义为A = aB(实际上,这是预先计算的)。
3. 计算消息的“预哈希” r = Hash(h || M),并计算椭圆曲线点 R = rB,这是签名的第一部分,签名点。
4. 计算数字 S = r + Hash(R,A,M) × a,签名的第二部分。
返回的签名是一个二元组(R,S)。
计算瓶颈是标量点乘法 rB,其中 B 是固定的基点。当消息较长时,哈希消息两次的成本也可能很大。然而,与 ECDSA 不同的是,你无需计算模逆。
在实践中,你可以优化实现——例如,避免每次签名时都重新计算公钥。你还必须将值设置为正确的类型(如需要模约简的数字),并以可靠且不含歧义的格式进行编码(例如曲线点)。
EdDSA 验证
给定签名(R,S),消息 M,公钥 A,和基点 B,验证过程包括检查 SB 是否等于 R + Hash(R,A,M)A。注意,SB = S × B。
如果你用第 4 步计算出的表达式替换 S,则 SB 等于以下值:

在这个表达式中,你知道 rB = R,aB = A。因此,最终得到预期结果,R + Hash(R,A,M) × A。
与 ECDSA 相比,你避免了计算模逆。与 ECDSA 一样,你需要两次标量点乘法(SB 和 Hash(R,A,M)A)。
Ed25519
Ed25519 是 EdDSA 的一个特定实例,具有以下参数:
-
基于 Curve25519 的扭曲爱德华曲线,你将在下一节中看到
-
使用 SHA-512 作为哈希函数
-
为了优化效率,选择一个基点 B
截至 2023 年,Ed25519 可能是第二受欢迎的椭圆曲线签名算法,因其性能优势和高安全性保障(见 <wbr>ed25519<wbr>.cr<wbr>.yp<wbr>.to)。
OpenSSH、苹果产品和许多区块链平台使用 Ed25519 来签署交易。2023 年 2 月,Ed25519 被纳入 NIST 标准,成为 FIPS 186-5《数字签名标准》的一部分。
Ed25519 遇到了一些互操作性问题,正如你在本章稍后会看到的那样。
使用椭圆曲线加密
尽管你通常会使用椭圆曲线进行签名,但也可以使用它们进行加密。不过,你很少会在实践中看到这样做,因为存在明文大小的限制:你最多可以加密约 100 比特的明文,而在相同安全等级下,RSA 可以加密接近 4000 比特。
你可以使用椭圆曲线集成加密方案(ECIES)进行加密,这是一种混合方案,结合了非对称加密和对称加密。它使用 Diffie–Hellman 操作来派生共享密钥,并通过认证密码保护数据。
给定接收者的公钥P,ECIES 加密消息M,如下所示:
1. 选择一个随机数d,并计算点Q = dG,其中基点G是一个固定参数。这里,(d, Q)充当临时密钥对,仅用于加密M。
2. 通过计算S = dP来计算 ECDH 共享密钥。
3. 使用密钥派生函数(KDF)从S中派生出对称密钥K。
4. 使用K和对称认证密码加密M,得到密文C和认证标签T。
ECIES 密文由临时公钥Q、C和T组成。解密过程很简单:接收者通过将Q与自己的私有指数相乘来计算S,然后派生出密钥K,解密C并验证T。
选择曲线
评估椭圆曲线安全性的标准包括你使用的群体的阶(即其点的数量)、加法公式以及参数的来源。
有多种类型的椭圆曲线,但并非所有曲线都适用于加密目的。在选择曲线方程y² = x³ + ax + b中的系数a和b时,需谨慎,按照既定的安全标准选择;否则,你可能会得到一条不安全的曲线。在实际应用中,你会使用已知的曲线进行加密,但了解什么样的曲线是安全的将有助于你在众多可用曲线中做出选择,并更好地理解相关风险。以下是一些需要记住的要点:
-
群体的阶不应该是小数的乘积;否则,解决 ECDLP 问题将变得更容易。
-
在第 235 页的《加法法则》中,你学习到,当Q = P时,添加点P + Q需要一个特定的加法公式。不幸的是,如果攻击者能够区分点的倍加与不同点之间的加法,单独处理这种情况可能会泄露关键信息。一些曲线之所以安全,正是因为它们对所有点加法使用统一的公式。(当曲线不需要特定的倍加公式时,它采用统一的加法法则。)
-
如果曲线的创建者没有解释a和b的来源,他们可能会被怀疑有不正当行为,因为你无法知道他们是否选择了较弱的值,从而使加密系统可能受到未知的攻击。
让我们回顾一些最常见的曲线,特别是用于签名或 Diffie–Hellman 密钥交换的曲线。
注意
你可以在 <wbr>safecurves<wbr>.cr<wbr>.yp<wbr>.to 上找到更多关于曲线的标准和细节。
NIST 曲线
2000 年,NIST 在 FIPS 186 文档中对几条曲线进行了标准化,作为“联邦政府使用的推荐椭圆曲线”。五条 NIST 曲线,称为 素数曲线,是在素数上进行模运算的(参见 第 233 页的《椭圆曲线整数运算》)。另外十条 NIST 曲线使用二进制多项式,这是一种使硬件实现更高效的数学对象。(由于二进制多项式很少与椭圆曲线一起使用,我不会进一步详细介绍它们。)
最常见的 NIST 曲线是素数曲线。其中最常见的是 P-256,这是一条在模 256 位数字 p = 2²⁵⁶ – 2²²⁴ + 2¹⁹² + 2⁹⁶ – 1 上工作的曲线。P-256 的方程是 y² = x³ – 3x + b,其中 b 是一个 256 位数字。NIST 还提供了 192 位、224 位、384 位和 521 位的素数曲线(这不是打字错误;它是 521,而不是 512)。
NIST 曲线有时会受到批评,因为只有 NSA(曲线的创建者)知道其方程中 b 系数的来源。他们提供的解释是,b 结果来自于使用 SHA-1 哈希运算对一个看似随机的常数进行处理。例如,P-256 的 b 参数来自以下常数:c49d3608 86e70493 6a6678e1 139d26b7 819f7e90。没有人知道为什么 NSA 选择了这个特定的常数,但大多数专家不认为曲线的起源隐藏着任何弱点。
Curve25519
Daniel J. Bernstein 于 2006 年将 Curve25519(通常发音为 curve-twenty-five-five-nineteen)带入了世界。出于性能考虑,他设计了 Curve25519,使其比标准曲线更快且使用更短的密钥。但 Curve25519 也带来了安全方面的好处;与 NIST 曲线不同,它没有可疑的常数,并且可以使用相同的统一公式来加法操作不同点或加倍一个点。
Curve25519 的方程形式 y² = x³ + 486662x² + x 与本章其他方程略有不同,但它仍属于椭圆曲线家族。该方程的不寻常形式允许特定的实现技术,使得 Curve25519 在软件中运行得更快。
Curve25519 使用模 2²⁵⁵ - 19 的数字运算,这是一个尽可能接近 2²⁵⁵ 的素数。b 系数 486662 是满足 Bernstein 设置的安全标准的最小整数。综合这些特性,使得 Curve25519 比 NIST 曲线及其可疑系数更值得信赖。
Curve25519 被广泛使用——例如,在 WhatsApp、TLS 1.3、OpenSSH 以及许多其他系统中。继这一成功之后,Curve25519 于 2023 年 2 月被加入到 NIST 批准的曲线列表中,作为文档 SP 800-186《基于离散对数的密码学推荐:椭圆曲线域参数》的一部分。
注意
欲了解 Curve25519 的详细信息及其背后的理由,请查看 Daniel J. Bernstein 于 2016 年的演讲《Curve25519 的前十年》 <wbr>cr<wbr>.yp<wbr>.to<wbr>/talks<wbr>.html#2016<wbr>.03<wbr>.09。
其他曲线
在我撰写本文时,大多数加密应用使用的是 NIST 曲线或 Curve25519(包括通过 Ed25519),但仍有其他旧的标准在使用,并且新的曲线正被标准化委员会推广。旧的国家标准包括法国的 ANSSI 曲线和德国的 Brainpool 曲线:这两种曲线不支持完整的加法公式,并且使用了来源不明的常数。
一些较新的曲线比旧的曲线更加高效,且没有任何嫌疑;它们提供不同的安全等级和各种效率优化。例如,Curve41417,它是 Curve25519 的一个变种,支持更大的数字并提供更高的安全级别(大约 200 位);Ed448-Goldilocks,它是一个 448 位的曲线,首次提出于 2014 年并在 RFC 8032 中指定;以及 Diego Aranha 等人在《A Note on High-Security General-Purpose Elliptic Curves》(见 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2013<wbr>/647)中提出的六条曲线,尽管这些曲线很少被使用。这些曲线的具体细节超出了本书的范围。
最后,Ristretto 计划(* <wbr>ristretto<wbr>.group *)是一种从非素数阶(因此有子群)的椭圆曲线构造素数阶点群的技术。Ristretto 提供了一种安全且明确的方式来表示椭圆曲线上的点,消除了与 Curve25519 结构相关的风险。
可能出现的问题
椭圆曲线的一个缺点是它们相对较复杂。相比 RSA 或经典的 Diffie–Hellman,椭圆曲线需要更多的参数,这也意味着更大的攻击面和更多设计错误与实现漏洞的可能性。ECC 实现还可能受到侧信道攻击的影响,特别是它们的大数运算——例如,当计算时间依赖于秘密值时。
在接下来的部分,我将讨论三种可能出现在椭圆曲线上的漏洞,尽管实现是安全的。
ECDSA 与不良随机数
ECDSA 签名是随机化的,因为在设置s = (h + rd) / k mod n时涉及一个秘密的随机数k。然而,如果你重复使用相同的k来签署第二条消息,攻击者可以将结果值组合起来,s[1] = (h[1] + rd) / k 和 s[2] = (h[2] + rd) / k,得到 s[1] – s[2] = (h[1] – h[2]) / k,然后 k = (h[1] – h[2]) / (s[1] – s[2])。一旦知道了k,你就可以轻松通过以下方式恢复私钥d:

与 RSA 签名不同,RSA 签名如果使用了弱的伪随机数生成器(PRNG)是无法恢复密钥的,但非随机数的使用可能导致 ECDSA 的k被恢复,就像 2010 年 PlayStation 3 游戏机遭遇的攻击一样,fail0verflow 团队在德国柏林举行的第 27 届 Chaos Communication Congress 大会上展示了这一攻击。他们发现相同的k被重用来签署不同的游戏,然后能够找到签名密钥,从而可以签署任何程序并授权其在游戏机上运行。
无效曲线攻击
如果输入点没有正确验证,你可以巧妙地破解 ECDH。主要原因是,给出点和点P + Q和坐标的公式从不涉及曲线的b系数;相反,它们仅依赖于P和Q的坐标以及a系数(当点加倍时)。不幸的是,结果是,当你加两个点时,你无法确定自己是否在正确的曲线上工作,因为你实际上可能在加另一个曲线上的点,这个曲线有不同的b系数。这意味着你可以破解 ECDH,如下场景所示——无效曲线攻击。
假设 Alice 和 Bob 正在运行 ECDH 协议,并且他们已经就一个曲线和基点G达成了协议。Bob 将他的公钥bG发送给 Alice。Alice 不是发送约定曲线上的公钥aG,而是发送一个不同曲线上的点,可能是故意的,也可能是意外的。不幸的是,这个新曲线是脆弱的,并允许 Alice 选择一个点P,使得解决 ECDLP 变得容易。她选择一个阶数较低的点,其中存在一个相对较小的k,使得kP = O。
Bob 认为他拥有一个合法的公钥,并计算他认为是共享密钥的bP,对其进行哈希处理,并使用结果密钥加密发送给 Alice 的数据。当 Bob 计算bP时,他不知情地在较弱的曲线上进行计算。因此,由于P被选择为属于较大点群体中的一个小子群,结果bP也属于该小子群,如果攻击者知道P的阶,可以高效地确定共享密钥bP。
防止这种情况的一种方法是通过确保点P和Q的坐标满足曲线方程,来确认它们属于正确的曲线。这可以确保你仅在安全的曲线上进行计算。
2015 年,TLS 协议的某些实现中发现了这种无效曲线攻击,该协议使用 ECDH 协议来协商会话密钥。(详细信息,请参见 Tibor Jager、Jörg Schwenk 和 Juraj Somorovsky 的论文《实用的 TLS-ECDH 无效曲线攻击》。)
不兼容的 Ed25519 验证规则
你看到,Ed25519 是一种经过优化的签名方案,旨在提供高效性和高安全性保证。Ed25519 的设计者在一篇科学论文中详细描述了它的工作原理,并发布了一个编码良好的参考实现。该算法已在 RFC 8032 中作为 IETF 标准进行规范。人们本应期待 Ed25519 只有一个版本,不同的实现应完全一致:对于给定的输入值,它们应该表现一致并返回相同的结果。
不幸的是,情况并非如此。正如密码学家 Henry de Valence 在文章《现在是 255:19AM,你知道你的验证标准是什么吗?》(参见 <wbr>hdevalence<wbr>.ca<wbr>/blog<wbr>/2020<wbr>-10<wbr>-04<wbr>-its<wbr>-25519am)中记录的那样,不同的 Ed25519 实现对有效签名的标准有所不同。事实上,RFC 8032 并没有完全描述验证标准——许多实现甚至未能遵守该标准。
在 de Valence 文章中的 15 个实现中,每个都有自己的验证标准。特别是,R(签名中的点)和 A(公钥)的验证通常不同,S × B 和 R + Hash(R, A, M) × A 之间的相等性验证也常常不同。分歧可能出现在多个层面,包括点的编码(即如何组装字节来描述其坐标)和验证点是否属于正确的子群。
结果是,同一方案的不同实现可能表现不同——例如,在区块链网络中,某些节点会接受签名,而其他节点则会拒绝,这对于共识协议来说是一个问题。所有细节在 de Valence 的博客文章中有解释。
进一步阅读
椭圆曲线密码学是一个迷人且复杂的话题,涉及大量的数学内容。我没有讨论诸如点的阶、曲线的伴数、射影坐标、扭转点以及解决 ECDLP 问题的方法等重要概念。如果你对数学感兴趣,可以在 Henri Cohen 和 Gerhard Frey 编写的《椭圆曲线和超椭圆曲线密码学手册》(Chapman and Hall/CRC,2005 年)中找到关于这些及其他相关主题的信息。Joppe Bos、Alex Halderman、Nadia Heninger、Jonathan Moore、Michael Naehrig 和 Eric Wustrow 在 2013 年发表的调查报告《椭圆曲线密码学的实践》也提供了带有实际示例的插图介绍(eprint.iacr.org/2013/734)。
第四部分 应用
第十四章:13 TLS

传输层安全(TLS)协议是互联网安全的主力军。TLS 保护服务器和客户端之间的连接,无论是网站与其访客之间、电子邮件服务器之间、移动应用程序与其服务器之间,还是视频游戏服务器与玩家之间。如果没有 TLS,互联网将不会那么安全。
TLS 与应用程序无关,意味着你可以将它用于依赖 HTTP 协议的基于 Web 的应用程序,也可以用于任何客户端计算机或设备需要与远程服务器建立连接的系统。例如,TLS 广泛应用于物联网(IoT)应用中的机器对机器通信,如与远程服务器通信的智能冰箱。
本章提供了 TLS 的简要概述,随着时间的推移,TLS 变得越来越复杂。不幸的是,复杂性和臃肿带来了多个漏洞,其实现中的错误也屡次成为头条新闻——Heartbleed、BEAST、CRIME 和 POODLE 都是影响了数百万个 Web 服务器的漏洞。
注意
你可能会听到有人将 TLS 称为安全套接字层(SSL),它是 TLS 的前身名称。
2013 年,工程师们开始着手开发 TLS 1.3。如你在本章中将学到的,TLS 1.3 摒弃了不必要且不安全的特性,用当时最先进的算法替代了旧算法,最终结果是一个更简洁、更快速、更安全的协议。
在我们探讨 TLS 1.3 的工作原理之前,让我们回顾一下 TLS 旨在解决的问题以及它存在的原因。
目标应用程序和要求
TLS 是 HTTPS 网站中的S,浏览器地址栏中出现的挂锁图标表示该页面是安全的。创建 TLS 的主要驱动力是通过认证站点并加密流量来实现安全浏览,保护个人数据、信用卡号码和用户凭证等敏感信息,广泛应用于电子商务和电子银行等领域。
TLS 还通过在客户端和服务器之间建立一个安全通道来保护一般的基于互联网的通信,确保传输的数据是机密的、已验证的并且未经修改。
TLS 的安全目标之一是防止中间人攻击,在这种攻击中,攻击者截获传输方的加密流量,解密流量以捕获明文内容,然后重新加密并发送给接收方。TLS 通过使用证书和受信任的证书颁发机构来验证服务器(可选地验证客户端),从而防止这些攻击,具体内容将在“证书和证书颁发机构”一节中讨论,见第 258 页。
为了确保广泛采用,TLS 需要满足四个额外的要求:效率、互操作性、可扩展性和多功能性。对于 TLS,效率意味着尽量减少与未加密连接相比的性能损失。这对服务器(减少服务提供商硬件成本)和客户端(避免明显的延迟或减少移动设备的电池寿命)都有好处。该协议需要具备互操作性,以便能够在任何硬件和任何操作系统上工作。它还必须具备可扩展性,以便支持额外的功能或算法。最后,它必须是多功能的——也就是说,不局限于某个特定的应用程序。这与传输控制协议(TCP)相似,TCP 不关心其上层应用协议是什么。
TLS 协议套件
为了保护客户端和服务器之间的通信,TLS 由多个版本的几个协议组成,形成了 TLS 协议的 套件。TLS 不是一个传输协议,通常位于传输协议(TCP)和应用层协议(如 HTTP 或 SMTP)之间,以加密通过 TCP 连接传输的数据。
TLS 也可以在 用户数据报协议 (UDP) 传输协议上工作,这通常用于“无连接”传输,尤其是在延迟必须最小化的场景中,如音频或视频流媒体以及在线游戏。然而,与 TCP 不同,UDP 不保证数据的传输或正确的包排序。因此,TLS 的 UDP 版本——数据报传输层安全协议 (DTLS)——有所不同。关于 TCP 和 UDP 的更多信息,请参见 Charles Kozierok 的 《TCP/IP 指南》(No Starch Press,2005 年)。
TLS 和 SSL 协议家族
TLS 始于 1995 年,当时 Netscape 开发了 TLS 的前身——SSL 协议。SSL 远非完美,SSL 2.0 和 SSL 3.0 都存在安全漏洞。你永远不应该使用 SSL,而应该始终使用 TLS——令人困惑的是,人们常常将 TLS 称为 SSL,包括安全专家。
并非所有版本的 TLS 都是安全的。TLS 1.0(1999 年)是最不安全的版本,尽管它比 SSL 3.0 更安全。TLS 1.1(2006 年)更好,但包含了一些弱算法。TLS 1.2(2008 年)更好,但它复杂,只有在正确配置的情况下才能提供高安全性。此外,它的复杂性增加了实现中的错误风险和配置错误的风险。例如,TLS 1.2 支持 AES 在 CBC 模式下,这常常容易受到填充 oracle 攻击。
TLS 1.2 继承了 TLS 较早版本的数十个特性和设计选择,这使得其在安全性和性能方面并不理想。为了整顿这一混乱局面,密码学工程师重新设计了 TLS——只保留了优秀的部分并添加了安全功能。结果是 TLS 1.3,这是一次全面革新,简化了臃肿的设计并提升了安全性和效率。本质上,TLS 1.3 是成熟的 TLS。
TLS 简介
TLS 有两个主要协议:握手协议(或简称握手)确定了双方共享的密钥;记录协议描述了如何使用这些密钥来保护数据。TLS 处理的数据包称为记录。TLS 定义了一种封装来自更高层协议数据的数据包格式,以便传输到另一方。
握手由启动安全连接的客户端开始。客户端发送一个称为 ClientHello 的初始消息,其中包含它希望使用的密码。服务器检查此消息及其参数,然后响应一个 ServerHello 消息。一旦客户端和服务器处理彼此的消息,它们就可以使用通过握手协议建立的会话密钥交换加密数据,详见“TLS 握手协议”第 263 页。
证书和证书颁发机构
TLS 握手中最关键的步骤,也是 TLS 安全性的核心,是证书验证步骤,在此步骤中,服务器使用证书向客户端进行身份验证。
证书本质上是一对公钥及其签名以及相关信息(包括域名)。例如,当连接到<wbr>www<wbr>.google<wbr>.com时,您的浏览器从某个网络主机接收证书,然后验证证书的签名,其内容类似于“我是google.com,我的公钥是[key]”。如果签名验证成功,证书及其公钥就是受信任的,浏览器会继续建立连接。(有关签名的详细信息,请参阅第十章和第十二章。)
浏览器通过证书颁发机构(CA)知道用于验证签名的公钥,这本质上是在浏览器或操作系统中硬编码的公钥。公钥的私钥(即其签名能力)属于一个受信任的组织,该组织确保其签发的证书中的公钥属于声称拥有它们的网站或实体。换句话说,CA 充当受信任的第三方。如果没有 CA,就无法验证由google.com提供的公钥是否属于 Google 而不是中间人进行的中间人攻击。
例如,清单 13-1 展示了当你使用 OpenSSL 命令行工具在 443 端口发起到 www.google.com 的 TLS 连接时会发生什么,443 端口是用于基于 TLS 的 HTTP 连接(HTTPS)的网络端口。
$ **openssl s_client -connect www.google.com:443**
CONNECTED(00000003)
---
Certificate chain
0 s:CN = www.google.com
i:C = US, O = Google Trust Services LLC, CN = GTS CA 1C3
1 s:C = US, O = Google Trust Services LLC, CN = GTS CA 1C3
i:C = US, O = Google Trust Services LLC, CN = GTS Root R1
2 s:C = US, O = Google Trust Services LLC, CN = GTS Root R1
i:C = BE, O = GlobalSign nv-sa, OU = Root CA, CN = GlobalSign Root CA
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIEiDCCA3CgAwIBAgIQNJvIv7ypW9IQHaA7P69MzzANBgkqhkiG9w0BAQsFADBG
MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM
QzETMBEGA1UEAxMKR1RTIENBIDFDMzAeFw0yMzA5MDQwODIzMjlaFw0yMzExMjcw
ODIzMjhaMBkxFzAVBgNVBAMTDnd3dy5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYI
KoZIzj0DAQcDQgAENEMvWAY0TRTb0w5ZxbUbX/Z+EcviE50SzQzvP/xyyVIaURM4
A0Jer9IJO/6Iq6o2AfDXUxrdBKpSzlzaeFCaqqOCAmgwggJkMA4GA1UdDwEB/wQE
AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW
BBRnwQnVC8ok1e6YPIbQwjB+XFpPRjAfBgNVHSMEGDAWgBSKdH+vhc3ulc09nNDi
RhTzcTUdJzBqBggrBgEFBQcBAQReMFwwJwYIKwYBBQUHMAGGG2h0dHA6Ly9vY3Nw
LnBraS5nb29nL2d0czFjMzAxBggrBgEFBQcwAoYlaHR0cDovL3BraS5nb29nL3Jl
cG8vY2VydHMvZ3RzMWMzLmRlcjAZBgNVHREEEjAQgg53d3cuZ29vZ2xlLmNvbTAh
BgNVHSAEGjAYMAgGBmeBDAECATAMBgorBgEEAdZ5AgUDMDwGA1UdHwQ1MDMwMaAv
oC2GK2h0dHA6Ly9jcmxzLnBraS5nb29nL2d0czFjMy9mVkp4YlYtS3Rtay5jcmww
ggEFBgorBgEEAdZ5AgQCBIH2BIHzAPEAdgDoPtDaPvUGNTLnVyi8iWvJA9PL0RFr
7Otp4Xd9bQa9bgAAAYpfgRjOAAAEAwBHMEUCID+BcS984SEh2E2UrKfLvF2fG7qa
SYkzbELytrDz91wmAiEAlwOPMM26CynmadsqomPMXKdRNMvzdyciHVimh0snrBAA
dwB6MoxU2LcttiDqOOBSHumEFnAyE4VNO9IrwTpXo1LrUgAAAYpfgRkAAAAEAwBI
MEYCIQDZf1tULkVCXRc68zJwgp5WFJUbTxFjz6CP+eLb3dgz3gIhAJ9uS7psu2Gl
HdTXokXTetMY7MCdIcuj60Qm/qTn+1dFMA0GCSqGSIb3DQEBCwUAA4IBAQCIEn0v
QzaqNCOhiI5TKcRhaR24yKid3F57a/GOM1LDE/v7oCm+3fxtvuK9HVa/Dmnvavqp
ci7TpMDj/ocXjE4dL4/yHaVx6GhTDKMW/bbBkDaqXoSdb9lAcUZPLRTV4AjFdjmB
8wZTf95bnfeuKNXWlbo/k/9pRRhFNKKMUI54xLiVdj4wk1EUAsrMTTn+Ol2ZbFeS
s614abBT5W0hhFkLvjvEht8p3UKQwwyhRjZMBsae/d0QfT8hglVtVhhGd7f1hFqI
XSERl8EsyDc3urCsa+RjUjCvE9Q+y2X8WVV0HPCjwsANU56qEZmh4kqgg5paW/SL
maM/8Vny/nKNd6Dj
-----END CERTIFICATE-----
subject=CN = www.google.com
issuer=C = US, O = Google Trust Services LLC, CN = GTS CA 1C3
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: ECDSA
Server Temp Key: X25519, 253 bits
---
SSL handshake has read 4295 bytes and written 396 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 256 bit
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 0 (ok)
清单 13-1:与 www
证书数据位于 BEGIN CERTIFICATE 和 END CERTIFICATE 标记之间。在此之前,证书链 包含证书链的描述,其中以 s: 开头的行描述了经认证实体的 主题,以 i: 开头的行描述了签名的 颁发者。证书 0 是 www
颁发证书 0 的组织是 Google(通过 Google Trust Services 实体,GTS),它授权颁发证书 0 给域名 www
在这个示例中,你的操作系统通常已经有证书 1 和证书 2,并将其视为受信任的证书。在这种情况下,你只需要验证两个签名:证书 0 中的 Google GTS CA 1C3 实体签名和证书 1 中的 Google GTS Root R1 实体签名。如果你的系统尚未将证书 2 作为受信任证书,但却包含了 GlobalSign 的根证书(GlobalSign Root CA),那么你还需要验证证书 2 中的 GlobalSign 签名。
证书颁发机构如 Google 和 GlobalSign 必须是可信的,只会向可信实体颁发证书,并且必须保护其私钥,以防止攻击者代表它们颁发证书(例如,伪装成合法的 www
要查看证书内容,可以在 Unix 终端中输入命令 openssl x509 -text -noout,然后将证书粘贴到 列表 13-1 中。输出将显示在 列表 13-2 中。
$ **openssl x509 –text –noout**
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
34:9b:c8:bf:bc:a9:5b:d2:10:1d:a0:3b:3f:af:4c:cf
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = US, O = Google Trust Services LLC, CN = GTS CA 1C3
Validity
Not Before: Sep 4 08:23:29 . . .
Not After : Nov 27 08:23:28 . . .
Subject: CN = www.google.com
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:34:43:2f:58:06:34:4d:14:db:d3:0e:59:c5:b5:
1b:5f:f6:7e:11:cb:e2:13:9d:12:cd:0c:ef:3f:fc:
72:c9:52:1a:51:13:38:03:42:5e:af:d2:09:3b:fe:
88:ab:aa:36:01:f0:d7:53:1a:dd:04:aa:52:ce:5c:
da:78:50:9a:aa
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage:
TLS Web Server Authentication
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Subject Key Identifier:
67:C1:09:D5:0B:CA:24:D5:EE:98:3C:86:D0:C2:30:7E:5C:5A:4F:46
X509v3 Authority Key Identifier:
8A:74:7F:AF:85:CD:EE:95:CD:3D:9C:D0:E2:46:14:F3:71:35:1D:27
Authority Information Access:
OCSP - URI:http://ocsp.pki.goog/gts1c3
CA Issuers - URI:http://pki.goog/repo/certs/gts1c3.der
X509v3 Subject Alternative Name:
DNS:www.google.com
X509v3 Certificate Policies:
Policy: 2.23.140.1.2.1
Policy: 1.3.6.1.4.1.11129.2.5.3
X509v3 CRL Distribution Points:
Full Name:
URI:http://crls.pki.goog/gts1c3/fVJxbV-Ktmk.crl
CT Precertificate SCTs:
Signed Certificate Timestamp:
Version : v1 (0x0)
Log ID : E8:3E:D0:DA:3E:F5:06:35:32:E7:57:28:BC:89:6B:C9:
03:D3:CB:D1:11:6B:EC:EB:69:E1:77:7D:6D:06:BD:6E
Timestamp : Sep 4 09:23:30.638 2023 GMT
Extensions: none
Signature : ecdsa-with-SHA256
30:45:02:20:3F:81:71:2F:7C:E1:21:21:D8:4D:94:AC:
A7:CB:BC:5D:9F:1B:BA:9A:49:89:33:6C:42:F2:B6:B0:
F3:F7:5C:26:02:21:00:97:03:8F:30:CD:BA:0B:29:E6:
69:DB:2A:A2:63:CC:5C:A7:51:34:CB:F3:77:27:22:1D:
58:A6:87:4B:27:AC:10
Signed Certificate Timestamp:
Version : v1 (0x0)
Log ID : 7A:32:8C:54:D8:B7:2D:B6:20:EA:38:E0:52:1E:E9:84:
16:70:32:13:85:4D:3B:D2:2B:C1:3A:57:A3:52:EB:52
Timestamp : Sep 4 09:23:30.688 2023 GMT
Extensions: none
Signature : ecdsa-with-SHA256
30:46:02:21:00:D9:7F:5B:54:2E:45:42:5D:17:3A:F3:
32:70:82:9E:56:14:95:1B:4F:11:63:CF:A0:8F:F9:E2:
DB:DD:D8:33:DE:02:21:00:9F:6E:4B:BA:6C:BB:61:A5:
1D:D4:D7:A2:45:D3:7A:D3:18:EC:C0:9D:21:CB:A3:EB:
44:26:FE:A4:E7:FB:57:45
Signature Algorithm: sha256WithRSAEncryption
Signature Value:
88:12:7d:2f:43:36:aa:34:23:a1:88:8e:53:29:c4:61:69:1d:
b8:c8:a8:9d:dc:5e:7b:6b:f1:8e:33:52:c3:13:fb:fb:a0:29:
be:dd:fc:6d:be:e2:bd:1d:56:bf:0e:69:ef:6a:fa:a9:72:2e:
d3:a4:c0:e3:fe:87:17:8c:4e:1d:2f:8f:f2:1d:a5:71:e8:68:
53:0c:a3:16:fd:b6:c1:90:36:aa:5e:84:9d:6f:d9:40:71:46:
4f:2d:14:d5:e0:08:c5:76:39:81:f3:06:53:7f:de:5b:9d:f7:
ae:28:d5:d6:95:ba:3f:93:ff:69:45:18:45:34:a2:8c:50:8e:
78:c4:b8:95:76:3e:30:93:51:14:02:ca:cc:4d:39:fe:3a:5d:
99:6c:57:92:b3:ad:78:69:b0:53:e5:6d:21:84:59:0b:be:3b:
c4:86:df:29:dd:42:90:c3:0c:a1:46:36:4c:06:c6:9e:fd:dd:
10:7d:3f:21:82:55:6d:56:18:46:77:b7:f5:84:5a:88:5d:21:
11:97:c1:2c:c8:37:37:ba:b0:ac:6b:e4:63:52:30:af:13:d4:
3e:cb:65:fc:59:55:74:1c:f0:a3:c2:c0:0d:53:9e:aa:11:99:
a1:e2:4a:a0:83:9a:5a:5b:f4:8b:99:a3:3f:f1:59:f2:fe:72:
8d:77:a0:e3
列表 13-2:解码从 www
本列表展示了命令 openssl x509 解码证书,证书最初以一块 base64 编码的数据形式提供。由于 OpenSSL 知道这些数据的结构,它可以告诉你证书中的内容,包括序列号、版本信息、标识信息、有效期(Not Before 和 Not After 行)、公钥(这里是 RSA 模数及其公钥指数),以及前述信息的签名。
尽管安全专家和密码学家经常声称整个证书系统本质上是有缺陷的,但它仍然是我们所拥有的最佳解决方案之一,例如 SSH 采用的“首次使用信任”(TOFU)策略。
记录协议
通过 TLS 1.3 通信交换的所有数据都以 TLS 记录的序列传输,TLS 使用的数据包。TLS 记录协议(即记录层)本质上是一个传输协议,与传输数据的意义无关;这使得 TLS 适用于任何应用。
TLS 记录协议首先传输在握手过程中交换的数据。一旦握手完成且双方共享一个秘密密钥,应用数据将被分段并作为 TLS 记录的一部分进行传输。
TLS 记录的结构
一个 TLS 记录是一个最多为 16KB 的数据块,具有以下结构:
-
第一个字节表示传输数据的类型,并设置为值 22 以表示握手数据,23 以表示加密数据,21 以表示警报。TLS 1.3 规范将此值称为 ContentType。
-
第二个和第三个字节分别设置为 03 和 01。这些字节是出于历史原因而固定的,并且并非 TLS 1.3 版本所特有。规范将此 2 字节值称为 ProtocolVersion。
-
第四个和第五个字节对要传输的数据的长度进行编码,作为一个 16 位整数,长度不能大于 2¹⁴ 字节(16KB)。
-
剩余的字节是要传输的数据(或负载),其长度等于记录第四和第五个字节所编码的值。
注意
TLS 记录有一个相对简单的结构。如你所见,TLS 记录的头部仅包含三个字段。相比之下,IPv4 数据包在负载之前包含 14 个字段,TCP 段包含 13 个字段。
当 TLS 1.3 记录的第一个字节(ContentType)设置为 23 时,经过身份验证的加密算法对负载进行加密和认证。负载包括密文和随后的认证标签,接收端分别对其进行解密和验证。接收者知道使用哪种加密算法和密钥进行解密,这是 TLS 协议的魔力:如果你接收到一个加密的 TLS 记录,你已经知道了加密算法和密钥,因为执行握手协议时已建立这些信息。
随机数
与许多其他协议不同,例如 IPsec 的封装安全负载(ESP),TLS 记录没有指定认证加密算法将使用的随机数。
加密和解密 TLS 记录的随机数(nonce)源自 64 位的序列号,由每一方本地维护,并在每个新记录中递增。当客户端加密数据时,它通过将序列号与 client_write_iv 值(本身源自共享密钥)进行异或操作来生成一个随机数。服务器在传输数据时使用类似的方法选择随机数,但使用的是 server_write_iv 值。
例如,如果你传输三个 TLS 记录,你将从第一个记录推导出随机数 0,从第二个记录推导出随机数 1,从第三个记录推导出随机数 2;如果随后接收三个记录,你也将按此顺序使用随机数 0、1 和 2。重新使用相同的序列号值加密传输数据并解密接收数据并不是一个弱点,因为它们与不同的常量(client_write_iv 和 server_write_iv)进行异或操作,而且每个方向使用不同的密钥。
零填充功能
TLS 1.3 记录支持零填充,这有助于缓解流量分析攻击。攻击者通过流量分析利用传输数据的时序、数据量等特征,从流量模式中提取信息。例如,即使在使用强加密的情况下,由于密文和明文的大小大致相同,攻击者仅通过观察密文的长度,就可以推测消息的大致大小。
零填充通过向明文中添加零来增加密文的大小,从而欺骗观察者认为加密后的消息比实际长度要长。
TLS 握手协议
握手是 TLS 协议的核心——这是客户端和服务器建立共享秘密密钥以开始安全通信的过程。在 TLS 握手期间,客户端和服务器扮演不同的角色。客户端提出一些配置(TLS 版本和一组密码套件,按优先顺序排列),服务器选择它将使用的配置。服务器应遵循客户端的偏好。为了确保不同实现之间的互操作性,并确保任何实现 TLS 1.3 的服务器都能读取任何实现 TLS 1.3 的客户端发送的数据(即使它使用的是不同的库或编程语言),TLS 1.3 规范还描述了数据应该以何种格式发送。
图 13-1 展示了握手过程如何交换数据,如 TLS 1.3 规范所描述。

图 13-1:TLS 1.3 握手过程
在 TLS 1.3 握手中,客户端向服务器发送一条消息,内容是:“我想与你建立 TLS 连接。这里是我支持的用于加密 TLS 记录的密码算法,还有一个 Diffie–Hellman 公钥。”该公钥必须专门为此 TLS 会话生成,客户端保留与之对应的私钥。客户端发送的消息还包括一个 32 字节的随机值和可选信息(如附加参数)。这条初始消息,ClientHello,在作为一系列字节传输时必须遵循特定的格式,如 TLS 1.3 规范所定义的。
服务器接收到 ClientHello 消息后,验证其格式是否正确,并响应一个包含丰富信息的 ServerHello 消息。通常,当连接到 HTTPS 网站时,该消息包含将用于加密 TLS 记录的密码算法、Diffie–Hellman 公钥、一个 32 字节的随机值(在“降级保护”一节中讨论,见第 266 页)、证书、ClientHello 和 ServerHello 消息中所有先前信息的签名(使用与证书公钥关联的私钥计算),以及该信息的 MAC 值,外加签名。MAC 是使用从 Diffie–Hellman 共享秘密派生的对称密钥计算的,服务器通过其 Diffie–Hellman 私钥和客户端公钥计算出该共享秘密。
当客户端收到 ServerHello 消息时,它验证证书的有效性,验证签名,计算共享的 Diffie–Hellman 秘密,并从中派生对称密钥,同时验证服务器发送的 MAC。一旦所有内容都验证无误,客户端就准备好向服务器发送加密消息。
注意
TLS 1.3 支持许多选项和扩展,因此它可能表现得不同。例如,你可以配置 TLS 1.3 握手,要求客户端证书,以便服务器验证客户端的身份。TLS 1.3 还支持使用预共享密钥的握手方式。
让我们看看实际操作中的情况。假设你已经部署了 TLS 1.3 来提供对网站 <wbr>www<wbr>.nostarch<wbr>.com 的安全访问。当你将浏览器(客户端)指向这个网站时,浏览器会向网站的服务器发送一个 ClientHello 消息,其中包含它支持的加密算法。网站会用一个 ServerHello 消息和一个包含与该域名 www
在成功完成 TLS 1.3 握手后,客户端和服务器之间的所有通信都将被加密并认证。窃听者可以得知某个特定 IP 地址的客户端正在与另一个特定 IP 地址的服务器通信,并且可以观察到交换的加密内容,但无法得知底层的明文内容或修改加密消息(如果他们修改了消息,接收方会注意到通信被篡改,因为消息是经过认证的)。这对于许多应用来说已经足够安全。
TLS 1.3 加密算法
TLS 1.3 使用已认证的加密算法、密钥派生函数(从共享秘密派生密钥的哈希函数)以及 Diffie–Hellman 操作——但是这些是如何工作的,使用了哪些算法,以及它们的安全性如何呢?
关于已认证的加密算法的选择,TLS 1.3 仅支持三种算法:AES-GCM、AES-CCM(比 GCM 略微低效的模式)以及结合 Poly1305 MAC 的 ChaCha20 流加密算法(如 RFC 7539 中所定义)。由于 TLS 1.3 防止使用不安全的密钥长度,例如 64 位或 80 位,因此密钥可以是 128 位(AES-GCM 或 AES-CCM)或 256 位(AES-GCM 或 ChaCha20-Poly1305)。
图 13-1 中的密钥派生操作(KDF)基于 HKDF,它是一个基于 HMAC(参见 第七章)的构造,并在 RFC 5869 中定义,使用 SHA-256 或 SHA-384 哈希函数。
执行 Diffie–Hellman 操作(TLS 1.3 握手的核心)的选项仅限于椭圆曲线加密和模素数的整数乘法群(如传统的 Diffie–Hellman)。但你不能随便使用任何椭圆曲线或群体:支持的曲线包括三种 NIST 曲线,以及 Curve25519(见 第十二章)和 Curve448,这两者在 RFC 7748 中有定义。TLS 1.3 还支持基于整数群体的 DH,而不是椭圆曲线。支持的群体是 RFC 7919 中定义的五个群体:2,048 位、3,072 位、4,096 位、6,144 位和 8,192 位。
2,048 位的组在理论上可能是 TLS 1.3 最薄弱的环节。其他选项至少提供 128 位的安全性,而 2,048 位的 Diffie–Hellman 被认为提供的安全性低于 100 位。因此,支持 2,048 位组可以被视为与 TLS 1.3 其他设计选择不一致。实际上,100 位的安全性大致等同于 128 位的安全性——也就是说,几乎不可能破解。
TLS 1.3 相对于 TLS 1.2 的改进
TLS 1.3 与其前身大不相同。首先,它淘汰了 MD5、SHA-1、RC4 和 CBC 模式中的 AES 等弱算法。此外,TLS 1.2 通常使用加密算法和 MAC(如 HMAC-SHA-1)的组合来保护记录,这种组合在 MAC-然后加密的构造中使用,而 TLS 1.3 只支持更高效且安全的认证加密算法。TLS 1.3 还摒弃了椭圆曲线点编码协商,并为每个曲线定义了一个单一的点格式。
TLS 1.3 移除了 1.2 中削弱协议的特性,降低了协议的整体复杂性,从而减少了攻击面。例如,TLS 1.3 放弃了可选的数据压缩功能,这是 TLS 1.2 中启用了 CRIME 攻击的功能。该攻击利用了消息压缩版本的长度泄露了消息内容的信息这一事实。
但 TLS 1.3 也带来了新的功能,使得连接更安全或更高效。我将讨论其中的三项功能:降级保护、单次往返握手和会话恢复。
降级保护
TLS 1.3 的 降级保护 功能是防止 降级攻击 的防御措施,在这种攻击中,攻击者强迫客户端和服务器使用比 1.3 更弱的 TLS 版本。为了执行降级攻击,攻击者通过拦截并修改 ClientHello 消息,将客户端不支持 TLS 1.3 的信息传递给服务器,从而迫使服务器使用较弱的 TLS 版本。现在,攻击者可以利用 TLS 较早版本中的漏洞。
为了防止降级攻击,TLS 1.3 服务器在 ServerHello 消息中发送的 32 字节随机值中使用三种类型的模式来识别请求的连接类型。模式应与客户端请求的特定类型的 TLS 连接相匹配。如果客户端收到错误的模式,它就会知道出了问题。
具体来说,如果客户端请求 TLS 1.2 连接,前 8 个 32 字节设置为44 4F 57 4E 47 52 44 01,如果请求 TLS 1.1 连接,则设置为44 4F 57 4E 47 52 44 00。然而,如果客户端请求 TLS 1.3 连接,这前 8 个字节应该是随机的。例如,如果客户端发送一个 ClientHello 请求 TLS 1.3 连接,但网络上的攻击者将其修改为请求 TLS 1.1 连接,当客户端收到包含错误模式的 ServerHello 时,它会知道自己的 ClientHello 消息已被修改。(攻击者无法随意修改服务器的 32 字节随机值,因为这个值是经过加密签名的。)
单回合握手
在典型的 TLS 1.2 握手中,客户端发送一些数据到服务器,等待回应,然后再发送更多数据并等待服务器回应后才发送加密消息。延迟是两次往返时间(RTT)。相比之下,TLS 1.3 的握手只需要一次往返时间(见图 13-1)。节省的时间可以达到几百毫秒。在考虑到流行服务的服务器每秒处理数千个连接时,这一差异非常重要。
会话恢复
TLS 1.3 比 TLS 1.2 更快,但通过完全消除加密会话前的往返时间,它甚至可以更快(可节省几百毫秒)。诀窍在于使用会话恢复,该方法利用在先前会话中客户端和服务器之间交换的预共享密钥来启动新会话。会话恢复带来了两个主要好处:客户端可以立即开始加密,并且后续会话无需使用证书。
图 13-2 展示了会话恢复的工作原理。

图 13-2:TLS 1.3 会话恢复握手,其中 0-RTT 数据是与 ClientHello 一起发送的会话恢复数据
首先,客户端发送包含已经与服务器共享的密钥标识符(称为PSK,即预共享密钥)和一个新的 DH 公钥的 ClientHello 消息。客户端还可以在这条消息中包含加密数据(称为0-RTT 数据)。当服务器响应 ClientHello 消息时,它会提供一个数据交换的 MAC。客户端验证 MAC 后,可以确认它与之前的服务器进行通信,从而使证书验证显得有些多余。客户端和服务器执行如同正常握手中的 Diffie–Hellman 密钥协商,后续的消息将使用依赖于 PSK 和新计算的 Diffie–Hellman 共享密钥的密钥进行加密。
TLS 安全性的优势
我们将评估 TLS 1.3 在第十一章中提到的两种主要安全概念下的优势:身份验证和前向保密。
身份验证
在 TLS 1.3 握手期间,服务器通过证书机制对客户端进行身份验证。然而,客户端并未进行身份验证,客户端可以通过在握手后向服务器端应用程序(如 Gmail)提供用户名和密码来进行身份验证。如果客户端已与远程服务建立会话,它可以通过发送安全 Cookie来进行身份验证,该 Cookie 只能通过 TLS 连接发送。
在某些情况下,客户端可以使用类似服务器用于身份验证的基于证书的机制来对服务器进行身份验证:客户端向服务器发送客户端证书,服务器在授权客户端之前验证该证书。然而,由于客户端证书会增加客户端和服务器(即证书颁发机构)的复杂性,因此你很少会使用客户端证书:客户端需要执行复杂的操作将证书集成到系统中并保护其私钥,而证书颁发机构需要确保只有授权的客户端收到证书,并且满足其他要求。
前向保密性
回顾第十一章中的“密钥协商协议”,密钥协商提供前向保密性,前提是当当前会话受到攻击时,先前的会话不会受到影响。在数据泄露模型中,只有临时秘密被泄露,而在漏洞模型中,长期秘密被暴露。
幸运的是,TLS 1.3 的前向保密性在数据泄露和安全漏洞面前依然有效。在数据泄露模型中,攻击者恢复了临时密钥,如特定会话的会话密钥或 Diffie–Hellman 私钥(图 13-1 中的 c、s、secret 和 keys 的值)。然而,攻击者只能使用这些值解密当前会话的通信,而无法解密先前的会话,因为不同的 c 和 s 值被使用了(从而产生不同的密钥)。
在漏洞模型中,攻击者也会恢复长期密钥(即与证书中的公钥对应的私钥)。然而,这在解密先前会话时并没有比临时密钥更有用,因为这个私钥仅用于验证服务器身份,前向保密性依然有效。
实际上,如果攻击者攻破了客户端的机器,并获得了其所有内存的访问权限,他们可能从内存中恢复出当前会话的 TLS 会话密钥和秘密信息。但更重要的是,如果先前的密钥仍然保存在内存中,攻击者可能会找到它们并解密先前的会话,从而绕过理论上的前向保密性。因此,为了确保前向保密性,TLS 实现必须在密钥不再使用时正确地将其从内存中擦除,通常通过清空内存来实现。
事情如何出错
TLS 1.3 作为一种通用的安全通信协议符合要求,但它并不是万无一失的。像任何安全系统一样,在某些情况下它可能会失败(例如,当设计者对真实攻击的假设错误时)。不幸的是,即使是配置了最安全密码的最新版本 TLS 1.3 也可能被攻破。例如,TLS 1.3 的安全性依赖于假设三方(客户端、服务器和证书授权机构)都将诚实地行为,但如果某一方被攻破,或者 TLS 实现本身存在问题该怎么办?
受损的证书授权机构
根证书授权机构(根 CA)是浏览器信任的组织,用来验证远程主机提供的证书。例如,如果你的浏览器接受 www
如果 CA 的私钥被攻破,攻击者能够使用 CA 的私钥为任何 URL 生成证书,例如google.com域名下的 URL,而不需要 Google 的批准。攻击者可以利用这些证书冒充合法服务器或子域名,如mail.google.com,并截获用户的凭证和通信。这正是 2011 年发生的事件,当时一名攻击者入侵了荷兰证书机构 DigiNotar 的网络,并创建了看似合法的证书。攻击者使用这些伪造的证书对多个 Google 服务进行攻击。
受损服务器
如果服务器被攻破并完全被攻击者控制,那么一切都失去了:服务器持有会话密钥,作为 TLS 连接的终点。攻击者可以在数据加密前看到所有传输的数据,也可以在数据解密后看到所有接收的数据。他们还可能获取到服务器的私钥,这可能使他们能够使用自己的恶意服务器冒充合法服务器。此时,TLS 并不能保护你。
幸运的是,这类安全灾难在像 Gmail 和 iCloud 这样的高关注度应用中很少发生,它们得到了很好的保护,有时甚至将私钥存储在独立的安全模块中,例如硬件安全模块(HSM),直接或通过密钥管理系统(KMS)应用。
通过诸如数据库查询注入和跨站脚本等漏洞对 Web 应用程序的攻击更为常见,因为它们大多数不依赖于 TLS 的安全性,并且通过攻击者在合法的 TLS 连接上进行。这类攻击可能会泄露用户名、密码等信息。
受损客户端
当客户端,如浏览器,受到远程攻击者的攻击时,TLS 安全性也会受到威胁。攻击者通过攻破客户端,能够捕获会话密钥、读取任何解密的数据等。他们甚至可以在客户端系统中安装一个恶意 CA 证书,使其默默接受本应无效的证书,从而让攻击者截获 TLS 连接。
受损的 CA 或服务器场景与受损客户端场景的区别在于,在受损客户端的情况下,只有目标客户端受到影响,而不是可能影响到所有客户端。
实现中的漏洞
和任何加密组件一样,当 TLS 的实现中存在漏洞时,它也可能会失败。TLS 漏洞的典型案例是 Heartbleed(见图 13-3),这是 OpenSSL 在一个名为heartbeat的小型 TLS 特性中的缓冲区溢出漏洞。Heartbleed 在 2014 年由一名 Google 研究员和 Codenomicon 公司独立发现,影响了数百万的 TLS 服务器和客户端。

图 13-3:OpenSSL 实现的 TLS 中的 Heartbleed 漏洞
客户端首先将一个缓冲区和缓冲区长度发送给服务器,以检查服务器是否在线。在这个例子中,缓冲区是字符串BANANAS,客户端明确表示这个词有七个字母。服务器读取这个七个字母的单词并将其返回给客户端。
问题在于服务器没有确认长度是否正确,而是按照客户端提供的长度读取尽可能多的字符。因此,如果客户端提供的长度比字符串的实际长度长,服务器就会从内存中读取过多的数据,并将其连同任何可能包含敏感信息(如私钥或会话 cookie)的额外数据一起返回给客户端。
Heartbleed 漏洞让人震惊。为了避免类似的未来漏洞,OpenSSL 和其他主要的 TLS 实现现在进行严格的代码审查,并使用自动化工具如模糊测试(fuzzers)来识别潜在问题。
进一步阅读
本章并不是 TLS 的全面指南,你可能想深入了解 TLS 的历史、以前的漏洞以及其最新版本。完整的 TLS 1.3 规范可以在 TLS 工作组(TLSWG)主页上找到,网址是 <wbr>tlswg<wbr>.org,其中包含了协议的所有内容(虽然不一定包括其底层的理论依据)。
我还建议你了解一些使用 TLS 的主要协议,如 QUIC(用于 Chrome 和 Google 服务器之间的连接)和 SRTP(用于视频会议和流媒体传输)。
此外,以下是与 TLS 部署相关的两个重要倡议:
-
SSL Labs TLS 测试 (
<wbr>www<wbr>.ssllabs<wbr>.com<wbr>/ssltest) 是由 Qualys 提供的免费服务,允许你测试浏览器或服务器的 TLS 配置,提供安全评分以及改进建议。如果你设置了自己的 TLS 服务器,可以使用这个测试来确保一切安全。 -
Let’s Encrypt (
<wbr>letsencrypt<wbr>.org) 是一个非营利组织,提供“自动化”部署 TLS 到你的 HTTP 服务器的服务。它包括自动生成证书和配置 TLS 服务器的功能,并支持所有常见的 Web 服务器和操作系统。
第十五章:14 量子与后量子

在本章中,我们将探讨加密学的未来,假设一个百年或更长的时间跨度——届时量子计算机可能已经存在。量子计算机利用量子物理中的现象来运行我们不常见的不同类型的算法。虽然目前还没有大型量子计算机,但它们有潜力破解 RSA、Diffie–Hellman 协议和椭圆曲线加密——这些是截至目前部署或标准化的所有公钥加密算法。
为了应对量子计算的风险,密码学研究人员已开发出替代性的公钥后量子算法。2015 年,美国国家安全局(NSA)呼吁转向量子抗性算法,而在 2017 年,美国国家标准与技术研究院(NIST)开始了一项标准化后量子算法的过程,并在 2022 年发布了相关的新标准。
本章提供了关于量子计算机原理的非技术性概述,并简要介绍了后量子算法。虽然其中涉及一些数学内容,但只是基础的算术和线性代数,所以不用担心那些不常见的符号。
量子计算机如何工作
量子计算利用量子物理以不同的方式进行计算,执行传统计算机无法完成的任务,比如高效地破解 RSA 和椭圆曲线加密。量子计算机并不是一种超快的普通计算机。事实上,量子计算机无法高效地解决那些传统计算机也无法解决的复杂问题,比如暴力搜索或NP-难题。
量子力学——研究亚原子粒子行为的物理学分支,这些粒子的行为是真正随机的——是量子计算机的基础。与传统计算机不同,传统计算机通过位(bit)来工作,而这些位只能是 0 或 1,量子计算机则通过量子位(或量子比特)来工作,量子比特可以“同时是 0 和 1”。这是一种模糊的状态,称为叠加,我的引号表示这是一种过于简化的说法。在这个微观世界中,物理学家发现,像电子和光子这样的粒子以一种高度反直觉的方式表现:在你观察电子之前,它并不在空间的一个确定位置,而是在多个位置同时存在(也就是处于叠加状态)。一旦你观察它——这个操作叫做测量——它会停在一个固定的、随机的位置,且不再处于叠加状态;量子态坍缩了。这使得在量子计算机中创造量子比特成为可能,并且伴随着量子纠缠和干涉现象的出现。
量子计算机的工作原理依赖于纠缠,即两个粒子以一种方式相互连接(纠缠),使得观察其中一个的值能够得到另一个的值,即使这两个粒子相隔甚远(可能是几公里,甚至是光年)。爱因斯坦-波多尔斯基-罗森(EPR)悖论阐述了这一现象,正是这一现象让阿尔伯特·爱因斯坦最初否定了量子力学。(详见<wbr>plato<wbr>.stanford<wbr>.edu<wbr>/entries<wbr>/qt<wbr>-epr<wbr>/了解更深入的解释。)
干涉对于量子计算机的运作也至关重要。利用这一特性,粒子可以因其波动性而相互结合或相互抵消正如著名的双缝实验所示。量子计算利用干涉作用,使得有效解的“波”相互增强,而无效解的波则相互抵消。
为了解释量子计算机的工作原理,我将区分实际的量子计算机(硬件,包括其量子比特)与量子算法(运行在其上的软件,由量子门组成)。
量子比特
你可以用振幅来表征量子比特或其组合,振幅是类似于概率的数字,但并不是完全的概率。概率是介于 0 和 1 之间的数字,而振幅是形式为a + bi(即a + b × i)的复数,其中a和b是实数,i是虚数单位。你使用数字i来形成形式为bi的虚数,其中b是实数。当你将i与实数相乘时,得到另一个虚数,再将其自身相乘会得到-1(即i² = -1)。
与实数不同,实数你可以看作属于一条直线(见图 14-1),复数属于一个平面(一个二维空间),正如图 14-2 所展示的那样。

图 14-1:实数作为无限直线上的点的示意图
在图 14-2 中,x 轴对应于a + bi中的a,y 轴对应于b,而虚线对应于每个数字的实部和虚部。例如,从点 3 + 2i到 3 的垂直虚线长 2 个单位(即虚部 2i中的 2)。

图 14-2:复数作为二维空间中的点的示意图
你可以使用勾股定理来计算从原点(0)到点a + bi的线段长度,将这条线视为三角形的斜边。斜边的长度等于点坐标的平方和的平方根,或者√(a² + b²),这叫做复数a + bi的模。你可以将模表示为|a + bi|,并将其作为复数的长度来使用。
在量子计算机中,寄存器由一个或多个量子比特组成,这些量子比特处于叠加态,可以通过一组这样的复数或振幅来表征。但是,正如你将看到的,这些振幅不能是任意的数。
单量子比特的振幅
你可以通过两个振幅来描述单一量子比特,我们将其表示为α(alpha)和β(beta)。然后,你可以将量子比特的状态表示为α|0⟩ + β|1⟩,其中| ⟩表示量子态中的向量。这种表示意味着,当你观察这个量子比特时,出现 0 的概率是|α|²,出现 1 的概率是|β|²。为了使这些成为实际的概率,|α|²和|β|²必须是 0 和 1 之间的数,且|α|² + |β|²必须等于 1。
例如,假设你有量子比特Ψ(psi),其振幅α = 1/√2 和β = 1/√2。你可以按如下方式表示:

在量子比特Ψ中,值 0 的振幅是 1/√2,值 1 的振幅也相同,都是 1/√2。为了从振幅中得到实际的概率,计算 1/√2 的模(因为它没有虚部,所以模是 1/√2),然后将其平方:(1/√2)² = 1/2。这意味着,如果你观察量子比特Ψ,你有 1/2 的机会看到 0,看到 1 的机会也相同。
现在考虑量子比特Φ(phi),其中:

量子比特Φ与Ψ在本质上是不同的,因为与Ψ中振幅具有相同值不同,量子比特Φ具有不同的振幅,α = i/√2(一个正的虚数)和β = –1/√2(一个负的实数)。然而,如果你观察Φ,看到 0 或 1 的机会是 1/2,和Ψ是一样的。根据之前的规则,计算看到 0 的概率如下:

注意,因为α = i/√2,你可以将α写为a + bi,其中a = 0,b = 1/√2,计算|α| = √(a² + b²)得到 1/√2。
不同的量子比特可能表现得很相似(对于两个量子比特,看到 0 的概率相同),但它们的振幅不同。这说明,看到 0 或 1 的实际概率只能部分地表征一个量子比特;这类似于观察物体在墙上的影子,它能提供物体的宽度和高度的提示,但无法得知其深度。在量子比特的情况下,这个隐藏的维度就是其振幅的值:它是正数还是负数?它是实数还是虚数?
注意
为了简化符号,我们通常将一个量子比特写作它的一对振幅(α, β)。因此,我们可以将前面的例子写作|Ψ⟩* = (1/√2, 1/√2)。*
量子比特组的振幅
我们如何理解多个量子比特呢?例如,当这八个量子比特的量子态通过纠缠连接时,它们可以形成一个量子字节。你可以如下描述这个量子字节,其中α是与这八个量子比特的 256 种可能值相关联的振幅:

请注意,你必须满足|α[0]|² + |〈α[1]|² + . . . + |α[255]|² = 1,以使所有概率的总和为 1。
你可以将这八个量子比特视为一组 2⁸ = 256 个振幅,因为它有 256 种可能的配置,每种配置都有其独特的振幅。然而,在物理现实中,你将拥有八个物理对象,而不是 256 个。256 个振幅是这组八个量子比特的隐性特征;这些 256 个数字中的每一个都可以取无穷多种不同的值。一般来说,你可以通过一组 2^n个复数来描述n个量子比特的组,这个数量随着量子比特数目的增加而指数级增长。
如果你想使用经典计算机模拟量子态的演化,你需要存储这个指数级别的振幅数量并进行计算来修改它们。这一要求是经典计算机无法高效模拟量子计算机的主要原因之一:因为这样做需要巨大的内存(大约是 2^n的数量级),以存储一个量子系统中* n *个量子比特所包含的信息。实际上,你最多可以模拟 50 到 60 个量子比特,具体取决于计算的类型。
量子门
振幅和量子门的概念是量子计算的独特特性。量子门本质上是对一个或多个量子比特的变换,它是量子计算领域中电子门的对应物。经典计算机使用寄存器、内存和微处理器对数据执行一系列指令,而量子计算机通过应用一系列量子门对一组量子比特进行可逆变换,然后测量一个或多个量子比特的值。量子计算机具有更强大的计算能力,因为仅凭* n 个量子比特,它们就能影响 2^n*个振幅的值。这一特性具有深远的影响。
从数学角度来看,量子算法本质上是一个量子门电路,它在最终测量之前会对一组复数(振幅)进行变换,在测量时观察一个或多个量子比特的值(见图 14-3)。

图 14-3:量子算法的原理
我们也将量子算法称为量子门阵列或量子电路。
量子门作为矩阵乘法
与经典计算机的布尔门(与门、异或门等)不同,量子门作用于一组幅度,就像矩阵在与向量相乘时的作用一样。例如,要对量子比特Φ应用最简单的量子门——恒等门,我们将I视为一个 2×2 的恒等矩阵,并与由Φ的两个幅度组成的列向量相乘:

这个矩阵-向量乘法的结果是另一个列向量,包含两个元素,其中上面的值等于I矩阵的第一行与输入向量的点积(即将第一元素 1 与i/√2 的乘积与第二元素 0 与–1/√2 的乘积相加的结果),底部值也类似。
恒等门I几乎没什么用,因为它不做任何事情,保持量子比特不变。
注意
实际上,量子计算机不会显式地计算矩阵-向量乘法,因为矩阵会大得太不可处理。(这也是为什么经典计算机无法模拟量子计算的原因。)相反,量子计算机会通过物理变换将量子比特转化为物理粒子,这些物理变换等效于矩阵乘法。困惑了吗?理查德·费曼曾说过:“如果你没有完全困惑于量子力学,那你就不理解它。”
Hadamard 量子门
最有用的量子门之一是Hadamard 门,通常表示为H。你可以按如下方式定义 Hadamard 门(注意右下位置的负值):

将这个门作用于量子比特|Ψ⟩ = (1/√2, 1/√2)会得到以下结果:

通过将 Hadamard 门H作用于|Ψ⟩,你得到一个量子比特|0⟩,其中值|0⟩的幅度为 1,|1⟩的幅度为 0。这告诉你量子比特是确定性行为的:如果你观察这个量子比特,你总是会看到 0,永远看不到 1。换句话说,你失去了初始量子比特|Ψ⟩的随机性。
再次将 Hadamard 门作用于量子比特|0⟩会得到以下结果:

这将使你回到量子比特|Ψ⟩并恢复随机状态。我们常在量子算法中使用 Hadamard 门,将确定性状态转变为均匀随机状态。
并非所有矩阵都是量子门
虽然你可以将量子门的应用视为矩阵乘法,但并不是所有的矩阵都对应量子门。回想一下,量子比特由复数 α 和 β 组成,分别是量子比特的幅度,满足条件 |α|² + |β|² = 1。如果你通过矩阵乘法对量子比特进行操作后得到的两个幅度不满足这个条件,那么结果就不能是一个量子比特。量子门仅对应 单位矩阵,它们保持 |α|² + |β|² = 1 的特性。
单位矩阵(以及量子门的定义)是 可逆的,这意味着给定一个操作的结果,你可以通过应用 逆 矩阵来计算回原始的量子比特。这就是为什么量子计算是一种 可逆计算 的原因。
量子加速
量子加速发生在量子计算机能够比经典计算机更快地解决问题时。例如,在经典计算机上查找一个无序列表中的 n 个项目时,平均需要进行 n/2 次操作,因为你需要查看列表中的每一项,直到找到你想要的那个。(平均来说,你将在查找列表的一半后找到该项。)没有任何经典算法能够比 n/2 更快。然而,存在一种量子算法可以仅用 O(√n) 次操作来查找某个项,这比 n/2 少了几个数量级。例如,如果 n 等于 1,000,000,则 n/2 为 500,000,而 √n 为 1,000。
我们通过 时间复杂度 来量化量子算法与经典算法之间的差异,我们用 O() 表示法来表示时间复杂度。在前面的例子中,量子算法的运行时间是 O(√n),而经典算法的运行时间不可能比 O(n) 更快。由于这种时间复杂度的差异是由于平方指数的关系,我们称之为 二次加速。虽然这样的加速可能带来不同,但实际上还有更强大的加速。
指数加速与西蒙问题
指数加速是量子计算的圣杯。当一个在经典计算机上需要指数级时间(例如 O(2^n))来完成的任务,能够在量子计算机上以多项式复杂度执行时——即 O(n**^k),其中 k 为某个固定数字时,就会出现指数加速。这种指数加速能够将一个几乎不可能完成的任务变为可能。(回想一下在第九章中,密码学家和复杂性理论家将指数级时间与不可能任务联系在一起,将多项式时间与实际任务联系在一起。)
指数加速的典型例子是Simon 问题。在这个计算问题中,一个函数f()将n位字符串转换为n位字符串,使得f()的输出看起来像一个随机的n位字符串,但有一个限制:存在一个秘密值m,使得对于任意两个值x、y,我们有f(x) = f(y)当且仅当y = x ⊕ m。Simon 问题的任务是,在给定黑箱访问f()的情况下找到m。
用经典算法解决 Simon 问题归结为找到碰撞,或者找到值x和y,使得f(x) = f(y)。这大约需要 2n*(/2)次查询f()。然而,图 14-4 显示,量子算法仅需大约n次查询即可解决 Simon 问题,且具有极高的时间效率,时间复杂度为O(n*)。

图 14-4:解决 Simon 问题的量子算法电路
在解决 Simon 问题的量子电路中,你初始化 2n个量子比特为|0⟩,对前n个量子比特应用 Hadamard 门(H),然后对两个n量子比特组应用门Q[f]。给定两个n量子比特组x和y,门Qf 将量子态|x⟩|y⟩转换为态|x⟩|f(x) ⊕ y⟩。也就是说,它可逆地计算函数f(),因为你可以通过计算f(x)并对f(x) ⊕ y进行异或操作,从新态恢复到旧态。(解释为什么这样有效超出了本书的范围。)
你只能在非常特定的情况下利用对称加密算法的指数加速来解决 Simon 问题,但下一节将讨论量子计算在一些真实的加密杀手级应用中的作用。
Shor 算法的威胁
1995 年,AT&T 的研究员彼得·肖尔(Peter Shor)发表了一篇开创性的文章,标题为《量子计算机上的素因子分解和离散对数的多项式时间算法》。肖尔算法是一种量子算法,当解决因式分解、离散对数(DLP)和椭圆曲线离散对数(ECDLP)问题时,能带来指数级的加速。用经典计算机无法高效地解决这些问题,但使用量子计算机可以。这意味着量子计算机能够破解任何依赖这些问题的加密算法,包括 RSA、Diffie–Hellman、椭圆曲线加密以及目前大多数已部署的公钥加密机制(除了那些已经转向后量子加密的算法)。换句话说,你可以将 RSA 或椭圆曲线加密的安全性降低到凯撒密码的级别。(肖尔本可以将他的文章命名为“在量子计算机上破解所有公钥加密”)著名的复杂性理论专家斯科特·阿伦森(Scott Aaronson)称肖尔算法为“20 世纪末期的重大科学成就之一”。
实际上,肖尔算法解决的比因式分解和离散对数问题更广泛的一个问题类别。具体来说,如果一个函数f()是周期性的——即,如果存在一个ω(周期),使得f(x + ω) = f(x) 对于任何 x 都成立——那么肖尔算法将高效地找到ω。(这看起来与西蒙问题非常相似,西蒙问题是肖尔算法的一个重要灵感来源。)
讨论肖尔算法如何实现加速的具体细节过于技术化,不适合本书讨论,但下一节将展示如何使用肖尔算法解决因式分解和离散对数问题(详见第九章),这正是 RSA 和 Diffie–Hellman 背后的难题。
因式分解问题
假设你要因式分解一个大数N = pq。如果你能计算出a**^x mod N的周期ω,对于某个常数a,那么因式分解N就变得容易了。这个任务对于经典计算机来说非常困难,但对于量子计算机来说却很容易。首先选择一个小于N的随机数a,然后使用肖尔算法求解函数f(x) = a**^x mod N的周期ω。找到周期后,你会得到a**^x mod N = a**^(x + ω) mod N(也就是说,a**^x mod N = a**x**a*ω mod N),这意味着a^ω mod N = 1,或等价地,a^ω – 1 mod N = 0。换句话说,a^ω – 1 是N的倍数,意味着a^ω – 1 = kN,其中k*是一个未知数。
当ω是偶数时,因式分解a^ω – 1 很容易,形式为(aω*/² – 1)(aω*/² + 1),其中aω*/²是单位根,因为(aω*/²)² mod N = 1。若周期ω是奇数,则需要用另一个值的a重新运行肖尔算法,直到得到偶数。
由于a^ω – 1 的因子包含k和N的素因子,你可以找到这些因子,并将它们分布在aω*/² – 1 和aω*/² + 1 的因子之间。然后,你可以计算aω*(/2)* – 1 和N之间的最大公约数(GCD),以及aω*(/2)* + 1 和N之间的最大公约数,从而得到N的一个非平凡因子——即一个不等于 1 或N的值。如果不是这种情况——例如,当aω*/² – 1 或aω*/² + 1 是N的倍数时——就重新开始攻击,选择另一个a。
得到N的因子后,你现在可以从 RSA 公钥中恢复出私钥,从而解密加密信息或伪造签名。
请注意,用于因式分解一个数字N的最佳经典算法的运行时间是指数级的,取决于N的位长n(即n = log[2] N)。然而,Shor 算法的运行时间是n的多项式级别——即O(n²(log n)(log log n))。这意味着,如果你有一个量子计算机,你可以运行 Shor 算法,并在比数千年更合理的时间内看到结果。
离散对数问题
离散对数问题的挑战在于给定y = g**^x mod p,对于一些已知的数字g和p,求解x。在经典计算机上解决这个问题需要(sub)指数级的时间,但通过 Shor 算法,你可以轻松地找到x,因为它有一个高效的周期查找技术。
例如,考虑函数f(a, b) = g**a**y**b。假设你想找到该函数的周期,数字ω和ω′,使得f(a + ω, b + ω′) = f(a, b)对任意的a和b成立。那么你要找的解就是x = –ω/ω′模q,其中q是g的阶,是一个已知参数。等式f(a + ω, b + ω′) = f(a, b)意味着gω*y*ω′ mod p = 1。通过将y替换为g**x*,你得到*g*ω ^(+ x)^ω′ mod p = 1,这相当于ω + xω′ mod q = 0,从而推导出x* = –ω/ω′。
再次,整体复杂度是O(n²(log n)(log log n)),其中n是p的位长。该算法可以推广,用于在任何有限的交换群中找到离散对数,而不仅仅是模素数的数字群。因此,你也可以将其应用于解决 ECDLP 问题,这是离散对数问题在椭圆曲线上的版本。
Grover 算法
量子加速的另一个重要形式是能够在n个项中进行搜索,时间复杂度为n的平方根,而任何经典算法的时间复杂度都是n。这种二次加速得益于Grover 算法,这是一种 1996 年发现的量子算法。我不会详细介绍 Grover 算法的内部原理,因为它们本质上是一些 Hadamard 门,但我会解释 Grover 解决了什么样的问题及其对加密安全的潜在影响。我还将解释为什么通过加倍密钥或哈希值大小,可以让对称加密算法免受量子计算机的破坏,而非对称算法则彻底被破坏。
可以将 Grover 算法视为一种在n个可能的值中找到值x的方法,使得f(x) = 1,而对于其他大多数值,f(x) = 0。如果m个值的x满足f(x) = 1,Grover 将在时间O(√(n/m))内找到解决方案;也就是说,所需时间与n除以m的平方根成正比。相比之下,经典算法的最优复杂度是O(n/m)。
现在考虑f()可以是任何函数。举个例子,它可以是“f(x) = 1,当且仅当 x 等于未知的密钥 K,使得 E(K, P) = C”对于某个已知的明文 P 和密文 C,其中 E() 是某个加密函数。实际上,这意味着如果你使用量子计算机寻找一个 128 位的 AES 密钥,你将在时间复杂度为 2⁶⁴的情况下找到密钥,而如果只使用经典计算机,则需要 2¹²⁸的时间。你需要一个足够大的明文以确保密钥的唯一性。(如果明文和密文是 32 位,比如会有很多候选密钥将该明文映射为该密文。)复杂度 2⁶⁴比 2¹²⁸小得多,这意味着恢复一个秘密密钥会更容易。但有一个简单的解决方案:为了恢复 128 位的安全性,只需使用 256 位密钥!Grover 算法将把搜索密钥的复杂度降到 2^(256 / 2) = 2¹²⁸次操作。
Grover 算法也可以找到哈希函数的原像(参见第六章)。为了找到某个值h的原像,我们将f()函数定义为“f(x) = 1,当且仅当 Hash(x) = h,否则 f(x) = 0。”因此,Grover 可以让你以 2n*(/2)次操作的代价找到n位哈希的原像。与加密一样,为了确保 2^n后量子安全性,使用比原来大两倍的哈希值,因为 Grover 算法至少需要 2^n次操作才能找到一个 2n*位值的原像。
结论是,你可以通过加倍密钥或哈希值的大小来拯救对称加密算法,使其免受量子计算机的破坏。
注意
有一个著名的量子算法,能在时间 O(2n*(/3))内找到哈希函数碰撞,而不是经典生日攻击中的 O(2n*(/2))。这表明量子计算机在寻找哈希函数碰撞方面可以超越经典计算机,尽管 O(2n*(/3))-时间的量子算法也需要* O(2n*(/3))的空间或内存来运行。将* O(2n*(/3))的计算机空间给经典算法,它可以运行一个并行碰撞搜索算法,碰撞时间只有* O(2n*(/6)),比 O(2n*(/3))量子算法要快得多。(有关此攻击的详细信息,请参见 Daniel J. Bernstein 的《哈希碰撞成本分析》,链接为<wbr>cr<wbr>.yp<wbr>.to<wbr>/papers<wbr>.html#collisioncost。)然而,2017 年,密码学家提出了一个量子算法,可以在时间* O*(2²n*(/5)**)内找到碰撞,要求* O(n)的量子内存和 O(2n*(/5)**)的经典内存。这可能超越经典搜索(见* <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2017<wbr>/847)。
为什么构建量子计算机如此困难?
尽管从理论上讲可以构建量子计算机,但我们并不知道这有多难,或者什么时候能够实现,甚至是否能够实现。截至 2024 年中,纪录保持者是一台拥有 1,121 个量子比特的机器(IBM 的“Condor”),而要破解任何加密,我们需要保持数百万个量子比特稳定运行数周。关键是,我们还没有达到这个阶段。
构建量子计算机的难点在于需要非常微小的物体来充当量子比特——比原子还要小,比如光子。由于量子比特必须非常小,它们也非常脆弱。
此外,量子比特必须保持在极低的温度下(接近绝对零度)才能保持稳定。即使在冰点温度下,量子比特的状态也会衰减,最终变得无用。到目前为止,我们尚未找到制造能够持续几秒钟以上(其相干时间)的量子比特的方法。
另一个挑战是环境因素,比如热量和磁场,可能会影响量子比特的状态并导致计算错误。从理论上讲,纠正这些错误是可能的,但实际上非常困难。纠正量子比特的错误需要量子纠错码,而这又需要额外的量子比特和足够低的错误率。
目前,形成量子比特的主要方法有两种:超导电路和离子阱。谷歌和 IBM 的实验室支持使用超导电路,这是基于将量子比特形成为微小的电路,这些电路依赖于超导材料中的量子现象,在这些材料中,电荷载体是电子对。由超导电路构成的量子比特有非常短的寿命。
离子阱,或称捕获离子,包含离子(带电原子),通过激光操控将量子比特准备在特定的初始状态。离子阱比超导电路通常更稳定,但其操作较慢,并且似乎更难扩展。
构建量子计算机实际上是一项登月计划般的努力。挑战在于:1)建立一个具有少量量子比特的系统,该系统稳定、容错,并能够应用基本的量子门操作;2)将这样一个系统扩展到数千或数百万个量子比特,从而使其具有实用价值。从纯物理的角度来看,据我们所知,没有什么能阻止大规模容错量子计算机的创造。但理论上许多事情是可能的,而实践中却很难实现或成本过高(比如安全计算机)。未来将证明谁是对的——量子乐观主义者(预测十年内会有大型量子计算机)还是量子怀疑论者(认为人类永远不会见到量子计算机)。
如前所述,截至 2024 年 1 月,最先进的成就之一是 IBM 的量子计算芯片 Condor,该芯片包含 1,121 个量子比特,采用基于超导电路的技术。但在比较量子计算系统时,量子比特的数量不应是唯一的衡量标准。其他重要因素包括稳定时间、纠缠在一起的量子比特数目,以及可靠地纠正错误的能力。### 后量子密码算法
后量子密码学领域致力于设计量子计算机无法破解的公钥算法;也就是说,它们是量子安全的,并且可以替代 RSA 和基于椭圆曲线的算法,尤其是在未来,当现成的量子计算机能够轻松破解 4,096 位的 RSA 模数时。
这样的算法不应依赖于 Shor 算法已知能高效求解的难题,因为 Shor 算法消除了因数分解和离散对数问题的计算难度。对称算法,如分组密码和哈希函数,在面对量子计算机时理论安全性只会丧失一半,但不会像 RSA 那样被完全破解。它们可能构成后量子方案的基础。
在接下来的章节中,我们将回顾四种主要的后量子算法类型:基于编码的、基于格的、多变量的和基于哈希的。
基于编码的密码学
基于码的后量子密码算法基于纠错码,这些技术旨在通过噪声信道传输位。纠错码的基本理论可以追溯到 1950 年代。第一个基于码的加密方案(McEliece密码系统)于 1978 年开发,至今尚未被破解。你可以使用基于码的加密方案进行加密和签名。它们的主要限制是公钥的大小,通常在几十兆字节左右。但当网页的平均大小大约是 2MB 时,这真的是个问题吗?
让我首先解释一下什么是纠错码。假设你想将一串比特传输为一串 3 位的单词,但传输不可靠,你担心其中一个或多个比特可能会被错误地传输:你发送的是 010,但接收器接收到的是 011。你可以通过使用一个基本的纠错重复码来解决这个问题:不是传输 010,而是传输 000111000(每个比特重复三次),接收器通过对每三位比特取多数值来解码接收到的字。
例如,一个接收器将解码重复码字 100110111 为 011,因为 100 包含两个 0,接着 110 包含两个 1,111 包含三个 1。这个特定的纠错码允许接收器每 3 位块最多纠正一个错误,因为如果在同一个 3 位块中发生两个错误,多数值将是错误的。
线性码是纠错码的一个不太简单的例子。在线性码的情况下,编码的单词被视为一个n位的向量v,编码过程是将v与一个m×n矩阵G相乘,计算得到码字w = vG。(在这个例子中,m大于n,意味着码字比原始单词长。)可以构造矩阵G,使得对于给定的数字t,w中的任何t位错误都能让接收方恢复正确的v。换句话说,t是可以纠正的最大错误数量。
为了使用线性码加密数据,McEliece 密码系统构造了G,作为三个矩阵的秘密组合,并通过计算w = vG加上一些随机值e(其中有固定数量的位设置为 1)来进行加密。在这里,G是公钥,私钥由矩阵A、B和C组成,使得G = ABC。知道A、B和C的人可以可靠地解码消息并恢复w。但是,如果没有这些矩阵,应该不可能解码该字,并因此解密。
McEliece 加密方案的安全性依赖于解码线性码时信息不足的难度,这是一个我们知道是NP-难的问题,因此超出了量子计算机的能力范围。然而,请记住,NP-难的问题并不意味着所有实例在实际中都无法解决。因此,有必要评估加密系统所呈现的困难问题的实例,以及这些实例是否总是很难解决。经过密码学家和编码理论专家多年的分析,McEliece 加密方案满足这一标准。
基于格的密码学
格是数学结构,本质上由一个在n维空间中的点集组成,并具有某种周期性结构。例如,图 14-5 展示了如何将二维格(n = 2)视为点集。

图 14-5:二维格点上的点,其中 v 和 w 是格的基向量, s 是距离星形点最近的向量
格理论催生了看似简单的加密方案。我将简要介绍一下它的要点。
短整型解(SIS) 是基于格的加密中的一个难题,要求给定(A,b)来找到秘密向量s,使得b = As mod q,其中 A 是一个随机的 m×n 矩阵,q 是一个素数。
另一个基于格的加密难题,带错误学习(LWE),包括给定(A,b)来寻找秘密向量s,其中b = As + e mod q,A 是一个随机的 m×n 矩阵,e 是一个随机噪声向量,q 是一个素数。这个问题看起来很像基于码的密码学中的有噪解码。
SIS 和 LWE 在某种程度上是等价的,我们可以将它们重新表述为最近向量问题(CVP),即通过组合一组基向量来找到与给定点最近的格向量。虚线向量s在图 14-5 中显示了如何通过组合基向量v和w来找到距离星形点最近的向量。
CVP 和其他格问题被认为对于经典计算机和量子计算机都很难解决。但这并不直接转化为安全的密码系统,因为一些问题只有在最坏情况下(即对于它们的最难实例)才是困难的,而不是在平均情况下(我们需要的是加密所需的)。此外,虽然找到 CVP 的精确解是困难的,但找到其近似解则可能容易得多。
也就是说,基于格的后量子密码系统已经证明提供了最佳的安全性和性能组合。NIST 在 2022 年选择的标准主要来自这个家族,稍后你会看到。
多变量密码学
多变量密码学 专注于构建密码方案,这些方案的破解难度与解多变量方程组的难度相当,方程组中涉及多个未知数,并且这些未知数在方程中被相乘。例如,考虑以下包含四个未知数 x[1]、x[2]、x[3]、x[4] 的方程组:

这些方程由一些项的和组成,这些项要么是单一未知数,例如 x[4](或一次项),要么是两个未知数的乘积,例如 x[2]x[3](二次项或 二次 项)。要解这个系统,你需要找到满足所有四个方程的 x[1]、x[2]、x[3]、x[4] 的值。方程的定义可以是实数范围内的,整数范围内的,或者是有限数集的。然而,在密码学中,方程通常是基于某些素数模数的,或者是基于二进制值(0 和 1)的。
这里的难题是解决一个随机的二次方程组,这被称为 多变量二次方程 (MQ)。这个问题是 NP-困难的,因此它是后量子系统的潜在基础,因为量子计算机无法高效地解决 NP-困难问题。
不幸的是,在 MQ 上构建加密系统并不那么简单。例如,如果你使用 MQ 来进行签名,私钥可能由三组方程组成:L[1]、N 和 L[2]。按照这个顺序将它们结合起来,会得到一个叫做 P 的方程组,即公钥。依次应用 L[1]、N 和 L[2](即按方程组的方式变换一组值)等价于应用 P,通过将 x[1]、x[2]、x[3]、x[4] 转换为 y[1]、y[2]、y[3]、y[4],例如,按以下方式定义:

在这样的加密系统中,L[1]、N 和 L[2] 的选择方式是,L[1] 和 L[2] 是线性变换(即只有加法项,没有乘法项),并且是可逆的,而 N 是一个二次方程组,也是可逆的。这使得三者的组合成为一个可逆的二次方程组,但在不知道 L[1]、N 和 L[2] 的逆运算的情况下,逆运算很难确定。
计算签名的过程是计算 L[1]、N 和 L[2] 的逆运算,这些运算应用于某个消息 M,我们将其视为一系列变量,x[1]、x[2]、...:

然后,验证签名的过程就是验证 P(S) = M。
攻击者如果能够计算出 P 的逆运算或从 P 中确定 L[1]、N 和 L[2],就能够破解这样的加密系统。解决这些问题的实际难度取决于方案的参数,如使用的方程数量和数字的大小与类型。但是,选择安全的参数非常困难,而且不止一个“安全”的多变量方案已经被破解。
多变量密码学由于难以在安全性和性能之间取得可靠的平衡,因此在主要应用中尚未普及。然而,多变量签名方案的一个实际好处是它们产生的签名较短。
基于哈希的密码学
与前面的方案不同,基于哈希的密码学依赖于加密哈希函数的安全性,而不是数学问题的难度。因为量子计算机无法破解哈希函数,所以它们无法破解任何依赖于查找碰撞或预像难度的内容,而这正是基于哈希函数的签名方案的关键思想。
基于哈希的密码学方案相当复杂,因此我们将看看它们最简单的构建模块:Winternitz 单次签名(WOTS),这是大约 1979 年发现的一个技巧。这里的 单次 意味着你只能用私钥签署一条消息;否则,签名方案会变得不安全。(你可以将 WOTS 与其他方法结合,签署多条消息,正如你很快会看到的那样。)
假设你想签署一条消息,该消息被视为一个介于 0 和 w – 1 之间的数字,其中 w 是该方案的某个参数。私钥是一个随机字符串,K。要签署消息 M,其中 0 ≤ M < w,计算 Hash(Hash(...(Hash(K))),其中哈希函数 Hash 重复 M 次。你将此值表示为 Hash^M(K)。公钥是 Hash^w(K),或者是 w 次嵌套 Hash 运算的结果,从 K 开始。
WOTS 签名通过检查Hash^(w – M)(S)是否等于公钥Hash^w(K)来验证S。注意,S是在对Hash应用了M次后得到的K,因此,如果你再进行w – M次Hash运算,你将得到一个等于K经过M + (w – M) = w次哈希运算后的值,这就是公钥。
该方案有显著的局限性:
攻击者可以伪造签名从HashM*(*K*),即*M*的签名,你可以计算出**Hash**(**Hash***M(K)) = Hash^M ^(+ 1)(K),这就是消息M + 1 的有效签名。你可以通过不仅对M进行签名,还对w – M进行签名,并使用第二个密钥来修复这个问题。
仅适用于短消息如果消息长度为 8 位,则最多有 2⁸ – 1 = 255 种可能的消息,因此你需要计算Hash最多 255 次才能创建签名。这对于短消息有效,但对于较长的消息则无效——例如,对于 128 位的消息,签名消息 2¹²⁸ – 1 将需要无限长的时间。一个解决方法是将较长的消息拆分为较短的消息,并分别对每个部分进行签名。
仅适用于一次如果你使用私钥签署多个消息,攻击者可以恢复足够的信息来伪造签名。例如,如果w = 8,并且你使用前述技巧签署数字 1 和 7,以避免简单的伪造,攻击者会得到Hash¹(K)和Hash⁷(K ′)作为数字 1 的签名,并且得到Hash⁷(K)和Hash¹(K ′)作为数字 7 的签名。从这些值,攻击者可以计算Hashx*(*K*)和**Hash***x(K ′),其中x是[1;7]范围内的任意值,从而伪造代表K和K ′所有者的签名。没有简单的方法可以修复这个问题。
最先进的基于哈希的方案依赖于 WOTS 的更复杂版本,结合了树形数据结构和旨在使用不同密钥签署不同消息的精密技术。不幸的是,最终得到的方案会生成大型签名(例如,SPHINCS+就是 NIST 在 2022 年标准化的一种签名算法,签名大小达到数十千字节)。
你还应该注意有状态和无状态签名方案之间的区别。SPHINCS+是无状态的,而 XMSS 是有状态的,因为它需要维护一个计数器。有状态性大大简化了算法设计,但要求用户在使用算法时必须维护诸如计数器之类的状态。
最后,请注意,仅依赖哈希函数的公钥构造只能提供签名方案,而不能提供加密方案。
NIST 标准
2017 年,NIST 组织了一场公开竞赛,以识别适用于加密和签名的后量子算法标准。像之前的竞赛一样,AES(Rijndael)和 SHA-3(Keccak)就是在此类竞赛中诞生的,NIST 的后量子密码学标准化项目邀请了密码学家提交算法,并对其他提交者的算法进行密码分析,以将其排除出竞赛。
NIST 收到了 69 份提交,其中大多数是基于格的。在这些提交中,26 个进入了第二轮。2020 年 7 月,NIST 选择了七个最终算法和八个备选算法。2022 年 7 月,NIST 宣布了前四个标准:
CRYSTALS-Kyber 一种基于格的密钥封装机制(KEM),它是一种可以视为秘密密钥加密方案的原语。它可以用于加密数据(在混合方案中,使用由 KEM 加密的密钥实际加密数据)并用于密钥协商,类似于 Diffie–Hellman 协议。
CRYSTAL-Dilithium 一种基于格的签名方案,由与 CRYSTALS-Kyber 相同的团队设计。
Falcon 一种基于格的签名方案,采用与 Dilithium 略有不同的技术和假设。
SPHINCS+ 一种基于哈希的签名方案,因此是唯一不基于格的算法。
NIST 在关于使用两个基于格的签名方案时声明了以下内容:
[两者]都因其强大的安全性和卓越的性能而被选中,NIST 预计它们将在大多数应用中表现良好。由于 CRYSTALS-Dilithium 签名可能过大,Falcon 也将被 NIST 标准化。
最短的 Dilithium 签名大约为 2KB,而 Falcon 的签名长度是其一半。NIST 还声明将标准化 SPHINCS+,“以避免仅依赖格的安全性来保证签名安全。”
截至撰写本文时,草案标准已在 FIPS 系列下发布,分别为 FIPS 203(模块-基于格的密钥封装机制标准)、FIPS 204(模块-基于格的数字签名标准)和 FIPS 205(无状态哈希基础数字签名标准),而 Falcon 的标准预计会稍晚发布。
这些后量子算法预计将首先以混合模式使用,与经典的、非量子抗性算法结合使用,以规避弱点风险。例如,Kyber 通常与 X25519 结合使用,X25519 是基于 Curve25519 的 Diffie–Hellman 方案,用于保护 TLS 连接。
NIST 还宣布了四个进入“第四轮”的算法。这些包括三个基于编码的加密方案:BIKE、Classic McEliece 和 HQC。基于同态的 SIKE 在宣布后不久就被发现完全被攻破,因此退出了比赛。
NIST 于 2022 年夏季启动了一个新项目,旨在识别更多的后量子签名方案,并表示“非基于结构化格的签名方案最为关注”,并期待收到“短签名和快速验证”的提交。2023 年 6 月,NIST 收到 50 个提交,包括 11 个多变量方案、7 个基于格的方案、6 个基于码的方案、2 个基于哈希的方案,以及 7 个使用MPC-in-the-head的方案。这一新兴技术将多方计算(MPC)协议转化为零知识证明,即通过验证一条数据来验证签名的有效性。这些方案的数量和种类将导致新的攻击和攻击技术,期待能够形成可靠的标准。
事情可能出错的方式
后量子密码学可能比 RSA 或椭圆曲线密码学更强大,但它并非万无一失或无所不能。我们对后量子方案及其实现的安全性理解比其他情况下更为有限,这也带来了更大的风险。
不明确的安全级别
后量子方案可能看起来异常强大,但在面对量子和经典攻击时却可能不安全。基于格的算法,如环-LWE 家族的计算问题(LWE 问题的变体,使用多项式进行运算),有时存在问题。环-LWE 对于密码学家具有吸引力,因为我们可以利用它构建加密系统,其破解难度原则上与解决环-LWE 问题中最难实例的难度相当,而这些问题通常是NP-难的。但当安全性看起来过于完美时,它往往并不是真的如此。
安全性证明通常是渐近性的,意味着它们仅对大参数值成立,比如底层格的维度。然而,实际中使用的参数要小得多。即使基于格的方案看起来和某些NP-难问题一样难以破解,其安全性仍然很难量化。在基于格的算法中,我们很少能清楚地了解针对它们的最佳攻击以及攻击的计算或硬件成本,因为我们对这些新构造的理解不足。这种不确定性使得基于格的方案更难与理解较为透彻的构造(如 RSA)进行比较。然而,研究人员在这方面已取得了一些进展,理想情况下,在几年内,基于格的问题将像 RSA 一样被充分理解。(有关环-LWE 问题的更多技术细节,请阅读 Peikert 的精彩综述,链接见* <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2016<wbr>/351*。)
大型量子计算机的最终存在
想象一下 2048 年 4 月 2 日的 CNN 头条:“ACME 公司揭露其秘密建造的量子计算机,推出破解加密即服务平台。” 好吧,RSA 和椭圆曲线加密完蛋了。那么接下来呢?
关键在于,后量子加密比后量子签名更为重要。首先,我们来看签名的情况。如果你仍在使用 RSA-PSS 或 ECDSA 作为签名方案,你可以使用后量子签名方案颁发新的签名,以恢复签名的可信度。你需要撤销之前的量子不安全公钥,并为你签署的每条消息计算新的签名。经过一些工作后,你就可以恢复正常。
只有在使用量子不安全的加密方案(如 RSA-OAEP)加密数据时,你才有理由感到恐慌。在这种情况下,所有传输的密文都有可能被破解,因此使用后量子算法重新加密这些明文没有意义,因为你的数据的机密性已经丧失。
那么密钥协议呢,像 Diffie–Hellman (DH)和它的椭圆曲线变种(ECDH)呢?乍一看,情况看起来和加密一样糟糕:攻击者如果已经收集了公钥 g**^a 和 g**^b,他们可以利用他们的新量子计算机计算出秘密指数 a 或 b,然后计算出共享的秘密 g**^(ab),进而推导出加密你流量的密钥。但实际上,Diffie–Hellman 并不总是如此简单地使用。加密你的数据的实际会话密钥可能是由 Diffie–Hellman 共享秘密和你系统的某些内部状态共同推导出来的。这就是现代移动消息系统的工作原理,得益于 Signal 应用程序开创的协议。当你通过 Signal 发送新消息给对方时,它会计算一个新的 Diffie–Hellman 共享秘密,并将其与依赖于该会话中先前消息的内部秘密相结合(该会话可能会持续较长时间)。这种 Diffie–Hellman 的高级使用方式,使得即使是量子计算机的攻击者也更难破解。
实现问题
实际上,后量子方案是由代码构成的——即在某些物理处理器上运行的软件——而不仅仅是抽象的算法。无论算法在理论上多么强大,它们也无法避免实现错误、软件漏洞或侧信道攻击。一个算法在理论上可能完全是后量子的,但一旦实现,可能会被攻破——例如,由于程序员忘记输入分号,导致经典计算机程序出现漏洞。
此外,像基于编码和基于格的算法等方案在很大程度上依赖于数学运算,其实现使用了各种技巧,使这些运算尽可能快速。同样,这些算法中代码的复杂性使得实现更容易受到旁道攻击的威胁,例如定时攻击,这类攻击通过测量执行时间推断关于秘密值的信息。事实上,我们已经将此类攻击应用于基于编码的加密(参见 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2010<wbr>/479)和基于格的签名方案(参见 <wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2016<wbr>/300)。
具有讽刺意味的是,由于实现中的潜在漏洞,使用后量子方案在实践中的安全性可能比非后量子方案更低。
进一步阅读
要学习量子计算的基础知识,可以阅读迈克尔·尼尔森(Michael Nielsen)和艾萨克·春光(Isaac Chuang)合著的经典书籍《量子计算与量子信息(纪念版)》(剑桥,2011)。斯科特·艾伦森(Scott Aaronson)的《自德谟克里特以来的量子计算》(剑桥,2013)是一本更具娱乐性的读物,涉及的不仅仅是量子计算。
有多个软件模拟器允许你进行量子计算实验,例如 <wbr>www<wbr>.quantumplayground<wbr>.net 上的量子计算游乐场,或 IBM 的 <wbr>quantum<wbr>.ibm<wbr>.com 平台。这些网站相对易于使用,得益于直观的可视化。
有关后量子密码学的最新研究,参见 <wbr>pqcrypto<wbr>.org 以及相关会议 PQCrypto。
未来几年,后量子密码学有望特别令人兴奋,这得益于 NIST 后量子密码学标准化项目的持续推进以及后量子解决方案的大规模部署。
第十六章:15 加密货币加密学

本章并未包含在 2017 年秋季初版的书中,那时加密货币和区块链正处于 hype 的巅峰期。虽然区块链并未完全实现其颠覆多个行业的承诺,但它已深刻影响了加密学研究和工程领域。区块链应用带来了新的激动人心的挑战,吸引了新的人才,并提供了一种新的方式来弥合理论与实践之间的差距。
在“加密”成为加密货币的代名词之前,加密算法和协议主要涉及标准功能,如加密和安全通道。那些较为深奥的协议通常局限于小众研究领域和技术文章,通常服务于研究人员的兴趣,并仅在学术会议上呈现。新的算法主要由学术研究人员设计,并且至少在经过五年的同行评审和分析,以及众多未成功破解的加密分析文章之后,才会在现实世界中部署(如果有的话)。
区块链颠覆了这一过程。就像 Signal 和 Tor 的加密协议避开了传统的学术途径一样,区块链爱好者在某种程度上也不受传统束缚。突破性的协议通常会在博客文章或非正式的白皮书中首次亮相,随后很快进行实现。有时,他们会完全放弃书面规范,让代码本身发声。只有在广泛采用后,学术界才会注意到它们。最著名的案例是比特币协议,在部署之前并没有经过正式的同行评审。
许多资深研究人员在区块链领域识别出了新颖的挑战,通常与区块链实体直接合作。他们开发了复杂的协议,这些协议不仅突破了边界并获得同行认可,而且在现实世界中得到了迅速实施,影响了成千上万,甚至百万个系统。重要的例子包括高效的 ECDSA 门限签名方案和零知识证明系统。在某些情况下,现有的几乎没有实际应用的协议找到了具有重大影响的应用场景,例如 Boneh–Lynn–Shacham(BLS)签名。
本章提供了这些加密算法和协议的概述——那些专为区块链量身定制的协议以及由于区块链而蓬勃发展的协议。我不会深入定义区块链或其工作原理,因为网络上有大量资源可以参考。相反,我强调的是支撑区块链的加密方案,这些方案在任何区块链应用场景中都有重要意义。即使你对区块链现象仍持怀疑态度,我相信你会发现本章内容具有启发性。
哈希应用
哈希函数,密码学的瑞士军刀,在区块链系统中担任着多种应用,包括:
哈希交易的数据
哈希函数可以生成由交易发行者签名的摘要,通常使用 ECDSA-secp256k1 或 Ed25519。验证签名确保给定地址的所有者批准了被哈希的信息,从而将交易包含在链的分类帐中。
在以太坊区块链中,交易的哈希作为其唯一标识符;例如,您可以在<wbr>etherscan<wbr>.io的搜索字段中输入哈希来检索相关交易。以太坊交易哈希使用 Keccak-256——这与 SHA3-256 类似但不完全相同——并处理编码数据。该数据包括收款人地址、发送的以太(代号 ETH)数量、任何智能合约输入、燃气价格和限制、一个随机数(每个新交易递增)以及交易的签名。
哈希块的内容
哈希函数可以在后续块中包含摘要以“链接”这些块。例如,每个比特币块都有一个块头,其中包括上一个块的哈希,记录的交易的树哈希,以及一些元数据(版本、时间戳、随机数等)。比特币通过对上一个块的头部进行双 SHA-256 哈希或SHA-256(SHA-256(块头))来计算上一个块的哈希。这种非正统的构建消除了长度扩展攻击的风险,并在 SHA-256 被发现不安全时提供了安全保障——尽管这种情况不太可能发生。
除了这些基本用例外,哈希函数是区块链系统关键组件中的主要构建块。
Merkle 树
Merkle 树是一种哈希树,它是一种根据树模式从叶子值计算根值的数据结构。在哈希树中,父节点通过对子节点进行哈希计算而生成。例如,Merkle 树用于创建比特币块的头部。它们以计算机科学家拉尔夫·默克尔命名,其 1979 年的加密方案构建使用了二进制哈希树。
树哈希计算
Merkle 树将构成其叶子的值作为输入,即计算其根(输出)的值。通常,您将数据结构树的根表示为顶部,叶子表示为底部。
例如,图 15-1 展示了一个 Merkle 树对四个值(A、B、C、D)进行哈希。

图 15-1:一个哈希树,其中叶子是输入,根是输出
然后,树哈希工作如下:
-
对四个值中的每一个进行哈希以获得四个哈希H(A)、H(B)、H(C)和H(D)。
-
将每一对连续的哈希值组合起来得到H(H(A) || H(B))和H(H(C) || H(D))。这里你将哈希值进行连接(如符号||所示)。
-
将两个哈希值组合在一起,得到树的根节点,这是树哈希的最终输出:H(H(H(A) || H(B)) || H(H(C) || H(D))).
如果省略了输入值的初始哈希——仅在输入值不是哈希大小时才需要——你将从四个值开始,最终得到一个具有两层树结构的单一值,或者说高度为二。注意,虽然输入数据可能具有不同的大小,但所有哈希值的大小都是相同的。通常,一个高度为n的树具有 2^n个叶子,允许你通过计算 2^n - 1 个哈希值(从哈希后的叶子开始)来处理最多 2^n个值。
如果输入值的数量不是 2^n(对于某个整数n),一种常见的技术是添加虚拟值(例如,将其设为零或列表中的最后一个值);但是,填充规则应该仔细选择,以避免出现简单的冲突。
Merkle 证明
Merkle 树的结构可以用来证明某个给定的值属于被哈希的 2^n个值的列表,而无需重新计算整个树(这将涉及 2^n次操作),而只需要与树的高度(即其层数n)成比例的时间。根据具体情况,这种证明被称为成员路径、包含证明或Merkle 证明。这是 Merkle 树的一个杀手级特性,而通用哈希函数则不提供这一功能。
图 15-2 展示了这一过程的工作原理。阴影单元格是足够证明V[1]是被哈希得到根节点的值之一。

图 15-2:一个 Merkle 树,其中阴影单元格构成了 A的成员路径
假设你想证明A是被哈希的值之一,而不暴露B、C或D。首先,对A进行哈希处理,得到哈希树的实际叶子节点。然后,假设你已经收到了A的成员路径,该路径包含其他阴影值,H(B)和H(H(C) || H(D))。为了验证A是否属于这棵树,计算以下内容:
-
X = H(H(A) || H(B)),因为你知道A和H(B)
-
H(H(X) || H(H(C) || H(D))),因为你知道H(H(C) || H(D))
仅通过两次哈希操作就能证明A的包含性,或者根据树的高度,进行的哈希操作次数即为树的高度。具有八个叶子的树的高度为三;因此,验证成员路径需要三个兄弟哈希。对于 16 个叶子,您需要四个兄弟哈希,以此类推,对于具有 2^n个叶子的树,需要n个兄弟哈希。
区块链应用通常使用梅克尔树将交易哈希成一个单一的梅克尔根,例如比特币区块头中包含的那个。一个典型的比特币区块注册大约 2,000 个交易,这需要一个高度为 11 的树(2¹¹ = 2,048)。在这种情况下,从叶子节点计算根节点需要 2,048 - 1 = 2,047 个兄弟哈希,每个叶子节点是交易数据的哈希。每个哈希计算一次双重 SHA-256,因此,在 2,048 个交易的情况下,需要 2,047 + 2,048 = 4,095 次双重 SHA-256 计算,或者 8,190 次 SHA-256 调用。
以太坊使用一种稍微复杂的基于树的数据结构,将梅克尔树与帕特里夏树(这不是打字错误;“trie”来自“检索”)结合起来,帕特里夏树是一种存储键值对的树状结构。该结构服务于以太坊的状态模型,这与比特币的 UTXO(未花费交易输出)模型有显著不同,后者对于简单的梅克尔树就足够了。 #### 工作量证明
工作量证明(PoW)可以说是区块链共识协议中最关键的组成部分——对于那些基于 PoW 而非权益证明(PoS)或其他协议的区块链。
PoW本质上是一个哈希函数,它接受一些固定输入和可变输入,并且其结果必须匹配某种模式才能有效。旨在解决 PoW 的各方会重复计算哈希,使用不同的可变输入值,直到结果满足某种约束条件。
例如,在比特币和一些其他基于 PoW 的区块链中,约束条件是将哈希值视为一个 256 位数字时,该值必须小于给定的数字。也可以将其视为哈希值具有一定数量的前导零,前提是将哈希值视为一个 256 位数字的大端编码。
例如,在 2022 年,比特币的最高难度值(如* <wbr>btc<wbr>.com<wbr>/stats<wbr>/diff 上报道)是 34,244,331,613,176,约为 2⁴⁵。你将其乘以 2³²,得到哈希值必须小于的实际值,即 2⁷⁷。对于每个区块,所有网络参与者(矿工*)共同计算大约 2⁷⁷次双重 SHA-256 计算,以找到 PoW 的解决方案。这样的解决方案包括一个 nonce(PoW 哈希输入的可变部分),该 nonce 使哈希值小于 2⁷⁷。PoW 哈希输入的固定部分包括区块头的值(版本、前一个区块哈希、梅克尔树根、时间戳和难度目标值)。
如果没有 PoW“减缓”区块链中区块生产的速度,新有效区块可能会瞬间生成,这意味着交易历史可以随意创建和重建。这样就不可能实现最终性(即,一旦区块被网络接受,不能被撤回或更改,交易也无法被修改)。特别是,无法保护网络免受双重支付攻击,即在两笔不同交易中花费相同的币。
并非所有 PoW 方案都像比特币那样简单,它使用了一种通用哈希函数(SHA-256)。一些 PoW 尝试使其在专用硬件上的计算效率低于通用 CPU,从而阻止由投资于优化硬件矿机技术的组织垄断挖矿——与任何人都能使用的现成服务器和计算机不同。实施的技巧包括以下几种:
内存难度 你可以强迫工作量证明(PoW)使用大量内存,通常通过生成一个巨大的表格并访问不可预测的地址。例如,以太坊的 PoW 使用了 Ethash 算法,需要大约 4GB 的内存。(在 2022 年 9 月,以太坊放弃了 Ethash,并从 PoW 机制切换为权益证明机制。)
虚拟机 就像一些恶意软件那样,你可以创建一组自定义的计算机指令,这些指令将由虚拟机应用程序转换为标准指令,同时还可能使用大量内存来计算 PoW 的解决方案。这是 Monero 区块链采用的 PoW 算法——RandomX 的做法。
分层密钥派生
区块链用户通常希望管理多个账户,每个账户由一对密钥组成,其中:
-
私钥必须保密,因为它是签名密钥,用于签署交易并从账户中转移资金。
-
公钥必须公开,因为它用于验证交易的签名。它也是从中衍生出账户地址的值。例如,比特币通过 SHA-256 和 RIPEMD-160 哈希的组合从公钥衍生出地址。需要注意的是,在一些区块链中,如比特币,公钥在账户发起交易之前并不公开。
为了可靠地管理所有这些密钥,区块链开发人员定义了分层确定性钱包(HD 钱包),这种方式比为每个新账户生成并备份一个随机密钥更加简便且减少风险。
使用 HD 钱包时,你生成并存储一个秘密,即种子(也称为主密钥或熵)。该种子是唯一随机生成的值,是唯一提供熵或不确定性,从而保证通过它推导出来的私有签名密钥的保密性。因此,钱包软件应用通常将种子编码为种子短语或助记符,这是一组由 12 到 24 个单词组成的序列,便于保存和记忆。
让我们回顾一下如何使用 HMAC-SHA-512 伪随机函数(通过 SHA-512 实现的 HMAC 构造)来执行此密钥推导:从一个 128 或 256 位的种子 S 开始,使用该种子作为密钥输入计算 HMAC-SHA-512,将底层椭圆曲线的标识符作为消息输入(该字符串可以是“比特币种子”,“Nist256p1 种子”或“ed25519 种子”)。结果的前 256 位是主密钥 k,后 256 位是主链码 c,该值用于推导更多的密钥。
你可以从 k 和 c 推导一个子私钥,方法如下:给定一个标识符 i(最大为 2³¹ 的数字),使用链码 c 作为密钥计算 HMAC-SHA-512,并将 k 和 i 作为消息输入。512 位的结果被解析为 256 位的值 L,后跟 256 位的链码 R。简化表示为 L || R = HMAC-SHA-512(c, k || i)。然后将子私钥设置为 k + L,其链码为 R。
你可以依此推导密钥,并通过获得的密钥和链码建立密钥的层次结构。例如,从标识符为 0 的密钥推导出所有比特币的密钥,从标识符为 60 的密钥推导出所有以太坊的密钥。将一个数字与每个区块链网络关联的约定已在文档“SLIP-0044: BIP-0044 注册硬币类型”中标准化。
如果你首先通过标识符 0 推导一个子密钥,然后再通过该密钥及其链码推导一个标识符为(假设)29 的密钥,那么该密钥的推导路径为 0/29。你将执行两次 HMAC-SHA-512 调用,分别生成L[1]和L[2]作为它们的前 256 位,最终的私钥为 k + L[1] + L[2]。因此,你可以将所有由给定主密钥 k 推导的密钥视为 k 加上 HMAC-SHA-512 返回的值的总和。此推导称为硬化,因为你需要父密钥的私钥 k 和链码 c。
注意
对于 非硬化 版本,请使用公钥而不是私钥,这样可以从父公钥确定子公钥。详细信息,请参阅初始比特币标准文档“BIP32: 分层确定性钱包”和其通用标准文档“SLIP-0010: 从主私钥推导通用私钥”。
代数哈希函数
哈希函数如 SHA-3 和 BLAKE3 作用于字节,或者 4 字节或 8 字节的字,其中一个字节是 8 位的一个数据单元。输入数据是一系列字节,每个字节可以取从 0x00 到 0xff(255)之间的 256 个可能值,输出数据同样是任意字节的序列。当数据有效地转化为字节序列时,并且字节或字的操作(如 XOR、字移位和整数加法)高效时,这种方式效果很好。
数学计算机
假设你有一台计算机,它只能处理特定范围内的数字,比如模 13 的数字。XOR 操作对这些数字的效果不好,因为并非所有 4 位数字都小于 13;例如,10 和 4 的 XOR 运算结果是 14,这超出了范围。你可能会将结果按模 13 进行缩减,得到 1,但这样一来,10 XOR 4 和 10 XOR 11 会产生相同的结果,而在处理字节时不会出现这种问题。这通常会显著降低哈希函数的安全性——例如,通过碰撞。
更糟的是,你的计算机只能进行模 13 的运算:加法、减法、乘法和除法。它没有内建的按位 XOR 指令,所以只能用模 13 的算术运算来模拟,这既不直接也不高效。我将把这个模拟过程的细节留给你作为练习。
你的任务是创建一个只使用(或主要使用)算术运算的哈希函数,适用于给定数字范围,并且不使用按位操作,包括 XOR、OR、AND 或位移运算。
你需要这种类型的函数来高效地运行某些高级加密协议——即多方计算(MPC)和零知识证明,后面你将看到这一章的内容。此类协议通常在数学结构的范围内操作,比如有限域(例如,模素数的整数集合),有时还需要将程序“转化”为数学方程。从原则上讲,任何程序都可以转化为方程。但当程序没有针对底层数学结构进行优化时,方程会变得非常庞大且计算缓慢。代数哈希函数旨在通过设计既安全又易于仅使用有限域中的算术运算实现的哈希函数来解决这个问题。
设计原则
让我们来考虑 Poseidon 的设计原则,这是一个在 2019 年为零知识证明系统设计的哈希函数,并迅速被许多区块链系统采纳。这类证明有时需要将哈希函数表示为一个关于大有限域的算术运算电路,比如模 255 位素数的整数集合。在这种情况下,Poseidon 比像 SHA-256 这样的通用哈希函数高效几个数量级。
Poseidon 使用海绵哈希函数构造(参见 第七章),因此需要构建一个 置换,这是一种输入输出大小相同的可逆变换。它将这个置换应用于一个有限域元素的向量状态。在相关应用中,这样的有限域通常由模素数的数字组成,大小可能从 31 位小到 381 位不等,具体取决于应用。
然后,像大多数哈希函数一样,置换会迭代一系列轮次,因此需要设计一个 轮次函数。
最后,Poseidon 将其置换分解为三个层,具有三个不同的目的,类似于 AES 的轮次:
-
一个 唯一性 层,AddRoundConstants,在 Poseidon 文档中标记为 ARC()。这个层向状态的元素添加常数值,使得每一轮的常数都不同。使每一轮具有唯一性可以防止包括滑动攻击在内的攻击。为了避免定义和存储许多常数,Poseidon 使用一个确定性随机位生成器来生成常数,该生成器初始化时使用 Poseidon 实例特征(如轮次数量、有限域、S-box 类型等)的编码。
-
一个 非线性 层或 S-box 层,SubWords,在 Poseidon 文档中标记为 S。这个层引入了 混淆,即输入和输出值之间通过高阶代数方程关联的特性——因此它们尽可能远离线性和低阶方程,后者是差分密码分析可以利用的。S-box 会独立地转换其状态的每个元素,通常将域元素 x 映射到 x³ 或 x⁵。指数保持相对较低,以便高效计算。
-
一个 线性 层,MixLayer,在 Poseidon 文档中标记为 M()。MixLayer 引入了 扩散,即初始状态中各元素之间差异的传播。例如,如果状态由四元素向量(x[1], x[2], x[3], x[4])组成,则 M() 会将每个元素替换为所有元素的线性组合。它可能会用 2x[1] + 10x[2] + x[3] + 3x[4] 的结果替换 x[1]。这样的变换对应于将一个向量乘以一个矩阵。Poseidon 的矩阵必须满足某些安全性要求,并应为高效实现而设计。
一个 完整轮次 的 Poseidon 按以下顺序应用三个层:ARC(),S 到每个元素,再应用 M()。一个 部分轮次 只对一个元素应用 S,并且可能在 M() 中使用不同的矩阵。一个 Poseidon 实例 会反复执行完整轮次、部分轮次和完整轮次——这些轮次的数量取决于实例、元素数量、有限域和目标安全等级。
注意
欲了解更多关于 Poseidon 的详细信息,请参见 <wbr>www<wbr>.poseidon<wbr>-hash<wbr>.info 以及初始的 Poseidon 论文(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2019<wbr>/458<wbr>.pdf)和改进版 Poseidon2 论文(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2023<wbr>/323<wbr>.pdf)。
Poseidon 是为了解决实际应用问题而创建的众多代数哈希函数之一。其他设计包括 MiMC、Monolith、Rescue-Prime 和 Tip5。
事情是如何出错的
让我们来看一下在区块链世界中涉及哈希函数及其应用的一些安全失败案例。
破损的自定义哈希
2017 年的区块链项目 Iota 相当奇怪。它声称采用不同于大多数区块链顺序区块链结构的架构,更接近直接无环图(DAG),这使得它的安全性大大降低。它还不是用位来编码数据,而是用三值(trit),这种单位有三种值而不是两种,目的是让计算在不存在的处理器上更高效。
Iota 并没有使用像 ECDSA 或 Ed25519 那样基于椭圆曲线的签名方案,而是使用了一种基于已建立的 Winternitz 结构的基于哈希的签名方案,从而提供了后量子安全性——然而 Iota 也设计了自己的自定义哈希函数 Curl。
Iota 成为了十大最流行的加密货币之一,并对其潜在的有用性和安全性做出了宏大的声明。但它声称通过人工智能的帮助开发的自定义哈希函数,在碰撞攻击面前却非常脆弱。研究人员使用市售硬件,在几分钟内就发现了碰撞,这些碰撞仅在设计好的攻击场景中才能被利用。Iota 很快修补了其哈希函数。
在这次失败之后,著名的密码学和安全专家布鲁斯·施奈尔评论道:“在 2017 年,让你的加密算法容易受到差分密码分析攻击是一个新手错误。这表明没有任何有能力的人分析他们的系统,而他们的修复措施让系统变得安全的可能性很低。”
低熵钱包
本节早些时候提到的分层密钥派生模型在理论上是安全的,但在实践中只有在正确实现时才是安全的。通常可以通过使用 BIP32 和 SLIP-0010 标准文档中的测试向量来验证这一点。如果你得到的输入/输出值与文档中记录的相同,那么你的实现很可能是正确的,尽管不一定是安全的。
2022 年,流行的加密货币移动钱包应用 Trust Wallet 宣布发布了一款浏览器扩展版本,采用 WebAssembly(Wasm)技术,能够在不同的浏览器中高效运行。然而,Wasm 无法使用与移动版本相同的 PRNG,它必须定义一个不同的 PRNG。
一个糟糕的 PRNG 可能是加密学的致命弱点(参见 第二章)。在 Trust Wallet 中,开发者使用了 Mersenne Twister PRNG(mt19937),它并不是一个加密学 PRNG。它的熵最多为 32 位,且通过对内部状态值的简单线性组合来生成输出比特。
由于 Trust Wallet 的 PRNG 具有 32 位的熵,它只能为用户的钱包生成 2³² 个不同的种子。攻击者可以计算出所有 2³² 个可能的种子,并针对每个种子,计算出通过层级密钥派生法得到的私钥和地址。然后,他们可以扫描区块链,找到由 Trust Wallet 生成的地址并窃取其代币。发现该漏洞的 Ledger 公司研究人员评论道:“执行这样的攻击需要的时间远不止几个小时,但只需几张 GPU,便可在一天之内完成。”
来自域分离失败的碰撞
让我们讨论如何找到一个抗碰撞的哈希函数的碰撞。正如加密学家 Moti Yung 所说:“如果这听起来不可能,那就从加密学的角度来看非常有趣。”
假设以下简单情况:一个应用从两方,Alice 和 Bob,接收消息 A 和 B,然后将这两条消息一起哈希,生成一个唯一的哈希值。它接着可以通过对字符串 A 后跟 B 进行哈希来计算 H(A || B)。即使你的哈希函数是抗碰撞的,当 A = COL 且 B = LISION 时,得到的哈希值与 A = CO 且 B = LLISION 时的哈希值相同。你会遇到针对应用输入值的哈希碰撞,尽管对哈希函数而言并不算碰撞,因为哈希函数 H 在这两种情况下处理的是相同的字符串,即 COLLISION。
为了避免这个问题,将应用的输入值编码为唯一的字符串,并且不采用模糊的编码方式:对于每个字符串,你应该能够唯一识别原始的输入值集合。在我们的例子中,在两个输入之间添加美元符号(\()作为分隔符似乎可以避免 COL || LISION 和 CO || LLISION 之间的碰撞:你将得到字符串 COL\)LISION 和 CO$LLISION,从而生成不同的哈希值。
但是,仅仅使用分隔符符号不足以消除当字符在应用程序的输入值中被授权时产生的歧义。例如,考虑字符串 COL$$LISION,它是两个输入字符串通过\(符号连接起来的结果。你可以从两个输入对中得到该字符串:COL 和\)LISION,或者 COL$和 LISION。注意,当输入值的大小固定且常量时,问题不会出现——例如,第一个字符串由两个字符组成,第二个字符串由三个字符组成。即便如此,仍然更安全使用某些分隔符或编码来防止冲突,因为未来的补丁可能会引入可变长度的输入。
研究人员发现了在门限签名协议中存在这种类型的漏洞,稍后你将在本章中看到,此外在电子投票协议中也有类似问题,其中生成的哈希冲突可能被利用来破坏这些协议的安全属性。
多重签名协议
在加密的多重签名协议中,参与者共同生成一条消息的签名,功能上相当于所有方分别签署消息;获得的签名意味着所有参与者同意签署该消息。其优点是,签名的数量不再与签署者的数量相等,而只有一个。任何拥有所有签署者公钥的人都可以验证该签名。
区块链平台在多个参与方共同管理账户时使用多重签名,以确保所有参与方都支持已发起的交易。如果单个方在多个设备上有多个密钥,他们也可以使用多重签名,以防止单个被泄露的密钥允许攻击者发起交易。此类多重签名协议是集体签名协议的众多类型之一,在这些协议中,参与方运行一个协议以生成签名。
在深入技术细节之前,让我们先澄清这些多重签名与相关协议的不同之处。
多方多重签名
尽管名称相似,我们将要讨论的多重签名协议与比特币和以太坊中使用的链上多重签名脚本或多重签名智能合约不同。后者并不是加密协议,因为参与者独立提交各自的签名到区块链网络,网络则验证如“如果交易拥有来自pub[1]和pub[2]的签名,则接受它”或“如果交易由pub[1]、pub[2]和pub[3]中的任意两方签署,则接受它”这样的规则——否则拒绝该交易。在这里,我们隐含地将公钥pub视为参与方的标识符,这在区块链协议中是常见的做法。
与链上多签不同,多签名协议生成单一签名;然后只需要验证一个签名,而不是多个签名。多签名脚本和智能合约代替处理几个签名并包含验证规则,而不是签署者端的协议。在两种情况下,验证都需要所有签署者的公钥。
多签名协议也不同于其他两种集体签名协议类型,你将在以下部分看到。在这两种协议中,结果是一个单一的签名,但不同之处在于如何以及从何创建:
聚合签名协议
-
像在多签名中一样,每个参与者都有自己的密钥对(公钥和私钥)。
-
不像在多签名中一样,参与者可以签署不同的消息,而不是相同的消息。一个参与者还可以签署多个消息。
-
像在多签名中一样,验证签名需要多个公钥(或其单个聚合版本,用于支持协议)。
门限签名协议
-
与多签名不同,参与者不使用自己的密钥。相反,他们有一个单一私钥的份额(也称为碎片),以便在协议执行期间没有单个参与者知道完整的密钥。
-
像在多签名中一样,参与者签署单个消息。
-
与多签名不同,验证只需要单个公钥。
现在我们已经定义了多签名,让我们看看它们在它们最显著的用例中是如何工作的:Schnorr 签名。
施诺尔签名协议
数学家克劳斯-彼得·施诺尔于 1989 年创建了同名签名方案,并对其进行了专利申请,这阻碍了其广泛采用,直到 2008 年 EdDSA 方案(参见第十二章)优化了它以适应现代椭圆曲线。施诺尔的方案比 ECDSA 标准更简单,更容易转换为多签名方案。比特币支持施诺尔签名,这些签名在 2022 年作为更好的多签名协议支持引入。
注意
我们将使用加法表示法(与 EdDSA 以及处理椭圆曲线时一样),而不是原始的乘法表示法(用于整数的乘法群中使用)。因此,与私钥 a 相关联的公钥 A 是椭圆曲线点 A = aG,其中 G 是预定义的基点,而组元素是通过加法组合的点。这与在乘法表示法中的情况相反,那里我们会有 A = g^a ,其中组元素是相乘在一起的数字。
单签名 Schnorr 签名
在了解如何进行多方签名之前,我们先看看单方 Schnorr 签名是如何工作的。假设 Alice 有一个私钥 a,对应的公钥是 A = aG。这里的 a 是一个数字,或者说是一个标量,在给定的数字范围内(具体来说,是椭圆曲线定义的有限域,通常是模某些大质数的正整数,至少大约为 256 位),而 G 是曲线的固定基点。
为了签署一条消息 M,Alice 按以下步骤进行:
1. 选择一个秘密随机数 r,并计算点 R = rG。值 r 是一个 nonce,即一次性的私钥,R 是其公钥。
2. 计算 h = H(R || A || M),这是你将“连接”到私钥 a 和一次性私钥 r 以签署 M 的值。我们不仅对消息进行哈希处理,而且通过非秘密值 A 和 R 分别将 h 绑定到签名者和 nonce 上。如果没有这些,不同的攻击将成为可能。
3. 计算 s = r + ha,并返回一对 (R, s) 作为签名。你可以把 s 看作是私钥和数据进行签名时的乘积,其中秘密 r 对结果进行掩码;如果没有这个,恢复私钥从签名中将变得很容易。
验证签名时,检查 sG 是否等于 R + H(R || A || M)A。这是因为从 s = r + ha,将 r + ha 代入 sG,你会得到:

其中 h = H(R || A || M),验证者必须根据消息M、公钥A和签名的R部分来计算该值。
Schnorr 多重签名
在多重签名中,我们不仅仅有一个签名者,而是多个签名者。为了简化,我们描述两位共同签名者的情况:见 Bob,他将与 Alice 共同签署消息。Bob 的私钥是b,他的公钥是B = bG。为了共同创建一个多重签名,Alice 和 Bob 可以按照以下步骤来签署消息M:
1. Alice 选择一个 nonce r[A],计算 R[A] = r[A]G,并将 R[A]发送给 Bob。
2. Bob 选择一个 nonce r[B],计算 R[B] = r[B]G,并将 R[B]发送给 Alice。
3. 他们计算 R = R[A] + R[B],并设置 h = H(R || A || B || M),这是 Alice 和 Bob 将用来生成其签名部分的值。h的特定值通过他们的公钥(A和B)与签名者绑定,并且仅通过 nonce R与当前的签名会话绑定,这个绑定只适用于由 nonce R定义的特定签名执行。
4. Alice 计算 s[A] = r[A] + ha,并将其发送给 Bob。
5. Bob 计算 s[B] = r[B] + hb,并将其发送给 Alice。
6. 他们一起计算 R = R[A] + R[B] 和 s = s[A] + s[B],并返回 (R, s) 作为签名。
验证签名时,检查 sG 是否等于 R + h(A + B)。将 s 代入 sG 后,结果如下:

请注意,验证者需要知道 A 和 B,而不仅仅是它们的和 A + B,因为他们需要这两个值来计算 H(R || A || B || M)。然而,如果 h 被定义为 H(R || A + B || M), 那么验证者就可以使用一个公钥 A + B,而无需知道签名是由两方发布的。我们称将多个公钥合并为一个的方式为 密钥聚合。这在有多个签名者的情况下尤其有用,可以减少哈希时数据的大小。
注意
我描述了基本的 Schnorr 多重签名方案,适用于两方的情况,但该协议可以扩展到任意数量的公钥 P1, P2, …, P[n] 的情况。在定义中,替换 A + B 为 P1 + P2 + … + P[n],将 A || B 替换为 P1 || P2 || … || P[n],并将“发送给 Bob/Alice”替换为“发送给所有人”。你可以将类似的协议应用到 EdDSA 和 Ed25519,这些是 Schnorr 方案的变种。
事情可能出错的方式
Schnorr 多重签名协议相对简单,但在以下攻击场景中可能会失败。
密钥取消攻击
在此攻击中,Bob 让签名验证者相信他与 Alice 一起签署了消息,而实际上 Alice 没有看到这条消息,也没有与 Bob 互动。攻击者可以在你期望 Alice 和 Bob 一起签署消息的场景中利用这一点——例如,在需要双方批准的交易中。在正常情况下,验证者会知道 Alice 的公钥 A 和 Bob 的公钥 B,并且 Bob 和 Alice 会知道彼此的公钥。
假设发生以下情况:Alice 将她的公钥 A 发送给所有人,包括 Bob,但 Bob 并没有分享他的公钥 B,而是将 C = B – A = (b – a)G 分享给验证者,并将 B 分享给 Alice。Bob 不知道与 C 对应的私钥,对于攻击来说并不重要。
Bob 必须使用他的私钥 b 来签署消息,就像单签名者的情况一样,但此时 h = H(R || A || C || M), 就像他在与 Alice 一起签名一样。他返回 (R, s) 作为签名,其中 R = rG(r 为他选择的值),s = r + hb。
在期望 Alice 和 Bob 签名的情况下,验证者检查 sG 是否等于 R + h(A + C), 这是正确的,因为 A + C = A + (B – A) = B。因此,Bob 可以伪造多重签名,而无需与 Alice 交互,也无需知道她的私钥。
实际上,你可以通过要求签名者证明他们知道私钥(例如,通过签署一条消息)来避免这种攻击。由于 Bob 不知道与 C 对应的私钥,他无法提供这种证明。如你在 MuSig 协议中看到的,你也可以在协议层面避免这种攻击。
该攻击可以扩展到任意数量的参与方,这种情境通常被称为 流氓密钥攻击。攻击者 Bob 在接收到所有其他方的公钥后,只需将他的公钥定义为 B – X,其中 X 是所有其他方公钥的总和。
重复随机数
就像在 ECDSA 中一样,重复的随机数对 Schnorr 多重签名协议致命。假设 Alice 的伪随机生成器出现故障,她在协议的两次运行中生成了相同的秘密随机数 r[A]:她第一次发送 s[A] = r[A] + ha 给 Bob,第二次发送 s[A]' = r[A] + h'a,其中第一次的 h 和第二次的 h' 也依赖于 Bob 的随机性。这样,你得到 r[A] = ha – s[A] 和 r[A] = h'a – s[A]',这意味着 ha – s[A] = h'a – s[A]',或者等价地,

通过此方法,你可以计算出 Alice 的私钥 a = (s[A] – s[A]') / (h – h' )。
消除由随机性失败引起的安全风险的一种方法是去除随机性。例如,通过对消息和私钥进行哈希计算来生成随机数,像在 Ed25519 中那样,当只有一个签名者时是有效的。然而,在多重签名的情况下,设置 r 为 H(a || M) 是行不通的:如果 Alice 和 Bob 两次签署相同的消息,Alice 会计算第一次的 s[A] = r[A] + ha 和第二次的 s[A]' = r[A] + h'a,在这两种情况下,r[A] = H(a || M),如果恶意的 Bob 发送了与 H(b || M) 不同的值,则 h' 会与 h 不同。在这种情况下,Bob——以及任何监听通信的人——都可以再次计算 a 为 (s[A] – s[A]') / (h – h' )。
并行执行的不安全性
当攻击者能够发起多个同时进行的签名协议时,Schnorr 多重签名协议是不安全的。这个攻击过程太复杂,无法在这里详细描述,但可以在以下研究文章中找到相关文献:eprint.iacr.org/2018/417 和 eprint.iacr.org/2020/945。
更安全的 Schnorr 多重签名
为了避免密钥冲突攻击和重复随机数问题,研究人员开发了更先进的多重签名协议,特别是 MuSig 协议:MuSig、MuSig2 和 MuSig-DN,其中 MuSig 代表 多重签名,DN 代表 确定性随机数。MuSig 协议还支持密钥聚合,允许验证者仅使用一个由签名者的密钥衍生出的公钥来检查签名,从而聚合的密钥不会泄露签名者的数量或公钥。
让我们看看 MuSig 的主要技巧如何运作。如果我们处于最简单的情况,只有两个签名者,Alice 和 Bob,并且使用与前面章节相同的符号,那么 Alice 不再计算s[A] = r[A] + ha作为她的签名部分,而是计算s[A] = r[A] + μ[A]ha,从而将* ha部分与 μ[A]值相乘。她通过哈希参与者的公钥列表,再加上 Alice 的密钥,H(A* || B || A),来计算* μ[A](其中 μ是希腊字母 mu)。同样,Bob 通过哈希公钥列表,再加上他的密钥,H(A* || B || B)来计算* μ[B],从而计算s[B] = r[B] + μ[B]hb*。
然后,Alice 计算聚合公钥为X = μ[A]A + μ[B]B,即公钥与各自* μ值的乘积之和。消息的哈希值为h* = H(R || X || M),而不是脆弱版本的多重签名方案中支持密钥聚合的H(R || (A + B) || M)。
这个技巧之所以有效,是因为恶意的 Bob 不再能够伪造另一个“取消”Alice 的公钥* A,就像他可以通过设置C* = B – A 在密钥取消攻击中做到的那样。在方程式* X* = μ[A]A + μ[B]B中,Bob 必须找到一个新的* B值,得出“正确”的 μ系数,以从方程中移除 A。但这现在是不可能的,因为该方程式对 A和 B*是非线性的(线性通常意味着不安全——参见第二章)。
当签名者超过两个时,可以以类似的方式应用这个技巧,通过哈希公钥列表和签名者的密钥来计算* μ系数,然后通过计算公钥乘以各自的 μ值,将公钥聚合为一个单一的 X*。
注意
有关 MuSig 协议的更多详情,以及 MuSig-DN 版本如何从消息中安全地推导出随机数,请参见 <wbr>bitcoinops<wbr>.org<wbr>/en<wbr>/topics<wbr>/musig<wbr>/。
聚合签名协议
聚合签名有多个签名者,每个签名者签署一条消息(每个签名者的消息可以不同);然后将这些签名合并为一个单一的签名。通过这个签名、签名者的公钥和他们签署的消息,验证过程检查所有签名者是否都签署了各自的消息。由于只需要存储一个签名,而不是与签名者数量相等的签名,因此验证时间与消息的数量成正比。当所有签名者签署相同的消息时,验证速度可以和单一签名者的签名验证一样快,无论签名者有多少个。
聚合签名在以太坊中特别用于其共识层。在这种用例中,验证节点支持提议以改变系统状态(作为区块),并利用聚合签名来最小化签名存储空间和验证时间。它们使用 Boneh-Lynn-Shacham(BLS)签名方案,你将在本节中了解到,从 BLS 签名的魔力开始:密码学配对。
配对
在椭圆曲线密码学中,配对是一种操作,它将两个来自两个椭圆曲线群(不一定相同)的点转换为有限域元素。两个椭圆曲线点P和Q之间的配对的标准表示法是e(P, Q)。在密码学中使用的配对具有称为双线性的属性,因此称为双线性配对,这意味着对于任何点P、Q和R,它们满足以下条件:

在这里,将点R添加到操作数相当于将结果乘以R与另一个操作数之间的配对。因此,如果你将一个点加上它自身n次,也就是说,将其乘以一个数字n,那么你得到的是

或者是e(P, Q)乘以它自身n次,这也等同于e(P, nQ)。
如果你有不同的点P[1]、P[2]、...、P[n],你可以将输入值的加法转换为输出值的乘法:

配对的内部工作原理超出了本书的范围。更多详情请参阅 Kristin Lauter 和 Michael Naehrig 的文章“密码学配对”(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2017<wbr>/1108)以及 Nadia El Mrabet 和 Marc Joye 的书籍《基于配对的密码学指南》(Chapman and Hall/CRC, 2016)。
BLS 签名
2006 年,Dan Boneh,Ben Lynn 和 Hovav Shacham 在文章“从 Weil 配对获得的短签名”中提出了 BLS 签名。文章指出,该方案设计用于“由人类输入签名或通过低带宽信道发送签名的系统”。BLS 签名后来被用于高度自动化的系统,这些系统通过后续论文描述的一种特性受益匪浅:签名和公钥的聚合。
注意
不要将 BLS 签名与 BLS(Barreto-Lynn-Scott)曲线混淆,这两者共同的作者是(Lynn)。BLS 曲线是设计成配对友好的椭圆曲线,允许安全高效的配对操作。事实上,BLS 签名通常使用 BLS 曲线上的点。例如,以太坊的 BLS 签名依赖于曲线 BLS12-381。
单签名签名与验证
在 BLS 签名中,爱丽丝的私钥是标量数值a,她的公钥是A = aG,其中G是预定义的基点。要对消息M进行签名,她首先计算H = H(M),其中哈希函数返回的是一个曲线点,而不是比特串或标量——H符号遵循一般惯例,将点表示为大写字母。签名是S = aH。这看起来比 Schnorr 或 ECDSA 签名要简单得多:只需对消息进行哈希,并将结果与私钥相乘。
要验证 BLS 签名,计算两个配对操作:
-
e(A, H)是公钥和哈希消息之间的配对(注意A = aG)
-
e(G, S)是基点和签名之间的配对(注意S = aH)
由于配对的双线性特性,这些值应该相等:

如果等式成立,则接受该签名;否则,拒绝该签名。
忽略配对操作的复杂性,这种基于配对的签名是最简单的签名方案。
来自多个签名者的聚合签名
让我们进一步利用 BLS 签名和双线性配对的神奇,考虑这样一个场景:n个签名者,私钥为k[1]、k[2]、…、kn,公钥为P[1]、P[2]、…、Pn,签署n条消息M[1]、M[2]、…、Mn,并生成签名S[1]、S[2]、…、Sn。注意,Hi = H(Mi)是第i条消息的哈希。
你可以通过将多个签名Si = kiHi 相加,聚合成一个签名S = S[1] + S[2] + . . . + Sn。观察到,在验证单个签名时,计算e(G, S)会因双线性特性而得到以下结果:

记住,配对满足e(nP, Q) = e(P, nQ)。因此,你可以通过将乘法因子ki“移到”配对的左操作数,来将每个e(G, Si)项替换为e(Pi, Hi):第一个操作数将是kiG = Pi,而不是G,第二个操作数将是Hi,而不是Si = kiHi。
将多个签名者在多个消息上的多个签名聚合成一个单一签名后,你可以通过签名者的公钥和签名消息来验证聚合签名。验证只需检查e(G, S)与所有配对乘积e(Pi, Hi)之间的相等性。需要计算 1 + n个配对,而不是如果没有聚合签名时需要计算的 2n个配对——在这种情况下,签名占用了聚合签名的n倍内存。
让我们考虑一个同时聚合签名和公钥的场景。
聚合公钥
假设所有签名者都对相同的消息 M 进行签名,并且你将所有公钥聚合成一个:P = P[1] + P[2] + . . . + Pn。注意,k[1]、k[2]、. . . 、kn 仍然代表各自的私钥,H = H(M) 是消息的哈希值。给定有效的签名 Si,你得到以下等式:

因此,你仅使用两个配对操作 e(P, H) 和 e(G, S) 来验证 n 方对相同消息的签名。这是非常高效的,因为它使得签名验证基本上不依赖于参与方的数量,你可以高效地添加点,而配对运算的计算成本较高。除了计算效率外,聚合密钥和签名还能节省内存。
事情如何出错
与许多椭圆曲线密码学方案一样,BLS 签名在确保安全性时应避免无效密钥和弱参数。与之前的协议类似,BLS 签名也可能受到密钥取消攻击的威胁。让我们来探讨一下具体细节。
无效密钥
BLS 签名在一份互联网草案中有明确规定,这是 IETF 的一份工作文档,地址为 <wbr>github<wbr>.com<wbr>/cfrg<wbr>/draft<wbr>-irtf<wbr>-cfrg<wbr>-bls<wbr>-signature。该文档指定了包括密钥生成(算法 KeyGen)、签名(CoreSign)、验证(CoreVerify)和密钥验证(KeyValidate)在内的核心操作。给定公钥,后者确保公钥的有效性,即它“表示一个有效的非单位点,且在正确的子群中。”
密钥验证可以防止使用弱的私钥/公钥对,因为这类密钥的签名更容易伪造。例如,考虑一个简单的情况,零秘密密钥 a = 0. 由此可得,任何消息 M 的签名为 0 × H(M) = 0. 因此,伪造任何消息的签名变得极其简单。在这种情况下,公钥则为 0 × G = O,即无穷远点。如果密钥验证拒绝等于 O 的公钥,它就能确保秘密密钥不是零。
一个不那么简单的情况是,当公钥作为椭圆曲线点时,其值使得伪造签名更容易——也就是说,可以在不知道私钥的情况下创建有效的签名。并非所有的椭圆曲线上的点都同样安全——特别是属于小子群而非主子群的点。如果公钥点属于这样的小子群,那么可能的有效签名会少得多,从而使伪造签名变得更加容易。同样,如果提供给验证函数的公钥不属于椭圆曲线,那么有效签名也容易被伪造。
因此,使用上述规范中的 KeyValidate 算法检查公钥是否有效是至关重要的,正如 清单 15-1 中所复制的那样。
Inputs:
- PK, a public key in the format output by SkToPk.
Outputs:
- result, either VALID or INVALID
Procedure:
1\. xP = pubkey_to_point(PK)
2\. If xP is INVALID, return INVALID
3\. If xP is the identity element, return INVALID
4\. If pubkey_subgroup_check(xP) is INVALID, return INVALID
5\. return VALID
清单 15-1:该 KeyValidate 算法确保 BLS 公钥是有效的。
如果你实现 BLS 签名,确保你的代码在验证公钥和签名时执行 BLS 规范中描述的所有检查。
密钥取消攻击
在其基本形式中,带有聚合公钥的 BLS 聚合签名会受到与 Schnorr 签名相同类型的密钥取消攻击:如果攻击者知道前 n - 1 个签名者的公钥 P[1], P[2], . . . , Pn – [1],他们可以声称自己的公钥是

其中 X 是他们知道私钥 x 的公钥,满足 X = xG。当攻击者提供一个由 x 创建的签名时,毫不怀疑的用户使用公钥 P[1] + P[2] + . . . + Pn 验证消息签名,这等于 X。攻击者可以单方面代表假定的签名者集合签署一条消息。
为了防止这种攻击,用户可以通过签名消息来证明他们知道其公钥的私钥。由于攻击者不知道 Pn 的私钥,他们会未能通过此测试。
另一种缓解方法是修改聚合签名方案,使返回的签名不仅仅是签名的和 S = S[1] + S[2] + . . . + Sn,而是通过从公钥派生的系数和,如下所示

其中 ti = H(Pi || P[1] || P[2] || . . . || Pn),对于 i = 1, 2, . . . , n。然后用于验证签名的聚合公钥是 P = t[1]P[1] + t[2]P[2] + . . . + tnPn。你可以通过检查 e(P, H(M)) 是否仍然等于 e(G, S) 来验证这个技巧。
门限签名协议
门限签名与多重签名和聚合签名不同——后者要求所有参与签名协议的成员都必须有自己的公钥和私钥——其区别在于,门限签名有一个单一的私钥k和一个单一的公钥P,并且有n个参与者,每个参与者拥有私钥k的一个独特的份额 ki,其中定义了一个参数t(门限),使得t < n,并且:
-
t + 1 个签名者可以共同签署一个有效的消息签名,使用公钥P进行验证,从而确保没有任何签名者了解私钥k。这是通过运行一个协议来实现的,协议使用每个签名者的ki 份额和需要签名的消息,在过程中永远不暴露私钥给任何一方。
-
少于t个签名者无法创建签名,因此也无法确定私钥k。
已签发的签名看起来像是一个普通的单签名签名,并且以这种方式进行验证。
门限签名是一种特定类型的多方计算(MPC),这是一个协议类,其中n个参与者计算某个函数f(x[1], x[2], . . . , xn)的输出,其中每个参与者知道自己的输入xi 并学习函数的输出,但不会知道其他参与者的xi 输入。对于门限签名而言,xi 是私钥的份额,输出是签名。我将在“秘密共享技术”部分的第 319 页详细说明什么是份额。
门限签名的好处是“隐藏”了共同签名者的数量和身份,因为验证者只看到来自单一私钥的签名。尽管一些多重签名和聚合签名方案也具有这个特性,但门限签名更适合加密资产托管的使用场景,因为只有一个私钥和一个公钥;你可以直接将门限签名应用于分配任何地址的控制权。
使用案例
门限签名被广泛应用于加密货币和数字资产管理,用于在多个系统或各方之间分配对某个地址的控制权。它们可以用于在服务提供商和用户设备之间共享账户控制权:每个方拥有一个密钥份额,必须共同运行协议来签署交易,从而支出资金。这种设置确保了攻击者无法单独授权交易,即使他们突破了用户设备并获取了密钥份额。同样,服务提供商也无法在没有用户同意的情况下发起交易。然而,可靠地实现这一模型面临着挑战,尤其是在密钥管理方面(密钥生成、密钥轮换、备份等)。
一个组织还可以使用门限签名将资金的托管分布在多个系统之间,例如不同的设备类型、数据中心、操作系统和软件组件。此方法特别适用于冷钱包和包含大量资产的账户。仅依赖门限签名是不够的,因为全面的安全措施和控制至关重要。例如,必须正确分离对 IT 组件的访问,确保不同的个人或 IT 服务提供商只能访问不同的系统(从而获得不同的份额)。此外,交易的发起和批准必须受到严格的控制,并具有审计跟踪。
安全模型
与所有密码学协议一样,我们需要定义门限签名方案安全的含义。这样的安全模型包括安全目标(攻击者应该难以做的事)和攻击者模型(对攻击者能力的假设)。让我们深入探讨这两个特性。
安全目标
门限签名的主要安全目标与单一签名的安全目标相同:攻击者必须无法伪造有效签名,这意味着他们必须无法确定私钥。这还意味着协议必须确保输入隐私:各方持有的密钥份额不应泄漏给任何其他方。最后,协议必须确保正确性:协议计算出的签名必须有效,并且所有参与者在协议执行过程中都能够访问该签名。
攻击者模型
门限签名没有统一的安全模型;相反,攻击者是根据多个维度来定义的。所有攻击场景中的共同威胁是假设攻击者可以主动攻击网络通信——捕获、修改和注入消息。通过使用安全通道来建立参与方之间的身份验证和加密通信,可以防止此类攻击。协议设计者还假设通信是可靠的,即所有传输的消息应按发送的顺序接收。
门限签名的攻击者模型考虑了参与者的腐化——即攻击者妥协其系统,获取其秘密,并基本上让他们做攻击者想做的事。该模型假设攻击者无法腐化超过t个参与者;否则,攻击者就能伪造签名,这符合门限签名功能的定义。
让我们来审视当攻击者能够腐化参与者时需要考虑的参数。
首先,我们通过攻击者可以破坏的方的数量来描述攻击者。存在两类阈值签名,每类都由可以被攻破的恶意参与者的最大数量定义,而不破坏协议的安全性:
诚实的多数在此模型下,攻击者仅限于破坏少于一半的密钥共享方。因此,阈值 t 必须满足 t < n/2。根据定义,具有参数(t, n)的阈值签名协议即使在 t 个参与方被破坏的情况下也必须是安全的。在诚实多数假设下设计的协议通常更高效,但它们无法容纳任意值的 t。例如,具有参数(4,5)的协议在此模型下是不可行的,因为它要求容忍最多四个被破坏的方。
不诚实的多数该模型允许协议支持从 1 到 n - 1 的任何 t 值。它使得创建协议成为可能,在这种协议中,n 个参与方中,除了一个,其他的都可能被攻破,但恶意方仍然无法伪造签名或恢复私钥。该模型在阈值值方面提供了更多的灵活性,但通常需要更复杂和更强大的安全机制。
我们还通过攻击者在破坏一个方并获取其秘密值(包括密钥份额)后的行为来描述攻击者。有两种攻击者模型定义了这一点:
被动或诚实但好奇他们从被破坏的方那里获取信息,但不能强迫其偏离协议。这种模型描述的是“只读”破坏,其中攻击者获得系统内存的快照,包括存储内存和易失性内存(RAM、处理器寄存器)。
主动或恶意各方可以任意偏离规定的协议。这种模型描述的是系统完全被攻击者攻破或被恶意内部人员(例如操作员、管理员或云服务提供商)控制的情况。
因此,一个对主动攻击者安全的协议总是对被动攻击者安全,但反之则不然。
攻击者有两种选择破坏哪些方的方式,这由以下破坏类型模型定义:
静态破坏攻击者必须在协议开始之前选择哪些参与方进行破坏。
自适应破坏攻击者可以等到协议开始后再选择破坏哪些参与方,并在协议过程中了解他们的操作历史。
一个对静态破坏安全的协议总是对自适应破坏安全,但反之则不然。然而,有一些技术可以将一个协议从对静态破坏安全转化为对自适应破坏安全。
秘密共享技术
阈值签名的一个关键组成部分是 秘密共享 协议,或者是将秘密分割成多个部分(共享)并分发给不同方的技术,这样各方可以共同重建初始的秘密。秘密共享协议可以特别用于创建私钥的备份,其中不同方在不同位置存储不同的共享。
加法共享
分享你认为是数字的秘密的最简单方法是通过 加法共享:给定一个数字 s,你将其分享为 n 个值 s[1]、s[2]、…、sn,使得 s[1] + s[2] + … + sn = s。例如,如果你使用模 100 的数字,可以将数字 s = 47 随机加法共享成四个份额,如下所示:随机选择三个介于 0 和 99 之间的数字,假设 s[1] = 12、s[2] = 94、s[3] = 80,然后设定 s[4] = s – s[1] – s[2] – s[3] = 61。 (注意,减法是按模 100 计算的,因此 –1 = 99,–2 = 98,依此类推。)
这种方法非常简单,但需要使用所有的共享来恢复原始秘密。
阈值共享
阈值共享更接近阈值签名的功能:给定参数 n 和 t < n 以及秘密 s,它生成共享,使得你可以通过任意 t 个共享来恢复秘密,n 个共享中任意选取。
最著名的阈值共享方法是 Shamir 的秘密共享,它利用多项式的以下特性:给定一个次数为 t 的多项式,形式为

你只需要对 f(x) 在 t + 1 个不同的 x 值上进行 t + 1 次评估,就可以确定所有的 ai 系数,这些系数是固定值。
要从这个性质创建阈值秘密共享,设定 a[0] = s,即秘密。然后随机选择 a[1] 到 at 的值,并计算 f(x) 在 n 个不同的 x 值上,这些将是 n 个秘密的共享。
从 f(x) 值重新计算系数是一种叫做 拉格朗日插值 的技术,这项技术源自 18 世纪的意大利数学家,他开发了一种通用方法来确定给定曲线上多个点的曲线方程。实际上,你可以从几何角度来看这个问题。如果方程的次数是 1(形式为 a[0] + a[1]x),那么它是直线方程,知道直线上的两个点足以唯一确定这条直线。同样,如果方程的次数是 2(形式为 a[0] + a[1]x + a[2]x²),那么曲线是抛物线,可以通过三个点来确定其方程。
我们有时会发现 Shamir 秘密共享的实现中定义了一个函数拉格朗日(),该函数计算插值并返回a[0]系数,即共享秘密。如果你有f(x)值要组合以恢复秘密,你可以定义操作拉格朗日(s[1], s[2], . . . , st),返回共享秘密s。这适用于任何t个不同共享的组合,而不一定是前t个共享。拉格朗日()操作的细节对于本书来说有些过于技术性,但你可以将其实现为一系列基本的加法、乘法和求逆运算。
平凡情况
最简单的门限签名类型之一使用了 BLS 签名,如前文在聚合签名的上下文中所提到的。回顾一下,给定一个私钥k,BLS 签名通过将k与曲线点H = H(M)相乘来计算签名。你可以使用加法共享创建一个门限方案,其参数为(n – 1, n)。例如,如果n = 3,将密钥分为三个共享,使得k[1] + k[2] + k[3] = k。然后,每个参与方通过将各自的共享ki 与H相乘来计算他们的签名部分。将三个共享相加后,得到如下结果:

通过加法组合三个共享,可以得到一个有效的签名,即使没有任何一方知道k。各方还可以通过相加各自的共享来恢复k,但如果共享是随机生成的,两个参与方共享相加不会泄露任何关于k的信息。
要创建一个具有任意t和n的门限签名方案,使用 Shamir 秘密共享技术,并利用拉格朗日()运算的线性特性:你生成如“门限共享”部分中描述的密钥共享ki,并按照以下方式计算签名:

同样,你可以用密钥k得到一个有效的签名。
简单情况
你现在将在门限设置中计算 Schnorr 签名,这比使用 BLS 签名更为技术性。回顾一下,Schnorr 方案通过s = r + ha来计算签名,其中h = H(R || A || M),r是每个签名的随机 nonce,a是签名者的私钥。签名还包括R = rG,即公共 nonce 值。由于它对秘密值的线性关系,这相对容易转化为一个门限签名方案:注意到秘密r与秘密a相加,乘以哈希h(哈希h不是秘密)。
想象最简单的情况:两个签名者,私钥k = a + b的加法共享,其中a和b是两个签名者的各自密钥份额。为了签名,两个参与方可以生成秘密随机数r[A] 和 r[B],其相应的公钥值为R[A] = r[A]G* 和 R[B] = r[B]G。参与方然后可以交换这些值并计算公钥随机数R = R[A] + R[B],该值将成为签名的一部分。R即是你从私有值r* = r[A] + r[B]中派生出来的公共值,因为你有:

接下来,参与方计算他们的签名份额:s[A] = r[A] + ha 和 s[B] = r[B] + hb,然后将其加起来得到:

因此,你得到r + hk,一个来自密钥k的签名,尽管参与方只在计算中使用了加法共享的a和b。要获得任意参数t和n的门限构造,可以使用 Shamir 的秘密共享,而不是加法共享。
之前的构造不足以满足门限签名方案的所有安全要求。特别是,除非参与方在协议的预备阶段承诺他们的随机数,否则它容易受到密钥撤销攻击——例如,通过发送其随机数的哈希值。它还存在一些微妙的漏洞,这些漏洞已通过协议“灵活轮次优化 Schnorr 门限(FROST)签名”得以解决,该协议由密码学家 Chelsea Komlo 和 Ian Goldberg 于 2020 年设计,并在<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2020<wbr>/852中有文档说明。
注意
第十二章中的 EdDSA 签名协议类似于 Schnorr 签名,但它是将消息的哈希值作为随机的、任意的值来计算 nonce,而不是一个随机数值。这使得根据原始 EdDSA 规范构建符合要求的门限签名协议变得复杂。
复杂情况
在门限设置中,最难运行的签名方案也是最常见的。ECDSA 签名算法(参见第十二章)比 Schnorr 签名和 EdDSA 更复杂,因为它涉及除法。给定消息哈希h = H(M),签名者选择一个随机数k,根据点kG的坐标计算数字r,并将签名计算为s = (h + ra) / k,其中a是签名者的私钥。
高效且安全的 ECDSA 门限签名仍然是密码学家们面临的一个具有挑战性的研究课题。第一个实际协议出现在 2010 年代末期,受到加密货币使用案例的推动——当时,大多数领先的加密货币,包括比特币和以太坊,只支持 ECDSA 作为交易签名方案。
密码学家们设计了几种方法来构建 ECDSA 阈值签名协议。例如,Yehuda Lindell 在 2017 年提出的“快速安全的双方 ECDSA 签名” (<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2017<wbr>/552) 需要一个承诺方案、同态加密方案和零知识证明系统。这些协议的复杂性使得它们的理解、实现和安全分析变得困难,从而导致了已部署系统中的一些安全漏洞。
事物如何出错
阈值签名协议中的具体安全问题通常非常复杂,涉及密码学构造的细节,而这些内容我在本书中没有涵盖。因此,我将讨论影响阈值签名实际部署的各种问题,而不是具体问题,这些问题来自广泛使用的开源软件到商业解决方案。
论文与代码
当工程师们需要根据研究论文实现一个阈值签名协议时,他们会遇到挑战。这些论文主要面向密码学研究人员,通常编辑质量不一,内容复杂、数学重,并且相当新颖。正因为如此,这些协议可能在实验中并没有达到预期的安全性。这些因素导致了一系列现实世界中的安全问题,可以分为四个主要领域:
不安全的协议 如果协议在纸面上不安全,那么在实现过程中也不会更安全。常见的问题包括忽略了边缘情况或在接收来自其他方的输入时缺乏充分的验证。例如,一些协议未能验证加密后的数字是否在预期范围内,从而导致实际攻击的发生。
描述不完整 研究论文不是技术规格,而是为学术读者编写的,因此通常缺乏像网络和编码这样的实际实现细节。一个显著的例子是 TSSHOCK 攻击,它利用了阈值签名中的哈希函数输入元素中的模糊编码,正如在“领域分离失败导致的冲突”一文中描述的那样,见第 305 页。
实现不完整 阈值签名协议的复杂性,包括其众多子组件和详细要求,可能导致一些安全验证被忽视,尤其是当这些验证仅在附录中提及时。一个例子是,当一个协议要求对一个数字 N = pq(用于 Paillier 加密)进行零知识证明,验证其为两个足够大的素数的乘积时,但在实现中忽略了这个验证,从而允许不安全的 N 值。
不安全的组件选择 协议的描述通常不会说“使用哈希函数 BLAKE3”或“使用 256 位椭圆曲线 nistp256”;相反,它们会说“使用提供所需安全级别的哈希函数和椭圆曲线”。因此,选择合适的原语并安全地使用它们的编程接口是由实现者来决定的。例如,使用 1,024 位的 RSA 模数并不足以确保 128 位的安全性。
此外,当实现者故意修改协议时,也会产生风险,可能是为了提高效率或适应特定的使用案例。这通常不会有好的结果。有关阈值签名攻击的示例,请参阅 Dmytro Tymokhanov 和 Omer Shlomovits 的论文“Alpha-Rays: Key Extraction Attacks on Threshold ECDSA Implementations”(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2021<wbr>/1621)以及 Nikolaos Makriyannis、Oren Yomtov 和 Arik Galansky 的论文“Practical Key-Extraction Attacks in Leading MPC Wallets”(<wbr>eprint<wbr>.iacr<wbr>.org<wbr>/2023<wbr>/1234*)。
密钥管理方面
一位朋友曾经说过:“每 10 行加密代码,就有 1,500 行密钥管理代码”,这强调了密钥管理过程的关键性和复杂性,包括密钥创建、存储、备份和恢复。尽管这些方面在专注于理论方面的学术论文中可能被忽视,但它们在实际应用中至关重要。在生产环境中实现阈值签名的工程师和安全专家必须优先考虑这些密钥管理问题——即使是最强大的阈值签名协议也无法替代全面的密钥管理实践。
让我们回顾一下一个组织在使用阈值签名来保护大量加密货币资产时的主要密钥管理考虑因素:
密钥生成 无论是使用分布式密钥生成还是集中式生成,必须确保在密钥生成期间或之后,秘密值不会暴露给未经授权的系统或方。这种保证通常通过严格的流程提供,如密钥仪式、确保供应链的完整性和审计跟踪,以证明密钥已正确生成。不要允许其他方(如云服务提供商)代表你生成密钥,或随时有能力读取它们。
密钥存储 将密钥作为多个份额存储而不是单一值,并不会减少对安全存储的必要性。在不同平台上保护多个秘密可能比在统一平台上保护单一秘密更加具有挑战性。密钥份额必须存储在某种类型的安全内存中,防止未经授权的访问、篡改和物理攻击。
密钥备份和恢复 实施一种阈值方案,例如要求六个共享中的三个签名以执行交易,可以防止密钥共享丢失或系统停机。然而,这并不意味着不再需要密钥备份,你仍然需要将备份作为阈值共享进行保存。至关重要的是,将这些备份共享的访问权限分配给不同的方。此外,定期验证备份的可靠性,以确保它们未被破坏,并且在需要时能够有效地用来重建密钥,例如在灾难恢复演练中。
零知识证明
零知识证明(ZKP)是密码学家最强大的工具之一。这些协议涉及两个参与方,一个是证明者,另一个是验证者,其中证明者向验证者证明某个事情是真实的,但并不会透露任何有关原因的信息。例如,证明者可以证明他们知道某个困难计算问题的解决方案,但并不透露该解决方案。你可能会为任何NP-完全问题创建一个 ZKP,以证明你知道解决方案,但不透露它。
更一般地说,ZKP 用于证明某个陈述,例如“这个密文中加密的数字在 100 和 200 之间”或“对于给定的明文P和密文C,我知道一个秘密密钥K,使得C = AES(K, P)。”
对于零知识证明的非技术性介绍,我推荐密码学家 Amit Sahai 的的视频“计算机科学家用 5 个难度级别解释一个概念”(* <wbr>youtu<wbr>.be<wbr>/fOGdb1CTu5c)。如需数学细节,请参阅<wbr>github<wbr>.com<wbr>/matter<wbr>-labs<wbr>/awesome<wbr>-zero<wbr>-knowledge<wbr>-proofs*上的链接。
注意
零知识证明这一术语是对研究文献中更准确术语的简化。例如,许多“零知识证明”协议实际上是零知识论证。研究人员将“证明”一词保留给无条件安全性,并将“论证”一词用于计算安全性。此外,“见证人”一词指的是使证明者能够证明其陈述的秘密,作为秘密或密钥的广义化,因为其涵盖范围更广。
安全模型
对于 ZKP 协议来说,什么才算安全?攻击者是证明者还是验证者?他们如何攻击该协议?让我们通过检查安全目标和攻击者模型来回答这些问题。
安全目标
一个安全的零知识证明(ZKP)必须满足以下概念:
完整性 如果证明者遵循协议并使用正确的密钥,诚实的验证者会确信陈述的真实性。换句话说,协议总是有效的。
健全性 如果证明者不知道秘密,他们无法说服诚实的验证者一个虚假的陈述(除非是极小的概率)。换句话说,证明者不能作弊。
零知识 验证者除了知道陈述为真之外,什么也不会学到。具体来说,他们不能学到任何关于证明者秘密的信息。
“验证者将被说服相信陈述的真实性”这一概念由协议满足这三项属性的相互一致性保证——即如果陈述为假(例如,如果他们不知道自己声称知道的解决方案),证明者将无法完成协议。
攻击者模型
双方都有可能是攻击者,分别尝试妥协健全性和零知识属性:
恶意证明者 想要证明一个虚假的陈述——例如,错误地说服验证者他们知道某个难题的解决方案。这样的攻击者可能会偏离协议,试图欺骗验证者。实际上,这对像机密程序执行这样的应用中的零知识证明(ZKP)构成了最大的威胁。
恶意验证者 想要提取关于秘密的信息(即 证人),从而破坏零知识特性。攻击者模型区分了 被动 验证者攻击者(诚实但好奇)和 主动 验证者攻击者(可以任意偏离协议)。
请注意,恶意验证者可能通过声称他们不相信陈述的真实性来挑战完整性。实际上,这不是问题,因为证明者可以为其他(诚实的)验证者重复证明协议,从而揭露撒谎的验证者。
Schnorr 协议
Claus-Peter Schnorr,发明了同名签名方案的他,也描述了一种类似的构造,它是零知识的 离散对数知识证明。这构成了 Schnorr 和 EdDSA 签名的基础,以及许多更复杂的零知识证明的基础。
Schnorr 协议通过三个步骤证明 a 的知识,即 aG = A——也就是 A 相对于生成元 G 的离散对数:
1. 承诺:证明者选择一个随机数 r 并发送 R = rG。
2. 挑战:验证者发送一个随机数 c。
3. 响应:证明者发送 s = r + ca,且当且仅当 sG = R + cA 时,验证者接受。
注意
这种具有承诺、挑战和响应的三步协议称为 sigma 协议,以大写希腊字母 Sigma(Σ)的形状命名。*
Schnorr 协议 的 完整性 最容易验证:如果 a 满足 aG = A,你将得到

这是证明者的验证条件。
为了证明协议的健全性——即证明者必须知道 a——假设证明者在协议的两次运行中使用相同的 r。验证者将得到两个回应,s[1] = r + c[1]a 和 s[2] = r + c[2]a,分别对应于两个不同的挑战者 c[1] 和 c[2]。现在他们可以计算

然后将结果除以 (c[1] - c[2]) 来得到 a。由于检查 sG 是否等于 R + cA 能确保 s = r + ca,因此可以推导出证明者在正确执行此协议时必须知道 a。这种逻辑推理被称为知识提取器,它是证明 ZKP(零知识证明)健全性的主要技术。
该协议也可以通过一种叫做模拟器的技术来证明其为零知识的。模拟器是一种算法,它生成的消息(或通信记录)与真实的零知识证明执行结果无法区分。然而,与真实的证明者不同,模拟器不一定知道正在证明的秘密(或见证)。尽管如此,它生成的消息在证明系统的上下文中依然看起来有效且具有说服力。
在 Schnorr 协议中,模拟器从后向前工作,首先选择一个随机回应 s,以证明一个真实的 s 将会和纯随机的 s “一样随机”。然后它挑选一个随机的挑战 c,并计算原始的承诺 R = sG - cA。随后,协议的零知识证明展示了这三个值与协议的真实执行结果无法区分,但不需要知道秘密 a。(注意,在 Schnorr 的情况下,你必须假设验证者遵循协议并选择一个随机的 c。)
非交互式证明
Schnorr 协议是交互式的:证明者发送第一个消息,验证者回应一个挑战,证明者再发送回应——双方在三个回合的消息交换中互动。那么,如果验证者无法发送消息,只想接收一个能够说服他们的单一消息,该如何创建这种非交互式证明呢?
让我们再看一遍 Schnorr 协议,其中验证者发送一个随机挑战 c,这个值对证明者来说必须是不可预测的。如果证明者在发送 R 之前知道 c,他们就可以作弊,如下所示:给定 c,选择任意一个 s 值,然后计算 R = sG - cA;接着将这个 R 发送给验证者,并将 s 作为对 c 的回应发送。验证成功,但证明者并没有使用秘密值 a。
如何在不与验证者交互的情况下使c对证明者不可预测?技巧是通过哈希函数从R派生出c,这阻止了证明者找到满足 sG = R + cA 的一对(R, c),因为哈希函数的伪随机行为。
为了使用 Schnorr 协议生成一个非交互式零知识(NIZK)知识证明,证明者按以下步骤进行:
-
承诺:证明者选择一个随机数 r 并计算 R = rG。
-
挑战:证明者计算 c = H(G || R || A);你必须包括值 G 和 A,以将 c 的生成绑定到生成器参数 G 和证明者的公钥 A。
-
响应:证明者计算 s = r + ca 并生成证明,作为 R 和 s 的编码。
为了验证从证明者那里收到的证明(R, s),并使用公钥A,验证者重新计算挑战c = H(G || R || A),并检查是否满足 sG = R + cA。
哈希函数协议数据中的技巧,替代了验证者生成的挑战,它由Fiat–Shamir 变换正式化,这是一种将交互式协议转化为非交互式协议的通用技术。为了使该变换适用,验证者的随机挑战必须与证明者的消息独立,并且是公开的(非保密)值。
zkSNARKs
让我们讨论一种因其强大和高效性而在区块链应用中得到广泛应用的零知识证明。例如,zkSNARK 是 Zcash 机密交易平台的基石:在 Zcash 中,zkSNARK 证明某个金额已从一个账户扣除并已存入另一个账户,而不透露账户金额,也不导致计算或存储量过大。
zkSNARK是一种非交互式知识证明,它提供了零知识(zk)特性,其中SNARK代表以下含义:
简洁 证明相较于声明和秘密的大小非常小。它的大小可能与声明大小的对数相似,甚至是常量大小——无论声明的大小如何,证明的大小始终相同。
非交互式 A SNARK 是一个非交互式知识论证,通常使用 Fiat–Shamir 变换将交互式协议转化为非交互式协议。它不需要验证者向证明者发送消息。
论证 知识的论证是一种计算上安全的证明,但其安全性是有条件的。换句话说,它不会抵抗拥有无限计算能力的攻击者,这通常是可以接受的限制。
知识 A SNARK 提供了完整性和健壮性,作为一种知识论证。
此外,证明和验证 zkSNARK 必须在计算上高效。
当你想证明一个问题的解的知识,而这个问题的描述甚至无法适应证明大小时,生成这样一个简洁的证明听起来似乎违反直觉。例如,像“我知道方程 f(x) = 0 的解”这样的陈述,如何处理,当方程 f(x) 可能是任意大小时?从理论角度看,简短的证明是有意义的,因为证明必须传达的信息仅仅是解的知识,以及通常是某个陈述的正确性,而不是实际的解和秘密;证明必须是零知识的,并且仅向验证者揭示这些信息。
2016 年,密码学家 Jens Groth 发布了文章《On the Size of Pairing-Based Non-interactive Arguments》 (eprint.iacr.org/2016/260),该文描述了一种极为高效的 zkSNARK。该证明只包含三个群元素,并且可以通过计算三个配对操作来验证(与 BLS 签名使用的配对类型相同)。Zcash 协议采用了这一突破性结果,为其他几种 zkSNARK 奠定了基础。Groth 的 zkSNARK 通常被称为Groth16。
从陈述到证明
zkSNARKs 是一些最复杂的密码学构造,其中最复杂和最昂贵的步骤之一是算术化,这是一种将待证明的陈述转换为固定数量的多项式方程的操作,这些方程通常的形式是:

对于一个n次多项式,其中系数ai 是某些有限环或有限域结构的元素。然后,证明算法处理这些多项式来创建 zkSNARK 证明。
算术化遵循一个通用的工作流程:
1. 使用正式符号描述待证明的陈述,如计算机程序、方程或逻辑公式。
2. 将步骤 1 中的正式表达式转化为一个电路,该电路通过对输入应用一系列门来定义输出值,类似于布尔或电子电路中的逻辑门,除了这些门可能是加法和乘法等代数运算。
3. 根据 zkSNARK 证明系统的约束系统,将电路转化为一个结构化的约束列表。这些约束是输入必须满足的条件列表,以证明所要证明的陈述。
要证明的陈述可能是如此简单,如“我知道整数x和y,它们满足方程 x³ + y² + xy + 55 = 0 mod 57。”通过得到一个正式的方程来表达问题,完成步骤 1。要完成步骤 2,你可以将该方程分解为一系列涉及两个操作数的简单操作(Groth16 要求这样做)。具体如下,你需要写出中间值 v[0]、v[1]、...、v[6]:
设置 v[0] = x × x。
设置 v[1] = x × v[0];因此 v[1] = x³。
设置 v[2] = y × y。
设置 v[3] = x × y。
设置 v[4] = v[1] + v[2]。
设置 v[5] = v[4] + v[3]。
设置 v[6] = v[5] + 55;因此 v[6] = x³ + y² + xy + 55。
将一个长方程转化为一系列小方程的过程称为展平。
然后,证明者将这些操作——电路——转换为一组数学结构,用于构建由证明者处理的多项式。这些长多项式最终通过使用随机性,特别是概率可验证证明(PCP)的概念,进行“压缩”以形成证明,这是复杂性理论领域的一个重大发现。这使得验证者能够通过少量的实际约束检查,确信许多约束条件已被满足,同时保持零知识特性。
要了解算术化的复杂性,研究 zkSNARKs 使用的两种主要方法:等级-1 约束系统(R1CS)和代数中间表示(AIR)。
最后,注意我们区分非通用和通用 zkSNARK 证明系统:在前者中,证明者仅适用于特定的预定义声明。特别地,证明系统的设置阶段会创建仅适用于给定声明的参数。然而,像 Marlin 和 Plonk 这样的通用证明系统则接受声明并为其生成证明。它们更灵活,但构建起来更为复杂,并且计算开销更高。
事情如何出错
zkSNARKs 可能会遇到与阈值签名上下文中相同的问题类别,从不安全的协议到实现缺陷。安全问题可能出现在工作流程的不同阶段,从声明定义到算术化步骤和证明计算。受影响的安全性概念可能是完备性、健壮性或零知识。但大多数情况下,最大风险涉及健壮性,或者攻击者可能欺骗并愚弄验证者——因为对应用的潜在影响以及健壮性缺陷的微妙性,而“知识”泄露的可能性较小,特别是当证明必须保持验证者接受的有效证明时。
在接下来的示例中,我们将重点介绍 Schnorr 的协议。这些问题相对简单,但更复杂的证明系统可能会有更微妙的问题。
回想一下,Schnorr 的非交互式协议通过发送验证者 s = r + ca 和 R = rG 来证明知识 a,其中 A = aG,并且 c = H(G || R || A)。然后验证者通过重新计算 c 来检查等式 sG = R + cA。在交互式版本中,验证者随机选择 c。
不充分的 Fiat–Shamir 哈希
想象一下,如果非交互式 Schnorr 证明中,c = H(G || A),使得挑战值c与随机数R无关,攻击者可以选择任意的s值,并计算出R = sG – cA。这样得到的证明,由s和R组成,是有效的,但攻击者并不需要知道a,从而破坏了协议的安全性。
同样,如果c = H(G || R),并且在定义挑战值c时省略了公钥A,则可能会发生攻击:攻击者可以选择任意的R和s值,并计算出点B = (1/c) × (sG – R),满足sG = R + cB。这使攻击者得到了B的离散对数证明——即b,使得B = bG——然而他们并不知道b。
这些攻击展示了在使用 Fiat–Shamir 变换将协议非交互化时,将所有必要的值包含在哈希函数输入中的重要性。
重放攻击
重放攻击是一种简单但可能造成严重后果的攻击。如果攻击者获知某个非交互式知识证明的值,他们可以将其发送给另一方并声称是自己创建的,从而窃取证明的信用。
你可以通过将证明绑定到证明者的身份,包含他们的公钥在哈希数据中来避免这种攻击。为了防止同一方随着时间的推移进行重放攻击,你可以通过将证明绑定到会话标识符或时间戳来避免这种情况,方法是将这些值包含在 Fiat–Shamir 哈希处理的数据中。
随机性重用
以交互式 Schnorr 协议为例,验证者选择一个随机的c。如果证明者拥有一个有缺陷的伪随机生成器,并且两次重用了相同的挑战值r,那么观察到交换值的攻击者可以通过使用两个不同挑战值c[1]和c[2]的两个证明s[1] = r + c[1]a和s[2] = r + c[2]a来恢复秘密a,并计算出a = (s[1] – s[2]) / (c[1] – c[2])。
真正严肃的加密学
在本章中,我们回顾了加密学领域中一些最引人入胜的话题,从理论和实践的角度进行探讨。尽管如此,我们仅仅触及了表面,特别是在零知识证明系统领域,这个领域是一个活跃的研究和工程领域,具有广泛的应用,超出了区块链技术的范畴。然而,酷炫的加密学并不是区块链的万能良方。从多方计算协议,如私密集合交集(PSI)到同态加密技术,应用于人工智能模型的私密评估,新的应用场景和用例要求更好、更快的加密功能。
我们正见证着密码学的黄金时代,理论原理与实际应用前所未有地融合。这种协同效应为一些最具挑战性的安全和隐私问题提供了几乎魔法般的解决方案。尽管如此,仍然有许多重大挑战需要解决,尤其是在法律和监管领域。技术人员和政策制定者必须密切合作,共同应对这些挑战,并且双方都要努力理解对方的观点。希望这本书,特别是最后一章,能够帮助解开密码学的神秘面纱,使其对所有读者更易理解,更加亲民。
第十七章:索引
-
数字
-
0-RTT 数据, 267
-
3DES(三重 DES), 67, 82–83. 另见 DES
-
A
-
A5/1, 21, 98–101
-
Aaronson, Scott, 185, 193, 281, 293
-
主动攻击者, 318
-
自适应破坏, 318
-
高级加密标准 (AES), 61, 67
-
AddRoundKey, 68–70
-
块大小, 62
-
与 DES 对比, 67, 90
-
与 GCM, 164–167, 171, 173
-
实现, 71–74
-
内部结构, 67–70
-
KeyExpansion, 68–69
-
MixColumns, 68–69
-
与 Poly1305 一起使用, 148
-
以及可证明的安全性, 50
-
安全性, 73
-
ShiftRows, 68–69
-
SubBytes, 68–69
-
与 TLS 1.3, 265–266
-
高级向量扩展 (AVX), 63
-
AEAD(带关联数据的认证加密), 18, 160, 169–170
-
AES. 参见 高级加密标准
-
AES-CBC, 78
-
AESENC 指令, 72
-
AESENCLAST 指令, 73
-
AES-GCM
-
效率, 166
-
内部结构, 164–165
-
安全性, 166
-
小标签, 173
-
与弱哈希密钥, 171–173
-
AES 原生指令 (AES-NI), 72
-
AEZ, 174
-
聚合签名协议, 311–314
-
AKA(认证密钥协议), 220–221
-
代数攻击, 96
-
Alvisi, Lorenzo, 136
-
振幅, 274–279
-
苹果, 243, 246
-
应用特定集成电路 (ASIC), 89
-
算术化, 328
-
关联数据, 160
-
非对称加密, 3, 18. 另见 RSA
-
攻击成本, 47–49
-
攻击模型, 12
-
黑盒, 13–14
-
灰盒, 14
-
针对密钥协商协议, 221
-
认证密码, 160
-
含关联数据, 160–161
-
功能性标准, 163
-
随机数, 161–162
-
在线, 163
-
性能, 162–163
-
基于置换的, 169–171
-
安全性, 162
-
流式处理性, 163
-
认证解密, 160
-
认证 Diffie–Hellman, 224–226
-
认证加密 (AE), 18, 157
-
AES-GCM, 164–167, 171–173
-
认证密码, 160–163
-
OCB, 167–169
-
基于置换的 AEAD, 169–171
-
SIV, 169
-
使用 MACs, 158–160
-
认证加密与关联数据 (AEAD), 18, 160, 169–170
-
认证密钥协商 (AKA), 220–221
-
认证标签, 18. 另见 认证加密; MACs
-
AVX (高级向量扩展), 63
-
B
-
回溯阻力, 30
-
向后保密性, 30
-
BcryptGenRandom() 函数, 37–38
-
贝拉雷,米希尔, 155
-
贝拉索,吉奥万·巴蒂斯塔, 5
-
Bellcore 攻击, 211
-
伯恩斯坦,丹尼尔·J., 57, 106, 110, 148, 151, 244, 248, 283
-
大数库, 206
-
双线性, 312
-
二进制指数运算, 207
-
生日攻击, 120
-
生日悖论, 120
-
比特币, 116, 297, 299–302, 306, 307
-
位安全性, 46–48
-
BLAKE, 131
-
BLAKE2, 115, 133–135, 143
-
BLAKE2b, 134
-
BLAKE2s, 134
-
压缩函数, 134–135
-
设计原理, 134
-
盲攻击, 203
-
块密码, 61. 另见 高级加密标准
-
块大小, 62–63
-
CBC 模式, 76–78
-
代码簿攻击, 63
-
CTR 模式, 80–82
-
解密算法, 62
-
ECB 模式, 74–76
-
加密算法, 62
-
费斯特尔方案, 66–67
-
密钥调度, 64
-
中间人攻击, 82–83
-
操作模式, 74
-
填充 Oracle 攻击, 83–85
-
轮密钥, 64–65
-
轮次, 64
-
安全目标, 62
-
滑动攻击, 64–65
-
替代-置换网络, 65–66
-
BLS 曲线, 312
-
BLS 签名, 312–313
-
蓝牙, 88
-
Boneh, Dan, 214
-
Bos, Joppe W., 251
-
广播攻击模型, 105
-
Brumley, David, 214
-
暴力破解攻击, 45, 100–101
-
C
-
CA(证书授权机构), 258–262, 269–270
-
缓存时间攻击, 72
-
凯撒密码, 4–5
-
CAESAR 竞赛, 174
-
Canetti, Ran, 155
-
无进位乘法(CLMUL), 165
-
CBC. 见 密码块链接
-
CBC-MAC, 146–147
-
CCA(选择密文攻击者), 13–14
-
CCM(计数器与 CBC-MAC), 174, 265
-
CDH(计算 Diffie–Hellman 协议), 218–219
-
证书授权机构(CA), 258–262, 269–270
-
证书链, 259, 269
-
ChaCha20, 106, 131, 151, 265
-
链接值, 122
-
中国剩余定理(CRT), 210–211
-
选择密文攻击者(CCA), 13–14
-
选择消息攻击, 140
-
选择明文攻击者(CPA), 13
-
Chrome 浏览器, 129, 271
-
Chuang, Isaac, 293
-
基于密码的 MAC(CMAC), 146–147
-
密码块链接(CBC), 76–78
-
密文偷窃, 79
-
填充, 78–79
-
填充 Oracle 攻击, 83–85
-
加密算法, 3
-
密文, 4
-
仅密文攻击者(COAs),13
-
密文偷取,79
-
电路,328
-
C 语言,91
-
克雷数学研究所,50,185
-
客户端证书,268
-
凸问题,184
-
CLMUL(无进位乘法),165
-
最接近向量问题(CVP),287
-
CMAC(基于密码的 MAC),146–147
-
CMAC-AES,169
-
基于编码的加密,285–286
-
码本攻击,63,101
-
Codenomicon,270
-
编码问题,194
-
Cohen,Henri,251
-
冷战,61
-
碰撞抗性,119–120,123,305–306
-
完备性,324
-
复杂性。参见 计算复杂性
-
复杂性类,182
-
复数,275
-
压缩函数,122
-
在 BLAKE2 中,134–135
-
Davies–Meyer 结构,124–125
-
在 Merkle–Damgård 结构中,122
-
在 SHA-1 中,127
-
计算复杂性,178
-
界限,182
-
类别,182
-
比较,180
-
常数因子,179
-
常数时间,179
-
指数型,179–181
-
指数阶阶乘,181
-
线性,179
-
线性对数,179
-
多项式,180–183
-
二次,180
-
超多项式,180
-
计算复杂性理论,177
-
计算 Diffie–Hellman(CDH),218–219
-
计算难度,178
-
计算安全性,44–46
-
保密性,3,21,116,162
-
混淆,65,303
-
常数时间实现,154
-
Coppersmith,Don,213
-
计数器模式(CTR),80–82,102,164
-
计数器与 CBC-MAC(CCM),174,265
-
CPA(选择明文攻击者),13
-
CRC(循环冗余校验),116
-
CRT(中国剩余定理), 210–211
-
CryptAcquireContext() 函数, 34
-
CryptGenRandom() 函数, 37
-
Crypto++, 214
-
Cryptocat, 41
-
密码学安全性, 43. 另见 security
-
CTR(计数器模式), 80–82, 102, 164
-
立方体攻击, 96
-
Curve448, 265
-
Curve25519, 248, 265
-
Curve41417, 248
-
CVP(最接近向量问题), 287
-
循环冗余校验(CRC), 116
-
D
-
Dahlin, Mike, 136
-
Damgård, Ivan, 122, 237
-
数据加密标准. 另见 DES
-
数据报传输层安全(DTLS), 257
-
Davies–Meyer 构造, 124–125, 127, 134
-
决策 Diffie–Hellman(DDH)
-
假设, 219
-
问题, 218–219
-
专用硬件, 90
-
DeMillo, Richard A., 214
-
DES(数据加密标准), 61, 90
-
与 AES 相对, 67, 90
-
块大小, 62
-
双重 DES, 83
-
Feistel 结构, 66–67
-
3DES, 67, 82–83
-
确定性随机比特生成器(DRBG), 16, 30, 88
-
/dev/random, 36–37
-
/dev/urandom, 34–37
-
Diehard, 33
-
差分密码分析, 109
-
Diffie, Whitfield, 195, 215
-
Diffie–Hellman 问题, 194
-
Diffie–Hellman 协议, 241
-
匿名, 223–224
-
已认证的, 224–227
-
CDH 问题, 218
-
DDH 问题, 218–219
-
函数, 216–217
-
生成参数, 217
-
和密钥协商, 219–222, 241
-
MQV 协议, 227–228
-
和共享密钥, 216, 228–229
-
在 TLS 中, 215, 229, 264–265
-
双重问题, 219
-
不安全的群参数, 229–230
-
扩散, 65–66, 304
-
摘要, 116
-
DigiNotar, 269
-
数字签名, 116–117, 196, 202–205
-
离散对数问题(DLP), 189–191
-
与 CDH 问题, 218
-
ECDLP, 240–241
-
与 Shor 算法, 281–282
-
不诚实多数, 318
-
分布, 26–27
-
域分离, 305–306
-
drand48, 32
-
DRBG(确定性随机位生成器), 16, 30, 88
-
DTLS(数据报传输层安全协议), 257
-
Durumeric, Zakir, 40
-
E
-
ECB(电子密码本模式), 74
-
ECC(椭圆曲线加密), 231
-
ECDH(椭圆曲线 Diffie–Hellman 密钥交换), 241, 249, 292
-
ECDLP(椭圆曲线离散对数问题), 240–241
-
ECDSA. 参见 椭圆曲线数字签名算法
-
ECIES(椭圆曲线集成加密方案), 246
-
Ed25519, 244, 250
-
Ed448-Goldilocks, 248
-
EdDSA, 244
-
爱因斯坦–波多尔斯基–罗森(EPR)悖论, 274
-
椭圆曲线加密(ECC), 231
-
椭圆曲线 Diffie–Hellman(ECDH), 241, 249, 292
-
椭圆曲线数字签名算法(ECDSA), 241, 321
-
与不良随机性, 249
-
与 RSA 签名相比, 243–244
-
签名生成, 242
-
签名验证, 242–243
-
椭圆曲线离散对数问题(ECDLP), 240–241
-
椭圆曲线集成加密方案(ECIES), 246
-
椭圆曲线, 231
-
加法法则, 235
-
Curve25519, 248, 265
-
Curve41417, 248
-
Curve448, 265
-
Edwards 曲线, 233, 244
-
群体, 239–240
-
与整数, 233–234
-
NIST 曲线, 247–248
-
阶数, 240
-
配对, 312
-
无穷远点, 236, 239–240, 314
-
点加倍, 237–238
-
点乘, 238–239
-
素曲线, 247
-
韦尔斯特拉斯形式, 232
-
严格并行, 48, 100
-
封装安全负载(ESP), 263
-
加密与 MAC, 158–159
-
加密, 3
-
非对称, 18
-
静态, 17
-
传输中, 17
-
随机化, 15–16
-
安全性, 12
-
加密后 MAC, 158, 159–160, 164
-
纠缠, 274, 277
-
熵, 27–31, 36–37
-
熵池, 29–31
-
EPR(爱因斯坦–波多尔斯基–罗森)悖论, 274
-
错误更正码, 285
-
ESP(封装安全负载), 263
-
eSTREAM 竞赛, 97, 106, 113
-
以太坊, 306
-
eth 根, 199
-
欧拉定理, 212
-
欧拉φ函数, 196
-
指数运算, 206–208, 210
-
扩展欧几里得算法, 198
-
F
-
阶乘, 9
-
因式分解方法, 187
-
因式分解问题, 50–51, 186
-
和NP完全性, 188–189
-
使用 Shor 算法求解, 281–282
-
因式分解, 187–188, 191–192
-
快速相关攻击, 96
-
故障注入, 211
-
FDH(全域哈希), 205
-
反馈移位寄存器(FSRs), 90–92
-
周期, 92
-
反馈函数, 90
-
线性, 93–95
-
非线性, 96
-
周期, 92
-
Feistel 方案, 66–67
-
费格森, 尼尔斯, 30, 173
-
FHE(全同态加密), 20
-
Fiat–Shamir 变换, 327, 330
-
可编程门阵列(FPGA), 89
-
滤波 LFSR, 95–96
-
第一原像抗性, 118
-
固定点, 125
-
Flame, 137
-
灵活的轮次优化 Schnorr 阈值(FROST), 321
-
伪造攻击, 140
-
格式保留加密(FPE), 19–20
-
Fortuna, 30–31
-
前向保密, 221–222, 225, 228
-
在认证的 DH 中, 225
-
在 TLS 1.3 中, 268–269
-
Fouque, Pierre-Alain, 155
-
FOX, 66
-
FPGA(现场可编程门阵列), 89
-
频率分析, 6
-
Frey, Gerhard, 251
-
FROST(灵活的轮次优化 Schnorr 阈值), 321
-
FSRs. 见 反馈移位寄存器
-
完全扩散, 110
-
完整域哈希(FDH), 205
-
完全同态加密(FHE), 20
-
G
-
GCD(最大公约数), 40, 198, 211, 282
-
GCHQ(政府通信总部), 216
-
GCM(伽罗瓦计数模式), 158, 164, 173. 另见 AES-GCM
-
gcm_ghash_clmul 函数, 165
-
一般数域筛选法(GNFS), 187, 218
-
getrandom() 系统调用, 36
-
GHASH, 165–166, 171–172
-
Gilbert, Edgar, 148
-
Git, 115
-
GitHub, 55
-
Gmail, 268, 270
-
GMR-1, 113
-
GMR-2, 113
-
GNFS(一般数域筛选法), 187, 218
-
GNU 多重精度(GMP), 206
-
Go, 152, 206, 208
-
Goldberg, Ian, 39, 321
-
Goldwasser, Shafi, 22
-
Google, 129, 258–261, 269, 270
-
Chrome 浏览器, 129, 271
-
GOST, 61, 67
-
Govaerts, René, 137
-
政府通信总部(GCHQ), 216
-
Grain-128a, 97–98
-
图形处理单元(GPU),101,178
-
最大公约数(GCD),40,198,211,282
-
Grøstl,131
-
群
-
公理,190
-
交换律,190
-
循环,190
-
有限的,190
-
生成器,190
-
在 RSA 中的应用,196–197
-
Grover 算法,282–283
-
GSM 移动通信,88,140
-
猜测和确定攻击,100–101
-
H
-
Hadamard 门,278–279
-
Halderman, Alex,40,251
-
难度假设,189
-
难题,177。另见 计算复杂度
-
最接近向量问题,287
-
离散对数问题,189–191
-
因数分解问题,50–51,186
-
错误学习,194,286
-
多元二次方程,287
-
NP-完全问题,183–186
-
P与NP问题,185–186
-
和可证明的安全性,50–51
-
短整型解,194,286
-
硬件,72,113
-
基于哈希的密码学,288–289
-
基于哈希的 MAC,144–145
-
哈希函数,115。另见 Merkle–Damgård 构造
-
在其中的碰撞,119–121
-
压缩函数,122
-
Davies–Meyers 构造,124–125
-
在数字签名中的应用,116
-
迭代的,122
-
有密钥的,139
-
多重碰撞,123–124
-
非密码学,116
-
P与NP问题,185–186
-
预映像抗性,117–119
-
在存储证明协议中的应用,136–137
-
安全性概念,116
-
海绵函数,122,125–126
-
3-碰撞,123
-
通用性,148–149
-
不可预测性,117
-
哈希值,116,117–121
-
Heartbleed,256,270
-
Hellman, Martin,195,215
-
Heninger, Nadia, 40, 251
-
启发式安全, 50, 52–53
-
基于 HMAC 的 KDF(HKDF), 229, 265
-
HMAC(基于哈希的 MAC), 144–146
-
诚实多数, 318
-
HTTPS, 258
-
不安全的, 166, 193
-
密钥, 53, 56
-
通过 TLS, 104, 215, 256
-
I
-
iCloud, 270
-
身份门, 278
-
IES(集成加密方案), 246
-
IETF(互联网工程任务组), 134, 164, 250, 314
-
IKE(互联网密钥交换), 146
-
虚数, 275
-
IND-CPA, 15–17
-
不可区分性, 137
-
不可区分性(IND), 15
-
初始值(IV), 76, 81, 89, 122, 147
-
集成加密方案(IES), 246
-
数据完整性, 19, 116, 140
-
英特尔, 38, 73, 165
-
互联网工程任务组(IETF), 134, 164, 250, 314
-
互联网密钥交换(IKE), 146
-
物联网(IoT), 255
-
难以求解的问题。参见 难题
-
无效曲线攻击, 249–250
-
无效密钥, 314–315
-
入侵性攻击, 14
-
离子阱, 284
-
ipad, 144
-
IPSec(互联网协议安全), 140, 144, 146, 160, 164, 263
-
迭代哈希, 122
-
IV(初始值), 76, 81, 89, 122, 147
-
J
-
Jager, Tibor, 250
-
Java, 22, 32, 152
-
JH, 131
-
K
-
KDF。参见 密钥派生函数
-
Keccak, 126, 131–133, 171. 参见 SHA-3
-
Kelsey, John, 30, 41, 50
-
Kerckhoffs, Auguste, 6, 13
-
Kerckhoffs 原则, 13
-
密钥协商协议, 53, 216, 219
-
AKA, 220–221
-
攻击模型, 221
-
破坏, 221, 225, 228
-
数据泄露, 221, 226, 268
-
窃听者, 216, 218
-
前向保密, 221–222, 225, 228
-
性能, 222
-
安全目标, 221
-
密钥取消攻击, 309–310, 315–316
-
密钥确认, 226, 228
-
密钥控制, 221, 225
-
密钥派生函数 (KDF), 53
-
在 DH 函数中, 216, 229
-
在 ECIES 中, 246
-
在 TLS 1.3 中, 265–266
-
密钥生成, 39, 40, 198, 323
-
密钥生成算法, 53, 54
-
密钥管理, 323
-
密钥调度算法 (KSAs), 102–104
-
密钥封装, 55
-
背包问题, 184
-
知识提取器, 326
-
已知消息攻击, 140
-
已知明文攻击者 (KPA), 13, 100
-
Knudsen, Lars, 51
-
Kohno, Tadayoshi, 31
-
Kotla, Ramakrishna, 136
-
Kozierok, Charles, 257
-
Krawczyk, Hugo, 155, 230
-
Krovetz, Ted, 168
-
KSAs (密钥调度算法), 102–104
-
Kupyna, 127
-
L
-
拉格朗日插值, 319
-
基于格的密码学, 286–287
-
格问题, 194, 287, 291
-
学习与错误 (LWE), 286, 291
-
最低有效位 (LSB), 179, 207
-
长度扩展攻击,135–136,142–143
-
Let’s Encrypt,271
-
Leurent, Gaëtan,155
-
线性码,285–286
-
线性组合,33
-
线性反馈移位寄存器(LFSRs),93
-
在 A5/1 中,98–100
-
过滤,95–96
-
在 Grain-128a 中,97–98
-
多项式,93
-
安全性,95
-
线性变换,33,93,287
-
Linux,34,36,74,152
-
Lipton, Richard J.,214
-
对数,27,46
-
长期密钥,225
-
下界,45
-
低指数攻击,209
-
LSB(最低有效位),179,207
-
Lucifer,66
-
LWE(带错误学习),286,291
-
M
-
MacBook,154,191,209
-
MAC(消息认证码),139
-
认证标签,140
-
CBC-MAC,146–147
-
选定消息攻击,140
-
CMAC,146–147
-
专用设计,148
-
加密与 MAC,158–159
-
加密-然后-MAC,158,159–160,164
-
伪造攻击,140
-
HMAC,144–146
-
MAC-然后-加密,158–160
-
与 PRFs 对比,141–142
-
重放攻击,141
-
时间攻击,140–142
-
Wegman–Carter,149–150
-
MAC-然后-加密,158–160
-
MacWilliams, F.J.,148
-
可塑性,199–200
-
中间人攻击,220,223–224,256
-
掩码生成函数,202
-
矩阵乘法,278
-
McEliece 加密系统,285–286
-
MD5,122,127,133
-
M–D 构造。见 Merkle–Damgård 构造
-
测量(量子物理),274,278
-
MediaWiki,40
-
中间遇见攻击(MitM),82–83
-
内存, 48
-
内存占用, 63
-
梅内泽斯–屈–范斯通(MQV), 227–228, 241
-
拉尔夫·梅克尔(Ralph Merkle), 122, 137, 216, 297
-
梅克尔–达姆戈尔德(M–D)构造, 122
-
长度扩展攻击, 135–136, 142–145
-
多重碰撞, 123–124
-
填充, 123
-
安全, 123
-
梅克尔谜题, 216
-
梅森旋转(Mersenne Twister,MT)算法, 32, 40, 305
-
消息认证码。见 MACs
-
西尔维奥·米卡利(Silvio Micali), 22
-
微软, 111, 131
-
微软 Windows CryptoAPI, 208
-
错误使用抵抗, 162
-
中间人攻击(MitM),82–83
-
操作模式, 7–8, 61, 74
-
乔纳森·摩尔(Jonathan Moore), 251
-
最重要位(MSB), 32, 147, 150, 229
-
MQ(多变量二次方程), 287
-
MQV(梅内泽斯–屈–范斯通), 227–228, 241
-
MT(梅森旋转)算法, 32, 40, 305
-
mt_rand, 32, 40
-
多重碰撞, 123–124
-
多方计算(MPC), 316
-
多重签名协议, 306
-
多变量密码学, 287–288
-
多变量问题, 194
-
多变量二次方程(MQ), 287
-
MuSig, 310–311
-
N
-
迈克尔·纳赫里格(Michael Naehrig), 251, 312
-
网景(Netscape), 39, 257
-
基于网络的入侵检测系统(NIDS), 115
-
塞缪尔·内夫斯(Samuel Neves), 133, 135
-
NFSR(非线性反馈移位寄存器), 96–98
-
冯·阮(Phong Q. Nguyen), 155
-
迈克尔·尼尔森(Michael Nielsen), 293
-
NIST(国家标准与技术研究院), 33, 61, 67, 129–131, 247–248, 289–291
-
NM(不可篡改性), 15
-
随机数,80–82,88–89
-
可预测性,161–162
-
重用,111,169
-
在 TLS 记录中,263
-
WEP 不安全性,103–104
-
非确定性多项式时间类。参见 NP 类
-
非交互式零知识(NIKZ),326–327
-
非线性方程,33,96
-
非线性反馈移位寄存器(NFSR),96–98
-
不可篡改性(NM),15
-
不可否认性,202
-
非均匀分布,27
-
NP(非确定性多项式时间)类,182–183
-
NP-完全问题,183–185
-
NP-困难问题,185–186
-
NSA(美国国家安全局),10,85,105,127,129,227,244,247,273
-
NSS 库,214
-
数域筛法,218
-
O
-
OAEP。参见 最优非对称加密填充
-
OCB(偏移代码本)
-
效率,168–169
-
内部实现,167–168
-
安全性,168
-
一次性密码本,9
-
使用加密,9–10
-
安全性,10–11,12,44
-
单向函数,117
-
opad,144
-
OpenSSH,148,159,231,246,248
-
OpenSSL 工具包
-
生成 DH 参数,217
-
生成密钥,53–54,192–193
-
GHASH 错误,165
-
Heartbleed,256,270
-
不安全的 DH 群组参数,229–230
-
最优非对称加密填充(OAEP),56,200
-
编码消息,201
-
掩码生成函数,202
-
P
-
P(多项式时间)类,180–185
-
P 与 NP,185–186
-
填充,22,78–79,83–84,123
-
OAEP,56,200
-
零填充,263
-
填充 Oracle 攻击,22,83–84
-
配对,312
-
并行性,47–48
-
并行化能力,162,166,168
-
父进程 ID (PPID),39
-
被动攻击者,318
-
密码,53,55,141
-
Peikert, Chris,291
-
完美保密性,9
-
周期,92–94,97–98,281–282
-
排列,6
-
基于排列的 AEAD,169–171
-
伪随机,62,150
-
安全性,7,8–9
-
在海绵函数中,125–126
-
陷门,196,197–199
-
PID(进程 ID),39
-
鸽巢原理,119
-
PKCS(公共密钥密码学标准),200–201
-
明文,4
-
PLD(可编程逻辑设备),89
-
Poly1305,148–151
-
Poly1305-AES,150–151
-
多项式,93
-
乘法,165–166
-
基本运算,93–94
-
多项式时间(P)类,180–185
-
后量子密码学,274,285
-
基于编码的,285–286
-
基于哈希的,288–289
-
基于格的,286–287
-
多变量,287–288
-
后量子密码学标准化项目,289
-
后量子安全,283,304
-
电源分析攻击,208
-
PPID(父进程 ID),39
-
PQCrypto,293
-
预计算,48,222
-
预测抗性,30
-
预像抗性,117–119
-
Preneel, Bart,137
-
预共享密钥 (PSK),265,267
-
伪随机函数 (PRFs)。参见 伪随机函数
-
素数,187
-
素数定理,187
-
私钥,18,195
-
PRNGs。参见 伪随机数生成器
-
概率签名方案 (PSS),203–205
-
概率,11,26
-
概率分布,26–27
-
进程 ID (PID),39
-
可编程逻辑设备 (PLD), 89
-
存储证明协议, 136–137
-
工作量证明, 300
-
可证明安全性, 50–53
-
伪随机函数 (PRFs), 139
-
与 MAC 的对比, 141–142
-
安全性, 141
-
伪随机数生成器 (PRNGs), 28–30
-
加密的, 32–33
-
和熵, 39–40
-
Fortuna, 30–31
-
在 Unix 上生成, 34–36
-
在 Windows 上生成, 37–38
-
硬件基础, 38
-
非密码学的, 32, 40
-
安全性, 30
-
伪随机置换 (PRP), 62, 67, 150
-
PSK (预共享密钥), 265, 267
-
PSPACE, 182
-
PSS (概率签名方案), 203–205
-
公钥密码学, 18, 231
-
公钥密码学标准 (PKCS), 201
-
公钥密码体制, 215
-
公钥, 195
-
PyCrypto, 70
-
毕达哥拉斯定理, 275
-
Python 语言, 70, 75, 80–81, 102, 212
-
Q
-
Qualys, 271
-
量子比特 (qubit), 274, 276–279, 284
-
量子字节, 277
-
量子电路, 278
-
量子计算机, 188–189, 274
-
量子门, 277–279
-
量子力学, 274
-
量子随机数生成器 (QRNGs), 29
-
量子加速, 279
-
指数的, 280
-
二次的, 280
-
四分之一轮函数, 106–107
-
量子比特 (qubit), 274, 276–279, 284
-
R
-
随机性, 25
-
随机数生成器 (RNGs), 28–29
-
随机预言机, 117
-
Ray, Marsh, 74
-
RC4, 89, 102
-
损坏的实现, 111–113
-
在 TLS 中, 104–105
-
在 WEP 中, 103–104
-
RDRAND 指令, 38
-
RDSEED 指令, 38
-
降维, 50
-
重放攻击, 142, 220, 330
-
Rho 方法, 120–121
-
Rijndael, 67
-
环-LWE, 291
-
Rivest, Ron, 102
-
Rivest–Shamir–Adleman. 参见 RSA
-
RNGs(随机数生成器), 28–29
-
Rogaway, Phillip, 168, 169
-
恶意密钥攻击, 310
-
单位根, 212
-
轮次, 53
-
往返, 222
-
往返时间(RTTs), 267
-
RSA(Rivest–Shamir–Adleman), 195
-
Bellcore 攻击, 211–212
-
CRT, 210–211
-
与 ECDSA 对比, 243–244
-
加密, 199
-
与因式分解问题, 50–51, 186
-
FDH, 205
-
群, 196–197
-
实现, 205–206
-
密钥生成, 208–209
-
模数, 212
-
OAEP, 200–202
-
私钥指数, 212–213
-
私钥, 54, 197, 198
-
问题, 218
-
PSS, 203–204, 205
-
公共指数, 197
-
公钥, 197
-
秘密指数, 197
-
安全性, 198
-
共享模数, 212–213
-
签名, 202–205
-
小指数, 208–209
-
速度, 208–210
-
平方乘法, 207–208
-
教科书加密, 199–200
-
教科书签名, 203
-
陷门置换, 196, 197, 199
-
RSAES-OAEP, 200
-
RSA Security, 102
-
RTT(往返时间), 267
-
S
-
Saarinen, Markku-Juhani O., 132, 173
-
安全素数, 217
-
SageMath, 191, 198, 239
-
Salsa20, 106
-
攻击, 110–111
-
列轮函数, 108
-
双轮函数,107
-
内部状态,107
-
以及非线性关系,110
-
四分之一轮函数,106–107
-
行轮函数,107
-
Salsa20/8,110–111
-
盐,204
-
三明治 MAC,144
-
卫星电话(satphone),113
-
S 盒(替代盒),65
-
调度问题,184
-
施奈尔,布鲁斯,30,31,41,131
-
施诺尔,克劳斯-彼得,244,307
-
施诺尔签名协议,307–309
-
施诺尔的知识证明协议,325–326
-
施文克,约尔格,250
-
可搜索加密,20
-
搜索算法,178
-
第二预影抗性,118
-
秘密前缀 MAC,143,145
-
秘密共享,319
-
加法,319
-
阈值,319
-
秘密后缀 MAC,143
-
安全通道,216,256
-
安全 cookie,268
-
带 Keccak 的安全哈希算法(SHAKE),132
-
安全外壳(SSH),55,140,144,159,160,241,262
-
安全套接字层(SSL),39,255,257
-
安全性
-
比特,46–47
-
计算,44–45
-
加密,43
-
目标,12,15
-
启发式,50,52–53
-
等级,选择,49–50
-
边距,53
-
概念,12,15–17
-
后量子,283
-
证明,46
-
可证明的,50–52
-
语义,15,16
-
会话密钥,53,219
-
SHA-0,127–128
-
SHA-1,127–129,266
-
SHA-2,129–133
-
SHA-224,129–130
-
SHA-256,117,123,129–130,242,297,300
-
压缩函数,130,146
-
安全性,130–131,297
-
SHA-3,126,132–133,229
-
竞争,131–132
-
安全性,133–134
-
Zoo,137
-
SHA-384,130
-
SHA-512,130
-
SHAKE(使用 Keccak 的安全哈希算法),132
-
Shamir 的秘密共享,319
-
Shannon, Claude,10
-
SHAs(安全哈希算法),126
-
Shor, Peter,281
-
Shor 算法,281–282
-
短整型解决方案(SIS),286
-
Shrimpton, Tom,169
-
辅助通道攻击,14,65,153,292
-
Signal,292
-
签名,116,196,202–205
-
SIM 卡,220
-
Simon 问题,280–281
-
简单邮件传输协议(SMTP),229,257
-
模拟器,326
-
SipHash,151–152,155
-
SipRound 函数,151–152
-
SIS(短整型解决方案),286
-
SIV(合成 IV),169
-
Skein,131
-
滑动攻击,64–65
-
滑动窗口方法,208
-
Sloane, N.J.,149
-
SM3,127
-
SMTP(简单邮件传输协议),229,257
-
SNOW3G,102
-
Somorovsky, Juraj,250
-
可靠性,324
-
空间复杂度,182
-
SPHINCS+,289–290
-
SPNs(替代-置换网络),65–66,68
-
海绵函数,122,125–126,155
-
吸收阶段,126
-
容量,126
-
压缩阶段,126
-
平方-乘法,207–208
-
SSH(安全外壳),55,140,144,159,160,241,262
-
SSL(安全套接层),39,255,257
-
SSL Labs,271
-
静态破坏,318
-
统计测试, 33–34
-
流式处理能力, 163,167–169
-
流密码, 87
-
基于计数器的, 89
-
加密与解密, 88
-
面向硬件的, 89–90
-
密钥流, 88
-
随机数重用, 111
-
面向软件的, 101
-
有状态的, 89
-
Streebog, 127
-
替代盒(S-盒), 65
-
替代-置换网络(SPN), 65–68
-
替代, 7
-
超导电路, 284
-
叠加, 274
-
对称加密, 3,18
-
合成 IV(SIV), 169
-
T
-
标签, 18。另见 认证加密; MACs
-
TEA, 137
-
门限签名协议, 316
-
时间复杂度, 180–182
-
时间-内存权衡(TMTO)攻击, 21,48,101
-
定时攻击, 153–154,208,292
-
TLS(传输层安全性), 39,88,140,142,159,255
-
ClientHello, 257,264–267
-
以及 Diffie–Hellman, 229
-
降级保护, 266–267
-
握手, 257,258–268
-
历史, 257
-
RC4,见 102,104–106
-
记录, 262
-
记录负载, 262
-
记录协议, 257,262
-
安全性, 256,268–271
-
ServerHello, 257,264–267
-
会话恢复, 267–268
-
单次往返握手, 267
-
版本 1.3, 265–267
-
零填充, 263
-
TLS 工作组(TLSWG), 271
-
TMTO(时间-内存权衡)攻击, 21,48,101
-
TOFU(首次使用时信任), 262
-
流量分析, 263
-
传输层安全性。见 TLS
-
陷门置换, 196,197,199
-
陷门, 196, 197–199
-
旅行商问题, 184
-
三重 DES (3DES), 67, 82–83
-
可信第三方, 258
-
信任首次使用 (TOFU), 262
-
图灵奖, 216
-
可调加密 (TE), 20–21
-
U
-
UDP (用户数据报协议), 257
-
不可伪造性, 140
-
均匀分布, 27
-
单位矩阵, 279
-
通用哈希函数, 148–149
-
Unix, 34
-
不可预测性, 117
-
上界, 46
-
V
-
Vandewalle, Joos, 137
-
van Oorschot, Paul C., 137
-
Vigenère, Blaise de, 5
-
Vigenère 密码, 5–6
-
虚拟私人网络 (VPN), 104
-
W
-
Wagner, David, 39, 41, 64, 112
-
Wegman–Carter MAC, 149–150, 155
-
Weierstrass 形式, 232
-
WEP (无线加密协议), 102, 103–104
-
Wiener, Michael, 57, 137, 213
-
Wi-Fi, 87, 103–104
-
Wilcox-O’Hearn, Zooko, 133, 135
-
Windows, 37–38
-
Winnerlein, Christian, 133
-
Winternitz 单次签名 (WOTS), 288–289
-
无线加密协议 (WEP), 102, 103–104
-
WPA2, 174
-
Wustrow, Eric, 40, 251
-
X
-
Xbox, 137
-
XOR 交换, 112–113
-
Y
-
Yao, Andrew C., 230
-
Yarrow, 30
-
Yung, Moti, 305
-
Z
-
零知识证明 (ZKP), 323–324
-
非交互式, 326
-
0-RTT 数据, 267
-
Zhao, Yunlei, 230
-
zkSNARK, 327–329
-
ZUC, 102



浙公网安备 33010602011771号