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

我写这本书,是为了成为我在学习密码学时希望拥有的书籍。2005 年,我在巴黎附近攻读硕士学位,我迫不及待地注册了下学期的密码学课程。不幸的是,由于报名人数太少,课程被取消了。学生们争论道:“加密太难了”,于是他们纷纷转而报读计算机图形学和数据库课程。
从那时起,我听到“加密很难”已经不下十几次了。但是,密码学真的那么难吗?要学会演奏乐器、精通一门编程语言,或将任何迷人的领域的应用付诸实践,你需要学习一些概念和符号,但做到这一点并不需要博士学位。我认为,成为一名称职的密码学家也是如此。我还认为,密码学之所以被认为难,是因为密码学家在教学方面做得不好。
我感到需要写这本书的另一个原因是,密码学不再只是关于加密——它已经扩展成为一个跨学科的领域。要在密码学中做一些有用且相关的事情,你需要对围绕密码学的概念有所了解:网络和计算机是如何工作的,用户和系统需要什么,以及攻击者如何滥用算法及其实现。换句话说,你需要与现实建立联系。
本书的方法论
本书的初衷标题是 现实中的密码学,旨在强调我所追求的以实践为导向、面向现实的、毫不做作的方式。我并不想通过简化密码学来让它变得容易理解,而是将其与实际应用结合起来。我提供了源代码示例,并描述了真实的漏洞和令人恐惧的故事。
本书的其他基石是其与现实的紧密联系、简洁性和现代性。我更多关注形式上的简洁,而非内容上的简化:我呈现了许多非琐碎的概念,但没有沉闷的数学形式化。我尝试传达的是密码学的核心思想,这些思想比记住一堆公式更为重要。为了确保本书的现代性,我涵盖了密码学的最新发展和应用,如 TLS 1.3 和后量子密码学。我没有讨论过时或不安全的算法,如 DES 或 MD5。唯一的例外是 RC4,但它的包含仅是为了说明它有多么脆弱,并展示这种类型的流密码是如何工作的。
严谨的密码学 不是一本关于密码软件的指南,也不是一本技术规范汇编——这些内容你可以轻松在线找到。相反,本书的首要目标是让你对密码学产生兴趣,并在过程中教授其基本概念。
适合阅读本书的人群
在写作时,我经常想象读者是一个接触过加密技术但在尝试阅读晦涩的教科书和研究论文后仍然感到困惑和沮丧的开发者。开发者常常需要——也希望——更好地理解加密技术,以避免不幸的设计选择,我希望这本书能有所帮助。
但是,如果你不是开发者,也不用担心!这本书不需要任何编程技能,任何理解计算机科学基础和大学数学(例如概率论、模算术等概念)的人都可以阅读。
尽管如此,这本书仍然可能让人感到有些望而生畏,尽管它相对容易理解,但要从中获得最大收益还是需要一些努力。我喜欢登山类比:作者为你铺设道路,提供绳索和冰镐来帮助你,但最终的攀登还是得靠你自己。学习本书中的概念需要付出努力,但最终会有回报。
本书的结构
本书共有十四章,分为四个部分。各章节之间大多独立,唯一例外是第九章,它为接下来的三章奠定了基础。我还建议在阅读其他内容之前,先阅读前三章。
基础知识
-
第一章: 加密介绍了安全加密的概念,从简单的手写密码到强大的、随机化的加密技术。
-
第二章: 随机性描述了伪随机生成器的工作原理,什么样的伪随机生成器是安全的,以及如何安全地使用它。
-
第三章: 加密安全讨论了安全的理论和实践概念,并比较了可证明的安全性与概率性安全性。
对称加密
-
第四章: 分组密码讲解了逐块处理信息的密码算法,重点介绍了最著名的高级加密标准(AES)。
-
第五章: 流密码介绍了生成一串看似随机的比特流并与要加密的消息进行异或操作的密码算法。
-
第六章: 哈希函数讲解了唯一不依赖于密钥的算法,它们实际上是最常见的加密基础构件。
-
第七章: 密钥哈希解释了如果将哈希函数与密钥结合,会发生什么情况,以及它是如何用于消息认证的。
-
第八章: 认证加密展示了某些算法如何在加密信息的同时验证其真实性,举例包括标准的 AES-GCM 算法。
非对称加密
-
第九章: 困难问题阐述了公钥加密背后的基本概念,运用了计算复杂度的相关概念。
-
第十章: RSA利用因式分解问题构建安全的加密和签名方案,所需的只是简单的算术操作。
-
第十一章:Diffie–Hellman 扩展了非对称加密到密钥协商的概念,在这一过程中,双方仅使用非秘密值来建立一个秘密值。
-
第十二章:椭圆曲线 轻松介绍了椭圆曲线密码学,这是最快的非对称加密方式。
应用
-
第十三章:TLS 聚焦于传输层安全(TLS),这是网络安全中最重要的协议之一。
-
第十四章:量子与后量子 以科幻的形式总结,介绍了量子计算和一种新的加密学概念。
致谢
我要感谢 Jan、Annie 以及所有参与本书工作的 No Starch 员工,特别是感谢 Bill 从一开始就相信这个项目,感谢他在消化困难话题时的耐心,感谢他将我笨拙的草稿变成了可读的内容。我还要感谢 Laurel 让本书看起来如此漂亮,并处理了我提出的许多修改请求。
在技术方面,如果没有以下几位人士的帮助,这本书将包含更多的错误和不准确之处:Jon Callas、Bill Cox、Niels Ferguson、Philipp Jovanovic、Samuel Neves、David Reid、Phillip Rogaway、Erik Tews,以及所有在早期版本中报告错误的读者。最后,感谢 Matt Green 为本书写的序言。
我还要感谢我的雇主,Kudelski Security,感谢他们给予我时间来撰写本书。最后,我要向 Alexandra 和 Melina 表达我最深的感谢,感谢她们的支持和耐心。
洛桑,2017 年 5 月 17 日(三个质数)
第一章:缩写
| AE | 认证加密 |
|---|---|
| AEAD | 带关联数据的认证加密 |
| AES | 高级加密标准 |
| AES-NI | AES 原生指令 |
| AKA | 认证密钥协议 |
| API | 应用程序接口 |
| ARX | 加法-旋转-XOR |
| ASIC | 专用集成电路 |
| CA | 证书授权机构 |
| CAESAR | 认证加密竞赛:安全性、适用性和鲁棒性 |
| CBC | 密码分组链接模式 |
| CCA | 选择密文攻击者 |
| CDH | 计算性 Diffie–Hellman |
| CMAC | 基于密码的消息认证码 |
| COA | 仅密文攻击者 |
| CPA | 选择明文攻击者 |
| CRT | 中国剩余定理 |
| CTR | 计数器模式 |
| CVP | 最接近向量问题 |
| DDH | 决策性 Diffie–Hellman 问题 |
| DES | 数据加密标准 |
| DH | Diffie–Hellman |
| DLP | 离散对数问题 |
| DRBG | 确定性随机比特生成器 |
| ECB | 电子代码本 |
| ECC | 椭圆曲线密码学 |
| ECDH | 椭圆曲线 Diffie–Hellman |
| 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 | 中间人攻击 |
| MQ | 多元二次方程 |
| MQV | Menezes–Qu–Vanstone |
| MSB | 最高有效位 |
| MT | 梅森旋转器 |
| NFSR | 非线性反馈移位寄存器 |
| NIST | 美国国家标准与技术研究院 |
| NM | 非可篡改性 |
| NP | 非确定性多项式时间 |
| OAEP | 最优非对称加密填充 |
| OCB | 偏移代码本 |
| P | 多项式时间 |
| PLD | 可编程逻辑设备 |
| PRF | 伪随机函数 |
| PRNG | 伪随机数生成器 |
| PRP | 伪随机置换 |
| PSK | 预共享密钥 |
| PSS | 概率签名方案 |
| QR | 四分之一回合 |
| QRNG | 量子随机数生成器 |
| RFC | 注释请求 |
| RNG | 随机数生成器 |
| RSA | Rivest–Shamir–Adleman |
| SHA | 安全哈希算法 |
| SIS | 简短整数解 |
| SIV | 合成初始向量 |
| SPN | 替代-置换网络 |
| SSH | 安全外壳协议 |
| SSL | 安全套接层 |
| TE | 可调加密 |
| TLS | 传输层安全协议 |
| TMTO | 时间-内存权衡 |
| UDP | 用户数据报协议 |
| UH | 通用哈希 |
| WEP | 无线加密协议 |
| WOTS | Winternitz 一次性签名 |
| XOR | 排他性或(exclusive OR) |
第二章:加密

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

图 1-1:基本加密与解密
注意
对于某些密码,密文与明文大小相同;对于其他一些密码,密文稍微长一些。然而,密文永远不可能比明文短。
经典密码
经典密码是指早于计算机的密码,因此它们操作的是字母而非比特。它们比现代密码(如 DES)简单得多——例如,在古罗马或第一次世界大战期间,你无法使用计算机芯片的强大算力来加密消息,因此只能用笔和纸完成所有工作。经典密码有很多种,但最著名的是凯撒密码和维吉尼亚密码。
凯撒密码
凯撒密码之所以得名,是因为罗马历史学家苏埃托尼乌斯报道了尤利乌斯·凯撒使用它。它通过将每个字母在字母表中向下移动三个位置来加密信息,当移位到 Z 时会回绕到 A。例如,ZOO 加密为 CRR,FDHVDU 解密为 CAESAR,依此类推,如图 1-2 所示。数字 3 并没有什么特别的意义,它只比 11 或 23 更容易在脑海中计算。
凯撒密码非常容易破解:要解密给定的密文,只需将字母向后移动三个位置,即可恢复明文。也就是说,凯撒密码在克拉苏斯和西塞罗时代可能足够强大。因为没有涉及秘密密钥(始终是 3),使用凯撒密码的人只需假设攻击者是文盲或者教育水平太低,无法破解密码——这一假设在今天显然不再现实。(事实上,2006 年,意大利警方在破译使用凯撒密码变体加密的纸条上的信息后逮捕了一名黑帮老大:例如,ABC 被加密为 456,而不是 DEF。)

图 1-2:凯撒密码
凯撒密码能否变得更加安全?你可以想象一个版本,使用一个秘密的偏移值,而不是始终使用 3,但这并不会有太大帮助,因为攻击者可以轻松地尝试所有 25 种可能的偏移值,直到解密出的消息有意义。
维杰尼尔密码
大约 1500 年后,凯撒密码才迎来了有意义的改进,即由意大利人乔凡·巴蒂斯塔·贝拉索(Giovan Battista Bellaso)在 16 世纪创造的维杰尼尔密码。“维杰尼尔”这个名字来源于法国人布莱兹·德·维杰尼尔(Blaise de Vigenère),他在 16 世纪发明了一种不同的密码,但由于历史上的误归功,维杰尼尔的名字被广泛使用。尽管如此,维杰尼尔密码变得非常流行,并且后来在美国内战期间被南方联邦军使用,第一次世界大战期间被瑞士军队等使用。
维杰尼尔密码与凯撒密码类似,不同之处在于字母不是按三个位置偏移,而是根据一个密钥的值来偏移,密钥是一个字母集合,根据字母在字母表中的位置表示数字。例如,如果密钥是 DUH,明文中的字母将使用 3、20、7 的值进行偏移,因为D距离A三个字母,U距离A二十个字母,H距离A七个字母。3、20、7 的模式将重复,直到你加密完整个明文。例如,单词 CRYPTO 使用 DUH 作为密钥加密后会变成 FLFSNV:C向后移动三位变成F,R向后移动二十位变成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 种置换;因此,你无法轻易解密。
-
不同的密钥应产生不同的置换。否则,它会变得更容易在没有密钥的情况下进行解密:如果不同的密钥产生相同的置换,这意味着不同的密钥比不同的置换少,因此在没有密钥的情况下解密时可尝试的可能性会更少。在维热内密码中,密钥中的每个字母决定了一个替换;有 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! = 403291461126605635584000000 ≈ 2⁸⁸
这里,感叹号 (!) 是阶乘符号,定义如下:
n! = n × (n − 1) × (n – 2) × … × 3 × 2
(要理解为什么我们得到这个数字,可以将置换视为字母重新排序的列表进行计数:第一个字母有 26 种选择,第二个字母有 25 种选择,第三个字母有 24 种选择,依此类推。)这个数字非常庞大:它的数量级与人体内原子的数量级相当。但经典的密码只能使用这些置换的一个小部分——即那些只需要简单操作(如移位)并且具有简短描述(如短算法或小型查找表)的置换。问题在于,一个安全的置换无法同时满足这两种限制。
你可以通过简单的操作来获得安全的置换,方法是选择一个随机的置换,将其表示为一个包含 25 个字母的表格(足够表示 26 个字母的置换,缺少第 26 个字母),然后通过查找该表格中的字母来应用它。但这样做就没有一个简短的描述。例如,描述 10 种不同置换需要 250 个字母,而不仅仅是维吉尼亚密码中使用的 10 个字母。
你还可以使用简短的描述生成安全的置换。你可以不只是对字母表进行简单的移位,而是使用更复杂的操作,例如加法、乘法等。这就是现代密码学的工作原理:给定一个通常为 128 或 256 位的密钥,它们执行数百次比特操作来加密单个字母。在每秒能执行数十亿次比特操作的计算机上,这个过程非常迅速,但如果手动进行,则需要数小时,而且仍然容易受到频率分析的攻击。
完美加密:一次性密码本
从本质上讲,经典的密码学方法如果没有一个巨大的密钥,是无法保证安全的,但用一个巨大的密钥进行加密是不切实际的。然而,一次性密码本就是这样一种密码,它是最安全的密码。事实上,它保证了完美的保密性:即使攻击者拥有无限的计算能力,也不可能通过密文得知明文的任何信息,除了它的长度。
在接下来的章节中,我将向你展示一次性密码本是如何工作的,并提供其安全性证明的概述。
使用一次性密码本进行加密
一次性密码本将明文 P 和与 P 长度相同的随机密钥 K 作为输入,生成密文 C,定义为
C = P ⊕ K
其中 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,则我们可以计算出以下内容:
C = P ⊕ K = 01101101 ⊕ 10110100 = 11011001
解密过程通过计算以下内容来恢复P:
P = C ⊕ K = 11011001 ⊕ 10110100 = 01101101
重要的是,一次性密码本只能使用一次:每个密钥K应该只使用一次。如果同一个K被用来加密P[1]和P[2],从而生成C[1]和C[2],那么窃听者可以计算出以下内容:
C[1] ⊕ C[2] = (P[1] ⊕ K) ⊕ (P[2] ⊕ K) = P[1] ⊕ P[2] ⊕ K ⊕ K = P[1] ⊕ P[2]
因此,窃听者将会得知P[1]和P[2]之间的异或差异,而这应该是保密的信息。此外,如果其中一个明文消息已知,则可以恢复另一个消息。
当然,一次性密码本在使用上极为不便,因为它要求密钥与明文一样长,并且每条新消息或每组数据都需要一个新的随机密钥。要加密一个一 TB 的硬盘,你还需要另一个一 TB 的硬盘来存储密钥!尽管如此,一次性密码本在历史上确实被使用过。例如,二战期间,英国特别行动处、KGB 间谍、NSA 都使用过一次性密码本,至今在某些特定的场景下仍然使用。(我听说过一些瑞士银行家,由于无法就双方都信任的密码达成一致,最终使用了一次性密码本,但我不推荐这么做。)
为什么一次性密码本是安全的?
尽管一次性密码本并不实用,但理解其安全性原理仍然很重要。在 1940 年代,美国数学家克劳德·香农证明了一次性密码本的密钥必须至少与消息一样长,才能实现完美的保密性。该证明的思路相当简单。你假设攻击者拥有无限的计算能力,因此可以尝试所有的密钥。目标是加密使得攻击者在给定某些密文的情况下,无法排除任何可能的明文。
一次性密码本的完美保密性的直觉可以这样理解:如果K是随机的,那么对攻击者来说,得到的C看起来和K一样随机,因为随机字符串与任何固定字符串进行异或运算得到的结果是随机的。为了验证这一点,考虑一下得到随机字符串的第一个比特为 0 的概率(即 1/2 的概率)。那么一个随机比特与第二个比特进行异或得到 0 的概率是多少?对,还是 1/2。相同的推理可以对任意长度的比特字符串进行迭代。因此,密文C对于一个不知道K的攻击者来说看起来是随机的,所以即使攻击者拥有无限的时间和能力,也不可能从C中了解任何关于P的信息。换句话说,知道密文不会提供任何关于明文的信息,除了它的长度——这几乎就是安全密码的定义。
例如,如果密文是 128 位长(意味着明文也是 128 位),那么有 2¹²⁸种可能的密文;因此,从攻击者的角度来看,应该有 2¹²⁸种可能的明文。但如果可能的密钥少于 2¹²⁸,攻击者就可以排除一些明文。如果密钥只有 64 位,例如,攻击者可以确定 2⁶⁴种可能的明文并排除掉绝大多数 128 位的字符串。攻击者虽然无法知道明文是什么,但他们可以知道明文不是什么,这使得加密的保密性不完美。
正如你所看到的,要实现完美的安全性,你必须拥有与明文长度相等的密钥,但这对于实际应用来说很快就变得不切实际。接下来,我将讨论现代加密中为实现既可能又实用的最佳安全性所采取的方法。
密码学中的概率
概率是一个数字,用来表示某个事件发生的可能性或机会。它的值在 0 和 1 之间,其中 0 表示“永不发生”,而 1 表示“总是发生”。概率越高,机会越大。你会看到很多关于概率的解释,通常是用袋子里的白球和红球,以及抽取任意颜色球的概率来描述。
密码学通常使用概率来衡量攻击成功的机会,方法是:1) 计算成功事件的数量(例如,事件“找到唯一正确的秘密密钥”);2) 计算所有可能事件的总数(例如,如果我们使用n位密钥,则所有可能的密钥总数为 2^(n))。在这个例子中,随机选择一个密钥是正确的概率为 1/2^(n),即成功事件的数量(1 个秘密密钥)与所有可能事件的数量(2^(n)个可能密钥)之比。对于常见的密钥长度如 128 位和 256 位来说,1/2^(n)的数字非常小,可以忽略不计。
如果事件的概率是p,那么该事件不发生的概率为 1 – p。因此,在我们之前的例子中,得到错误密钥的概率是 1 – 1/2^(n),这是一个非常接近 1 的数字,意味着几乎确定。
加密安全
你已经知道经典的加密算法并不安全,而像一次性密码本这样的完全安全的加密算法则不实际。因此,如果我们想要既安全又可用的加密算法,就不得不在安全性上做一些妥协。但是,“安全”到底意味着什么呢?除了显而易见且非正式的“窃听者无法解密安全信息”之外,还有什么含义?
直观来说,如果即使在拥有大量明文–密文对的情况下,也无法得出任何关于密码行为的信息,那么该密码就是安全的。这引出了新的问题:
-
攻击者是如何获得这些对的?什么算是“大量”?这一切都由攻击模型来定义,即关于攻击者能够做什么和不能做什么的假设。
-
我们讨论的“学习内容”和“密码行为”是什么?这是由安全目标定义的,描述了什么是成功的攻击。
攻击模型和安全目标必须结合在一起;你不能在没有解释对谁或从什么方面安全的情况下声称系统是安全的。安全概念因此是某个安全目标与某个攻击模型的结合。如果在给定的模型中,任何攻击者都无法达到安全目标,我们就说一个密码实现了某种安全概念。
攻击模型
攻击模型是一组关于攻击者如何与密码交互以及他们能做什么和不能做什么的假设。攻击模型的目标如下:
-
为设计密码的密码学家设定要求,使他们知道需要防范哪些攻击者和哪些类型的攻击。
-
为用户提供指导,帮助他们判断密码在其环境中是否安全可用。
-
为试图破解密码的密码分析人员提供线索,让他们知道某个攻击是否有效。只有当某个攻击在考虑的模型中是可行的,才能称之为有效攻击。
攻击模型不需要完全符合现实;它们是近似的。正如统计学家乔治·E·P·博克斯所说:“所有模型都是错误的;实际的问题是它们必须错误到什么程度才不再有用。” 在密码学中,为了有用,攻击模型至少应涵盖攻击者能够做的事情来攻击密码。如果一个模型高估了攻击者的能力,那是可以接受的,也是件好事,因为它有助于预测未来的攻击技术——只有偏执的密码学家才能生存。一个不好的模型低估了攻击者的能力,通过让密码在理论上看似安全,实则在现实中并不安全,从而提供了错误的信心。
克尔霍夫原则
所有模型中都做出一个假设,即所谓的克尔霍夫原则,该原则指出,密码的安全性应仅依赖于密钥的保密性,而不依赖于密码本身的保密性。今天,这听起来可能很显然,因为密码和协议都是公开指定并被大家使用的。但是从历史上看,荷兰语言学家奥古斯特·克尔霍夫指的是专为某个军队或军区设计的军事加密机器。引用他在 1883 年发表的《军事密码学》一文中的话,其中列出了军事加密系统的六项要求:“系统不应要求保密,并且可以被敌人窃取而不会造成问题。”
黑盒模型
现在让我们考虑一些有用的攻击模型,这些模型通过攻击者能观察到的内容和他们能对密码做出的查询来表达。对于我们的目的,查询是指将输入值传送给某个函数并返回输出的操作,而不暴露该函数的细节。
举个例子,一个加密查询接受明文并返回相应的密文,而不泄露密钥。
我们称这些模型为黑盒模型,因为攻击者只能看到加密过程的输入和输出。例如,一些智能卡芯片安全地保护加密器的内部结构以及其密钥,但你可以连接到芯片并请求它解密任何密文。攻击者随后会收到相应的明文,这可能帮助他们确定密钥。这是一个现实的例子,其中解密查询是可能的。
有几种不同的黑盒攻击模型。在这里,我按从最弱到最强的顺序列出了它们,并描述了每种模型中攻击者的能力:
-
密文-only 攻击者(COA) 观察密文,但不知道关联的明文,也不知道这些明文是如何被选择的。COA 模型中的攻击者是被动的,不能执行加密或解密查询。
-
已知明文攻击者(KPA) 观察密文并知道关联的明文。KPA 模型中的攻击者因此得到明文-密文对的列表,假设明文是随机选择的。再一次,KPA 是一个被动攻击者模型。
-
选择明文攻击者(CPA) 可以对自己选择的明文进行加密查询,并观察结果的密文。这个模型描述了攻击者可以选择全部或部分被加密的明文,并查看相应的密文的情形。与 COA 或 KPA 这种被动模型不同,CPA 是主动攻击者,因为他们影响加密过程,而不是被动地窃听。
-
选择密文攻击者(CCA) 可以同时执行加密和解密;也就是说,他们能够执行加密查询和解密查询。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[2] = P[1] ⊕ 1,或者P[2] = 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 安全性的一种方法是使用随机加密。顾名思义,它使加密过程具有随机性,并在对同一明文进行两次加密时返回不同的密文。加密可以表示为C = E(K, R, P),其中R是新生成的随机位。然而,解密仍然是确定性的,因为给定E(K, R, P),无论R的值是什么,你都应该始终得到P。
如果加密不是随机的呢?在《安全目标》中引入的 IND 游戏中,攻击者选择两个明文,P[1]和P[2],并收到其中一个的密文,但不知道密文对应的是哪个明文。也就是说,他们得到C[i] = E(K, P[i]),并且必须猜测i是 1 还是 2。在 CPA 模型中,攻击者可以执行加密查询,确定C[1] = E(K, P[1])和C[2] = E(K, P[2])。如果加密不是随机的,攻击者只需查看C[i]是否等于C[1]或C[2],即可判断加密的是哪个明文,从而赢得 IND 游戏。因此,随机化是 IND-CPA 概念的关键。
注意
对于随机加密,密文必须比明文稍长,以便每个明文有多个可能的密文。例如,如果每个明文有 2⁶⁴ 个可能的密文,那么密文必须至少比明文长 64 位。
实现语义安全加密
一种最简单的语义安全密码构造方法使用了 确定性随机位生成器(DRBG),这是一种根据某些秘密值返回看似随机位的算法:
E(K, R, P) = (DRBG(K || R) ⊕ P, R)
在这里,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 的有效密文,从而与不可篡改性的概念相矛盾。
但是,相反的关系是成立的:NM-CPA 蕴含 IND-CPA。直觉上,IND-CPA 加密就像把物品放进一个袋子里:你看不见它们,但可以通过上下摇动袋子来重新排列它们的位置。NM-CPA 更像一个保险箱:一旦放入,你就无法与里面的物品互动。但这种类比不适用于 IND-CCA 和 NM-CCA,它们是等价的概念,每个都蕴含另一个。我就不赘述证明过程了,那个非常技术性。
两种类型的加密应用
加密应用有两种主要类型。传输中加密保护从一台机器发送到另一台机器的数据:数据在发送之前被加密,接收之后被解密,就像电子商务网站的加密连接。静态加密保护存储在信息系统中的数据。数据在写入内存之前被加密,在读取之前被解密。例子包括笔记本电脑上的磁盘加密系统,以及云虚拟实例的虚拟机加密。我们看到的安全概念适用于这两种类型的应用,但需要考虑的正确概念可能取决于具体应用。
非对称加密
到目前为止,我们只考虑了对称加密,其中两个方共享一个密钥。在非对称加密中,有两个密钥:一个用于加密,另一个用于解密。加密密钥称为公钥,通常被认为是公开的,任何想给你发送加密消息的人都可以使用。解密密钥必须保持秘密,称为私钥。
公钥可以从私钥中计算得出,但显然私钥不能从公钥中计算得出。换句话说,计算方向是单向的,容易计算,但反向计算几乎是不可能的——这就是公钥加密学的关键,其函数在一个方向上易于计算,但几乎无法逆向计算。
非对称加密的攻击模型和安全目标与对称加密差不多,只不过由于加密密钥是公开的,任何攻击者都可以使用公开密钥进行加密查询。因此,非对称加密的默认模型是选择明文攻击者(CPA)。
对称加密和非对称加密是两种主要的加密方式,通常结合使用来构建安全通信系统。它们也用作更复杂方案的基础,正如你接下来会看到的。
当密码学做的不仅仅是加密
基本加密将明文转化为密文,将密文转化为明文,唯一的要求是安全性。然而,一些应用往往需要更多的东西,不论是额外的安全特性还是额外的功能。因此,加密学家们创建了对称加密和非对称加密的变体。某些变体已被广泛理解、高效且广泛应用,而其他一些则是实验性的,使用很少,且性能较差。
认证加密
认证加密(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, 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)类似于基础加密,只不过增加了一个叫做调整值的附加参数,它旨在模拟密码的不同版本(见图 1-6)。调整值可能是一个独特的每个客户的值,以确保客户的加密算法无法被其他使用相同产品的方克隆,但 TE 的主要应用是磁盘加密。然而,TE 并不局限于单一应用,它是一种低级别的加密类型,可用于构建其他方案,如认证加密模式。

图 1-6:可调加密
在磁盘加密中,TE 加密存储设备的内容,如硬盘或固态硬盘。(由于随机化加密会增加数据的大小,这对于存储介质上的文件来说是不可接受的,因此无法使用随机化加密。)为了使加密不可预测,TE 使用一个取决于加密数据位置的调整值,通常是扇区号或块索引。
错误的可能性
加密算法或其实现可能以多种方式未能保护机密性。这可能是因为未能匹配安全要求(例如“符合 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 攻击这样的攻击,因为它们通常依赖于应用程序的行为以及用户与应用程序的交互方式。但是,如果你没有预见到这些攻击,并且在设计和部署加密时没有将它们纳入模型,你可能会遇到一些令人吃惊的麻烦。
进一步阅读
本书中我们详细讨论了加密及其多种形式,尤其是现代安全密码的工作原理。不过,我们无法涵盖所有内容,许多有趣的主题将不会讨论。例如,要学习加密的理论基础并深入理解不可区分性(IND)的概念,你应该阅读 1982 年由 Goldwasser 和 Micali 提出的关于语义安全的论文《概率加密及如何在保持所有部分信息秘密的情况下玩心理扑克》。如果你对物理攻击和密码硬件感兴趣,CHES 会议的论文集是主要参考资料。
还有许多其他类型的加密方法,远超本章所介绍的,包括基于属性的加密、广播加密、功能加密、基于身份的加密、消息锁定加密和代理重加密等。关于这些主题的最新研究,你可以查看eprint.iacr.org/,这是一个密码学研究论文的电子档案。
第三章:随机性

随机性在密码学中无处不在:在生成秘密密钥、加密方案,甚至在密码系统的攻击中。没有随机性,密码学将无法进行,因为所有操作都将变得可预测,因此不安全。
本章将介绍密码学中随机性的概念及其应用。我们讨论伪随机数生成器以及操作系统如何产生可靠的随机性,并通过实际例子展示有缺陷的随机性如何影响安全性。
随机还是非随机?
你可能已经听过“随机位”这个词,但严格来说,并没有所谓的“随机位”序列。真正随机的是产生随机位序列的算法或过程;因此,当我们说“随机位”时,实际上指的是随机生成的位。
随机位是什么样子的?例如,对于大多数人来说,8 位字符串 11010110 比 00000000 更具随机性,尽管它们生成的机会是相同的(即 1/256)。11010110 看起来更随机,是因为它具有典型的随机生成值的特征。也就是说,11010110 没有明显的模式。
当我们看到字符串 11010110 时,大脑会注册到它的零(3 个)和一(5 个)的数量大致相同,就像其他 55 个 8 位字符串(11111000、11110100、11110010 等),但只有一个 8 位字符串是全零的。因为模式“三个零和五个一”比“八个零”的模式更可能出现,我们将 11010110 识别为随机,而将 00000000 识别为非随机。如果一个程序生成了 11010110,你可能认为它是随机的,即使它并不随机。相反,如果一个随机程序生成了 00000000,你可能会怀疑它是不是随机的。
这个例子展示了人们在识别随机性时常犯的两种错误:
把非随机性误认为是随机性 认为某个对象是随机生成的,仅仅因为它看起来是随机的。
把随机性误认为是非随机性 认为偶然出现的模式是出于其他原因,而非偶然。
随机看起来与真正随机之间的区别至关重要。事实上,在密码学中,非随机性通常意味着不安全。
随机性作为概率分布
任何随机化过程都由概率分布来表征,概率分布提供了关于该过程随机性的所有信息。概率分布,或者简而言之分布,列出了随机化过程的结果,其中每个结果都被分配一个概率。
概率衡量一个事件发生的可能性。它表示为介于 0 和 1 之间的实数,其中概率为 0 表示不可能,概率为 1 表示确定。例如,当抛一枚双面硬币时,每一面朝上的概率为 1/2,我们通常假设硬币落在边缘的概率为零。
概率分布必须包括所有可能的结果,以使所有概率的总和为 1。具体来说,如果有 N 个可能的事件,那么有 N 个概率 p[1],p[2],…,p[N],并且 p[1] + p[2] + … + p[N] = 1。例如,在抛硬币的情况下,正面和反面的分布分别是 1/2 和 1/2。两个概率的总和为 1/2 + 1/2 = 1,因为硬币会落在两个面之一。
均匀分布发生在分布中的所有概率相等时,这意味着所有结果发生的可能性相等。如果有 N 个事件,那么每个事件的概率为 1/N。例如,如果一个 128 位的密钥是均匀随机选择的——即根据均匀分布——那么所有 2¹²⁸ 个可能的密钥的概率应该是 1/2¹²⁸。
相反,当分布是 非均匀 时,概率并不相等。例如,一次具有非均匀分布的硬币抛掷可能会以 1/4 的概率出现正面,3/4 的概率出现反面。
熵:不确定性的度量
熵是衡量系统中不确定性或无序度的指标。你可以将熵看作是随机过程结果中的惊讶程度:熵越高,结果的不确定性越大。
我们可以计算概率分布的熵。如果你的分布包含概率 p[1],p[2],…,p[N],那么它的熵是所有概率与它们的对数相乘的负和,如下所示:
−p[1] × log(p[1]) − p[2] × log(p[2]) − … − p[N] × log(p[N])
这里的函数 log 是 二进制对数,即以二为底的对数。与自然对数不同,二进制对数以位为单位表示信息,并且当概率是二的幂时,它会返回整数值。例如,log(1/2) = -1,log(1/4) = -2,更一般地,log(1/2^(n)) = - n。(这就是为什么我们实际取 负和,以得到一个正数。)因此,使用均匀分布生成的随机 128 位密钥的熵为:
2¹²⁸ × (−2^(−128) × log(2^(−128))) = −log(2^(−128)) = 128 位
如果你将 128 替换为任意整数 n,你会发现均匀分布的 n 位字符串的熵为 n 位。
当分布是均匀时,熵最大,因为均匀分布最大化了不确定性:没有结果比其他结果更有可能发生。因此,n 位值的熵不能超过 n 位。
同样地,当分布不均匀时,熵就较低。考虑硬币抛掷的例子。公平抛掷的熵是如下:
−(1/2) × log (1/2) − (1/2) × log (1/2) = 1/2 + 1/2 = 1 bit
如果硬币的一面比另一面更可能朝上怎么办?比如正面朝上的概率是 1/4,反面朝上的概率是 3/4(记住所有概率的总和应该为 1)。
这种偏向的抛掷的熵为:
−(3/4) × log(3/4) − (1/4) × log(1/4) ≈ −(3/4) × (−0.415) − (1/4) × (−2) ≈ 0.81 bit
0.81 比公平抛掷的 1 位熵要少,这告诉我们硬币越偏,分布越不均匀,熵越低。进一步说,如果正面的概率是 1/10,那么熵为 0.469;如果概率降至 1/100,熵降至 0.081。
注意
熵也可以视为信息的度量。例如,公平抛硬币的结果给你正好一位信息——正面或反面——你无法预先预测抛掷结果。在不公平的硬币抛掷中,你提前知道反面更可能出现,因此你通常可以预测抛掷的结果。硬币抛掷的结果提供了预测结果所需的信息,且可以确定预测。
随机数生成器(RNGs)和伪随机数生成器(PRNGs)
加密系统需要随机性来保持安全性,因此需要一个组件来获取随机性。这个组件的任务是,在被请求时返回随机位。那么,如何生成这种随机性呢?你需要两个东西:
-
不确定性的来源,或称熵的来源,由随机数生成器(RNGs)提供。
-
一个加密算法,用于从熵的来源产生高质量的随机位。这在伪随机数生成器(PRNGs)中有所体现。
使用 RNGs 和 PRNGs 是使加密技术实用且安全的关键。在深入研究 PRNGs 之前,我们先简要了解 RNGs 的工作原理。
随机性来自环境,它是模拟的、混乱的、不确定的,因此是不可预测的。仅靠计算机算法无法生成随机性。在加密学中,随机性通常来自随机数生成器(RNGs),这些是利用模拟世界中的熵生成数字系统中不可预测位的软件或硬件组件。例如,一个 RNG 可能直接从温度、声噪、空气湍流或电气静电的测量中采样位。不幸的是,这些模拟熵源并不总是可用,而且它们的熵通常难以估计。
RNG 还可以通过从附加的传感器、I/O 设备、网络或磁盘活动、系统日志、运行中的进程和用户活动(如按键和鼠标移动)中获取熵来收集一个正在运行的操作系统中的熵。此类系统和人为生成的活动可以是一个很好的熵源,但它们可能很脆弱,且容易受到攻击者的操控。此外,它们生成随机位的速度较慢。
量子随机数生成器(QRNGs) 是一种依赖于量子力学现象(如放射性衰变、真空波动和观察光子偏振)产生随机性的 RNG。它们可以提供真正的随机性,而不仅仅是表面上的随机性。然而,实际上,QRNG 可能存在偏差,并且不能快速生成位;像前面提到的熵源一样,它们需要额外的组件才能在高速下稳定生成。
伪随机数生成器(PRNGs) 解决了我们在生成随机性方面的挑战,通过可靠地从少量真正的随机位中生成许多人工随机位。例如,一个将鼠标移动转换为随机位的 RNG 如果停止移动鼠标就会停止工作,而 PRNG 每次请求时都会返回伪随机位。
PRNG 依赖于 RNG,但其行为有所不同:RNG 从模拟源中以非确定性的方式相对缓慢地产生真正的随机位,并且没有高熵的保证。相比之下,PRNG 从数字源中以确定性的方式快速生成看似随机的位,并且具有最大熵。本质上,PRNG 将少量不可靠的随机位转换为适用于加密应用的大量可靠伪随机位,如图 2-1 所示。

图 2-1:RNG 从模拟源中生成少量不可靠的位,而 PRNG 将这些位扩展为可靠的长位流。
PRNG 工作原理
PRNG 定期从 RNG 获取随机位,并使用它们更新一个大型内存缓冲区,称为 熵池。熵池是 PRNG 的熵源,就像物理环境对于 RNG 一样。当 PRNG 更新熵池时,它将熵池中的位混合在一起,以帮助消除任何统计偏差。
为了生成伪随机位,PRNG 运行一个确定性随机位生成器(DRBG)算法,将熵池中的一些位扩展成更长的序列。顾名思义,DRBG 是确定性的,而不是随机的:给定一个输入,你总是会得到相同的输出。PRNG 确保其 DRBG 永远不会接收到相同的输入两次,从而生成唯一的伪随机序列。
在其工作过程中,PRNG 执行三个操作,如下所示:
init() 初始化熵池和 PRNG 的内部状态
refresh(R) 使用一些数据 R 更新熵池,数据通常来源于 RNG
next(N) 返回 N 个伪随机比特并更新熵池
init 操作将 PRNG 重置为一个全新的状态,重新初始化熵池为默认值,并初始化 PRNG 用于执行 refresh 和 next 操作的任何变量或内存缓冲区。
refresh 操作通常称为 重新播种,其参数 R 被称为 种子。当没有 RNG 可用时,种子可能是系统中硬编码的唯一值。refresh 操作通常由操作系统调用,而 next 则通常由应用程序调用或请求。next 操作运行 DRBG 并修改熵池,确保下一次调用将生成不同的伪随机比特。
安全问题
我们简要讨论一下 PRNG 如何解决一些高级安全问题。具体来说,PRNG 应该保证 回溯抵抗 和 预测抵抗。回溯抵抗(也称为 前向保密性)意味着无法恢复先前生成的比特,而预测抵抗(后向保密性)意味着未来的比特无法预测。
为了实现回溯抵抗,PRNG 应确保在通过 refresh 和 next 操作更新状态时所执行的转换是不可逆的,这样即使攻击者攻破系统并获得熵池的值,也无法确定熵池之前的值或先前生成的比特。为了实现预测抵抗,PRNG 应定期调用 refresh,使用攻击者无法知道且难以猜测的 R 值,从而防止攻击者在熵池被完全攻破的情况下预测未来的熵池值。(即使知道了使用的 R 值列表,你仍然需要知道 refresh 和 next 调用的顺序,才能重建熵池。)
PRNG Fortuna
Fortuna 是一种 PRNG 构造,最初由 Niels Ferguson 和 Bruce Schneier 于 2003 年设计,用于 Windows。Fortuna 替代了 Yarrow,后者是 Kelsey 和 Schneier 于 1998 年设计的,现在用于 macOS 和 iOS 操作系统。我不会在这里提供 Fortuna 的规格说明或实施方法,但我会尝试解释其工作原理。你可以在 Ferguson、Schneier 和 Kohno 的《Cryptography Engineering》一书的 第九章 中找到 Fortuna 的完整描述(Wiley,2010)。
Fortuna 的内部内存包括以下内容:
-
三十二个熵池,P[1],P[2],…,P[32],其中 P[i] 每 2^(i) 次重新播种时使用一次。
-
一个密钥 K 和一个计数器 C(均为 16 字节)。这两者组成了 Fortuna DRBG 的内部状态。
简单来说,Fortuna 的工作方式如下:
-
init() 将 K 和 C 设置为零,并清空 32 个熵池 P[i],其中 i = 1 … 32。
-
刷新(R)将数据R附加到一个熵池中。系统选择用于生成R值的 RNG,并且应该定期调用刷新。
-
下一步(N)使用来自一个或多个熵池的数据更新K,熵池的选择主要取决于K已经更新的次数。然后,通过使用K作为密钥加密C来生成请求的N比特。如果加密C不足够,Fortuna 将依次加密C + 1,C + 2,依此类推,直到获得足够的比特。
尽管 Fortuna 的操作看起来相当简单,但正确实现它们却很难。首先,你需要把算法的所有细节弄对——即如何选择熵池、下一步使用什么类型的加密算法、在没有熵的情况下如何处理等等。虽然规范定义了大部分细节,但它们没有包含全面的测试套件来检查实现是否正确,这使得确保你对 Fortuna 的实现按预期工作变得困难。
即使 Fortuna 正确实现,也可能由于其他原因发生安全失败,而非仅仅是使用了不正确的算法。例如,如果 RNG 未能生成足够的随机比特,Fortuna 可能不会察觉,结果 Fortuna 会生成质量较低的伪随机比特,或者可能完全停止生成伪随机比特。
Fortuna 实现中另一个固有的风险是可能会暴露关联的种子文件给攻击者。Fortuna 种子文件中的数据用于在 RNG 不可用时通过刷新调用为 Fortuna 提供熵,例如系统重启后,系统的 RNG 尚未记录任何不可预测事件时。然而,如果相同的种子文件被使用了两次,那么 Fortuna 将生成相同的比特序列。因此,种子文件在使用后应被擦除,以确保不被重复使用。
最后,如果两个 Fortuna 实例处于相同的状态,因为它们共享一个种子文件(意味着它们共享相同的数据在熵池中,包括相同的C和K),那么下一步操作将在两个实例中返回相同的比特。
加密与非加密 PRNGs
存在加密和非加密的伪随机数生成器(PRNG)。非加密 PRNG 被设计用来生成均匀分布,适用于科学模拟或视频游戏等应用。然而,绝对不要在加密应用中使用非加密 PRNG,因为它们不安全——它们只关注比特的概率分布质量,而不关心其可预测性。另一方面,加密 PRNG 是不可预测的,因为它们还关注用于生成均匀分布比特的底层操作的强度。
不幸的是,大多数编程语言暴露的伪随机数生成器(PRNGs),例如 libc 的rand和drand48,PHP 的rand和mt_rand,Python 的random模块,Ruby 的Random类等,都是非加密的。默认使用非加密 PRNG 是一个灾难的开端,因为它通常最终会在加密应用中被使用,所以一定要在加密应用中只使用加密 PRNG。
一种流行的非加密 PRNG:梅森旋转算法
梅森旋转算法(MT)是一种非加密的伪随机数生成器(PRNG),目前(撰写本文时)在 PHP、Python、R、Ruby 和许多其他系统中使用。MT 将生成均匀分布的随机位,没有统计偏差,但它是可预测的:给定由 MT 生成的几个位,足够容易地判断出接下来会产生哪些位。
让我们深入看看,是什么使得梅森旋转算法不安全。MT 算法比加密伪随机数生成器(crypto PRNGs)的算法要简单得多:其内部状态是一个由 624 个 32 位字组成的数组,S。这个数组最初设置为 S[1]、S[2]、…、S[624],然后根据这个公式演变为 S[2]、…、S[625],接着是 S[3]、…、S[626],以此类推:
S[k + 624] = S[k + 397] ⊕ A((S[k] ∧ 0x80000000) ∨ (S[k + 1] ∧ 0x7fffffff))
这里,⊕表示按位异或(在 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.5 千字节的数据。反过来说,初始状态中的位也可以表示为输出位的异或。
线性不安全性
我们将位的异或(XOR)组合称为线性组合。例如,如果X、Y和Z是位,那么表达式 X ⊕ Y ⊕ Z 就是一个线性组合,而 (X ∧ Y) ⊕ Z 不是,因为有一个与运算(∧)。如果你翻转 X 中的一个位,在 X ⊕ Y ⊕ Z 中,结果也会改变,无论 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)测试套件这样的统计测试套件是测试伪随机位质量的一种方式。这些测试会取样由 PRNG 生成的伪随机位(比如,取一兆字节的数据),计算这些位中某些模式的分布的统计量,并将结果与完美、均匀分布的典型结果进行比较。例如,一些测试会计算 1 位与 0 位的数量,或者 8 位模式的分布。但统计测试在密码学安全性方面基本上是无关的,完全可以设计出一个密码学上弱的 PRNG,它能骗过任何统计测试。
当你对随机生成的数据运行统计测试时,通常会看到一些统计指标作为结果。这些通常是p-值,常见的统计指标。由于它们通常不像通过或失败那样简单,结果不总是容易解释。如果你的第一次测试结果看起来不正常,不要担心:它们可能是某种意外偏差的结果,或者你测试的样本太少。为了确保你看到的结果是正常的,可以将其与一些可靠的相同大小的样本进行比较;例如,使用以下命令通过 OpenSSL 工具包生成的样本:
$ openssl rand *<number of bytes>* -out *<output file>*
现实世界中的 PRNG
让我们将注意力转向如何在实际世界中实现伪随机数生成器(PRNG)。你会在大多数平台的操作系统(OS)中找到加密 PRNG,包括桌面和笔记本电脑、嵌入式系统(如路由器和机顶盒)以及虚拟机、手机等。大多数这些 PRNG 是软件实现的,但有些是纯硬件的。这些 PRNG 被操作系统上运行的应用程序使用,有时也被运行在加密库或应用程序上的其他 PRNG 使用。
接下来,我们将看一下最广泛部署的 PRNG:用于 Linux、Android 和许多其他 Unix 系统的那个;Windows 中的那个;以及最近的英特尔微处理器中的那个,它是硬件实现的。
在 Unix 系统中生成随机比特
设备文件/dev/urandom是常见nix 系统加密伪随机数生成器(PRNG)与用户空间的接口,它通常用于生成可靠的随机比特。因为它是一个设备文件,所以从/dev/urandom请求随机比特是通过将其当作文件读取来完成的。例如,以下命令使用/dev/urandom*将 10MB 的随机比特写入一个文件:
$ dd if=/dev/urandom of=*<output file>* bs=1M count=10
错误的使用/dev/urandom 方法
你可以编写一个像清单 2-1 所示的简单且不安全的 C 程序来读取随机比特,并寄希望于它能正常工作,但那是一个不好的主意。
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
这段代码是不安全的;它甚至没有检查open()和read()的返回值,这意味着你期望的随机缓冲区可能会被零填充,或者保持不变。
更安全的使用/dev/urandom 方法
从 LibreSSL 复制过来的清单 2-2 展示了更安全的使用/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 进行了几个合理性检查。比较一下❶处的open()调用和❷处的read()调用,与清单 2-1 中的调用,你会注意到,更安全的代码检查了这些函数的返回值,并在失败时关闭文件描述符并返回-1。
Linux 中/dev/urandom 与/dev/random 的区别
不同的 Unix 版本使用不同的 PRNG。Linux 的 PRNG 定义在 Linux 内核的drivers/char/random.c中,主要使用哈希函数 SHA-1 将原始熵位转化为可靠的伪随机位。PRNG 从各种来源(包括键盘、鼠标、磁盘和中断时序)收集熵,并拥有一个 512 字节的主熵池,以及一个用于/dev/urandom的非阻塞池和一个用于/dev/random的阻塞池。
/dev/urandom和/dev/random有什么区别?简而言之,/dev/random试图估算熵的数量,并且如果熵的水平太低,则拒绝返回位。虽然这听起来像是一个好主意,但实际上并不是。首先,熵估算器通常不可靠,并且容易被攻击者欺骗(这也是 Fortuna 放弃 Yarrow 熵估算的原因之一)。此外,/dev/random很快就会用完估算的熵,这可能会导致拒绝服务状况,拖慢被迫等待更多熵的应用程序。总的来说,实际上,/dev/random并不比/dev/urandom更好,反而带来了更多的问题。
估算/dev/random的熵
你可以通过在 Linux 上读取/proc/sys/kernel/random/entropy_avail中的当前熵值(以位为单位)来观察/dev/random的熵估算是如何演变的。例如,清单 2-3 中显示的脚本首先通过从/dev/random读取 4KB 来最小化熵估算,然后等待直到估算值达到 128 位,从/dev/random读取 64 位,然后显示新的估算值。运行脚本时,注意到用户活动如何加速熵恢复(读取的字节以 base64 编码打印到标准输出)。
#!/bin/sh
ESTIMATE=/proc/sys/kernel/random/entropy_avail
timeout 3s dd if=/dev/random bs=4k count=1 2> /dev/null | base64
ent=`cat $ESTIMATE`
while [ $ent -lt 128 ]
do
sleep 3
ent=`cat $ESTIMATE`
echo $ent
done
dd if=/dev/random bs=8 count=1 2> /dev/null | base64
cat $ESTIMATE
清单 2-3:展示/dev/urandom*熵估算演变的脚本
清单 2-3 的示例运行输出了清单 2-4 所示的结果。(猜猜我开始随机移动鼠标并敲击键盘来收集熵的时间。)
xFNX/f2R87/zrrNJ6Ibr5R1L913tl+F4GNzKb60BC+qQnHQcyA==
2
18
19
27
28
72
124
193
jq8XWCt8
129
清单 2-4:展示清单 2-3 中熵估算演变脚本的示例执行
如清单 2-4 所示,根据/dev/random的估算器,我们在熵池中剩下了 193 − 64 = 129 位熵。仅仅因为从 PRNG 中读取了N位熵,是否有意义认为 PRNG 的熵减少了N位呢?(剧透:没有。)
注意
像/dev/random一样,Linux 的 getrandom()系统调用在未收集到足够初始熵时会阻塞。然而,不同于* /dev/random,它不会尝试估算系统中的熵,并且在初始化阶段之后永远不会阻塞。这是可以的。(你可以通过调整标志强制 getrandom()使用/dev/random并阻塞,但我不明白为什么你会这么做。)*
Windows 中的 CryptGenRandom()函数
在 Windows 中,系统的传统用户空间接口是通过加密应用程序接口(API)中的 CryptGenRandom() 函数实现的。CryptGenRandom() 函数在最近的 Windows 版本中已被 Cryptography API: Next Generation (CNG) API 中的 BcryptGenRandom() 函数所替代。Windows 的伪随机数生成器(PRNG)从内核模式驱动程序 cng.sys(前身为 ksecdd.sys)中获取熵,其熵收集器大致基于 Fortuna。像 Windows 中通常的情况一样,这个过程比较复杂。
清单 2-5 展示了一个典型的 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-5:使用 Windows CryptGenRandom() PRNG 接口
请注意,在 清单 2-5 中,在调用实际的 PRNG 之前,您需要声明一个 加密服务提供者(HCRYPTPROV),然后通过 CryptAcquireContext() 获取一个 加密上下文,这增加了出错的几率。例如,TrueCrypt 加密软件的最终版本曾经以一种可能默默失败的方式调用 CryptAcquireContext(),从而导致随机性不佳且未通知用户。幸运的是,Windows 中更新的 BCryptGenRandom() 接口要简单得多,不需要代码显式地打开句柄(或者至少让不使用句柄变得更容易)。
基于硬件的 PRNG:英特尔微处理器中的 RDRAND
我们到目前为止只讨论了软件 PRNG,现在我们来看看硬件 PRNG。英特尔数字随机数生成器 是一个硬件 PRNG,它于 2012 年在英特尔的 Ivy Bridge 微架构中推出,基于 NIST 的 SP 800-90 指南,并在 CTR_DRBG 模式下使用高级加密标准(AES)。英特尔的 PRNG 通过 RDRAND 汇编指令访问,该指令提供了一个独立于操作系统的接口,原则上比软件 PRNG 更快。
而软件 PRNG 尝试从不可预测的来源收集熵,RDRAND 具有单一的熵源,它提供一个连续的熵数据流,由零和一组成。从硬件工程的角度来看,这个熵源是一个具有反馈的双差分闩锁;本质上,它是一个小型硬件电路,根据热噪声波动,在 800 MHz 的频率下在两种状态(0 或 1)之间跳跃。这种方式通常是相当可靠的。
RDRAND 汇编指令以 16 位、32 位或 64 位的寄存器作为参数,然后写入一个随机值。当被调用时,RDRAND 如果目标寄存器中的数据是有效的随机值,则会将进位标志设置为 1,否则为 0,这意味着如果你直接编写汇编代码,应该检查 CF 标志。请注意,常见编译器中可用的 C 内建函数不会检查 CF 标志,但会返回其值。
注意
英特尔的 PRNG 框架提供了一条不同于RDRAND的汇编指令:RDSEED汇编指令直接从熵源返回随机位,经过一些条件处理或加密处理后。它旨在为其他 PRNG 提供种子。
英特尔的 PRNG 文档仅部分公开,但它是基于已知标准构建的,并且已经由著名的公司 Cryptography Research 进行了审计(参见他们的报告《分析 Intel 的 Ivy Bridge 数字随机数生成器》)。尽管如此,仍然有人对其安全性表示担忧,尤其是在斯诺登揭露了加密后门的背景下,PRNG 的确是破坏的完美目标。如果你担心但仍希望使用RDRAND或RDSEED,只需将它们与其他熵源混合使用。这样做将防止在除最极端的情况下,有效地利用 Intel 硬件或相关微代码中假设存在的后门。
如何出错
总结一下,我将展示一些随机性失败的例子。虽然有无数例子可供选择,但我选择了四个足够简单的例子,以便理解并说明不同的问题。
熵源不足
1996 年,Netscape 浏览器的 SSL 实现根据清单 2-6 中的伪代码计算 128 位 PRNG 种子,该伪代码复制自 Goldberg 和 Wagner 的页面,地址为www.cs.berkeley.edu/~daw/papers/ddj-netscape.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-6:Netscape 浏览器生成 128 位 PRNG 种子的伪代码
这里的问题是,PID 和微秒是可以猜测的值。假设你能猜测到秒的值,那么微秒只有 10⁶个可能的值,因此其熵为 log(10⁶),即约 20 位。进程 ID(PID)和父进程 ID(PPID)是 15 位值,因此你预期得到 15 + 15 = 30 个额外的熵位。但如果你查看如何在❶处计算b,你会发现三个比特的重叠只会产生约 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》,该论文可以在* factorable.net/ *找到。
非加密 PRNG
之前我们讨论了加密和非加密 PRNG 之间的区别,以及为什么后者永远不应该用于加密应用。遗憾的是,许多系统忽视了这一细节,因此我想至少给你一个这样的例子。
流行的 MediaWiki 应用程序在 Wikipedia 和许多其他 wiki 网站上运行。它使用随机性来生成诸如安全令牌和临时密码等内容,这些内容当然应该是不可预测的。不幸的是,MediaWiki 的一个已废弃版本使用了非加密 PRNG——梅森旋转算法,来生成这些令牌和密码。以下是易受攻击的 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),这是之前讨论过的非加密 PRNG。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条件中存在一个越界错误。具体细节我留给你作为练习。你应该会发现,生成的值的熵是 45,而不是大约 53 位(提示:<=应该改为<)。
进一步阅读
在这一章中,我仅仅触及了密码学中随机性的表面。关于随机性的理论还有很多内容需要学习,包括不同的熵概念、随机提取器,以及随机化和去随机化在复杂性理论中的应用等主题。要深入了解伪随机数生成器(PRNG)及其安全性,可以阅读 1998 年由 Kelsey、Schneier、Wagner 和 Hall 发表的经典论文《Cryptanalytic Attacks on Pseudorandom Number Generators》。然后,查看你最喜欢的应用程序中 PRNG 的实现,并尝试找出它们的弱点。(可以在线搜索“random generator bug”来找到许多相关实例。)
然而,我们并没有结束对随机性的讨论。我们将在本书中多次遇到它,你将发现它在构建安全系统时的多种帮助作用。
第四章:加密安全

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

在冷战时期,美国和苏联分别开发了各自的密码。美国政府创建了数据加密标准(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¹⁶位呢?
一方面,块的大小不应过大,以最小化密文的长度和内存占用。关于密文的长度,块密码处理的是块,而不是比特。这意味着,当块为 128 位时,为了加密一个 16 位的消息,首先需要将消息转换为一个 128 位块,只有这样,块密码才会处理它并返回一个 128 位的密文。块越宽,开销就越大。至于内存占用,为了处理一个 128 位的块,你至少需要 128 位的内存。这个内存大小足够小,可以适应大多数 CPU 的寄存器或使用专用硬件电路实现。64 位、128 位甚至 512 位的块在大多数情况下都足够短,可以高效实现。但更大的块(例如几千字节)可能会显著影响实现的成本和性能。
当密文的长度或内存占用至关重要时,可能需要使用 64 位块,因为它们生成的密文较短,占用更少的内存。否则,128 位或更大的块更好,主要因为 128 位块在现代 CPU 上比 64 位块处理更高效,而且也更安全。特别是,CPU 可以利用特殊的 CPU 指令来高效地并行处理一个或多个 128 位块——例如,英特尔 CPU 中的高级向量扩展(AVX)指令集。
密码本攻击
虽然块不应太大,但也不应太小;否则,它们可能容易受到密码本攻击的威胁,密码本攻击是针对块密码的一种攻击,只有在使用较小块时才会高效。密码本攻击在使用 16 位块时的工作原理如下:
-
获取对应于每个 16 位明文块的 65536(2¹⁶)个密文。
-
构建查找表——即密码本——将每个密文块映射到其对应的明文块。
-
要解密一个未知的密文块,可以在表中查找其对应的明文块。
当使用 16 位块时,查找表只需要 2¹⁶ × 16 = 2²⁰位内存,即 128 千字节。使用 32 位块时,内存需求增加到 16 千兆字节,仍然可以管理。但使用 64 位块时,你需要存储 2⁷⁰位(一个泽比特,或 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相同。否则,所有轮次将是相同的,分组密码的安全性会降低,正如接下来所描述的那样。
滑动攻击与轮密钥
在分组密码中,为了避免滑动攻击,每一轮的操作不应与其他轮次相同。滑动攻击寻找两个明文/密文对(P[1], C[1])和(P[2], C[2]),其中 P[2] = R(P[1]),如果R是密码的轮函数(参见图 4-1)。当轮次相同的时候,两个明文之间的关系 P[2] = R(P[1]),就意味着它们各自的密文之间也存在关系 C[2] = R(C[1])。图 4-1 显示了三轮,但无论轮数是 3、10 还是 100,关系 C[2] = R(C[1]) 始终成立。问题在于,知道单轮的输入和输出通常有助于恢复密钥。(详情请阅读 1999 年 Biryukov 和 Wagner 的论文《高级滑动攻击》,可以在 www.iacr.org/archive/eurocrypt2000/1807/18070595-new.pdf 上找到)
使用不同的轮密钥作为参数可以确保各轮操作的不同,从而有效防止滑动攻击。

图 4-1:针对具有相同轮次的分组密码的滑动攻击原理
注意
使用轮密钥的一个潜在副作用和好处是防御旁道攻击,旁道攻击是指利用密码实现过程中泄露的信息(例如电磁辐射)进行的攻击。如果从主密钥 K 到轮密钥 K[i] 的转换不可逆,那么如果攻击者找到 K[i] ,他们就无法使用该密钥来恢复 K。* 不幸的是,很少有分组密码采用单向密钥调度。AES 的密钥调度允许攻击者从任何一个轮密钥* K[i] 计算出 K*,例如。
替代–置换网络
如果你读过关于密码学的教材,你一定会接触到混淆和扩散这两个概念。混淆意味着输入(明文和加密密钥)经历复杂的转换,而扩散则意味着这些转换依赖于输入的所有位。在高级层面上,混淆关注的是深度,而扩散关注的是广度。在分组密码的设计中,混淆和扩散以替代和置换操作的形式出现,这些操作结合在替代–置换网络(SPNs)中。
替代通常以S-boxes(或替代盒)的形式出现,这是用于转换 4 位或 8 位数据块的小型查找表。例如,分组密码 Serpent 的八个 S-box 中的第一个由 16 个元素组成(3 8 f 1 a 6 5 b e d 4 2 7 0 9 c),其中每个元素代表一个 4 位的字节。这个特定的 S-box 将 4 位字节 0000 映射到 3(0011),4 位字节 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 的分组密码,其工作原理如下:
-
将 64 位块分为两个 32 位的部分,L 和 R。
-
将 L 设为 L ⊕ F(R),其中 F 是一个替代-置换回合。
-
交换 L 和 R 的值。
-
跳转到第 2 步并重复 15 次。
-
将 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 是世界上最常用的加密算法。在 AES 采用之前,标准的加密算法是 DES,它的 56 位安全性完全不够,而且升级版的 DES 称为三重 DES(3DES)。
尽管 3DES 提供了更高的安全性(112 位安全性),但它效率低下,因为为了达到 112 位的安全性,密钥需要 168 位长,而且在软件中运行较慢(DES 是为集成电路设计的,并非为主流 CPU 设计)。AES 解决了这两个问题。
NIST 在 2000 年将 AES 标准化,作为 DES 的替代方案,至此它成为全球公认的加密标准。今天,大多数商业加密产品都支持 AES,NSA 也已批准其用于保护最高机密的信息。(一些国家确实更喜欢使用自己的加密算法,主要是因为他们不想使用美国标准,但 AES 实际上比美国更具比利时背景。)
注意
AES 曾被称为 Rijndael(这是它的发明者 Rijmen 和 Daemen 名字的合成词,发音类似于“rain-dull”),当时它是 AES 竞赛中的 15 个候选算法之一。该竞赛由 NIST 在 1997 至 2000 年间举办,目的是指定一个“非机密的、公开的加密算法,能够有效保护敏感的政府信息,直至下个世纪”。这项竞赛可以说是加密学家的“天才大赛”,任何人都可以通过提交自己的加密算法或破解其他参赛者的算法来参与。
AES 内部结构
AES 处理 128 位的块,使用 128、192 或 256 位的密钥,其中 128 位密钥是最常见的,因为它使加密速度稍快一些,而且 128 位和 256 位安全性的差异对于大多数应用来说并不重要。

图 4-3:AES 的内部状态,视为一个 16 字节的 4 × 4 数组
有些加密算法处理单个比特或 64 位字,但 AES 操作的是字节。它将 16 字节的明文视为一个二维的字节数组(s = s[0],s[1],…,s[15]),如图 4-3 所示。(字母s表示这个数组,因为它被称为内部状态,或简称状态。)AES 通过变换该数组的字节、列和行来生成最终的密文。
为了变换其状态,AES 采用了如图 4-4 所示的 SPN 结构,128 位密钥使用 10 轮,192 位密钥使用 12 轮,256 位密钥使用 14 轮。

图 4-4:AES 的内部操作
图 4-4 展示了 AES 轮次的四个基本构件(注意,除了最后一轮,所有轮次都是 SubBytes、ShiftRows、MixColumns 和 AddRoundKey 的序列):
AddRoundKey 将一个轮密钥与内部状态进行 XOR 运算。
SubBytes 用一个 S 盒根据查找表替换每个字节(s[0]、s[1]、…、s[15])。在这个例子中,S 盒是一个包含 256 个元素的查找表。
ShiftRows 将第i行按i个位置进行移位,i的范围从 0 到 3(见图 4-5)。
MixColumns 对状态的四列(即每组具有相同灰度的单元格,如图 4-5 左侧所示)应用相同的线性变换。
记住,在 SPN 中,S代表替代(substitution),P代表置换(permutation)。在这里,替代层是 SubBytes,置换层是 ShiftRows 和 MixColumns 的组合。
密钥调度函数KeyExpansion,如图 4-4 所示,是 AES 的密钥调度算法。该扩展使用与 SubBytes 相同的 S 盒和一系列 XOR 操作,从 16 字节密钥中创建 11 个轮密钥(K[0]、K[1]、…、K[10]),每个轮密钥为 16 字节。KeyExpansion 的一个重要特性是,给定任何轮密钥 K[i],攻击者可以通过反转算法确定所有其他轮密钥以及主密钥 K。从任何轮密钥中获取密钥的能力通常被视为对抗旁道攻击的一种不完美防御,因为攻击者可以轻松恢复一个轮密钥。

图 4-5:ShiftRows 在内部状态的每一行内旋转字节。
如果没有这些操作,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 实践
要尝试使用 AES 进行加密和解密,你可以使用 Python 的加密库,如列表 4-1 所示。
#!/usr/bin/env python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from binascii import hexlify as hexa
from os import urandom
# pick a random 16-byte key using Python's crypto PRNG
k = urandom(16)
print "k = %s" % hexa(k)
# create an instance of AES-128 to encrypt a single block
cipher = Cipher(algorithms.AES(k), modes.ECB(), backend = default_backend())
aes_encrypt = cipher.encryptor()
# set plaintext block p to the all-zero string
p = '\x00'*16
# encrypt plaintext p to ciphertext c
c = aes_encrypt.update(p) + aes_encrypt.finalize()
print "enc(%s) = %s" % (hexa(p), hexa(c))
# decrypt ciphertext c to plaintext p
aes_decrypt = cipher.decryptor()
p = aes_decrypt.update(c) + aes_decrypt.finalize()
print "dec(%s) = %s" % (hexa(c), hexa(p))
列表 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。
/* 第一轮:*/
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];
/* 第二轮:*/
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 C 实现
基本的基于表格的 AES 加密实现需要四千字节的表格,因为每个表格存储 256 个 32 位值,分别占用 256 × 32 = 8192 位,或者一千字节。解密还需要另外四个表格,因此总共需要四千字节的存储。但有一些技巧可以将存储需求从四千字节减少到一千字节,甚至更少。
可惜,基于表格的实现容易受到缓存时间攻击的影响,这种攻击通过利用程序读取或写入缓存内存元素时的时间差异来进行。根据访问的元素在缓存内存中的相对位置,访问时间会有所不同。于是,时间差泄露了关于访问了哪个元素的信息,而这又进一步泄露了涉及的密钥信息。
缓存时间攻击难以避免。一种显而易见的解决方案是完全放弃查找表,编写一个程序,使得执行时间不依赖于输入,但要做到这一点几乎是不可能的,并且仍能保持相同的速度。因此,芯片制造商采取了一个激进的解决方案:他们不依赖于可能存在漏洞的软件,而是依赖于硬件。
原生指令
AES 原生指令(AES-NI)解决了 AES 软件实现中缓存时间攻击的问题。要理解 AES-NI 的工作原理,你需要考虑软件在硬件上运行的方式:要运行一个程序,微处理器将二进制代码翻译成由集成电路组件执行的一系列指令。例如,MUL汇编指令在两个 32 位值之间执行时,将激活微处理器中实现 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 原生指令
这段代码将 128 位明文最初存放在寄存器 xmm0 中,假设寄存器 xmm5 到 xmm15 存储着预计算好的轮密钥,每条指令将结果写入 xmm0。初始的 PXOR 指令在计算第一轮之前对第一个轮密钥进行异或运算,最后的 AESENCLAST 指令在执行最后一轮时略有不同(MixColumns 被省略)。
注意
AES 在实现了本地指令的平台上速度大约是普通平台的十倍,当前几乎所有的笔记本、台式机和服务器微处理器,以及大多数手机和平板都实现了这些本地指令。事实上,在最新的英特尔微架构上,AESENC 指令的延迟为四个周期,反向吞吐量为一个周期,这意味着调用 AESENC 需要四个周期完成,并且每个周期都可以进行新的调用。连续加密一系列数据块时,因此完成 10 轮加密需要 4 × 10 = 40 个周期,或者 40 / 16 = 每字节 2.5 个周期。在 2 GHz(每秒 2 × 10⁹ 个周期)的频率下,这意味着约每秒 736 兆字节的吞吐量。如果待加密或解密的数据块是独立的,如某些操作模式所允许,那么可以并行处理四个数据块,以充分利用 AESENC 电路,从而将每个数据块的延迟从 40 个周期缩短到 10 个周期,吞吐量约为每秒 3 吉字节。
AES 是否安全?
AES 和所有块密码一样安全,且永远不会被破解。从根本上说,AES 的安全性来源于所有输出位都依赖于所有输入位,以某种复杂的伪随机方式进行处理。为了实现这一点,AES 的设计者们仔细选择了每个组成部分,每个选择都有其特定原因——MixColumns 以其最大的扩散特性和 SubBytes 以其最优的非线性特性——并且他们已证明这种组合可以保护 AES 免受一系列密码分析攻击。
但是并没有证据证明 AES 对所有可能的攻击都免疫。首先,我们并不知道所有可能的攻击是什么,而且我们也不总是知道如何证明一个密码算法能抵御某种特定的攻击。真正获得对 AES 安全性信心的唯一方法就是众包攻击:让许多技术熟练的人尝试破解 AES,并且希望他们未能成功。
在超过 15 年和数百篇研究论文之后,AES 的理论安全性只被轻微触及。2011 年,密码分析学家发现通过进行大约 2¹²⁶ 次操作而非 2¹²⁸ 次操作,可以恢复 AES-128 密钥,这相当于加速了四倍。但这种“攻击”需要大量的明文-密文对——大约 2⁸⁸ 位。这意味着虽然是个有趣的发现,但并不是你需要担心的事情。
关键点是,在实现和部署加密时,你需要关心很多事情,但 AES 的安全性并不是其中之一。对分组密码的最大威胁并不来自它们的核心算法,而是它们的操作模式。当选择了不正确的模式,或者正确的模式被滥用时,即使是像 AES 这样的强加密算法也无法保护你。
操作模式
在 第一章 中,我解释了加密方案如何结合置换和操作模式来处理任意长度的消息。在本节中,我将介绍分组密码常用的主要操作模式、它们的安全性和功能特性,以及如何(不)使用它们。我将从最愚蠢的一个开始:电子代码本。
电子代码本(ECB)模式

图 4-6:ECB 模式
最简单的分组密码加密模式是电子代码本(ECB),它几乎不算是一种操作模式。ECB 将明文块 P[1]、P[2]、…、P[N] 分别处理,通过计算 C[1] = E(K, P[1]),C[2] = E(K, P[2]),依此类推,如 图 4-6 所示。这是一个简单的操作,但也是一个不安全的操作。我再重复一遍:ECB 是不安全的,你不应该使用它!
微软的密码学家 Marsh Ray 曾说过:“每个人都知道 ECB 模式不好,因为我们可以看到企鹅。”他指的是一个著名的示例,展示了 ECB 不安全性的图像,使用了 Linux 吉祥物 Tux 的图像,如 图 4-7 所示。你可以看到左侧是 Tux 的原始图像,右侧是用 AES 加密的 ECB 模式图像(尽管底层的加密算法并不重要)。由于原始图像中相同灰度的所有块被加密为新图像中相同的灰度,因此很容易在加密后的版本中看到企鹅的形状;换句话说,ECB 加密只是给你一个相同的图像,但颜色不同。

图 4-7:原始图像(左)和 ECB 加密后的图像(右)
列表 4-4 中的 Python 程序也展示了 ECB 的不安全性。它选择一个伪随机密钥并加密一个包含两个空字节块的 32 字节消息 p。请注意,加密后的结果是两个相同的块,且使用相同的密钥和相同的明文重复加密时,结果再次是这两个相同的块。
#!/usr/bin/env python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from binascii import hexlify as hexa
from os import urandom
BLOCKLEN = 16
def blocks(data):
split = [hexa(data[i:i+BLOCKLEN]) for i in range(0, len(data), BLOCKLEN)]
return ' '.join(split)
k = urandom(16)
print 'k = %s' % hexa(k)
# create an instance of AES-128 to encrypt and decrypt
cipher = Cipher(algorithms.AES(k), modes.ECB(), backend=default_backend())
aes_encrypt = cipher.encryptor()
# set plaintext block p to the all-zero string
p = '\x00'*BLOCKLEN*2
# encrypt plaintext p to ciphertext c
c = aes_encrypt.update(p) + aes_encrypt.finalize()
print 'enc(%s) = %s' % (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)模式
密码块链接(CBC)与 ECB 类似,但有一个小小的变化,这个变化带来了大不同:CBC 不是直接加密第i个块 P[i],而是将 C[i] 设置为 E(K, P[i] ⊕ C[i − 1]),其中 C[i − 1] 是前一个密文块——从而将块 C[i − 1] 和 C[i] 链接起来。当加密第一个块 P[1] 时,没有前一个密文块可用,因此 CBC 会使用一个随机的初始值(IV),如图 4-8 所示。

图 4-8:CBC 模式
CBC 模式使每个密文块都依赖于之前的所有块,确保相同的明文块不会变成相同的密文块。随机的初始值保证了当使用两个不同的初始值调用密码时,即使明文相同,加密后的密文也会不同。
清单 4-5 展示了这两个好处。这个程序接收一个全零的 32 字节消息(像清单 4-4 中的那样),用 CBC 加密两次,并展示这两个密文。加粗显示的iv = urandom(16)为每次新的加密选择一个新的随机 IV。
!/usr/bin/env python
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from binascii import hexlify as hexa
from os import urandom
BLOCKLEN = 16
blocks()函数将数据字符串分割为以空格分隔的块
def blocks(data):
split = [hexa(data[i:i+BLOCKLEN]) for i in range(0, len(data), BLOCKLEN)]
return ' '.join(split)
k = urandom(16)
print 'k = %s' % hexa(k)
选择一个随机的 IV
iv = urandom(16)
print 'iv = %s' % hexa(iv)
选择一个 AES CBC 模式的实例
aes = Cipher(algorithms.AES(k), modes.CBC(iv), backend=default_backend()).encryptor()
p = '\x00'BLOCKLEN2
c = aes.update(p) + aes.finalize()
print 'enc(%s) = %s' % (blocks(p), blocks(c))
现在使用不同的 IV 和相同的密钥
iv = urandom(16)
print 'iv = %s' % hexa(iv)
aes = Cipher(algorithms.AES(k), modes.CBC(iv), backend=default_backend()).encryptor()
c = aes.update(p) + aes.finalize()
print 'enc(%s) = %s' % (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
唉,CBC 通常使用常量 IV 而不是随机 IV,这会暴露相同的明文和以相同块开头的明文。例如,假设两个块的明文P[1] || P[2]被加密为两个块的密文C[1] || C[2]。如果P[1] || P[2]′使用相同的 IV 加密,其中P[2]′是与P[2]不同的块,那么密文将看起来像C[1] || C[2]′,其中C[2]′与C[2]不同,但与第一个块C[1]相同。因此,攻击者可以猜测这两个明文的第一个块是相同的,即使他们只能看到密文。
注意
在 CBC 模式中,解密需要知道用于加密的 IV,因此 IV 会与密文一起明文传输。
使用 CBC 时,由于并行处理,解密比加密速度要快得多。虽然新块P[i]的加密需要等待前一个块C[i* − 1],但一个块的解密计算为P[i] = D(K, C[i]) ⊕ C[i* − 1],其中不需要前一个明文块P[i* − 1]*。这意味着,只要你知道前一个密文块,所有的块就可以并行解密,通常你是知道的。
如何在 CBC 模式下加密任何消息
让我们回到块终止问题,看看如何处理一个长度不是块长度倍数的明文。例如,当块大小是 16 字节时,如何使用 AES-CBC 加密一个 18 字节的明文?剩下的两个字节怎么办?我们将介绍两种广泛使用的技术来解决这个问题。第一种是填充,它使得密文比明文稍长,而第二种是密文偷取,它生成的密文与明文长度相同。
填充消息
填充是一种技术,它允许你加密任意长度的消息,即使消息小于一个完整的块。块加密的填充方法在 PKCS#7 标准和 RFC 5652 中有所规定,并且在几乎所有使用 CBC 的地方都有应用,比如一些 HTTPS 连接中。
填充用于通过向明文添加额外字节来扩展消息,以填充完整的块。以下是填充 16 字节块的规则:
-
如果剩下一个字节——例如,如果明文是 1 个字节,17 个字节或 33 个字节长——则用 15 个 0f 字节(十进制 15)填充消息。
-
如果剩余两个字节,用 14 个 0e 字节(十进制 14)填充消息。
-
如果剩余三个字节,使用 13 个 0d 字节(十进制 13)填充消息。
如果有 15 个明文字节,缺少一个字节来填充一个块,填充会添加一个 01 字节。如果明文已经是 16 的倍数(块大小),则会添加 16 个 10 字节(十进制 16)。你应该明白了。这种技巧可以推广到任何块大小,最多到 255 字节(对于更大的块,一个字节太小,无法编码大于 255 的值)。
填充消息的解密过程如下:
-
像未填充的 CBC 一样解密所有的块。
-
确保最后一个块的最后几字节符合填充规则:它们至少以一个 01 字节、两个 02 字节或三个 03 字节等结尾。如果填充无效——例如,如果最后的字节是 01 02 03——消息将被拒绝。否则,解密会去掉填充字节,返回剩余的明文字节。
填充的一个缺点是,它会使密文的长度至少增加一个字节,最多增加一个块。
密文偷窃
密文偷窃是另一种用于加密长度不是块大小倍数的消息的技巧。密文偷窃比填充更加复杂且不太流行,但它至少有三个优点:
-
明文可以是任意比特长度,而不仅仅是字节。例如,你可以加密一个 131 比特的消息。
-
密文的长度与明文完全相同。
-
密文偷窃不会受到填充 oracle 攻击的威胁,这是一种强大的攻击方式,有时会对使用填充的 CBC 模式产生作用(我们将在“填充 Oracle 攻击”中看到,见第 74 页)。
在 CBC 模式下,密文偷窃通过从前一个密文块中延伸最后不完整的明文块,然后对结果块进行加密。最后的不完整密文块由前一个密文块的前几个块组成;也就是说,是那些没有附加到最后一个明文块中的位。

图 4-9:CBC 模式下的密文偷窃加密
在图 4-9 中,我们有三个数据块,其中最后一个数据块,P[3],是不完整的(用零表示)。P[3]与前一个密文块的最后几位进行异或操作,得到的加密结果作为C[2]返回。最后一个密文块,C[3],则由前一个密文块的前几位组成。解密操作就是此过程的逆操作。
密文偷窃并没有什么重大问题,但它不够优雅且难以正确实现,特别是当 NIST 的标准指定了三种不同的实现方式时(参见《特殊出版物 800-38A》)。
计数器(CTR)模式
为了避免麻烦并保留密文偷窃的优点,你应该使用计数器模式(CTR)。CTR 几乎不是一种块加密模式:它将块加密算法转化为一种流加密算法,直接处理比特流并输出比特流,而不会涉及块的概念。(我将在第五章中详细讨论流加密算法。)

图 4-10:CTR 模式
在 CTR 模式中(见 图 4-10),块加密算法不会转换明文数据。相反,它将加密由 计数器 和 nonce 组成的块。计数器是一个整数,每个块递增一次。在一条消息中,不能有两个块使用相同的计数器,但不同的消息可以使用相同的计数器序列(1、2、3、…)。nonce 是一个仅使用一次的数字。在单个消息中,它对所有块都是相同的,但不同的消息不应使用相同的 nonce。
如 图 4-10 所示,在 CTR 模式下,加密过程将明文与来自“加密” nonce N 和计数器 Ctr 的流进行异或。解密过程相同,因此加密和解密只需要使用相同的加密算法。Python 脚本在 列表 4-6 中提供了一个实际示例。
#!/usr/bin/env python
from Crypto.Cipher import AES
from Crypto.Util import Counter
from binascii import hexlify as hexa
from os import urandom
from struct import unpack
k = urandom(16)
print 'k = %s' % hexa(k)
# pick a starting value for the counter
nonce = unpack('<Q', urandom(8))[0]
# instantiate a counter function
ctr = Counter.new(128, initial_value=nonce)
# pick an instance of AES in CTR mode, using ctr as counter
aes = AES.new(k, AES.MODE_CTR, counter=ctr)
# no need for an entire block with CTR
p = '\x00\x01\x02\x03'
# encrypt p
c = aes.encrypt(p)
print 'enc(%s) = %s' % (hexa(p), hexa(c))
# decrypt using the encrypt function
ctr = Counter.new(128, initial_value=nonce)
aes = AES.new(k, AES.MODE_CTR, counter=ctr)
p = aes.encrypt(c)
print 'enc(%s) = %s' % (hexa(c), hexa(p))
列表 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 是 n 位,则加密 2^(N/ 2) 次和使用相同数量的 nonce 后,很可能会遇到重复值。因此,64 位的随机 nonce 是不够的,因为在大约 2³² 个 nonce 后就会出现重复,这是一个不可接受的低数字。
如果每次新的明文都会递增计数器,并且计数器足够长(例如 64 位计数器),那么计数器就能保证唯一性。
CTR 模式的一个特别好处是,它比任何其他模式都要快。它不仅可以并行化,而且即使在不知道消息内容之前,你也可以选择一个 nonce,并计算出稍后用来与明文进行异或运算的流。
错误发生的情况
有两种必须了解的块密码攻击:中间人攻击,这是一种在 1970 年代发现的技术,但至今仍然在许多密码分析攻击中使用(与中间人攻击不同),以及填充 Oracle 攻击,这类攻击在 2002 年由学术密码学家发现,最初大多被忽视,最终在十年后重新被发现,并伴随着几个易受攻击的应用程序。
中间人攻击
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,即 E(K[1], E(K[2], P))?事实证明,MitM 攻击使得双重 DES 的安全性仅相当于单 DES。图 4-12 展示了 MitM 攻击的实际操作。

图 4-12: meet-in-the-middle 攻击*
meet-in-the-middle 攻击的工作原理如下,用于攻击双重 DES:
-
假设你有 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⁵⁶ 个 K[2] 的值,计算 D(K[2], C) 并检查计算结果是否出现在表中作为索引(因此作为一个中间值,在图 4-12 中用问号表示)。
-
如果在表中找到一个中间值作为索引,你需要从表中提取对应的 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 字节的元素,或者大约 128 PB(拍字节)。这已经非常庞大,但有一个技巧可以让你用几乎不占内存的方式运行相同的攻击(你将在第六章中看到)。
正如你所看到的,你几乎可以用和攻击双重 DES 时一样的方法来攻击 3DES,唯一的区别是第三阶段会遍历所有 2¹¹² 个 K[2] 和 K[3] 的值。因此,整个攻击大约需要执行 2¹¹² 次操作,意味着尽管 3DES 拥有 168 位的密钥材料,但它的安全性只有 112 位。
填充 Oracle 攻击
让我们以 2000 年代最简单但最具破坏性的攻击之一作为本章的结尾:填充 oracle 攻击。记住,填充是为了填满一个块而在明文中添加额外的字节。例如,一个 111 字节的明文是六个 16 字节块和接着的 15 字节。为了形成完整的块,填充会添加一个 01 字节。对于一个 110 字节的明文,填充会添加两个 02 字节,依此类推。
填充 oracle 是一种根据 CBC 加密密文中的填充是否有效而表现不同的系统。你可以将其看作一个黑盒或一个 API,它返回“成功”或“错误”值。填充 oracle 可以在远程主机上的服务中找到,当它接收到格式错误的密文时会发送错误消息。给定一个填充 oracle,填充 oracle 攻击记录哪些输入具有有效的填充,哪些没有,并利用这些信息来解密所选的密文值。

图 4-13:填充 oracle 攻击通过选择 C[1] 并检查填充的有效性来恢复 X。
假设你想解密密文块 C[2]。我将 X 称为你要查找的值,即 D(K, C[2]),而 P[2] 是在 CBC 模式下解密得到的块(见图 4-13)。如果你选择一个随机块 C[1] 并将这两个块的密文 C[1] || C[2] 发送给 oracle,解密只有在 C[1] ⊕ P[2] = X 以有效的填充结尾时才会成功——即一个 01 字节、两个 02 字节或三个 03 字节,依此类推。
基于这个观察,填充 oracle 攻击可以像这样解密 CBC 加密的一个块 C[2](字节用数组表示法表示:C[1][0] 是 C[1] 的第一个字节,C[1][1] 是第二个字节,以此类推,直到 C[1][15],即 C[1] 的最后一个字节):
-
选择一个随机的块 C[1],并通过改变其最后一个字节,直到填充 oracle 接受密文为有效。通常,在有效的密文中,C[1][15] ⊕ X[15] = 01,因此你需要尝试大约 128 个 C[1][15] 的值后,就能找到 X[15]。
-
通过将 C[1][15] 设置为 X[15] ⊕ 02,并搜索能给出正确填充的 C[1][14],来找到 X[14] 的值。当 oracle 接受密文为有效时,这意味着你已找到 C[1][14],使得 C[1][14] ⊕ X[14] = 02。
-
对所有 16 字节重复步骤 1 和步骤 2。
攻击平均需要对每个 16 字节进行 128 次 oracle 查询,总共大约 2000 次查询。(注意每个查询必须使用相同的初始值。)
注意
实际上,实现填充 oracle 攻击比我所描述的要复杂一些,因为你必须处理步骤 1 中的错误猜测。一个密文可能具有有效的填充,并非因为 P[2] 以一个单独的 01 结尾,而是因为它以两个 02 字节或三个 03 字节结尾。但这可以通过测试更多字节被修改的密文的有效性来轻松管理。
深入阅读
关于分组密码有很多要说的,无论是算法如何工作,还是它们如何被攻击。例如,Feistel 网络和 SPN 并不是构建分组密码的唯一方式。分组密码 IDEA 和 FOX 使用 Lai–Massey 结构,而 Threefish 使用 ARX 网络,它结合了加法、字节旋转和 XOR 操作。
还有许多其他模式,而不仅仅是 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)以及一些更现代的“轻量级”分组密码,例如 KATAN、PRESENT 或 PRINCE。
第六章:流密码

对称密码可以是块密码或流密码。回想一下第四章,块密码通过将明文比特与密钥比特混合在一起,产生相同大小的密文块,通常为 64 或 128 位。而流密码则不同,它们不会将明文和密钥比特混合在一起;相反,它们通过密钥生成伪随机比特,并通过与伪随机比特进行异或(XOR)操作来加密明文,就像第一章中解释的一次性密钥一样。
流密码有时会被排斥,因为历史上它们比块密码更脆弱,而且更容易被破解——无论是业余爱好者设计的实验性密码,还是在包括移动电话、Wi-Fi 和公共交通智能卡在内的系统中使用的密码。但这些都已经是历史了。幸运的是,虽然花费了 20 年时间,我们现在知道如何设计安全的流密码,并且相信它们能够保护像蓝牙连接、移动 4G 通信、TLS 连接等内容。
本章首先介绍流密码的工作原理,并讨论流密码的两大主要类别:有状态密码和基于计数器的密码。然后,我们将研究硬件和软件导向的流密码,并查看一些不安全的密码(例如 GSM 移动通信中的 A5/1 和 TLS 中的 RC4)以及一些安全的、最先进的密码(如硬件使用的 Grain-128a 和软件使用的 Salsa20)。
流密码的工作原理
流密码更像是确定性随机比特生成器(DRBGs),而不是完整的伪随机数生成器(PRNGs),因为像 DRBGs 一样,流密码是确定性的。流密码的确定性使得你可以通过重新生成用于加密的伪随机比特来解密。使用 PRNG,你可以加密但无法解密——这虽然安全,但毫无用处。
区分流密码与 DRBGs 的关键在于,DRBGs 只接受一个输入值,而流密码接受两个值:一个密钥和一个随机数。密钥应该是保密的,通常为 128 位或 256 位。随机数不需要保密,但它应该对于每个密钥都是唯一的,通常为 64 到 128 位之间。

图 5-1:流密码如何加密,采用一个秘密密钥,K,和一个公共的随机数,N
流密码生成一个称为 密钥流 的伪随机比特流。密钥流与明文进行异或操作来加密明文,然后再次与密文进行异或操作来解密密文。图 5-1 展示了基本的流密码加密操作,其中 SC 是流密码算法,KS 是密钥流,P 是明文,C 是密文。
流密码计算 KS = SC(K, N),加密为 C = P ⊕ KS,解密为 P = C ⊕ KS。加密和解密函数是相同的,因为两者执行的操作相同——即用密钥流进行异或运算。这就是为什么某些加密库提供一个单独的 encrypt 函数,用于加密和解密的原因。
流密码允许你使用密钥 K[1] 和 nonce N[1] 加密一条消息,然后使用密钥 K[1] 和与 N[1] 不同的 nonce N[2] 或使用与 K[1] 不同的密钥 K[2] 和 nonce 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]。
注意
名称 nonce 实际上是 number used only once(仅使用一次的数字)的缩写。在流密码的上下文中,它有时被称为 IV,即 initial value(初始值)。
有状态与基于计数器的流密码
从高层次的角度来看,流密码有两种类型:有状态和基于计数器的。有状态流密码 具有一个在生成密钥流的过程中不断变化的秘密内部状态。密码从密钥和 nonce 初始化状态,然后调用更新函数来更新状态值,并从状态中生成一个或多个密钥流位,如图 5-2 所示。例如,著名的 RC4 就是一个有状态密码。

图 5-2:有状态流密码
基于计数器的流密码 从密钥、nonce 和计数器值生成密钥流块,如图 5-3 所示。与有状态流密码(例如 Salsa20)不同,基于计数器的流密码在生成密钥流时不会记住任何秘密状态。

图 5-3:基于计数器的流密码
这两种方法定义了流密码的高层架构,无论核心算法如何工作。流密码的内部实现也分为两类,取决于密码的目标平台:硬件导向和软件导向。
硬件导向的流密码
当密码学家谈到硬件时,他们指的是应用特定集成电路(ASICs)、可编程逻辑设备(PLDs)和现场可编程门阵列(FPGAs)。密码算法的硬件实现是一个电子电路,按位实现加密算法,且不能用于其他任何用途;换句话说,这个电路是专用硬件。另一方面,密码算法的软件实现仅仅是告诉微处理器执行什么指令来运行算法。这些指令处理字节或字,并调用实现一般操作(如加法和乘法)的电子电路。软件处理 32 位或 64 位的字节或字,而硬件处理的是比特。最早的流密码按比特工作,以避免复杂的字操作,从而在当时的硬件平台上更加高效。
流密码在硬件实现中被广泛使用的主要原因是它们比分组密码更便宜。流密码需要的内存和逻辑门比分组密码少,因此占用集成电路的面积更小,从而降低了制造成本。例如,以门等效(gate-equivalents)为计量标准,流密码的面积通常小于 1000 个门等效;相比之下,典型的软件导向分组密码至少需要 10000 个门等效,这使得加密在成本上比流密码贵了一个数量级。
然而,今天,分组密码不再比流密码更昂贵——首先,因为现在有一些硬件友好的分组密码,它们的体积与流密码相差无几,其次,因为硬件的成本已经大幅下降。尽管如此,流密码仍然常与硬件相关联,因为它们曾经是最佳选择。
在下一节中,我将解释硬件流密码的基本机制,这种机制叫做反馈移位寄存器(FSRs)。几乎所有的硬件流密码都以某种方式依赖于 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],依此类推。也就是说,给定 R[t],FSR 在时刻t的状态,下一个状态 R[t + 1] 如下:
R[i + 1] = (R[t] << 1)|f(R[t])
在这个方程中,|是逻辑或运算符,<<是位移运算符,像在 C 语言中一样使用。例如,给定 8 位字符串 00001111,我们得到:

位移操作将位向左移动,丢失最左侧的位,以保持状态的位长,并将最右侧的位置为 0。FSR 的更新操作是相同的,不同之处在于,最右侧的位不是置为 0,而是设置为f(R[t])。
例如,考虑一个 4 位的 FSR,其反馈函数f将所有 4 个比特进行异或操作。将状态初始化为:
1 1 0 0
现在将位向左移,其中输出 1,最右侧的位设置为以下内容:
f(1100) = 1 ⊕ 1 ⊕ 0 ⊕ 0 = 0
现在状态变为:
1 0 0 0
下一次更新输出 1,左移状态,并将最右侧的位设置为:
f(1000) = 1 ⊕ 0 ⊕ 0 ⊕ 0 = 1
现在状态是:
0 0 0 1
接下来的三次更新输出三个 0 位,并给出以下状态值:

因此,在五次迭代后,我们回到最初的状态 1100,我们可以看到,从该周期中观察到的任一值更新状态五次都会使我们回到初始值。我们称 5 是 FSR 的周期,对于任何一个值 1100、1000、0001、0011 或 0110 来说都适用。由于这个 FSR 的周期是 5,时钟信号让寄存器时钟跳动 10 次时将输出两次相同的 5 位序列。同理,如果你让寄存器跳动 20 次,从 1100 开始,输出位将是 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 位寄存器的 2⁴ = 16 个可能值中的 15 个。第 16 个可能值是 0000,正如 图 5-4 所示,它是一个周期为 1 的循环,因为 FSR 会将 0000 转换为 0000。
你已经看到,FSR 本质上是一个位寄存器,其中每次更新寄存器都会输出一个位(寄存器的最左边位),而函数计算出寄存器的新最右边位。(其他位都会左移。)FSR 的周期是从某个初始状态开始,直到 FSR 再次进入相同状态所需的更新次数。如果需要 N 次更新才能达到这一点,那么 FSR 会一次又一次地生成相同的 N 位。
线性反馈移位寄存器
线性反馈移位寄存器(LFSR)是具有 线性 反馈函数的 FSR——即一个函数,它是某些状态位的异或(XOR),就像上一节中 4 位 FSR 的示例及其反馈函数返回寄存器 4 位的异或结果一样。回想一下,在密码学中,线性性意味着可预测性,并暗示着一个简单的基础数学结构。正如你可能预期的那样,得益于这种线性性,LFSR 可以通过线性复杂度、有限域和原始多项式等概念进行分析——但我会跳过数学细节,直接给你核心要点。
哪些位被异或在一起对于 LFSR 的周期及其密码学价值至关重要。好消息是,我们知道如何选择位的位置,以确保最大周期,即 2^(n) – 1。具体来说,我们取位的索引,从最右边的 1 到最左边的 n,并写出多项式表达式 1 + X + X² + … + X^(n),其中只有当第 i 位是反馈函数中异或的位之一时,才会包含 X^(i) 这一项。只有当该多项式是 原始 时,周期才是最大周期。为了是原始的,该多项式必须具备以下特性:
-
多项式必须是不可约的,这意味着它不能被因式分解;也就是说,不能写成更小多项式的积。例如,X + X³ 不是不可约的,因为它等于 (1 + X)(X + X²):
(1 + X)(X + X²) = X + X² + X² + X³ = X + X³
-
多项式必须满足一些其他的数学性质,这些性质不能用简单的数学概念来解释,但可以通过测试轻松验证。
注意
一个 n 位 LFSR 的最大周期是 2^n - 1,而不是 2^n,因为全零状态总是无限循环在自己上。由于任何数量零的异或结果是零,从反馈函数进入状态的新位将始终为零;因此,全零状态注定会一直保持为全零。
例如,图 5-5 展示了一个具有反馈多项式 1 + X + X³ + X⁴的 4 位 LFSR,其中位置 1、3 和 4 的位进行异或运算以计算新位,设置为L[1]。然而,这个多项式不是原始的,因为它可以分解为(1 + X³)(1 + X)。

图 5-5:具有反馈多项式 1 + X + X³ + X⁴的 LFSR
确实,图 5-5 所示的 LFSR 的周期并不是最大值。为了证明这一点,从状态 0001 开始。
0 0 0 1
现在左移 1 位,并将新位设置为 0 + 0 + 1 = 1:
0 0 1 1
重复该操作四次后,得到以下状态值:

正如你所看到的,经过五次更新后的状态与初始状态相同,这证明我们处于一个周期为 5 的循环中,并且证明了 LFSR 的周期不是最大值 15。
现在,相比之下,考虑图 5-6 所示的 LFSR。

图 5-6:具有反馈多项式 1 + X³ + X⁴的 LFSR,一个原始多项式,确保最大周期*
这个反馈多项式是由 1 + X³ + X⁴描述的原始多项式,你可以验证它的周期确实是最大值(即 15)。具体来说,从初始值开始,状态按如下方式演变:

该状态涵盖了所有可能的值,除了 0000,并且没有重复,直到最终进入循环。这证明了周期是最大的,并且证明了反馈多项式是原始的。
可惜,使用 LFSR 作为流密码是不安全的。如果n是 LFSR 的位长度,攻击者只需要n个输出位就能恢复 LFSR 的初始状态,从而确定所有先前的位并预测所有未来的位。之所以可能进行此攻击,是因为 Berlekamp–Massey 算法可以用来解 LFSR 数学结构定义的方程,不仅能找到 LFSR 的初始状态,还能找到它的反馈多项式。事实上,你甚至不需要知道 LFSR 的确切长度就能成功;你可以对所有可能的n值重复使用 Berlekamp–Massey 算法,直到找到正确的值。
结果是 LFSR 在加密上较弱,因为它们是线性的。输出位和初始状态位通过简单且短小的方程式相互关联,这些方程可以通过高中线性代数技巧轻松解决。
为了加强 LFSR,我们因此需要加入一些非线性。
过滤后的 LFSR

图 5-7:经过滤波的 LFSR
为了减轻 LFSRs 的不安全性,可以通过将其输出位通过非线性函数处理,再返回以生成所谓的过滤 LFSR(见图 5-7)。
图 5-7 中的g函数必须是一个非线性函数—既能对比特进行异或操作,又能结合逻辑与或或操作。例如,L[1]L[2] + L[3]L[4]是一个非线性函数(我省略了乘号,因此L[1]L[2]表示L[1] × L[2],或在 C 语言中表示为L[1] & L[2])。
注意
你可以直接通过 FSR 的位来编写反馈函数,例如 L[1]L[2] + L[3]L[4],或者使用等效的多项式表示法 1 + XX² + X³X⁴。直接表示法更易于理解,但多项式表示法更适合 FSR 性质的数学分析。除非我们关心数学性质,否则我们将坚持使用直接表示法。
滤波 LFSRs 比普通 LFSRs 更强,因为它们的非线性函数能抵挡简单的攻击。然而,更复杂的攻击如以下几种将破坏系统:
-
代数攻击将解出从输出位推导出的非线性方程组,其中方程中的未知数是 LFSR 状态中的比特。
-
立方体攻击将计算非线性方程的导数,以将系统的阶数降到一,然后像线性系统一样高效地求解。
-
快速相关攻击将利用过滤函数,尽管其具有非线性,仍然倾向于像线性函数一样表现。
这里的教训,如我们在之前的例子中看到的,是创可贴无法修复枪伤。用稍微强一点的层修补破损的算法并不会使整个系统安全。问题必须从根本上解决。
非线性反馈移位寄存器(NFSRs)
非线性反馈移位寄存器(NFSRs)类似于 LFSRs,但它们采用非线性反馈函数,而不是线性反馈函数。也就是说,反馈函数不仅仅是逐位异或,可能还包括逐位与(AND)和或(OR)操作—这一特性既有优点也有缺点。
添加非线性反馈函数的一个好处是,它们使 NFSRs 在密码学上比 LFSRs 更强,因为输出位依赖于初始秘密状态,以一种复杂的方式,符合指数大小的方程。LFSRs 的线性函数保持关系简单,最多有n项(N[1]、N[2]、……、N[n],如果N[i]是 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] + 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 竞赛吗?它与 AES 分组密码有关。流密码 Grain 是一个类似项目的产物,名为 eSTREAM 竞赛。该竞赛于 2008 年结束,并公布了一份推荐的流密码候选名单,包含了四个面向硬件的密码和四个面向软件的密码。Grain 是这些硬件密码之一,Grain-128a 是 Grain 原作者提出的升级版。图 5-8 展示了 Grain-128a 的工作机制。

图 5-8:Grain-128a 的机制,包含一个 128 比特的 NFSR 和一个 128 比特的 LFSR
如图 5-8 所示,Grain-128a 可以说是尽可能简单的流密码,它结合了一个 128 比特的 LFSR,一个 128 比特的 NFSR,以及一个滤波函数h。LFSR 具有 2¹²⁸ – 1 的最大周期,这确保了整个系统的周期至少为 2¹²⁸ – 1,从而防止了 NFSR 中可能出现的短周期。同时,NFSR 和非线性滤波函数h增强了密码学强度。
Grain-128a 采用 128 比特的密钥和 96 比特的随机数(nonce)。它将 128 比特的密钥位复制到 NFSR 的 128 比特中,并将 96 比特的随机数位复制到 LFSR 的前 96 个比特中,剩下的 32 个比特用 1 填充,并在末尾加一个零比特。初始化阶段更新整个系统 256 次,然后才返回第一个密钥流比特。在初始化过程中,h函数返回的比特不会作为密钥流输出,而是进入 LFSR,确保其后续状态同时依赖于密钥和随机数。
Grain-128a 的 LFSR 反馈函数为
f(L) = L[32] + L[47] + L[58] + L[90] + L[121] + L[128]
其中,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 还将来自 LFSRs 的位进行 XOR 运算,将结果反馈作为 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 的机制
A5/1 依赖于三个 LFSR,并使用一个乍一看很巧妙但实际上无法提供安全性的技巧(参见图 5-9)。

图 5-9:A5/1 密码
正如你在图 5-9 中看到的,A5/1 使用了 19、22 和 23 位的 LFSR,每个 LFSR 的多项式如下:

如何仅通过 LFSRs 而没有 NFSR 来认为这能被视为安全?其中的技巧在于 A5/1 的更新机制。A5/1 的设计者没有在每个时钟周期更新所有三个 LFSR,而是添加了一个时钟规则,执行以下操作:
-
检查 LFSR 1 的第九位、LFSR 2 的第十一位和 LFSR 3 的第十一位,这些被称为时钟位。在这三个位中,要么它们的值都相同(1 或 0),要么恰好有两个值相同。
-
时钟对那些时钟位与大多数值相同的寄存器进行时钟操作,0 或 1。每次更新时,可能会时钟两个或三个 LFSR。
如果没有这个简单的规则,A5/1 根本不会提供任何安全性,绕过这个规则就足以破解密码。然而,正如你将看到的那样,这并不容易实现。
注意
在 A5/1 的不规则时钟规则中,每个寄存器在每次更新时有 3/4 的概率被时钟驱动。也就是说,至少有一个其他寄存器具有相同位值的概率是 1 – (1/2)²,其中(1/2)²是另外两个寄存器具有不同位值的机会。
2G 通信使用具有 64 位密钥和 22 位随机数的 A5/1,每个数据帧都会更改该随机数。对 A5/1 的攻击可以恢复系统的 64 位初始状态(19 + 22 + 23 的 LFSR 初始值),进而揭示随机数(如果它尚未被知道)和密钥,通过解开初始化机制。这些攻击被称为已知明文攻击(KPA),因为部分加密数据是已知的,这使得攻击者能够通过将密文与已知的明文片段进行异或运算来确定相应的密钥流部分。
对 A5/1 的攻击有两种主要类型:
微妙攻击 利用 A5/1 的内部线性性及其简单的不规则时钟系统
暴力攻击 仅利用 A5/1 的短密钥和帧号注入的可逆性
让我们看看这些攻击是如何工作的。
微妙攻击
在一种叫做猜测与确定的微妙攻击中,攻击者猜测状态中的某些秘密值,以确定其他值。在密码分析中,“猜测”意味着暴力破解:对于 LFSR 1 和 LFSR 2 的每一个可能值,以及 LFSR 3 在前 11 次时钟期间的所有可能时钟位值,攻击者通过求解依赖于猜测位的方程式来重构 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⁶⁴个元素的表格,表格中包含密钥和值对(key:value),并存储每个 2⁶⁴个可能密钥的输出值。要使用这个预计算的表格进行攻击,你只需收集一个 A5/1 实例的输出,然后在表格中查找哪个密钥对应该输出。攻击本身很快—只需要查找内存中的一个值的时间—但表格的创建需要进行 2⁶⁴次 A5/1 计算。更糟糕的是,代码本攻击需要大量的内存:2⁶⁴ × (64 + 64)位,即 2⁶⁸字节,或者 256 exabytes。这相当于数十个数据中心,所以我们可以忘掉这个方法。
TMTO 攻击通过增加攻击在线阶段的计算量,减少了代码本攻击所需的内存;表格越小,破解一个密钥所需的计算就越多。无论如何,准备表格仍需要大约 2⁶⁴次操作,但这只需要做一次。
2010 年,研究人员花费约两个月时间,使用图形处理单元(GPU)并行运行 100000 个 A5/1 实例,生成了两个 TB 大小的表格。在这些大型表格的帮助下,使用 A5/1 加密的通话几乎可以实时解密。电信运营商已实施解决方法来减轻这种攻击,但真正的解决方案出现在后来的 3G 和 4G 移动通信标准中,这些标准彻底放弃了 A5/1。
面向软件的流密码
软件流密码使用字节或 32 位或 64 位字(而不是单独的比特)进行操作,这在现代 CPU 上更为高效,因为指令在相同的时间内可以对字进行算术运算,和对比特操作是一样的。因此,软件流密码比硬件密码更适用于运行在个人计算机上的服务器或浏览器,在这些设备上,强大的通用处理器将密码作为原生软件运行。
今天,由于以下几个原因,软件流密码引起了相当大的兴趣。首先,由于许多设备嵌入了强大的 CPU,且硬件变得更便宜,因此对小型比特定向密码的需求减少了。例如,移动通信标准 4G 中的两个流密码(欧洲的 SNOW3G 和中国的 ZUC)使用 32 位字而不是比特,这与较旧的 A5/1 不同。
其次,流密码在软件中的流行度超过了块密码,特别是在对块密码的 CBC 模式填充 oracle 攻击事件之后。此外,流密码比块密码更容易指定和实现:流密码只是将密钥比特作为机密输入,而不是将消息和密钥比特混合在一起。事实上,最流行的流密码之一实际上是伪装成块密码的:以计数器模式(CTR)使用的 AES。
一种软件流密码设计,SNOW3G 和 ZUC 使用的设计,模仿硬件密码及其线性反馈移位寄存器(FSRs),将比特替换为字节或字。但这些设计对密码学家来说并不是最有趣的设计。截至本文写作时,最受关注的两种设计是 RC4 和 Salsa20,它们被应用于许多系统,尽管其中一种已经完全被攻破。
RC4
RC4 由 RSA 安全公司(RSA Security)的 Ron Rivest 于 1987 年设计,随后在 1994 年被逆向工程并泄露。RC4 长期以来是最广泛使用的流密码。RC4 已被应用于无数应用程序中,最著名的包括第一个 Wi-Fi 加密标准无线等效隐私(WEP)和用于建立 HTTPS 连接的传输层安全(TLS)协议。不幸的是,RC4 对大多数应用程序来说并不够安全,包括 WEP 和 TLS。为了理解原因,让我们看看 RC4 是如何工作的。
RC4 的工作原理
RC4 是迄今为止最简单的密码之一。它不执行任何类似密码学的操作,也没有异或、没有乘法、没有 S 盒……什么都没有。它只是交换字节。RC4 的内部状态是一个 256 字节的数组S,最初设置为S[0] = 0, S[1] = 1, S[2] = 2, … , S[255] = 255,然后通过n字节的K使用其密钥调度算法(KSA)初始化,具体工作方式如列表 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]
列表 5-1:RC4 的密钥调度算法
一旦这个算法完成,数组S仍然包含从 0 到 255 的所有字节值,但现在它们的顺序是随机的。例如,对于全零 128 位密钥,状态S(从S[0]到S[255])变成了这样:
0, 35, 3, 43, 9, 11, 65, 229, (…), 233, 169, 117, 184, 31, 39
然而,如果我翻转第一个密钥位并重新运行 KSA,我会得到一个完全不同的、看似随机的状态:
32, 116, 131, 134, 138, 143, 149, (…), 152, 235, 111, 48, 80, 12
给定初始状态S,RC4 生成一个与明文P长度相同的密钥流KS,用于计算密文:C = P ⊕ KS。密钥流KS的字节是根据 S5-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]
Listing 5-2: RC4 的密钥流生成,其中S是 Listing 5-1 中初始化的状态
在 S5-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 字节 nonce(这是帧的部分,包含元数据,位于实际有效载荷之前)。看到问题了吗?
问题在于,RC4 不支持 nonce,至少在其官方规格中不支持,并且流加密算法在没有 nonce 的情况下无法使用。WEP 设计者通过一种变通方法解决了这个限制:他们在无线帧的头部加入了一个 24 位的 nonce,并将其添加到 WEP 密钥中,作为 RC4 的密钥。也就是说,如果 nonce 是字节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 的计数器,直到发生溢出,攻击者也需要几 GB 的数据才能找到重复的 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 的实现并没有犯同样的明显错误,即为了使用公共 nonce 而修改 RC4 规格。相反,TLS 只是为 RC4 提供了一个唯一的 128 位会话密钥,这意味着它比 WEP 稍微安全一些。
TLS 的弱点仅仅是由于 RC4 及其不可原谅的缺陷:统计偏差或非随机性,而我们知道这是流密码的致命缺陷。例如,RC4 产生的第二个密钥流字节为零的概率为 1/128,而理想情况下应该是 1/256。(回想一下,一个字节可以取 0 到 255 之间的 256 个值;因此,一个真正随机的字节出现零的概率应该是 1/256。)更疯狂的是,尽管自 2001 年起就已经知道 RC4 的统计偏差,大多数专家直到 2013 年仍然继续信任 RC4。
RC4 的已知统计偏差本应足以让我们完全放弃这种加密算法,即使我们不知道如何利用这些偏差来破坏实际应用。在 TLS 中,RC4 的缺陷直到 2011 年才被公开利用,但据称,NSA 在那之前就已经利用 RC4 的弱点来破解 TLS 的 RC4 连接。
事实证明,RC4 的第二个密钥流字节有偏差,而前 256 个字节也都有偏差。2011 年,研究人员发现,这些字节中某个字节为零的概率等于 1/256 + c/256²,其中 c 是一个常数,取值范围在 0.24 到 1.34 之间。这不仅仅是针对字节零,其他字节值也存在这种偏差。RC4 的惊人之处在于,它在许多非加密的伪随机数生成器(PRNG)成功的地方失败——即生成均匀分布的伪随机字节(也就是说,每个 256 个字节中,每个字节出现的概率为 1/256)。
即使是最弱的攻击模型也可以用来利用 RC4 在 TLS 实现中的缺陷:基本上,你收集密文并寻找明文,而不是密钥。但有一个警告:你需要多条密文,使用不同的密钥多次加密 相同的明文。这种攻击模型有时被称为 广播模型,因为它类似于将相同的消息广播给多个接收者。
例如,假设你想要解密明文字节 P[1],而你已经截获了多条相同消息的不同密文字节。那样,前四个密文字节将看起来像这样:

由于 RC4 的偏差,密钥流字节 KS[1]^(i) 更有可能为零,而不是其他字节值。因此,C[1]^(i) 字节更有可能等于 P[1],而不是任何其他值。为了根据 C[1]^(i) 字节确定 P[1],你只需计算每个字节值的出现次数,并返回出现频率最高的字节作为 P[1]。然而,由于统计偏差非常小,你需要数百万个字节值才能有足够的把握得到正确答案。
这个攻击可以推广到恢复多个明文字节,并利用多个偏差值(这里是零)。算法只会变得稍微复杂一些。然而,这个攻击难以付诸实践,因为它需要收集多条加密相同明文但使用不同密钥的密文。例如,这种攻击无法破解所有使用 RC4 的 TLS 保护连接,因为你需要欺骗服务器加密相同的明文并发送给多个接收者,或者用不同的密钥多次加密同一个接收者。
Salsa20
Salsa20 是一种简单的软件导向密码,针对现代 CPU 进行了优化,并已在众多协议和库中实现,以及它的变种 ChaCha。其设计者、备受尊敬的密码学家丹尼尔·J·伯恩斯坦(Daniel J. Bernstein)在 2005 年将 Salsa20 提交到 eSTREAM 竞赛,并成功获得 eSTREAM 软件组合中的一席之地。Salsa20 的简单性和速度使它在开发者中广受欢迎。

图 5-10:Salsa20 对 512 位明文块的加密方案
Salsa20 是一种基于计数器的流密码——它通过不断处理递增的计数器生成密钥流。正如在图 5-10 中所示,Salsa20 核心算法使用一个密钥(K)、一个随机数(N)和一个计数器值(Ctr)来转换一个 512 位的块。然后,Salsa20 将结果加到该块的原始值上,生成一个密钥流块。(如果算法直接返回核心的置换结果作为输出,Salsa20 将完全不安全,因为它可以被反转。最终将初始秘密状态K || N || Ctr 加入,使得转换从密钥到密钥流块不可逆。)
四分之一轮函数
Salsa20 的核心置换使用一个叫做四分之一轮(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 位),这些常数对于每次加密/解密以及所有块都是相同的。
为了转换初始的 512 位状态,Salsa20 首先将QR变换独立应用于所有四列(称为列轮),然后应用于所有四行(行轮),如图 5-12 所示。列轮/行轮的序列称为双轮。Salsa20 重复进行 10 个双轮,总共进行 20 轮,因此得名Salsa20。

图 5-11:Salsa20 状态的初始化

图 5-12:Salsa20 的四分之一轮(QR)函数转化的列和行
列轮将四列如图所示转换:

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

请注意,在列轮(column-round)中,每个QR都会按照从上到下的顺序接受 x[i] 参数,而行轮(row-round)的QR则将对角线上的单词作为第一个参数(如图 5-12 右侧的数组所示),而不是来自第一列的单词。
评估 Salsa20
清单 5-3 显示了 Salsa20 在使用全零密钥(00 字节)和全一 nonce(ff 字节)初始化时,第一个和第二个块的初始状态。这两个状态仅在计数器中有一位差异,如粗体所示:具体来说,第一个块为 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:使用全零密钥和全一 nonce 初始化的前两个块的 Salsa20 初始状态
然而,尽管只有一位差异,但在经过 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 更安全,我们来看看差分密码分析的基础,即研究状态之间的差异,而不是它们的实际值。例如,图 5-13 中的两个初始状态在计数器中有一位差异,或者在 Salsa20 的状态数组中由单词 x[8] 表示。这两个状态之间的逐位差异在这个数组中展示如下:

这两个状态之间的差异实际上是这两个状态的异或(XOR)。粗体显示的 1 位对应于这两个状态之间的 1 位差异。在两个状态的 XOR 中,任何非零的位都表示存在差异。
为了查看 Salsa20 的核心算法如何快速传播变化,我们来看一下初始状态在整个轮次迭代中的差异。经过一轮之后,差异传播到第一列的三个单词中的两个:

经过两轮后,差异进一步传播到已经包含差异的行中,除了第二行。此时,状态之间的差异相当稀疏;如图所示,单词内部的变化位数不多:

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

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

因此,仅经过四轮,一个单一的差异就会传播到 512 位状态的大部分位。在密码学中,这称为完全扩散。
我们已经看到差异在 Salsa20 的轮次中迅速传播。但不仅差异会跨越所有状态传播,它们还会按照复杂的方程传播,这使得未来的差异难以预测,因为高度的非线性关系推动了状态的演变,这要归功于 XOR、加法和旋转的混合。如果仅使用 XOR,差异仍然会传播,但过程将是线性的,因此不安全。
攻击 Salsa20/8
Salsa20 默认进行 20 轮,但有时仅使用 12 轮,这个版本被称为 Salsa20/12,目的是使其运行更快。尽管 Salsa20/12 比 Salsa20 少了八轮,但它仍然比更弱的 Salsa20/8 强得多,后者只有八轮,且较少使用。
破解 Salsa20 理想情况下需要进行 2²⁵⁶次操作,因为它使用的是 256 位密钥。如果通过进行少于 2²⁵⁶次操作就能恢复密钥,那么该密码算法在理论上就被攻破了。这正是 Salsa20/8 的情况。
对 Salsa20/8 的攻击(在 2008 年发表的论文《拉丁舞蹈的新特征:Salsa、ChaCha 和 Rumba 的分析》中发布,我是该论文的共同作者,并因此获得了丹尼尔·J·伯恩斯坦颁发的密码分析奖)利用了 Salsa 核心算法在四轮之后的统计偏差来恢复八轮 Salsa20 的密钥。实际上,这主要是一个理论攻击:我们估计其复杂度为 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³⁶次操作,这一计算远远超过了找到这 220 位所需的 2²²⁰ × 2³¹ = 2²⁵¹次试验,这是攻击的第一部分所需的试验次数。
错误的地方
唉,流密码有许多可能出错的地方,从脆弱、不安全的设计到错误实现的强算法。我将在接下来的章节中探讨每类潜在问题。
Nonce 重用
流密码中最常见的失败是一个业余错误:当一个nonce与相同的密钥被多次重用时,就会发生这种错误。这会产生相同的密钥流,使你可以通过将两个密文进行异或操作来破解加密。这样,密钥流就会消失,剩下的就是两个明文的异或结果。
例如,Microsoft Word 和 Excel 的旧版本为每个文档使用了唯一的 nonce,但一旦文档被修改,nonce 就不会再改变。因此,旧版本文档的明文和加密文本可以用于解密后续加密版本。如果连 Microsoft 都犯下了这样的错误,你可以想象这个问题可能有多大。
一些在 2010 年代设计的流密码通过构建“抗滥用”结构(或即使 nonce 被重用也能保持安全的密码)来试图降低 nonce 重用的风险。然而,达到这种安全水平是有性能代价的,正如我们在第八章中看到的 SIV 模式一样。
RC4 的错误实现
尽管 RC4 已经很弱,但如果你盲目优化它的实现,它可能会变得更弱。例如,考虑 2007 年 Underhanded C Contest 中的一个例子,这是一个非正式的竞赛,程序员编写看似无害的代码,实际上包含恶意功能。
这是它的工作原理。在 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,然后第三行将x设置为x ⊕ y ⊕ x ⊕ y ⊕ y = y。使用这个技巧来实现 RC4 的结果,如清单 5-5 所示(改编自 Wagner 和 Biondi 提交的程序,参与了 Underhanded C Contest,并在线发布于www.underhanded-c.org/_page_id_16.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:由于使用了 XOR 交换,RC4 的 C 语言实现错误
现在停止阅读,尝试找出 Listing 5-5 中 XOR 交换的问题。
当 i = j 时,事情就会出问题。与其让状态保持不变,XOR 交换会将 S[i] 设置为 S[i] ⊕ S[i] = 0。实际上,每当密钥调度或加密过程中 i 等于 j 时,状态中的一个字节会被设置为零,最终导致全零状态,从而生成全零的密钥流。例如,当处理了 68KB 的数据后,256 字节状态中的大部分字节都变成了零,输出的密钥流看起来如下:
00 00 00 00 00 00 00 53 53 00 00 00 00 00 00 00 00 00 00 00 13 13 00 5c 00 a5 00 00 …
这里的教训是,避免过度优化你的加密实现。在加密学中,清晰和自信总是优于性能。
弱加密算法嵌入硬件中
当一个加密系统不再安全时,一些系统可以通过远程静默更新受影响的软件来迅速响应(例如某些付费电视系统),或通过发布新版本并提示用户升级(例如移动应用)。然而,其他一些系统就不那么幸运了,它们在升级到安全版本之前,必须坚持使用被攻破的加密系统,就像某些卫星电话那样。
在 2000 年代初期,美国和欧洲的电信标准化机构(TIA 和 ETSI)共同开发了两项卫星电话(satphone)通信标准。卫星电话类似于移动电话,只不过它们的信号通过卫星而不是地面站传输。其优势在于几乎可以在全球任何地方使用。它们的缺点是价格、质量、延迟以及,结果证明,安全性。
GMR-1 和 GMR-2 是大多数商业厂商(如 Thuraya 和 Inmarsat)采用的两种卫星电话标准。两者都包含流密码用于加密语音通信。GMR-1 的加密算法以硬件为导向,结合了四个线性反馈移位寄存器(LFSRs),类似于 A5/2,这是 2G 移动通信标准中故意不安全的加密算法,目的是为了服务于非西方国家。GMR-2 的加密算法以软件为导向,使用 8 字节状态和 S 盒。两种流密码都不安全,只能保护用户免受业余攻击,而无法防御国家级的机构。
这个故事应该提醒我们,流密码曾经比块密码更容易破解,而且它们更容易被破坏。为什么?嗯,如果你故意设计一个弱的流密码,当漏洞被发现时,你仍然可以把责任归咎于流密码的弱点,并否认任何恶意意图。
进一步阅读
要了解更多关于流密码的信息,可以从[www.ecrypt.eu.org/stream/project.html]的 eSTREAM 竞赛档案开始,在那里你将找到关于流密码的数百篇论文,包括 30 多个候选算法的细节以及许多攻击方法。其中一些最有趣的攻击方法包括相关攻击、代数攻击和立方攻击。特别可以参考 Courtois 和 Meier 的工作,了解前两种攻击类型,Dinur 和 Shamir 的工作则涉及立方攻击。
关于 RC4 的更多信息,可以参考 Paterson 及其团队的工作,[www.isg.rhul.ac.uk/tls/],这项工作研究了 RC4 在 TLS 和 WPA 中的安全性。同时也可以了解 Spritz,这是一种类似 RC4 的密码,由 Rivest 于 2014 年设计,他在 1980 年代设计了 RC4。
Salsa20 的遗产也值得关注。流密码 ChaCha 与 Salsa20 相似,但核心置换稍有不同,后来这种置换被用于哈希函数 BLAKE,如你将在第六章中看到的那样。这些算法都利用了 Salsa20 的软件实现技术,使用并行化指令,如在[cr.yp.to/snuffle.html]中所讨论的。
第七章:哈希函数

哈希函数——例如 MD5、SHA-1、SHA-256、SHA-3 和 BLAKE2——是加密学家的瑞士军刀:它们广泛应用于数字签名、公钥加密、完整性验证、消息认证、密码保护、密钥协商协议及许多其他加密协议中。无论是加密电子邮件、发送手机消息、连接 HTTPS 网站,还是通过 IPSec 或 SSH 连接远程机器,背后都可能有某种哈希函数在发挥作用。
哈希函数无疑是所有加密算法中最具多功能性和最为普及的。它们在现实世界中有很多应用实例:云存储系统利用哈希函数识别相同的文件并检测文件是否被修改;Git 版本控制系统利用哈希函数识别仓库中的文件;基于主机的入侵检测系统(HIDS)利用哈希值检测修改过的文件;基于网络的入侵检测系统(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仅相差一到两位(二进制:a是 01100001,b是 01100010,c是 01100011),它们的哈希值却完全不同。

仅凭这三个哈希值,无法预测d的 SHA-256 哈希值或其中任何一位的值。为什么?因为安全哈希函数的哈希值是不可预测的。一个安全的哈希函数应该像一个黑盒子,每次接收到输入时都会返回一个随机字符串。
安全哈希函数的一般理论定义是,它的行为像一个真正的随机函数(有时称为随机预言机)。具体而言,安全哈希函数不应具有任何随机函数没有的特性或模式。这个定义对理论学者有帮助,但在实际应用中我们需要更具体的概念:即预影像抗性和碰撞抗性。
预影像抗性
给定哈希值H的预影像是任何消息M,使得Hash(M) = H。预影像抗性描述了这样一个安全保证:给定一个随机的哈希值,攻击者永远无法找到该哈希值的预影像。事实上,哈希函数有时被称为单向 函数,因为你可以从消息得到其哈希值,但无法反向操作得到原消息。
首先,注意到即使有无限的计算能力,哈希函数也无法反转。例如,假设我使用 SHA-256 哈希函数对某个消息进行哈希,并得到这个 256 位的哈希值:
f67a58184cef99d6dfc3045f08645e844f2837ee4bfcc6c949c9f7674367adfd
即使有无限的计算能力,你也永远无法确定我选择的那个消息来生成这个特定的哈希值,因为有许多消息哈希到相同的值。因此,你会找到一些生成这个哈希值的消息(可能包括我选择的那个),但你无法确定我使用的消息。
例如,一个 256 位哈希(这是实际中常用的哈希函数长度)有 2²⁵⁶个可能的值,但例如 1024 位消息有更多的值(即,2¹⁰²⁴个可能的值)。因此,平均而言,每个可能的 256 位哈希值将有 2¹⁰²⁴ / 2²⁵⁶ = 2^(1024 - 256) = 2⁷⁶⁸个 1024 位的预像。
在实际应用中,我们必须确保几乎不可能找到任何映射到给定哈希值的消息,而不仅仅是使用的消息,这正是预像抗性的含义。具体来说,我们讨论的是第一预像抗性和第二预像抗性。第一预像抗性(或简称预像抗性)描述的是几乎不可能找到一个映射到给定值的消息的情况。另一方面,第二预像抗性描述的是在给定一个消息M[1]的情况下,几乎不可能找到另一个消息M[2],使得M[1]和M[2]哈希到相同的值。
预像的代价
给定一个哈希函数和一个哈希值,你可以通过尝试不同的消息直到找到目标哈希来搜索第一个预像。你可以使用类似于 Listing 6-1 中find-preimage()的算法来完成这项工作。
find-preimage(H) {
repeat {
M = random_message()
if Hash(M) == H then return M
}
}
Listing 6-1:安全哈希函数的最优预像搜索算法
在 Listing 6-1 中,random_message()生成一个随机消息(例如,随机的 1024 位值)。显然,如果哈希的位长n足够大,find-preimage()将永远无法完成,因为平均需要 2^(n)次尝试才能找到一个预像。当处理n = 256 时,这种情况是绝望的,就像现代的 SHA-256 和 BLAKE2 哈希一样。
为什么第二预像抗性较弱
我声明,如果你能找到第一个预像,你也能找到第二个预像(对于相同的哈希函数)。作为证明,如果算法solve-preimage()返回给定哈希值的一个预像,你可以使用 Listing 6-2 中的算法来找到某个消息M的第二个预像。
solve-second-preimage(M) {
H = Hash(M)
return solve-preimage(H)
}
Listing 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:天真碰撞搜索算法
也就是说,任何碰撞抗性的哈希函数也是二次预映像抗性的。如果不是这样的话,就会有一个高效的solve-second-preimage算法可用于破解碰撞抗性。
找到碰撞
找到碰撞比找到预映像要快,大约需要 2^(n/2)次操作,而不是 2^(n),这要归功于生日攻击,其关键思想如下:给定N个消息和相同数量的哈希值,通过考虑每一对哈希值的对,你可以产生N × (N – 1) / 2 个潜在的碰撞(一个与N²数量级相同的数字)。之所以称之为生日攻击,是因为它通常使用所谓的生日悖论来说明,即只有 23 个人的群体,便有 1/2 的概率包含两个人拥有相同的生日。
注意
N × (N – 1) / 2 是两个不同消息对的数量,我们除以 2,因为我们把* (M[1], M[2])* 和* (M[2], M[1])* 视为相同的对。换句话说,我们不在乎顺序。
为了进行比较,在预影像搜索的情况下,N 个消息只会得到 N 个候选预影像,而相同的 N 个消息则给出大约 N² 个潜在碰撞,正如前面所讨论的那样。通过 N² 而不是 N,我们可以说找到解决方案的机会是二次增长的。搜索的复杂度也因此二次降低:为了找到一个碰撞,你只需要使用 2^(n/2) 个消息,也就是 2^(n/2),而不是 2^(n)。
天真的生日攻击
下面是执行生日攻击以寻找碰撞的最简单方法:
-
计算 2^(n/2) 个哈希值,对 2^(n/2) 个任意选择的消息进行哈希,并将所有消息/哈希对存储在一个列表中。
-
根据哈希值对列表进行排序,以便将所有相同的哈希值放置在一起。
-
在排序列表中搜索,找到两个连续的条目,它们具有相同的哈希值。
不幸的是,这种方法需要大量内存(足以存储 2^(n/2) 个消息/哈希对),而且排序大量元素会减慢搜索速度,使用快速排序算法时平均需要进行约 n2^(n) 次基本操作。
低内存碰撞搜索:Rho 方法
Rho 方法是一种寻找碰撞的算法,与天真的生日攻击不同,它只需要少量的内存。它的工作原理如下:
-
给定一个 n 位的哈希值,选择一个随机的哈希值 (H[1]),并定义 H[1] = H′[1]。
-
计算 H[2] = Hash(H[1]),以及 H′[2] = Hash(Hash(H′[1])); 也就是说,在第一个情况下,我们应用哈希函数一次,而在第二个情况下,我们应用两次。
-
反复进行该过程,计算 H[i] [+ 1] = Hash(H[i]), H′[i] [+ 1] = Hash(Hash(H′[i])),直到你找到 i 使得 H[i] [+ 1] = H′[i] [+ 1]。
图 6-3 将帮助你可视化攻击,其中例如从 H[1] 到 H[2] 的箭头表示 H[2] = Hash(H[1])。请注意,H[i] 的序列最终进入一个循环,也称为 cycle,其形状类似于希腊字母 rho (ρ)。这个循环从 H[5] 开始,并且具有碰撞 Hash(H[4]) = Hash(H[10]) = H[5]。这里的关键观察是,要找到碰撞,你只需要找到这样的一个循环。上面的算法允许攻击者检测循环的位置,因此找到碰撞。

图 6-3:Rho 哈希函数的结构。每个箭头表示一次哈希函数的评估。以 H[5] 为起点的循环对应一个碰撞,Hash*(H[4]) = Hash(H[10]) = H[5]。
高级碰撞查找技术通过首先检测循环的起始位置,再找到碰撞,而不需要在内存中存储大量值,也不需要排序长列表。Rho 方法需要大约 2^(n/2)次操作才能成功。事实上,图 6-3 的哈希值要比实际的 256 位或更长的摘要函数少得多。平均而言,循环和尾部(从图 6-3 中H[1]到H[5]的部分)每个包含大约 2^(n/2)个哈希值,其中n是哈希值的位长度。因此,您需要至少 2^(n/2) + 2^(n/2)次哈希评估才能找到碰撞。
构建哈希函数
在 1980 年代,密码学家意识到对消息进行哈希的最简单方法是将其拆分为多个块,并使用相似的算法依次处理每个块。这种策略被称为迭代哈希,它有两种主要形式:
-
使用压缩函数进行迭代哈希,该函数将输入转换为较小的输出,如图 6-4 所示。这种技术也被称为Merkle–Damgård构造(以密码学家 Ralph Merkle 和 Ivan Damgård 的名字命名)。
-
使用一个将输入转换为相同大小输出的函数进行迭代哈希,使得任何两个不同的输入产生两个不同的输出(即置换),如图 6-7 所示。这样的函数被称为海绵函数。
接下来我们将讨论这些构造如何实际运作,以及压缩函数在实践中的样子。
基于压缩的哈希函数: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]、…被称为链值,而内部状态的最终值即为消息的哈希值。

图 6-4:使用名为 Compress 的压缩函数的 Merkle–Damgård 构造
消息块通常是 512 或 1024 位,但原则上可以是任意大小。然而,给定哈希函数的块长度是固定的。例如,SHA-256 使用 512 位块,而 SHA-512 使用 1024 位块。
填充块
如果你想哈希一个无法分割成完整块的消息会发生什么呢?例如,如果块是 512 位,那么一个 520 位的消息将包含一个 512 位块加上 8 位。在这种情况下,M–D 结构将按如下方式形成最后一个块:取剩余的比特块(在我们的例子中是 8 位),附加 1 位,然后附加 0 位,最后附加原始消息的长度,并以固定的比特数进行编码。这种填充技巧确保了任何两个不同的消息都会产生不同的块序列,从而得到不同的哈希值。
例如,如果你使用 SHA-256 对 8 位字符串 10101010 进行哈希运算,而 SHA-256 是一个具有 512 位消息块的哈希函数,那么第一个且唯一的块将以比特形式如下所示:

这里,消息的比特是前八个比特(10101010),而填充比特是所有后续的比特(以斜体显示)。块末尾的 1000(下划线部分)是消息的长度,或以二进制编码的 8。填充最终生成一个由单个 512 位块组成的 512 位消息,准备好由 SHA-256 的压缩函数处理。
安全性保障
Merkle-Damgård 结构本质上是一种将小的、固定长度输入的安全压缩函数转换为接受任意长度输入的安全哈希函数的方法。如果一个压缩函数是抗预像和抗碰撞的,那么基于它使用 M–D 结构构建的哈希函数也将具有抗预像和抗碰撞的特性。这是因为任何成功的预像攻击都可以转化为针对压缩函数的成功预像攻击,正如 Merkle 和 Damgård 在他们 1989 年的论文中所展示的那样(见“进一步阅读” 第 126 页)。碰撞的情况也是如此:攻击者无法破解哈希函数的碰撞抗性,除非首先破解底层压缩函数的碰撞抗性;因此,后者的安全性保障了哈希的安全性。
请注意,反向的论点是错误的,因为压缩函数的碰撞不一定会导致哈希函数的碰撞。对于链接值 X 和 Y(都不同于 H[0]),Compress(X, M[1]) = Compress(Y, M[2]) 的碰撞不会给你哈希碰撞,因为你不能将碰撞插入到哈希的迭代链中——除非其中一个链值恰好是 X,另一个是 Y,但这种情况发生的可能性极低。
查找多重碰撞
多重碰撞 发生在三个或更多消息哈希到相同的值时。例如,三元组 (X, Y, Z),使得 Hash(X) = Hash(Y) = Hash(Z) 被称为 3-碰撞。理想情况下,多重碰撞应该比碰撞更难发现,但有一个简单的技巧可以在几乎与单一碰撞相同的成本下找到它们。其工作原理如下:
-
找到第一次碰撞:Compress(H[0], M[1.1]) = Compress(H[0], M[1.2]) = H[1]。现在你有一个 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]。
-
重复并找到 N 次碰撞,你将得到 2^(N) N 块消息哈希到相同的值,或者 2^(N)-碰撞,代价是“大约”只有 N2^(N) 次哈希计算。
在实践中,这个技巧并不特别实用,因为它首先要求你找到一个基本的 2-碰撞。
构建压缩函数:Davies-Meyer 构造

图 6-5:Davies-Meyer 构造。黑色三角形显示了块密码的密钥输入位置。
实际哈希函数中使用的所有压缩函数,例如 SHA-256 和 BLAKE2,都基于块密码,因为这是构建压缩函数的最简单方式。图 6-5 显示了基于块密码的压缩函数中最常见的,即 Davies-Meyer 构造。
给定一个消息块,M[i],和前一个链式值 H[i − 1],Davies-Meyer 压缩函数使用块密码 E 来计算新的链式值,公式如下:
H[i] = E(M[i], H[i − 1]) ⊕ H[i − 1]
消息块 M[i] 充当块密码的密钥,链式值 H[i] [– 1] 充当其明文块。只要块密码是安全的,结果的压缩函数也是安全的,并且具有抗碰撞和抗原像性。若没有前一个链式值的 XOR 操作(⊕ H[i] [– 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))——只留下 ⊕ H[i – 1] 部分,作为压缩函数输出的表达式。你可以找到 SHA-2 函数的压缩函数的固定点,类似于标准的 MD5 和 SHA-1,它们也基于 Davies-Meyer 构造。幸运的是,固定点并不是安全隐患。
除了 Davies–Meyer 之外,还有许多基于分组密码的压缩函数,如图 6-6 所示,但它们不太流行,因为它们更复杂或要求消息数据块与链值的长度相同。

图 6-6:其他安全的基于分组密码的压缩函数构造
基于置换的哈希函数:海绵函数
经过数十年的研究,密码学家已知晓基于分组密码的哈希技术的所有知识。但难道就没有一种更简单的哈希方法吗?为什么要使用分组密码这种需要密钥的算法,而哈希函数不需要密钥?为什么不使用固定密钥的分组密码,结合单一的置换算法来构建哈希函数?
那些更简单的哈希函数被称为海绵函数,它们使用单一的置换而不是压缩函数和分组密码(参见图 6-7)。海绵函数不是使用分组密码将消息位与内部状态混合,而是直接执行异或操作。海绵函数不仅比 Merkle–Damgård 函数更简单,而且更具通用性。你会发现它们不仅用作哈希函数,还可以用作确定性随机位生成器、流密码、伪随机函数(参见第七章)和认证密码(参见第八章)。最著名的海绵函数是 Keccak,也称为 SHA-3。

图 6-7:海绵构造
海绵函数的工作方式如下:
-
它将第一个消息数据块M[1]与H[0]进行异或操作,H[0]是预定义的内部状态初始值(例如,全零字符串)。消息数据块的大小相同,且小于内部状态的大小。
-
置换P将内部状态转换为另一个相同大小的值。
-
它对数据块M[2]进行异或操作,并再次应用P,然后对消息数据块M[3]、M[4]等重复此操作。这称为吸收阶段。
-
在注入所有消息数据块后,它再次应用P,并从状态中提取一块比特形成哈希。(如果需要更长的哈希,重复应用P并提取一块。)这称为挤压阶段。
海绵函数的安全性取决于其内部状态的长度和数据块的长度。如果消息块的长度为 r 位,内部状态的长度为 w 位,那么有 c = w – r 位的内部状态是无法被消息块修改的。c 的值称为海绵函数的 容量,而海绵函数所保证的安全级别是 c/2。例如,为了在使用 64 位消息块时达到 256 位的安全性,内部状态应该是 w = 2 × 256 + 64 = 576 位。当然,安全级别还取决于哈希值的长度 n。因此,碰撞攻击的复杂度是 2^(n/2) 和 2^(c/2) 之间的最小值,而第二预影像攻击的复杂度是 2^(n) 和 2^(c/2) 之间的最小值。
为了确保安全,置换 P 应该像随机置换一样运作,没有统计偏差,也没有允许攻击者预测输出的数学结构。与基于压缩函数的哈希一样,海绵函数也会对消息进行填充,但由于不需要包含消息的长度,填充更加简单。最后的消息位后面跟着一个 1 位,接着是必要数量的零位。
SHA 哈希函数族
安全哈希算法(SHA)哈希函数是由美国国家标准与技术研究院(NIST)为非军事的美国联邦政府机构定义的标准。它们被认为是全球标准,只有一些非美国政府出于主权原因而选择自己的哈希算法(如中国的 SM3、俄罗斯的 Streebog 和乌克兰的 Kupyna),而非因对 SHA 安全性的缺乏信任。美国的 SHA 哈希函数比非美国的哈希函数经过了更多密码分析学家的审查。
注意
消息摘要 5 (MD5) 是从 1992 年到大约 2005 年最流行的哈希函数,直到它被攻破为止,许多应用程序随后转向了某种 SHA 哈希函数。MD5 处理 512 位的消息块并更新一个 128 位的内部状态以生成一个 128 位的哈希,从而提供最多 128 位的预影像安全性和 64 位的碰撞安全性。1996 年,密码分析师警告 MD5 的压缩函数存在碰撞问题,但他们的警告没有得到重视,直到 2005 年,中国的密码分析团队发现了如何计算 MD5 哈希的碰撞。直到现在,计算 MD5 的碰撞只需要几秒钟,但许多系统仍然使用或支持 MD5,通常是出于向后兼容的原因。
SHA-1
SHA-1 标准源于美国国家安全局(NSA)原始 SHA-0 哈希函数的失败。1993 年,NIST 将 NSA 的 SHA-0 哈希算法标准化,但在 1995 年,NSA 发布了 SHA-1 以修复 SHA-0 中未明示的安全问题。1998 年,两个研究人员发现如何在大约 2⁶⁰ 次操作中找到 SHA-0 的碰撞,而不是预期的 2⁸⁰ 次操作(对于 160 位哈希函数,如 SHA-0 和 SHA-1)。后来的攻击将复杂度降低到大约 2³³ 次操作,使得 SHA-0 在不到一小时内出现实际碰撞。
SHA-1 内部结构
SHA-1 结合了 Merkle–Damgård 哈希函数和基于特制块密码的 Davies–Meyer 压缩函数,后者有时被称为 SHACAL。即,SHA-1 通过对 512 位消息块(M)迭代以下操作工作:
H = E(M, H) + H
这里使用加号(+)而不是 ⊕(异或)是有意为之。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(),在 列表 6-5 中以粗体显示,接受一个 512 位消息块 M 作为密钥,通过迭代 80 步短序列的操作,将五个 32 位字(a、b、c、d 和 e)转换,替换字 a 为所有五个字的组合。然后,它会像移位寄存器一样将数组中的其他字移动。
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-6 中显示的 expand() 函数通过将 W 的前 16 个字设置为 M,并将其余字设置为前面字的异或组合(左移一位),从 16 字消息块创建了一个由 80 个 32 位字组成的数组 W。
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 函数之间的唯一差异。
最后,f() 函数(见 列表 6-7)在 SHA1-blockcipher() 中是一个基本的按位逻辑操作(布尔函数)序列,取决于轮次。
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 仍然不安全,这也是 Chrome 浏览器将使用 SHA-1 的 HTTPS 网站标记为不安全的原因。尽管其 160 位哈希应该赋予其 80 位碰撞抗性,但在 2005 年,研究人员发现了 SHA-1 的弱点,并估计找到一个碰撞大约需要 2⁶³ 次计算。(如果算法完美无缺,这个数字应该是 2⁸⁰)。真正的 SHA-1 碰撞直到十二年后才出现,当时,Marc Stevens 和其他研究人员通过与 Google 研究人员的合作,展示了两个相撞的 PDF 文档(见 shattered.io/)。
结论是你不应该使用 SHA-1。如前所述,互联网浏览器现在将 SHA-1 标记为不安全,NIST 也不再推荐 SHA-1。请改用 SHA-2 哈希函数,或者 BLAKE2 或 SHA-3。
SHA-2
SHA-2 是 SHA-1 的继任者,由 NSA 设计并由 NIST 标准化。SHA-2 是四个哈希函数的系列:SHA-224、SHA-256、SHA-384 和 SHA-512,其中 SHA-256 和 SHA-512 是两个主要算法。这些三位数字表示每个哈希的位长。
SHA-256
开发 SHA-2 的最初动机是生成更长的哈希,从而提供比 SHA-1 更高的安全性。例如,虽然 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、逻辑与(AND)和字旋转。
其他 SHA-2 算法
SHA-2 系列包括 SHA-224,它与 SHA-256 在算法上完全相同,唯一不同的是其初始值是一组不同的 8 个 32 位字,而其哈希值长度为 224 位,而不是 256 位,并且该值取自最终链接值的前 224 位。
SHA-2 系列还包括 SHA-512 和 SHA-384 算法。SHA-512 与 SHA-256 相似,只是它使用 64 位字而不是 32 位字。因此,它使用 512 位的链接值(8 个 64 位字),并处理 1024 位的消息块(16 个 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-2 与 SHA-1 相似,许多人认为 SHA-2 被攻击只是时间问题。然而,截至目前,我们尚未看到对 SHA-2 的成功攻击。不管怎样,NIST 制定了备选方案:SHA-3。
SHA-3 竞赛
2007 年宣布的 NIST 哈希函数竞赛(SHA-3 竞赛的官方名称)开始时发布了征集公告,并给出了基本要求:哈希算法的安全性和速度至少要与 SHA-2 一样,并且至少要具备与 SHA-2 相同的功能。SHA-3 的候选算法还应该与 SHA-1 和 SHA-2 有足够的区别,以避免遭受破坏 SHA-1 甚至 SHA-2 的攻击。到 2008 年,NIST 收到了来自全球的 64 个提交,包括来自大学和大型企业(如 BT、IBM、微软、Qualcomm 和索尼等)的提交。在这 64 个提交中,51 个符合要求,进入了第一轮比赛。
在竞赛的最初几周,密码分析师们对各个提交进行了毫不留情的攻击。2009 年 7 月,NIST 宣布了 14 个第二轮候选算法。经过 15 个月对这些候选算法的分析和评估,NIST 选出了五个决赛入围者:
BLAKE 是一种增强型 Merkle-Damgård 哈希,其压缩函数基于块密码,块密码又基于流密码 ChaCha 的核心函数,包括一系列加法、XOR 运算和字轮转。BLAKE 由一个包括我在内的瑞士和英国的学术研究团队设计。
Grøstl 一种增强型 Merkle–Damgård 哈希,其压缩函数使用基于 AES 块密码核心功能的两种置换(或固定密钥块密码)。Grøstl 由来自丹麦和奥地利的七位学术研究人员团队设计。
JH 一种调整过的海绵函数构造,其中消息块在置换之前和之后都被注入,而不仅仅是在之前。该置换还执行类似于替代–置换块密码的操作(如第四章所讨论)。JH 由一位来自新加坡大学的密码学家设计。
Keccak 一种海绵函数,其置换仅执行按位操作。Keccak 由一个位于比利时和意大利的半导体公司工作的四人密码学家团队设计,其中包括 AES 的两位设计者之一。
Skein 一种基于不同于 Merkle–Damgård 操作模式的哈希函数,其压缩函数基于一种新型块密码,仅使用整数加法、XOR 和字轮转。Skein 由来自学术界和工业界的八位密码学家团队设计,其中除了一个人之外,其他人均位于美国,包括著名的布鲁斯·施奈尔(Bruce Schneier)。
在对五个候选算法进行了广泛分析后,NIST 宣布 Keccak 为最终获胜者。NIST 的报告称 Keccak 具有“优雅的设计、大的安全余量、良好的通用性能、卓越的硬件效率和灵活性”。让我们来看一下 Keccak 是如何工作的。
Keccak (SHA-3)
NIST 选择 Keccak 的原因之一是它与 SHA-1 和 SHA-2 完全不同。首先,它是一种海绵函数。Keccak 的核心算法是一种 1600 位状态的置换,它可以处理 1152、1088、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 的 MIT 许可证 tiny_sha3 (github.com/mjosaarinen/tiny_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];
}
(⊕)
}
Listing 6-9: tiny_sha3 实现
tiny_sha3 程序实现了 Keccak 的置换P,这是对一个 1600 位状态的可逆变换,状态被视为 25 个 64 位字的数组。查看代码时,请注意它会迭代一系列回合,每个回合由四个主要步骤组成(如❶、❷、❸和❹所标记):
-
第一步,
Theta❶,包括 64 位字之间的 XOR 操作,或是对字的 1 位旋转值进行 XOR(ROTL64(w, 1)操作是将字w向左旋转 1 位)。 -
第二步,
Rho Pi❷,包括将 64 位字旋转,通过硬编码在keccakf_rotc[]数组中的常数进行旋转。 -
第三步,
Chi❸,包括更多的 XOR 操作,但也包含 64 位字之间的逻辑与(& 运算符)。这些与操作是 Keccak 中唯一的非线性操作,它们带来了加密强度。 -
第四步,
Iota❹,包括与一个 64 位常数进行 XOR 操作,该常数被硬编码在keccakf_rndc[]中。
这些操作为 SHA-3 提供了一个强大的置换算法,没有任何偏差或可利用的结构。SHA-3 是经过十多年研究的成果,数百名经验丰富的密码分析师都未能破解它。它在短时间内不太可能被破解。
BLAKE2 哈希函数
安全性可能是最重要的,但速度排在第二位。我见过许多开发者仅仅因为 MD5 比 SHA-1 快,而不愿意从 MD5 切换到 SHA-1,或者因为 SHA-2 比 SHA-1 明显更慢,而不愿从 SHA-1 切换到 SHA-2。不幸的是,SHA-3 并没有比 SHA-2 更快,并且由于 SHA-2 仍然安全,因此升级到 SHA-3 的动机不大。那么,如何比 SHA-1 和 SHA-2 更快地进行哈希并且更加安全呢?答案就在哈希函数 BLAKE2 中,它是在 SHA-3 竞赛后发布的。
注意
完全披露:我是 BLAKE2 的设计者之一,和 Samuel Neves、Zooko Wilcox-O’Hearn、Christian Winnerlein 一起设计的。
BLAKE2 的设计考虑了以下几点:
-
它应该至少与 SHA-3 同样安全,甚至可能更强。
-
它应该比所有之前的哈希标准都更快,包括 MD5。
-
它应该适用于现代应用程序,能够处理大量数据,无论是作为少量大消息还是大量小消息,带或不带秘密密钥。
-
它应该适用于支持多核系统并行计算的现代 CPU,以及单核中的指令级并行性。
该工程过程的结果是一对主要的哈希函数:
-
BLAKE2b(或简称 BLAKE2),针对 64 位平台进行了优化,能够生成从 1 到 64 字节不等的哈希值。
-
BLAKE2s,针对 8 到 32 位平台进行了优化,能够生成从 1 到 32 字节不等的哈希值。
每个函数都有一个并行变体,可以利用多个 CPU 核心。BLAKE2b 的并行版本 BLAKE2bp 可以在四个核心上运行,而 BLAKE2sp 可以在八个核心上运行。前者在现代服务器和笔记本电脑的 CPU 上最快,并且在笔记本电脑 CPU 上的哈希速度接近 2 Gbps。事实上,BLAKE2 是目前最快的安全哈希,其速度和特点使它成为最受欢迎的非 NIST 标准哈希。BLAKE2 被广泛应用于各种软件,并已集成到 OpenSSL 和 Sodium 等主要的加密库中。
注意
你可以在 blake2.net/ 找到 BLAKE2 的规格和参考代码, github.com/BLAKE2/ 可以下载优化过的代码和库。参考代码还提供了 BLAKE2X,这是 BLAKE2 的扩展,可以生成任意长度的哈希值。

图 6-8:BLAKE2 的压缩函数。在分组密码之后,状态的两个半部分通过异或操作结合。
BLAKE2 的压缩函数,如图 6-8 所示,是 Davies-Meyer 构造的一个变种,采用参数作为附加输入——即一个 计数器(确保每个压缩函数的行为像不同的函数)和一个 标志(指示压缩函数是否正在处理最后一个消息块,以提高安全性)。
BLAKE2 压缩函数中的分组密码基于流密码 ChaCha,而 ChaCha 本身是第五章讨论的 Salsa20 流密码的变种。在这个分组密码中,BLAKE2b 的核心操作由以下一系列操作组成,这些操作通过两个消息字 M[i] 和 M[j] 转换四个 64 位字的状态:

BLAKE2s 的核心操作类似,但使用的是 32 位而不是 64 位字(因此使用不同的旋转值)。
错误发生的方式
尽管哈希函数表面上看起来很简单,但当在错误的地方或以错误的方式使用时,可能会引发严重的安全问题——例如,当像 CRC 这样的弱校验和算法被用来替代加密哈希函数检查文件完整性,尤其是在传输数据的网络应用中。然而,这种弱点与一些其他弱点相比显得微不足道,后者可能会导致看似安全的哈希函数完全失效。我们将看到两个失败的例子:第一个例子适用于 SHA-1 和 SHA-2,但不适用于 BLAKE2 或 SHA-3,而第二个例子适用于这四个函数。
长度扩展攻击
长度扩展攻击,如图 6-9 所示,是对 Merkle-Damgård 构造的主要威胁。

图 6-9:长度扩展攻击
基本上,如果你知道某个未知消息M的哈希(M),它由块M[1]和M[2](经过填充)组成,你可以为任何块M[3]确定哈希(M[1] || M[2] || M[3])。因为M[1] || M[2]的哈希值是紧接着M[2]之后的链式值,你可以添加另一个块M[3]到哈希消息中,即使你不知道已经哈希的数据。而且,这个技巧可以推广到任何数量的未知消息块(这里是M[1] || M[2])或后缀(M[3])。
长度扩展攻击不会影响哈希函数的大多数应用,但如果哈希函数使用得过于创意,它可能会危及安全。不幸的是,SHA-2 哈希函数容易受到长度扩展攻击,尽管 NSA 设计了这些函数,而 NIST 也在两者都知道这个缺陷的情况下对它们进行了标准化。这个缺陷本可以通过让最后一次压缩函数调用与其他所有调用不同来避免(例如,在前面的调用使用 0 位时,最后一次调用可以使用 1 位作为额外参数)。实际上,BLAKE2 就是这样做的。
欺骗存储证明协议
云计算应用已在存储证明协议中使用哈希函数——即,协议中服务器(云服务提供商)向客户端(云存储服务的用户)证明服务器确实存储了它应该为客户端存储的文件。
2007 年,Ramakrishna Kotla、Lorenzo Alvisi 和 Mike Dahlin 发表的论文“SafeStore: A Durable and Practical Storage System” (www.cs.utexas.edu/~lorenzo/papers/p129-kotla.pdf) 提出了一个存储证明协议来验证某个文件M的存储,具体如下:
-
客户端随机选择一个值,C,作为挑战。
-
服务器计算哈希(M || C)作为响应并将结果发送给客户端。
-
客户端还计算哈希(M || C)并检查它是否与从服务器接收到的值匹配。
论文的前提是,服务器不应该能够欺骗客户端,因为如果服务器不知道M,它就无法猜测哈希(M || C)。但有一个问题:实际上,哈希将是一个迭代哈希,它按块处理输入,每个块之间计算中间链式值。例如,如果哈希是 SHA-256,而M的长度为 512 位(即 SHA-256 中的一个块大小),那么服务器可以作弊。如何作弊?当服务器第一次接收到M时,它计算 H[1] = 压缩(H[0], M[1]),这是从 SHA-256 的初始值H[0]和 512 位的M中得到的链式值。然后它将 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 年代的经典文献:例如拉尔夫·梅尔克尔的《单向哈希函数与 DES》和伊万·达姆高德的《哈希函数的设计原则》。另外,还可以阅读第一篇全面研究基于分组密码的哈希方法的论文《基于分组密码的哈希函数:一种综合方法》,该文由普雷内尔、戈瓦尔茨和范德瓦尔勒撰写。
想了解更多碰撞搜索的内容,可以阅读 1997 年由范·奥尔肖特和维纳撰写的论文《带有密码分析应用的并行碰撞搜索》。想深入了解支撑预映像抗性、碰撞抗性以及长度扩展攻击的理论安全概念,可以搜索不可区分性。
有关哈希函数的最新研究,请参阅 SHA-3 竞赛的档案,其中包括所有不同的算法及其被破解的方式。你可以在* ehash.iaik.tugraz.at/wiki/The_SHA-3_Zoo 上找到关于 SHA-3 Zoo 的许多参考资料,另一个是在 NIST 的页面, csrc.nist.gov/groups/ST/hash/sha-3/ *。
想了解更多关于 SHA-3 冠军 Keccak 和海绵函数的内容,请访问* keccak.noekeon.org/ 和 sponge.noekeon.org/ *,这是 Keccak 设计者的官方网站。
最后但同样重要的是,研究以下两个弱哈希函数的实际利用案例:
-
国家级恶意软件 Flame 利用了 MD5 碰撞来制造伪造证书,并伪装成合法的软件。
-
Xbox 游戏主机使用了一个弱的分组密码(称为 TEA)来构建哈希函数,这被利用来破解主机并在其上运行任意代码。
第八章:带密钥的哈希

第六章 中讨论的哈希函数接受一条消息并返回其哈希值——通常是一个 256 位或 512 位的短字符串。任何人都可以计算一条消息的哈希值,并验证特定消息是否哈希到特定值,因为其中没有涉及秘密值,但有时你不希望随便让任何人做这个操作。这就是 带密钥 的哈希函数的作用,或者说是使用秘密密钥的哈希。
带密钥的哈希形式是两种重要加密算法的基础:消息认证码(MAC),它用于认证消息并保护其完整性,以及 伪随机函数(PRF),它生成随机看似的哈希大小值。在本章的第一节中,我们将探讨为什么 MAC 和 PRF 是相似的;然后我们将回顾实际的 MAC 和 PRF 如何工作。一些 MAC 和 PRF 基于哈希函数,一些基于块加密算法,其他的则是原创设计。最后,我们将回顾一些攻击示例,这些攻击对其他安全的 MAC 进行攻击。
消息认证码(MAC)
MAC 通过生成一个值 T = MAC(K, M) 来保护消息 M 的完整性和真实性,这个值被称为消息的认证标签 T(通常令人困惑地称为 M 的 MAC)。就像你知道加密算法的密钥可以解密消息一样,如果你知道 MAC 的密钥,你也可以验证消息是否被篡改。
例如,假设 Alex 和 Bill 共享一个密钥 K,并且 Alex 向 Bill 发送消息 M 及其认证标签 T = MAC(K, M)。收到消息和认证标签后,Bill 重新计算 MAC(K, M) 并检查它是否等于收到的认证标签。因为只有 Alex 能够计算出这个值,Bill 知道消息在传输过程中没有被篡改(确认完整性),无论是意外的还是恶意的,并且消息是由 Alex 发送的(确认真实性)。
MAC 在安全通信中的应用
安全通信系统通常结合使用加密算法和 MAC 来保护消息的机密性、完整性和真实性。例如,互联网协议安全(IPSec)、安全外壳协议(SSH)和传输层安全(TLS)中的协议都会为每个传输的网络数据包生成一个 MAC。
并非所有通信系统都使用 MAC。有时候,认证标签会给每个数据包增加不可接受的开销,通常在 64 到 128 位之间。例如,3G 和 4G 移动电话标准对编码语音通话的数据包进行加密,但并不进行认证。攻击者可以修改加密的音频信号,接收者却无法察觉。因此,如果攻击者破坏了加密的语音数据包,它将解密为噪声,听起来像是静电噪音。
伪造和选择消息攻击
对于 MAC(消息认证码)来说,什么才算安全呢?首先,与加密算法一样,秘密密钥应该保持机密。如果一个 MAC 是安全的,攻击者在不知道密钥的情况下,不能创建某个消息的标签。这样伪造的消息/标签对被称为伪造,而恢复密钥只是一个更广泛的攻击类别——伪造攻击——的特定案例。提出“伪造应该无法找到”的安全概念称为不可伪造性。显然,从标签列表中恢复秘密密钥应该是不可能的;否则,攻击者可以使用密钥伪造标签。
攻击者可以做什么来破坏 MAC 呢?换句话说,攻击模型是什么?最基本的模型是已知消息攻击,即被动收集消息及其关联的标签(例如,通过窃听网络)。但真正的攻击者通常会发起更强大的攻击,因为他们通常可以选择要认证的消息,从而获得他们想要的消息的 MAC。因此,标准模型是选择消息攻击,即攻击者获得他们选择的消息的标签。
重放攻击
MAC 并不安全于涉及标签重放的攻击。例如,如果你窃听了 Alex 和 Bill 的通信,你可以捕获 Alex 发送给 Bill 的消息及其标签,并在稍后再次将这些信息发送给 Bill,假装是 Alex。为了防止这种重放攻击,协议会在每个消息中包含一个消息编号。每个新消息的编号都会递增,并与消息一起进行认证。接收方会收到编号为 1、2、3、4 等的消息。因此,如果攻击者试图再次发送编号为 1 的消息,接收方会注意到该消息的顺序不对,并且这可能是之前编号为 1 的消息的重放。
伪随机函数(PRF)
PRF(伪随机函数)是一种使用秘密密钥返回PRF(K, M)的函数,其输出看起来是随机的。因为密钥是保密的,攻击者无法预测输出值。
与 MAC 不同,PRF 并不打算单独使用,而是作为密码学算法或协议的一部分。例如,PRF 可以用于创建在第 55 页“如何构建分组密码”中讨论的 Feistel 结构中的分组密码。密钥衍生方案使用 PRF 从主密钥或密码中生成加密密钥,身份验证方案使用 PRF 从随机挑战中生成响应。(基本上,服务器发送一个随机挑战消息,M,客户端在响应中返回PRF(K, M)以证明它知道K。)4G 电话标准使用 PRF 来验证 SIM 卡及其服务提供商,类似的 PRF 还用于生成电话通话期间使用的加密密钥和 MAC 密钥。TLS 协议使用 PRF 从主密钥以及会话特定的随机值中生成密钥材料。甚至 Python 语言内置的非加密hash()函数中也有一个 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(K, M) = PRF1(K, M) || 0
因为PRF2的输出定义为PRF1的输出后跟一个 0 位,它看起来不像一个真正的随机字符串,你可以通过那个最后的 0 位来区分它的输出。因此,PRF2不是一个安全的 PRF。然而,由于PRF1是安全的,PRF2仍然可以作为一个安全的 MAC。为什么?因为如果你能够伪造一个标签,T = PRF2(K, M),对于某个M,那么你也能够伪造一个PRF1的标签,而我们知道,伪造PRF1的标签本来就是不可能的,因为PRF1是一个安全的 MAC。因此,PRF2是一个带密钥的哈希,它是一个安全的 MAC,但不是一个安全的 PRF。
但别担心:你在实际应用中不会遇到这种 MAC 构造。事实上,许多已经部署或标准化的 MAC 也是安全的 PRF,且经常同时作为两者使用。例如,TLS 使用 HMAC-SHA-256 算法既作为 MAC,也作为 PRF。
从无密钥哈希创建带密钥哈希
在密码学的历史中,MAC 和 PRF 很少是从头开始设计的,而是基于现有算法,通常是基于哈希函数或块加密算法构建的。一种看似显而易见的方式是给(无密钥的)哈希函数输入一个密钥和一个消息,但这并非易事,正如我接下来要讨论的那样。
秘密前缀构造
我们将要审视的第一个技术,称为秘密前缀构造,通过将密钥加到消息前面并返回Hash(K || M), 将一个普通的哈希函数转变为带密钥的哈希函数。尽管这种方法并不总是错误,但当哈希函数易受长度扩展攻击(如在《长度扩展攻击》第 125 页中讨论的那样)和哈希函数支持不同长度的密钥时,它可能会不安全。
对长度扩展攻击的脆弱性
回想一下第六章,SHA-2 家族的哈希函数允许攻击者在给定较短版本消息的哈希值时,计算出部分未知消息的哈希值。从形式上讲,长度扩展攻击允许攻击者计算出Hash(K || M[1] || M[2]),只需知道Hash(K || M[1]),而无需知道M[1]或K。这些函数允许攻击者免费伪造有效的 MAC 标签,因为他们本不应该能够仅凭M[1]的 MAC 来猜测M[1] || M[2]的 MAC。这一事实使得秘密前缀构造在使用 SHA-256 或 SHA-512 时与 MAC 和 PRF 一样不安全。Merke–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 这样的哈希函数为例,这意味着Hash(M[1] || K)和Hash(M[2] || K)也会相等,因为内部会根据之前哈希的内容处理K,即Hash(M[1]),其值等于Hash(M[2])。因此,无论K的值如何,你都会得到相同的哈希值,无论是将K放在M[1]后面,还是放在M[2]后面。
为了利用这一特性,攻击者会:
-
找到两个碰撞消息,M[1]和M[2]。
-
请求M[1]的 MAC 标签Hash(M[1] || K)
-
假设Hash(M[2] || K)与Hash(M)相同,从而伪造一个有效标签并破坏 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 所示,并根据以下表达式计算:
哈希((K ⊕ opad) 哈希((K ⊕ ipad) M))
opad(外填充)是一个字符串(5c5c5c … 5c),它的长度与 哈希 的块大小相同。密钥 K 通常比一个块短,且用 00 字节填充,并与 opad 做异或运算。例如,如果 K 是 1 字节的字符串 00,则 K ⊕ opad = opad。(如果 K 是任意长度的全零字符串,且长度不超过一个块的大小,结果也一样。)K ⊕ opad 是由外层调用 哈希 处理的第一个块——即方程中最左边的 哈希,或者 图 7-1 中的底部哈希。
ipad(内填充)是一个字符串(363636 … 36),它的长度与 哈希 的块大小相同,并且也是以 00 字节填充的。生成的块是由内层调用 哈希 处理的第一个块——即方程中最右边的 哈希,或者 图 7-1 中的顶部哈希。

图 7-1:基于 HMAC 的哈希 MAC 构造
注意
信封方法比秘密前缀和秘密后缀更加安全。它表示为 哈希(K || M || K),一种被称为 三明治 MAC 的构造,但它理论上比 HMAC 安全性低。*
如果使用 SHA-256 作为 哈希 函数,则我们称这个 HMAC 实例为 HMAC-SHA-256。更一般地,我们称使用哈希函数 哈希 的 HMAC 实例为 HMAC-哈希。这意味着,如果有人让你使用 HMAC,你应该总是问:“使用哪种哈希函数?”
针对基于哈希的 MAC 的通用攻击
针对所有基于迭代哈希函数的 MAC 存在一种攻击。回想一下在 “秘密后缀构造”(第 131 页)中我们利用哈希碰撞来获取 MAC 的碰撞。你可以使用相同的策略来攻击秘密前缀 MAC 或 HMAC,尽管后果不如前者那样严重。
为了说明这一攻击,考虑带有秘密前缀的 MAC Hash(K || M),如图 7-2 所示。如果摘要长度为n位,你可以找到两个消息,M[1]和M[2],使得Hash(K || M[1]) = Hash(K || M[2]),通过向系统请求大约 2(*n*)(/2)个 MAC 标签。 (回顾第六章中的生日攻击。)如果哈希函数容易受到长度扩展攻击(如 SHA-256),你可以使用M[1]和M[2]来伪造 MAC,通过选择任意数据M[3],然后查询 MAC oracle 以获取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 位,攻击的工作量变得不可行。)

图 7-2:基于哈希的 MAC 的通用伪造攻击原理
即使哈希函数本身不容易受到长度扩展攻击,这个攻击依然有效,并且同样适用于 HMAC。攻击的成本取决于链值的大小和 MAC 长度:如果一个 MAC 的链值为 512 位,标签为 128 位,那么进行 2⁶⁴次计算将找到一个 MAC 碰撞,但可能找不到内部状态的碰撞,因为找到这样的碰撞需要进行 2^(512/2) = 2²⁵⁶次操作。
从块密码创建带密钥的哈希:CMAC
回顾第六章,许多哈希函数中的压缩函数是基于块密码构建的。例如,HMAC-SHA-256 PRF 是对 SHA-256 的压缩函数的多次调用,而 SHA-256 本身就是一个块密码,重复进行一系列的轮次。换句话说,HMAC-SHA-256 是在哈希内部的压缩函数中的块密码,位于 HMAC 构造之中。那么,为什么不直接使用块密码,而是要构建这样的分层构造呢?
CMAC(即基于密码的消息认证码)是一种构造方法:它仅使用块密码(如 AES)来创建消息认证码(MAC)。尽管 CMAC 不如 HMAC 流行,但它已经在许多系统中得到部署,包括 Internet 密钥交换(IKE)协议,这是 IPSec 套件的一部分。例如,IKE 使用名为 AES-CMAC-PRF-128 的构造来生成密钥材料,作为核心算法(或者基于 AES 的 128 位输出的 CMAC)。CMAC 在 RFC 4493 中有详细规定。
破解 CBC-MAC
CMAC 是在 2005 年作为CBC-MAC的改进版设计的,CBC-MAC 是一种基于块密码的简单消息认证码,源自密码块链接(CBC)块密码的操作模式(见“操作模式”第 65 页)。
CBC-MAC,CMAC 的前身,简单:要计算一个消息 M 的标签,给定一个块密码 E,你可以使用全零的初始值(IV)在 CBC 模式下加密 M,并丢弃除最后一个密文块以外的所有块。也就是说,你计算 C[1] = E(K, M[1]),C[2] = E(K, M[2] ⊕ C[1]),C[3] = E(K, M[3] ⊕ C[2]),以此类推,对每个 M 的块进行计算,并只保留最后一个 C[i],即你为 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]) 组成的消息的标签。实际上,如果你对 M[1] || (M[2] ⊕ T[1]) 应用 CBC-MAC 并计算 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;或者设置为 (L << 1) ⊕ 87,如果 L 的 MSB 为 1。(数字 87 是根据其数学性质精心选择的,当数据块为 128 位时是合适的;如果块不是 128 位,则需要选择其他值。)
K[2] 的值设置为 (K[1] << 1),如果 K[1] 的 MSB 为 0;否则,K[2] = (K[1] << 1) ⊕ 87。
给定 K[1] 和 K[2],CMAC 的工作方式类似于 CBC-MAC,除了最后一个块。如果最终消息块 M[n] 恰好是一个块的大小,CMAC 返回值 E(K, M[N] ⊕ C[n] [− 1] ⊕ K[1]) 作为标签,如 图 7-3 所示。但如果 M[N] 的位数少于一个块,CMAC 会用 1 位和零进行填充,并返回值 E(K, M[n] ⊕ C[n − 1] ⊕ K[2]) 作为标签,如 图 7-4 所示。请注意,第一个情况仅使用 K[1],第二个情况仅使用 K[2],但两者都只使用主密钥 K 来处理前面的消息块。

图 7-3:当消息是整数块序列时,基于 CMAC 块密码的 MAC 构造

图 7-4:当消息的最后一个块需要填充 1 位和零以填满一个块时,基于 CMAC 分组密码的 MAC 构造
请注意,与 CBC 加密模式不同,CMAC 不需要将 IV 作为参数,并且是确定性的:CMAC 对于给定的消息M始终返回相同的标签,因为CMAC(M)的计算是非随机的——这是可以接受的,因为与加密不同,MAC 计算不需要随机化才能保证安全,这也消除了选择随机 IV 的负担。
专用 MAC 设计
你已经看到如何通过回收哈希函数和分组密码来构建安全的伪随机函数(PRF),只要它们底层的哈希函数或密码是安全的。像 HMAC 和 CMAC 这样的方案仅仅是将现有的哈希函数或分组密码组合在一起,以得到一个安全的 PRF 或 MAC。重复使用现有的算法是方便的,但这是否是最有效的方式?
直观地看,PRF 和 MAC 的安全性应当比无密钥的哈希函数所需的工作量少——它们使用一个秘密密钥,防止攻击者篡改算法,因为他们没有密钥。而且,PRF 和 MAC 只向攻击者暴露一个短标签,不像分组密码那样暴露与消息一样长的密文。因此,PRF 和 MAC 不需要哈希函数或分组密码的全部计算能力,这正是专用设计的意义——即,专门为了作为 PRF 和/或 MAC 而设计的算法。
接下来的部分将集中讨论两种广泛使用的算法:Poly1305 和 SipHash。我将解释它们的设计原则以及为什么它们可能是安全的。
Poly1305
Poly1305 算法(发音为poly-thirteen-o-five)由丹尼尔·J·伯恩斯坦(Daniel J. Bernstein)于 2005 年设计(他是第五章中讨论的 Salsa20 流密码的创造者,也是启发了第六章中讨论的 BLAKE 和 BLAKE2 哈希函数的 ChaCha 密码的发明者)。Poly1305 经过优化,能够在现代 CPU 上运行得非常快速,而在我写这篇文章时,它已经被 Google 用来保护 HTTPS(基于 TLS 的 HTTP)连接,并且被 OpenSSH 等许多应用程序所使用。与 Salsa20 不同,Poly1305 的设计基于 20 世纪 70 年代的技术——即,通用哈希函数和 Wegman-Carter 构造。
通用哈希函数
Poly1305 MAC 在内部使用通用哈希函数,其强度远低于加密哈希函数,但也要快得多。通用哈希函数不必具备抗碰撞性,例如,这意味着为了实现其安全目标,需要做的工作较少。
类似于伪随机函数(PRF),通用哈希由一个秘密密钥参数化:给定一条消息 M 和密钥 K,我们写作UH(K, M),这是通用哈希函数的输出计算,记作UH。通用哈希函数只有一个安全性要求:对于任意两条消息 M[1] 和 M[2],UH(K, M[1]) = UH(K, M[2]) 的概率对于一个随机密钥 K 必须是微不足道的。与伪随机函数(PRF)不同,通用哈希不需要是伪随机的;它只是要求不存在一对(M[1],M[2])能为许多不同的密钥计算出相同的哈希。由于它们的安全性要求更容易满足,因此所需的操作较少,因此通用哈希函数比伪随机函数(PRF)快得多。
然而,你可以使用通用哈希作为 MAC 来验证至多一条消息。例如,考虑在 Poly1305 中使用的通用哈希,它被称为多项式评估哈希。(有关这种概念的更多信息,请参见 Gilbert、MacWilliams 和 Sloane 在 1974 年发表的开创性文章《检测欺骗的编码》。)这种多项式评估哈希由一个质数p参数化,并以一个由两个数字 R 和 K 组成的密钥作为输入,范围在[1, p]之间,以及一条由n个块组成的消息 M(M[1],M[2],…,M[n])。然后,通用哈希的输出计算如下:
UH(R, K, M) = R + M[1]K + M[3]K² + M[3]K³ + … + M[n]K^n mod p
加号(+)表示正整数的加法,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 MACs
利用通用哈希函数进行多条消息认证的诀窍来源于 IBM 研究员 Wegman 和 Carter 以及他们 1981 年发表的论文《新哈希函数及其在认证和集合等值中的应用》。所谓的 Wegman–Carter 构造通过使用两个密钥 K[1] 和 K[2],并结合一个通用哈希函数和一个 PRF,构建一个 MAC,返回结果如下:
MAC(K[1], K[2], N, M) = UH(K[1], M) + PRF(K[2], N)
其中 N 是一个唯一数,应对每个密钥 K[2] 唯一,并且 PRF 的输出与通用哈希函数 UH 的输出一样大。通过将这两个值相加,PRF 的强伪随机输出掩盖了 UH 的密码学弱点。你可以将其视为对通用哈希结果的加密,其中 PRF 充当流密码,防止了之前的攻击,使得使用相同的密钥 K[1] 对多条消息进行认证成为可能。
总结一下,Wegman–Carter 构造 UH(K[1], M) + PRF(K[2], N) 提供一个安全的 MAC,前提是我们假设以下条件:
-
UH 是一个安全的通用哈希函数。
-
PRF 是一个安全的 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 返回以下内容:
Poly 1305(K[1], M) + AES(K[2], N) mod 2¹²⁸
2¹²⁸ 的模减法确保结果适合 128 位。消息 M 被解析为一系列 128 位的块 (M[1],M[2],…,M[n]),并且在每个块的最高有效位添加了第 129 位,使所有块的长度为 129 位。(如果最后一个块小于 16 字节,则在最终的第 129 位之前会用一个 1 位和若干个 0 位进行填充。)接下来,Poly1305 评估多项式来计算以下内容:
Poly 1305(K[1], M) = M[1]K[1]^(i) + M[2]K[1]^(n − 1) + … +M[n]K[1] mod 2¹³⁰ − 5
这个表达式的结果是一个最大长度为 129 位的整数。当与 128 位的 AES(K[2], N) 值相加时,结果会通过模 2¹²⁸ 操作来减少,从而生成一个 128 位的 MAC。
注意
AES 并不是一个 PRF;它是一个伪随机置换(PRP)。然而,这里并不重要,因为 Wegman–Carter 构造既适用于 PRP 也适用于 PRF。因为如果你给定一个函数,无论它是 PRF 还是 PRP,仅凭观察函数的输出值是难以确定它是 PRF 还是 PRP。
Poly1305-AES 的安全性分析(参见* cr.yp.to/mac/poly1305-20050329.pdf *中的“Poly1305-AES 消息认证码”)表明,只要 AES 是一个安全的分组密码,并且所有内容都被正确实现,Poly1305-AES 是 128 位安全的——这与任何密码学算法一样。
Poly1305 通用哈希可以与 AES 以外的其他算法结合使用。例如,Poly1305 曾与流密码 ChaCha 一起使用(请参见 RFC 7539,“ChaCha20 和 Poly1305 用于 IETF 协议”)。毫无疑问,Poly1305 将在需要快速 MAC 的地方继续使用。
SipHash
虽然 Poly1305 快速且安全,但它也有几个缺点。首先,它的多项式计算实现效率较低,尤其是对许多不熟悉相关数学概念的人来说,难以高效实现。(可以查看* github.com/floodyberry/poly1305-donna/*上的示例)。其次,仅靠它本身,它只能保证一个消息的安全,除非使用 Wegman–Carter 构造法。但在这种情况下,它需要一个 nonce,如果 nonce 重复,算法就变得不安全。最后,Poly1305 优化了长消息的处理,但如果只处理小消息(例如少于 128 字节),就显得过于复杂。在这种情况下,SipHash 就是解决方案。
我在 2012 年与 Dan Bernstein 共同设计了 SipHash,最初是为了解决一个非密码学问题:哈希表的拒绝服务攻击。哈希表是用于在编程语言中高效存储元素的数据结构。在 SipHash 出现之前,哈希表依赖于非密码学的带密钥哈希函数,而这些函数很容易产生碰撞,攻击者可以通过拒绝服务攻击轻松地使远程系统变慢。我们认为伪随机函数(PRF)可以解决这个问题,因此我们着手设计了 SipHash,一个适用于哈希表的 PRF。由于哈希表主要处理短输入,SipHash 针对短消息进行了优化。然而,SipHash 不仅仅可以用于哈希表:它是一个完整的 PRF 和 MAC,特别适合处理大多数短输入的情况。
SipHash 的工作原理
SipHash 使用了一种技巧,使其比基本的海绵函数更安全:它不是仅在置换前对消息块进行一次 XOR 操作,而是将它们在置换前后都进行 XOR 操作,如图 7-5 所示。SipHash 的 128 位密钥被视为两个 64 位字,K[1]和K[2],它们通过 XOR 运算生成一个 256 位的固定初始状态,该状态被视为四个 64 位字。接下来,密钥被丢弃,计算 SipHash 的过程简化为通过一个核心函数 SipRound 进行迭代,然后将消息块进行 XOR 操作,以修改四字内部状态。最后,SipHash 通过将四个状态字进行 XOR 运算,返回一个 64 位的标签。

图 7-5:SipHash-2-4 处理一个 15 字节的消息(一个 8 字节的块 M[1] 和一个 7 字节的块 M[2],再加上 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。
注意
我们将 SipHash-x-y 写作 SipHash 版本,表示它在每次消息块注入之间进行 x 轮 SipRounds,然后是 y 轮。更多的轮数意味着更多的操作,这会减慢操作速度,但也会增加安全性。默认版本是 SipHash-2-4(简写为 SipHash),目前它已经抵抗了密码分析。然而,你可能希望保守一些,选择 SipHash-4-8,这样会进行两倍的轮数,因此速度也会变慢两倍。
错误发生的方式
像密码算法和无密钥哈希函数一样,纸面上安全的 MAC 和 PRF 在真实环境中面对现实攻击者时,也可能会受到攻击。我们来看两个例子。
MAC 验证中的时间攻击
侧信道攻击 针对的是加密算法的实现,而非算法本身。特别地,时间攻击 利用算法的执行时间来确定秘密信息,如密钥、明文和随机数值。正如你可能想象的那样,变量时间的字符串比较不仅会在 MAC 验证中引入漏洞,还会在许多其他加密和安全功能中产生问题。
当远程系统在验证标签时,其所用时间依赖于标签的值,从而使得 MAC 在面对时间攻击时可能会受到威胁,这允许攻击者通过尝试多个错误标签,找出执行时间最长的正确标签。问题发生在服务器通过逐字节比较正确标签和错误标签,直到两者的字节不同。例如,列表 7-1 中的 Python 代码逐字节比较两个字符串,且所用时间是可变的:如果第一个字节不同,函数将在一次比较后返回;如果字符串 x 和 y 相同,函数将根据字符串的长度进行 n 次比较。
def compare_mac(x, y, n):
for i in range(n):
if x[i] != y[i]:
return False
return True
列表 7-1:比较两个 n-字节字符串,所用时间可变
为了演示 verify_mac() 函数的漏洞,让我们编写一个程序,测量 100000 次调用 verify_mac() 的执行时间,首先使用相同的 10 字节 x 和 y 值,然后使用 x 和 y 在第三个字节上有所不同的值。我们应该预期,后者的比较时间会明显比前者短,因为 verify_mac() 将比较更少的字节,而不像相同的 x 和 y 那样,正如在 清单 7-2 中所示。
from time import time
MAC1 = '0123456789abcdef'
MAC2 = '01X3456789abcdef'
TRIALS = 100000
# each call to verify_mac() will look at all eight bytes
start = time()
for i in range(TRIALS):
compare_mac(MAC1, MAC1, len(MAC1))
end = time()
print('%0.5f' % (end-start))
# each call to verify_mac() will look at three bytes
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
在我的测试环境中,程序在 清单 7-2 中的典型执行时间分别为 0.215 秒和 0.095 秒。这一差异足够显著,可以让你识别算法中的发生情况。现在,将差异移到字符串的其他偏移位置,你会观察到不同偏移位置的执行时间不同。如果 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 年 Bellare、Canetti 和 Krawczyk 的论文《为消息认证键控哈希函数》,该文介绍了 HMAC 及其兄弟 NMAC,以及 2006 年 Bellare 的后续论文《NMAC 和 HMAC 的新证明:无碰撞抗性安全性》,该文证明了 HMAC 不需要碰撞抗性哈希,只需要具有压缩函数的哈希,该函数是伪随机函数(PRF)。在攻击方面,2007 年 Fouque、Leurent 和 Nguyen 的论文《对 HMAC/NMAC-MD4 和 NMAC-MD5 的完全密钥恢复攻击》展示了当 HMAC 和 NMAC 建立在脆弱的哈希函数(如 MD4 或 MD5)之上时,如何进行攻击。(顺便提一下,HMAC-MD5 和 HMAC-SHA-1 并不是完全破坏的,但风险足够高。)
Wegman–Carter MAC 也值得更多关注,既因为它们在实践中的兴趣,也因为它们的基础理论。Wegman 和 Carter 的开创性论文可以在cr.yp.to/bib/entries.html上找到。其他最先进的设计包括 UMAC 和 VMAC,它们是长消息上最快的 MAC 之一。
本章未讨论的一种 MAC 是Pelican,它使用简化的 AES 分组密码(将完整的分组密码从 10 轮简化为 4 轮)在简化的结构中认证消息块,具体描述见eprint.iacr.org/2005/088/。然而,Pelican 更像是一种好奇心,它在实际中很少使用。
最后,如果你对发现加密软件中的漏洞感兴趣,可以查找使用 CBC-MAC 的情况,或者查找由于 HMAC 处理任意大小密钥而导致的弱点——如果K太长,就使用Hash(K)作为密钥,而不是K,从而使K和Hash(K)成为等效密钥。或者,直接查找那些在应该使用 MAC 时没有使用的系统——这是一个常见的情况。
在第八章中,我们将探讨如何将 MAC 与密码算法结合以保护消息的真实性、完整性,以及机密性。我们还将探讨如何通过认证密码实现这一目标,认证密码结合了基本密码的功能与 MAC 的功能,通过返回一个标签与每个密文一起传递。
第九章:认证加密

本章讨论的是一种不仅保护消息机密性,还保护其真实性的算法。回想一下第七章中的内容,消息认证码(MAC)是通过创建标签来保护消息真实性的算法,标签是一种签名。与 MAC 类似,本章将讨论的认证加密(AE)算法也会生成认证标签,但它们同时也会加密消息。换句话说,单一的 AE 算法同时具备普通密码算法和 MAC 的功能。
结合密码算法和 MAC 可以实现不同级别的认证加密,正如你在本章中将要学习的那样。我将回顾几种将 MAC 与密码算法结合的可能方式,解释哪些方法是最安全的,并向你介绍既生成密文又生成认证标签的密码算法。接下来,我们将关注四种重要的认证密码算法:三种基于分组密码的构造,重点介绍流行的高级加密标准(AES-GCM)在 Galois 计数模式下的应用,以及一种仅使用置换算法的密码。
使用 MAC 的认证加密
如图 8-1 所示,MAC 和密码算法可以通过三种方式结合,以同时对明文进行加密和认证:加密并 MAC、MAC 后加密以及加密后 MAC。

图 8-1:密码和 MAC 组合
这三种组合方式的区别在于加密应用的顺序和认证标签的生成方式。然而,选择具体的 MAC 或密码算法并不重要,只要每种算法本身是安全的,并且 MAC 和密码算法使用不同的密钥。
如图 8-1 所示,在加密并 MAC 的组合方式中,明文首先被加密,然后直接从明文生成认证标签,因此这两种操作(加密和认证)是相互独立的,可以并行计算。在 MAC 后加密方案中,标签首先从明文生成,然后将明文和 MAC 一起加密。与此相反,在加密后 MAC 方法中,明文首先被加密,然后从密文中生成标签。
这三种方法的资源消耗大致相同。接下来我们将看看哪种方法可能是最安全的。
加密并 MAC
加密并 MAC 方法将密文和 MAC 标签分别计算。给定一个明文(P),发送方计算密文 C = E(K[1], P),其中 E 是加密算法,C 是生成的密文。认证标签(T)由明文计算得到,T = MAC(K[2], P)。你可以先计算 C 和 T,也可以并行计算。
一旦密文和认证标签生成完毕,发送方将两者传输给目标接收方。当接收方接收到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),该标签通过未加密的明文数据包P发送。此公式中的N是一个 32 位的序列号,每发送一个包都会递增,用于帮助确保接收到的包按正确顺序处理。实际上,由于使用了像 HMAC-SHA-256 这样的强大 MAC 算法(它不会泄露P的信息),加密-然后 MAC 已被证明足够适用于 SSH。
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(encrypt-and-MAC)类似,当使用 MAC-然后加密时,接收方必须在确定是否接收到损坏的数据包之前先解密C——这一过程可能会让接收方看到潜在损坏的明文。然而,MAC-然后加密比加密-然后 MAC 更安全,因为它隐藏了明文的认证标签,从而防止标签泄露明文信息。
MAC-然后加密(MAC-then-encrypt)已经在 TLS 协议中使用多年,但 TLS 1.3 将 MAC-然后加密替换为经过认证的加密算法(有关 TLS 1.3 的更多信息,请参见第十三章)。
加密-然后 MAC
加密-再计算 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,这使得攻击者更难将恶意数据传输给接收方。
这种功能组合使得加密-再计算 MAC 比加密-和 MAC 计算、以及 MAC-再加密的方式更强大。这也是广泛使用的 IPSec 安全通信协议套件采用它来保护数据包(例如在 VPN 隧道内)的原因之一。
那么为什么 SSH 和 TLS 不使用加密-再计算 MAC(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)。如果 C 或 T 任意一个或两个无效,AD 将返回错误,以防止接收方处理可能已被伪造的明文。换句话说,如果 AD 返回明文,你可以确定它已经被某个知道秘密密钥的人或事物加密过。
认证加密算法的基本安全要求很简单:其认证应当与 MAC 的安全性一样强,这意味着不可能伪造出解密函数 AD 会接受并解密的密文和标签对 (C, T)。
就保密性而言,经过认证的密码系统本质上比基本密码系统更强,因为持有密钥的系统只有在认证标签有效的情况下才能解密密文。如果标签无效,明文将被丢弃。这个特点防止了攻击者执行选择密文查询攻击,即他们生成密文并请求相应的明文。
带有关联数据的认证加密
加密学家将关联数据定义为通过认证密码处理的任何数据,这些数据已经被认证(感谢认证标签),但未被加密。事实上,默认情况下,所有输入到认证密码的明文数据都会被加密并认证。
那么,如果你只是想认证整个消息,包括它的未加密部分,而不是加密整个消息怎么办?也就是说,你希望在加密消息的基础上认证和传输其他数据。例如,如果一个密码处理一个由头部和有效负载组成的网络数据包,你可能选择加密有效负载以隐藏实际传输的数据,但不加密头部,因为它包含将数据包交付给最终接收者所需的信息。同时,你可能仍然希望认证头部的数据,以确保它是从预期的发送方收到的。
为了实现这些目标,加密学家提出了带有关联数据的认证加密(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。
通过 Nonce 避免可预测性
请回想一下第一章中提到的,为了确保安全,加密方案必须是不可预测的,并且在多次调用加密同一明文时返回不同的密文——否则,攻击者可以判断是否对相同的明文进行了两次加密。为了确保不可预测性,分组加密和流加密算法会向加密算法提供一个额外的参数:初始值(IV)或 nonce——一个只能使用一次的数字。认证加密算法也使用了相同的技巧。因此,认证加密可以表示为AE(K, P, A, N),其中N是 nonce。加密操作负责选择一个从未与相同密钥一起使用过的 nonce。
与分组加密和流加密算法一样,使用认证加密算法进行解密时,需要加密时使用的 nonce(随机数),以确保解密操作正确。因此,我们可以将解密表示为AD(K, C, A, T, N) = (P, A),其中N是用于生成C和T的 nonce。
什么才是一个好的认证加密算法?
自 2000 年代初期以来,研究人员一直在努力定义什么才算一个好的认证加密算法,直到我写下这段话时,答案仍然难以捉摸。由于 AEAD 具有多种角色不同的输入,定义其安全性比基本加密算法(仅加密消息)更为困难。然而,在本节中,我将总结在评估认证加密算法的安全性、性能和功能时需要考虑的最重要标准。
安全性标准
用于衡量认证加密算法强度的最重要标准是其保护数据机密性(即明文的保密性)以及通信的真实性和完整性(例如,MAC 检测消息损坏的能力)。一个认证加密算法必须在这两个方面都具有竞争力:它的机密性必须与最强的加密算法一样强,其真实性必须与最强的 MAC 一样强。换句话说,如果你去掉 AEAD 中的认证部分,应该得到一个安全的加密算法;如果去掉加密部分,应该得到一个强大的 MAC。
另一个衡量认证加密算法安全性强度的标准则基于一些更微妙的因素——即其在面对重复的 nonce 时的脆弱性。例如,如果一个 nonce 被重复使用,攻击者是否能够解密密文或区分明文之间的差异?
研究人员将这种稳健性的概念称为误用抗性,并设计了抗误用认证加密算法来评估重复随机数的影响,并尝试确定在此类攻击下,机密性、真实性或两者是否会受到威胁,以及有关加密数据的信息可能会泄漏的程度。
性能标准
与每个加密算法一样,认证加密算法的吞吐量可以用每秒处理的比特数来衡量。这个速度取决于加密算法执行的操作次数以及认证功能的额外开销。正如你所想,认证加密算法的额外安全特性会带来性能上的损失。然而,衡量加密算法性能的不仅仅是纯粹的速度。它还与并行化能力、结构以及加密算法是否支持流式处理有关。让我们更仔细地分析这些概念。
加密算法的并行化能力是衡量其能否同时处理多个数据块而不需要等待前一个数据块处理完成的能力。基于块加密的设计,当每个数据块可以独立于其他数据块处理时,通常容易并行化。例如,第四章中讨论的 CTR 块加密模式是可以并行化的,而 CBC 加密模式则不行,因为它们的块是串联的。
认证加密算法的内部结构是另一个重要的性能标准。主要有两种结构类型:单层结构和双层结构。在双层结构中(例如,广泛使用的 AES-GCM),一种算法处理明文,然后第二种算法处理结果。通常,第一个层是加密层,第二个层是认证层。但正如你所预料的,双层结构会使实现变得更加复杂,并且通常会导致计算变慢。
当认证加密算法能够逐块处理消息并丢弃已经处理过的块时,它就是流式(也称为在线)加密算法。相比之下,非流式加密算法必须存储整个消息,通常是因为它们需要对数据进行两次连续的处理:第一次从开始到结束,第二次从结束到开始,使用第一次处理所得到的数据。
由于可能需要较高的内存,一些应用程序无法使用非流式加密算法。例如,一个路由器可以接收一个加密的数据块,解密后返回明文块,然后再继续解密消息的后续数据块,尽管解密后的消息接收方仍然需要验证发送的认证标签。
功能标准
功能标准是指加密算法或其实现的特性,这些特性与安全性或性能没有直接关系。例如,一些认证加密算法只允许关联数据出现在待加密数据之前(因为它们需要访问这些数据以开始加密)。其他算法要求关联数据出现在待加密数据之后,或者支持在任何位置包括关联数据——即使是在明文块之间。这种最后一种情况是最好的,因为它使用户能够在任何可能的情况下保护数据,但它也是最难以安全设计的:一如既往,更多的功能往往带来更多的复杂性——也可能带来更多的潜在漏洞。
另一个需要考虑的功能标准是,是否可以使用相同的核心算法进行加密和解密。例如,许多认证加密算法是基于 AES 块加密算法的,该算法指定使用两种相似的算法分别进行加密和解密块。如第四章中所讨论的,CBC 块加密模式需要两种算法,而 CTR 模式只需要加密算法。同样,认证加密算法可能不需要两种算法。尽管实现加密和解密算法的额外成本对大多数软件影响不大,但在低成本专用硬件上,通常会很明显,因为在这种硬件上,实施成本是以逻辑门或加密所占硅面积来衡量的。
AES-GCM:认证加密标准
AES-GCM 是最广泛使用的认证加密算法。AES-GCM 当然是基于 AES 算法的,而 Galois 计数器模式(GCM)实际上是对 CTR 模式的一个改进,其中加入了一个小巧高效的组件来计算认证标签。在我写这篇文章时,AES-GCM 是唯一一个被 NIST 标准化的认证加密算法(SP 800-38D)。AES-GCM 也是 NSA Suite B 和互联网工程任务组(IETF)用于安全网络协议 IPSec、SSH 和 TLS 1.2 的一部分。
注意
尽管 GCM 可以与任何块加密算法一起使用,但你可能只会看到它与 AES 配合使用。有些人不想使用 AES,因为它是美国的算法,但他们也不使用 GCM,原因相同。因此,GCM 很少与其他加密算法配对使用。
GCM 内部结构:CTR 和 GHASH

图 8-2:AES-GCM 模式,应用于一个关联数据块 A[1],和两个明文块 P[1] 和 P[2]。圈出的乘法符号表示由 H 进行的多项式乘法,H* 是从* K *衍生的认证密钥。
图 8-2 显示了 AES-GCM 的工作原理:由一个秘密密钥(K)参数化的 AES 实例将由一次性数(N)与计数器(从 1 开始,然后递增为 2、3 等)连接组成的块进行变换,然后将结果与明文块进行 XOR 操作,从而得到密文块。到目前为止,与 CTR 模式相比,这没有什么新变化。
接下来,密文块使用 XOR 和乘法的组合进行混合(如你接下来将看到的)。你可以将 AES-GCM 视为执行 1)CTR 模式加密和 2)对密文块进行 MAC 操作。因此,AES-GCM 本质上是一种加密后再进行 MAC 的构造,其中 AES-CTR 使用 128 位密钥(K)和 96 位随机数(N)进行加密,唯一的不同是计数器从 1 开始,而不是像正常的 CTR 模式那样从 0 开始(就安全性而言,这并不重要)。
为了认证密文,GCM 使用 Wegman-Carter MAC(见第七章)来认证密文,它将AES(K, N || 0)的值与名为GHASH的通用哈希函数的输出进行 XOR。在图 8-2 中,GHASH 对应于“⊗[H]”的操作序列,后面是与 len(A) || len(C)进行 XOR 操作,或者说是与A(关联数据)的位长和C*(密文)的位长进行 XOR。
因此,我们可以将认证标签的值表示为T = GHASH(H, C) ⊕ AES(K, N || 0),其中C是密文,H是哈希密钥或认证密钥。该密钥被确定为H = AES(K, 0),即加密等于一串空字节的块(为了清晰起见,这一步在图 8-2 中没有出现)。
注意
在 GCM 中,GHASH 并不直接使用 K ,以确保如果 GHASH 的密钥被泄露,主密钥 K 仍然保持机密。给定 K ,你可以通过计算AES(K, 0)得到 H ,但是你无法从这个值恢复 K ,因为 K 在这里作为 AES 的密钥作用。
正如图 8-2 所示,GHASH 使用多项式表示法将每个密文块与认证密钥H相乘。这种多项式乘法使得 GHASH 在硬件和软件中都非常快速,因为许多常见微处理器中都提供了一个特殊的多项式乘法指令(CLMUL,即无进位乘法)。
不幸的是,GHASH 远非理想。首先,它的速度并不最优。即使使用了CLMUL指令,加密明文的 AES-CTR 层仍然比 GHASH MAC 快。其次,GHASH 的实现非常麻烦。实际上,连 OpenSSL 项目的经验丰富的开发人员——世界上最常用的加密软件——也在实现 AES-GCM 的 GHASH 时出了问题。一次提交中,gcm_ghash_clmul函数中有一个 bug,允许攻击者伪造 AES-GCM 的有效 MAC。(幸运的是,这个错误在 bug 进入下一版本的 OpenSSL 之前被英特尔工程师发现了。)
多项式乘法
虽然对于我们来说多项式乘法明显比经典的整数运算复杂,但对于计算机来说,它更简单,因为没有进位。例如,假设我们想计算多项式 (1 + X + X²) 和 (X + X³) 的乘积。我们首先像进行普通的多项式乘法那样,将这两个多项式 (1 + X + X²) 和 (X + X³) 相乘,得到以下结果(两个 X³ 项互相抵消):
(1 + X + X²) ⊕ (X + X³) = X + X³ + X² + X⁴ + X³ + X⁵ = X + X² + X⁴ + X⁵
我们现在进行模运算,将 X + X² + X⁴ + X⁵ 对 1 + X³ + X⁴ 进行模约简,得到 X²,因为 X + X² + X⁴ + X⁵ 可以表示为 X + X² + X⁴ + X⁵ = X ⊗ (1 + X³ + X⁴) + X²。更一般地,A + BC 对 B 取模等于 A,这是模约简的定义。
GCM 安全性
AES-GCM 的最大弱点是在面对重复 nonce 时的脆弱性。如果在 AES-GCM 实现中使用相同的 nonce N 两次,攻击者可以获得认证密钥 H,并利用它伪造任何密文、关联数据或两者的组合的标签。
查看 AES-GCM 计算背后的基本代数(如 图 8-2 所示)将帮助我们理解这种脆弱性。具体来说,标签 (T) 是通过 T = GHASH(H, A, C) ⊕ AES(K, N || 0) 计算出来的,其中 GHASH 是一个输入输出线性相关的通用哈希函数。
如果你得到两个标签,T[1] 和 T[2],它们是用相同的 nonce N 计算出来的,结果会怎样?没错,AES 部分会消失。如果我们有两个标签,T[1] = GHASH(H, A[1], C[1]) ⊕ AES(K, N || 0) 和 T[2] = GHASH(H, A[1], C[1]) ⊕ AES(K, N || 0),那么将它们异或得到以下结果:

如果相同的 nonce 被使用两次,攻击者就可以恢复值 GHASH(H, A[1], C[1]) ⊕ GHASH(H, A[2], C[2]),对于一些已知的 A[1]、C[1]、A[2] 和 C[2]。GHASH 的线性特性使得攻击者可以轻松地确定 H。(如果 GHASH 使用了与加密部分相同的密钥 K,情况会更糟,但由于 H = AES(K, 0),所以无法通过 H 来找到 K。)
直到 2016 年,研究人员仍在互联网上扫描 AES-GCM 通过 HTTPS 服务器暴露的实例,寻找具有重复 nonce 的系统(见 eprint.iacr.org/2016/475/)。他们发现了 184 台具有重复 nonce 的服务器,其中 23 台总是使用全零字符串作为 nonce。
GCM 效率
GCM 模式的一个优势是,GCM 加密和解密都可以并行处理,允许你独立地加密或解密不同的明文块。然而,AES-GCM 的 MAC 计算无法并行化,因为在 GHASH 处理完任何关联数据后,它必须从密文的开始到结束进行计算。缺乏并行化意味着任何先接收明文再接收关联数据的系统,必须等到所有关联数据被读取并哈希完后,才能开始哈希第一个密文块。
然而,GCM 是可以流式处理的:由于其两层计算可以流水线处理,因此在计算 GHASH 时不需要先存储所有密文块,因为 GHASH 会在每个块被加密时处理它。换句话说,P[1] 会被加密为 C[1],然后 GHASH 处理 C[1],同时 P[2] 被加密为 C[2],之后 P[1] 和 C[1] 就不再需要,以此类推。
OCB: 比 GCM 更快的认证密码
缩写 OCB 代表 offset codebook(尽管其设计者 Phil Rogaway 更喜欢直接称之为 OCB)。OCB 于 2001 年首次开发,早于 GCM,并且像 GCM 一样,它从一个块密码生成一个认证密码,尽管它做得更快、更简单。那么为什么 OCB 没有得到更广泛的采用呢?不幸的是,直到 2013 年,所有 OCB 的使用都需要从发明人那里获得许可证。幸运的是,正如我写这篇文章时,Rogaway 允许非军事软件实现免费授权(参见 web.cs.ucdavis.edu/~rogaway/ocb/license.htm)。因此,尽管 OCB 还不是正式标准,也许我们会开始看到它的更广泛应用。
与 GCM 不同,OCB 将加密和认证合并成一个处理层,并只使用一个密钥。没有单独的认证组件,因此 OCB 基本上可以“免费”获得认证,并且它的块密码调用次数几乎和非认证密码一样。实际上,OCB 几乎和 ECB 模式一样简单(参见 第四章),只不过它是安全的。
OCB 内部原理
图 8-3 显示了 OCB 的工作原理:OCB 将每个明文块 P 加密为密文块 C = E(K, P ⊕ O) ⊕ O,其中 E 是一个块密码加密函数。这里的 O(称为 偏移量)是一个依赖于密钥和对每个新处理的块递增的 nonce 的值。
为了生成认证标签,OCB 首先对明文块进行 XOR 运算,计算 S = P[1] ⊕ P[2] ⊕ P[3] ⊕ …(即所有明文块的 XOR)。然后认证标签为 T = E(K, S ⊕ O^),其中 O^ 是从处理的最后一个明文块的偏移量计算出来的偏移值。

图 8-3:OCB 加密过程,当处理两个明文块且没有关联数据时
与 AES-GCM 类似,OCB 也支持作为一系列块的关联数据,A[1]、A[2] 等。当 OCB 加密消息包含关联数据时,认证标签会根据公式进行计算。
T = E(K, S ⊕ O^) ⊕ E(K, A[1] ⊕ O[1]) ⊕ E(K*, A[2] ⊕ O[2]) ⊕ …
其中,OCB 指定的偏移值与用于加密 P 的偏移值不同。
与 GCM 和加密后 MAC(Encrypt-then-MAC)不同,后者通过结合密文生成认证标签,而 OCB 是通过结合明文数据来计算认证标签。这个方法没有问题,而且 OCB 得到了坚实的安全性证明。
注意
关于如何正确实现 OCB 的更多信息,请参见 RFC 7253 或 Krovetz 和 Rogaway 2011 年的论文《Authenticated-Encryption Modes 的软件性能》,该论文涵盖了最新且最好的 OCB 版本 OCB3。有关 OCB 的进一步细节,请参见 OCB 常见问题解答 web.cs.ucdavis.edu/~rogaway/ocb/ocb-faq.htm。
OCB 安全性
与 GCM 相比,OCB 对重复的 nonce 要稍微不那么脆弱。例如,如果 nonce 被使用两次,攻击者看到这两个密文后,会注意到,第一个消息的第三个明文块与第二个消息的第三个明文块相同。对于 GCM,攻击者不仅可以找到重复的块,还能在同一位置找到块之间的 XOR 差异。因此,重复的 nonce 对 GCM 的影响要比 OCB 更严重。
与 GCM 一样,重复的 nonce 会破坏 OCB 的真实性,但效果较差。例如,攻击者可以将通过 OCB 验证的两个消息的块合并,生成另一个加密消息,该消息的校验和和标签与原始两个消息之一相同,但攻击者无法像 GCM 那样恢复出密钥。
OCB 效率
OCB 和 GCM 的速度大致相同。像 GCM 一样,OCB 也是可并行化和可流式处理的。在原始效率方面,GCM 和 OCB 调用底层块密码(通常是 AES)的次数大致相同,但 OCB 比 GCM 略高效,因为它仅仅是对明文进行 XOR 操作,而不像 GCM 那样执行相对昂贵的 GHASH 计算。(在早期的英特尔微处理器中,AES-GCM 的速度比 AES-OCB 慢了三倍以上,因为 AES 和 GHASH 指令必须竞争 CPU 资源,无法并行执行。)
OCB 和 GCM 实现之间的一个重要区别是,OCB 需要块密码的加密和解密功能才能进行加解密,这在只有有限硅资源用于加密组件的硬件实现中会增加成本。相比之下,GCM 仅使用加密功能来进行加解密。
SIV:最安全的认证加密算法?
合成 IV,也称为SIV,是一种经过认证的加密模式,通常与 AES 一起使用。与 GCM 和 OCB 不同,SIV 即使使用相同的 nonce 两次也依然安全:如果攻击者获得了使用相同 nonce 加密的两个密文,他们只能知道是否是同一明文被加密了两次。与使用 GCM 或 OCB 加密的消息不同,攻击者无法判断两个消息的第一个块是否相同,因为用于加密的 nonce 首先是作为给定 nonce 和明文的组合计算出来的。
SIV 的构造规范比 GCM 更为通用。与 GCM 的 GHASH 详细内部机制不同,SIV 只是告诉你如何将一个加密器(E)和一个伪随机函数(PRF)结合起来得到一个认证加密器。具体来说,你计算标签T = PRF(K[1], N || P),然后计算密文C = E(K[2], T, P),其中T作为E的 nonce。因此,SIV 需要两个密钥(K[1]和K[2])和一个 nonce(N)。
SIV 的主要问题在于它不能流式处理:计算完T之后,它必须将整个明文P保存在内存中。换句话说,要用 SIV 加密一个 100GB 的明文,你必须首先存储这 100GB 的明文,以便 SIV 加密可以读取它。
基于 2006 年 Rogaway 和 Shrimpton 的论文《确定性认证加密》(Deterministic Authenticated-Encryption),文档 RFC 5297 规定 SIV 使用 CMAC-AES(一种基于 AES 的 MAC 构造)作为 PRF,并将 AES-CTR 作为加密器。2015 年,提出了一种更高效的 SIV 版本,称为 GCM-SIV,它结合了 GCM 的快速 GHASH 函数和 SIV 的模式,速度几乎与 GCM 一样。然而,像原始 SIV 一样,GCM-SIV 也不能流式处理。(有关更多信息,请参见 eprint.iacr.org/2015/102/。)
基于置换的 AEAD
现在我们来看一种完全不同的构建认证加密器的方法:我们不围绕像 AES 这样的块加密器来构建工作模式,而是看一种围绕置换构建工作模式的加密器。置换仅仅是将一个输入转换为一个与其大小相同的输出,且是可逆的,不使用密钥,这是最简单的组成部分。更好的是,得到的 AEAD 是快速的,具有可证明的安全性,并且比 GCM 和 OCB 对 nonce 重用的抵抗力更强。
图 8-4 展示了基于置换的 AEAD 是如何工作的:从某个固定的初始状态H[0]开始,你将密钥K和 nonceN按顺序进行 XOR 操作,得到与原始状态大小相同的新状态。接着你用P变换新状态,并获得另一个新状态值。然后,你将第一个明文块P[1]与当前状态进行 XOR 操作,并将结果作为第一个密文块C[1],其中P[1]和C[1]大小相等,但都比状态小。
要加密第二个块,你需要用 P 变换当前状态,将下一个明文块 P[2] 与当前状态进行异或运算,然后将结果作为 C[2]。然后,你对所有明文块进行迭代,在最后一次调用 P 后,从内部状态中提取比特作为认证标签 T,如 图 8-4 右侧所示。

图 8-4:基于置换的认证加密算法
注意
如图 8-4 所示的模式可以调整以支持关联数据,但过程稍微复杂,因此我们将跳过其描述。
设计基于置换的认证加密算法有一些要求,以确保安全性。首先,需要注意的是,你只能对部分状态进行异或操作:这一部分越大,成功攻击者对内部状态的控制越多,从而降低了算法的安全性。实际上,所有安全性都依赖于内部状态的保密性。
此外,块必须适当地填充额外的位,以确保任何两个不同的消息都会产生不同的结果。作为反例,如果最后一个明文块小于完整块的大小,则不应仅用零填充;否则,一个两字节(0000)的明文块将变成完整的明文块(0000 … 0000),而一个三字节(000000)的块也会变成相同的完整块。因此,尽管这两个消息的大小不同,你将得到相同的标签。
如果在这种基于置换的加密算法中重用了一个随机数(nonce)会怎样?好消息是,影响没有 GCM 或 OCB 那么严重——认证标签的强度不会受到影响。如果随机数重复,成功的攻击者只能知道两个加密消息是否以相同的值开始,以及这个公共值或前缀的长度。例如,虽然使用相同的随机数加密两个六块的消息 ABCXYZ 和 ABCDYZ(这里每个字母代表一个块)可能会得到相同前缀的密文 JKLTUV 和 JKLMNO,攻击者无法知道这两个明文共享相同的最后两个块 (YZ)。
在性能方面,基于置换的加密算法具有单层操作、可流处理以及加解密使用单一核心算法的优点。然而,它们不像 GCM 或 OCB 那样可以并行化,因为对 P 的新调用需要等待前一个调用完成。
注意
如果你有冲动选择你最喜欢的置换算法并自己制作认证密码,不要这么做。你很可能会弄错细节,最终得到一个不安全的密码。阅读由经验丰富的密码学家编写的算法规范,例如 Keyak(一个源自 Keccak 的算法)和 NORX(由 Philipp Jovanovic、Samuel Neves 和我设计),你会发现基于置换的密码比它们初看起来要复杂得多。
错误可能发生的情况
认证密码比哈希函数或分组密码有更大的攻击面,因为它们不仅要实现保密性 和 真实性。它们需要接受多个不同的输入值,并且必须在所有输入情况下保持安全——无论是仅包含关联数据而没有加密数据,还是非常大的明文,或者不同的密钥大小。它们还必须在所有随机数值下保持安全,抵御攻击者收集大量消息/标签对,并在一定程度上抵御随机数的意外重复。
这是一个很大的要求,正如你接下来将看到的那样,甚至 AES-GCM 也有几个缺陷。
AES-GCM 和弱哈希密钥
AES-GCM 的一个弱点存在于其认证算法 GHASH 中:某些哈希密钥 H 的值大大简化了对 GCM 认证机制的攻击。具体来说,如果 H 的值属于某些特定的、数学上定义的 128 位字符串子群,攻击者可能通过重排前一条消息的块来猜测某条消息的有效认证标签。
为了理解这个弱点,让我们来看一下 GHASH 是如何工作的。
GHASH 内部结构
正如你在图 8-2 中看到的,GHASH 从一个 128 位值 H 开始,初始值设为 AES(K, 0),然后反复计算
X[i] = (X[i − 1] ⊕ C[i]) ⊗ H
从 X[0] = 0 开始,处理密文块 C[1]、C[2] 等等。最终的 X[i] 由 GHASH 返回,用于计算最终标签。
现在为了简化,假设所有的 C[i] 值都等于 1,那么对于任何 i,我们得到:
C[i] ⊗ = 1 ⊗ H = H
接下来,从 GHASH 方程式出发:
X[i] = (X[i − 1] ⊕ C[i]) ⊗ H
我们得到:
X[1] = (X[0] ⊕ C[1]) ⊕ H = (0 ⊕ 1) ⊗ H = H
将 X[0] 替换为 0,C[1] 替换为 1,得到以下结果:
(0 ⊕ 1) = 1
得益于 ⊗ 在 ⊕ 上的分配律,我们将 X 替换为 H,并将 C[2] 替换为 1,然后计算下一个值 X[2] 如下:
X[2] = (X[1] ⊕ X[2]) ⊗ H = (H ⊕ 1) ⊗ H = H² ⊕ H
其中 H² 是 H 的平方,或 H ⊗ H。
现在通过将 X[2] 的推导代入,得出 X[3],并得到以下结果:
X[3] = (X[2] ⊕ C[3]) ⊗ H = (H² ⊕ H ⊕ 1) ⊗ H = H³ ⊕ H² ⊕ H
接下来,我们推导出 X[4] 为 X[4] = H ⁴ ⊕ H ³ ⊕ H ² ⊕ H,以此类推,最终最后的 X[i] 为:
X[n] = H^n ⊕ H^(n − 1) ⊕ H^(n − 2) ⊕ … ⊕ H² ⊕ H
记住,我们将所有块C[i]设为 1。如果这些值是任意的,我们将得到以下结果:
X[n] = C[1] ⊕ H^n ⊕ C[2] ⊕ H^(n − 1) ⊕ C[3] H^(n − 2) ⊕ … ⊕ C[n − 1] H² ⊕ C[n] ⊕ H
然后,GHASH 会将消息的长度异或到这个最后的X[n],将结果乘以H,然后将这个值与AES(K, N || 0)进行异或,生成最终的认证标签T。
问题出现的地方
从这里可能出什么问题呢?我们首先来看两个最简单的情况:
-
如果H = 0,则X[n] = 0,无论C[i]的值如何,因而无论消息如何。也就是说,如果H为 0,所有消息的认证标签将相同。
-
如果H = 1,则标签仅为密文块的异或值,重新排序密文块将得到相同的认证标签。
当然,0 和 1 只是 2¹²⁸中H的可能值中的两个,因此它们发生的概率仅为 2/2¹²⁸ = 1/2¹²⁷。但还有其他弱值——即,所有当H被提升到i次方时属于短周期的值。例如,值H = 10d04d25f93556e69f58ce2f8d035a4 属于长度为五的周期,因为它满足H ⁵ = H,因此H^(e) = H,对于任何e是五的倍数的情况(这是关于五次方的周期的定义)。因此,在前面的最终 GHASH 值X[n]的表达式中,将块C[n](与H相乘)和块C[n - 4](与H ⁵相乘)交换,将不会改变认证标签,这相当于伪造。攻击者可能会利用这一性质,在不知情的情况下构造一个新的消息及其有效标签,而这是一个安全的认证密码所不应该允许的。
前面的示例基于一个长度为五的循环,但实际上有许多更长的循环,因此有许多值的H比应该的要弱。结果是,在极不可能的情况下,如果H属于一个短周期的值,并且攻击者可以伪造任意数量的认证标签,那么除非他们知道H或K,否则无法确定H的周期长度。因此,尽管这一漏洞无法被利用,但通过更仔细地选择用于模运算的多项式,本来是可以避免的。
注意
关于此攻击的更多细节,请阅读 Markku-Juhani O. Saarinen 的《GCM、GHASH 和其他多项式 MAC 和哈希的循环攻击》,可在 eprint.iacr.org/2011/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),而是 2(*m*)/2(n),其中 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(如 AEasy),它建立在一种不可流式化的两层模式上,使其具有抗滥用能力;AEGIS,一种简单美观的认证密码,利用了 AES 的轮函数。
本章中,我集中讨论了 GCM,但实际上还有一些其他模式在实际应用中也有使用。具体来说,带 CBC-MAC 的计数器模式(CCM)和 EAX 模式在 2000 年代初期曾与 GCM 竞争标准化,尽管 GCM 最终被选中,但这两种竞争者仍在一些应用中使用。例如,CCM 用于 WPA2 Wi-Fi 加密协议。你可能想要阅读这些密码的规范,并回顾它们在安全性和性能上的相对优劣。
这就结束了我们关于对称密钥加密的讨论!你已经了解了分组密码、流密码、(带密钥的)哈希函数,以及现在的认证密码——这些都是与对称密钥或完全不使用密钥的主要加密组件。在我们进入非对称加密之前,第九章将更多地聚焦于计算机科学和数学,为 RSA(第十章)和 Diffie–Hellman(第十一章)等非对称方案提供背景知识。
第十章:难题

困难的计算问题是现代密码学的基石。它们是描述起来简单,但实际上几乎不可能解决的问题。这些问题即使是最好的算法,也无法在太阳熄灭之前找到解决方案。
在 1970 年代,对难题的严格研究催生了一个新的科学领域——计算复杂性理论,它将对密码学以及许多其他领域产生深远影响,包括经济学、物理学和生物学。在本章中,我将为你提供理解密码安全基础所需的复杂性理论概念工具,并介绍公共密钥方案(如 RSA 加密和 Diffie–Hellman 密钥协议)背后的难题。我们将涉及一些深奥的概念,但我会尽量减少技术细节,只做表面探讨。不过,我希望你能看到密码学如何利用计算复杂性理论来最大化安全性之美。
计算难度
计算问题是通过足够的计算来回答的问题,例如,“2017 是质数吗?”或“在incomprehensibilities中有多少个i字母?”计算难度是指某些计算问题没有任何算法能在合理的时间内运行完成。这类问题也被称为难解问题,通常在实践中几乎不可能解决。
令人惊讶的是,计算难度与所使用的计算设备类型无关,无论是通用 CPU、集成电路,还是机械图灵机。事实上,计算复杂性理论的最初发现之一是,所有计算模型都是等效的。如果一个问题能在某个计算设备上高效解决,那么通过将算法移植到其他设备的语言上,也能在任何其他设备上高效解决——量子计算机是一个例外,但它们尚未问世(至少目前还没有)。因此,在讨论计算难度时,我们不需要指定底层计算设备或硬件,而是直接讨论算法。
为了评估难度,我们首先需要找到一种衡量算法复杂度或运行时间的方法。然后,我们将运行时间分为困难或容易。
运行时间的测量
大多数开发者都熟悉计算复杂度,即算法执行的操作次数,通常是输入大小的函数。大小通常以比特数或输入元素的数量来衡量。例如,以下是清单 9-1 中展示的伪代码。它在包含n个元素的数组中查找一个值x,然后返回其索引位置。
search(x, array, n):
for i from 1 to n {
if (array[i] == x) {
return i;
}
}
return 0;
}
列表 9-1:一个简单的搜索算法,采用伪代码编写,复杂度与数组长度n成线性关系。该算法返回在[1,n]中找到的值x的索引,如果找不到x,则返回 0。*
在这个算法中,我们使用for循环通过遍历数组来查找特定值x。每次迭代时,我们将变量i赋值为从 1 开始的数字。然后我们检查array中位置i的值是否等于x的值。如果是,我们返回位置i。否则,我们增加i并尝试下一个位置,直到我们到达n,即数组的长度,此时返回 0。
对于这种算法,我们将复杂度定义为for循环的迭代次数:最佳情况为 1(如果x等于array[1]),最坏情况为n(如果x等于array[n],或者x在array中找不到),平均情况下为n/2(如果x随机分布在数组的n个单元格中的某一个)。如果数组的大小是原来的 10 倍,算法的速度将变慢 10 倍。因此,复杂度与n成正比,或者说是n的“线性”复杂度。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³ 次操作,或者 12345 × 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 的平方根。多项式时间算法在复杂度理论和密码学中非常重要,因为它们是实际可行性的定义。当一个算法在 多项式时间(或简称 poly**time)内运行时,即使输入量很大,它也能在合理的时间内完成。这就是为什么多项式时间对于复杂度理论学家和密码学家来说是“高效”的代名词。
相比之下,运行在超多项式 时间中的算法——即在O(f(n))中,其中f(n)是任何比任何多项式增长得更快的函数——被认为是不可行的。我这里说的是超多项式,而不仅仅是指数,因为在多项式和著名的指数复杂度O(2(*n*))之间还有一些复杂度,例如*O*(*n*(log(n))),如图 9-2 所示。

图 9-2:2n、n(log(n)) 和 n² 函数的增长,从增长最快到最慢
注意
指数复杂度 O(2^n) 并不是最差的情况。有些复杂度增长得更快,从而使得算法计算变得更慢——例如,复杂度 O(n^n) 或 指数阶乘 O(n^(f(n – 1))),其中对于任何 x,函数 f 通过递归定义为 f(x) = x^(f(x – 1))。实际上,你永远不会遇到如此荒谬复杂度的算法。
O(n²) 或 O(n³) 可能是高效的,但 O(n⁹⁹⁹⁹⁹⁹⁹⁹⁹⁹⁹) 显然不是。换句话说,只要指数不太大,多项式时间就是快速的。幸运的是,所有被发现能解决实际问题的多项式时间算法都有较小的指数。例如,O(n^(1.465)) 是两个 n 位整数相乘的时间,或 O(n^(2.373)) 是两个 n × n 矩阵相乘的时间。2002 年突破性的多项式时间算法用于识别素数,最初的复杂度是 O(n¹²),但后来改进为 O(n⁶)。因此,多项式时间可能不是算法实际时间的完美定义,但它是我们所能拥有的最佳定义。
进一步来说,无法通过多项式时间算法解决的问题被认为是不可行的,或称为困难。例如,对于简单的密钥搜索,除非加密算法被某种方式攻破,否则无法突破O(2^(n))的复杂度。
我们可以确定,对于暴力破解密钥搜索的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 并不代表非多项式时间,而是代表 非确定性 多项式时间。这是什么意思呢?
NP是一个问题类,其中的解可以在多项式时间内验证——也就是说,即使解可能很难找到,也能高效地验证。所谓的验证是指,给定一个潜在的解,你可以运行某个多项式时间的算法来验证你是否找到了一个实际的解。例如,通过已知明文恢复密钥的问题属于NP,因为给定P,C = E(K, P),以及某个候选密钥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-完全问题的示例:
旅行商问题 给定地图上的一组点(城市、地址或其他地理位置)以及每两个点之间的距离,找出一条访问所有点的路径,使得总距离小于给定的x。
团问题 给定一个数字 x 和一个图(如图 9-3 所示,图是由一组通过边连接的节点组成),确定是否存在一个由 x 个或更少的点组成的集合,使得这些点彼此之间都连接。
背包问题 给定两个数字 x 和 y,以及一组物品,每个物品都有已知的价值和重量,能否挑选出一组物品,使得总价值至少为 x,且总重量不超过 y?

图 9-3:包含四个点的团的图。寻找一个给定大小的团(所有节点互相连接的集合)在图中的一般问题是NP-完全的。
这种NP-完全问题无处不在,从调度问题(给定某些优先级和持续时间的工作,和一个或多个处理器,分配工作到处理器,同时尊重优先级并最小化总执行时间)到约束满足问题(确定满足一组数学约束的值,例如逻辑方程)。甚至在某些视频游戏中获胜的任务有时也被证明是NP-完全的(例如,俄罗斯方块、超级马里奥兄弟、宝可梦和糖果传奇等著名游戏)。例如,文章《经典任天堂游戏是(计算上)困难的》(arxiv.org/abs/1203.1895)考虑了“可达性决策问题”,即判断是否可以从某个特定起点到达目标点。
这些视频游戏问题中的一些实际上比NP-完全问题还要难,称为NP-困难。我们说一个问题是NP-困难的,当它至少和NP-完全问题一样难。更正式地说,如果解决一个问题所需的步骤能够证明同时解决NP-完全问题,那么这个问题就是NP-困难的。
我必须提到一个重要的警告,并非所有NP-完全问题的实例都确实难以解决。一些特定的实例,因为它们较小或者具有特定的结构,可能会高效地解决。以图 9-3 中的图为例,仅仅观察几秒钟,你就能找到那个团,它就是顶部四个连接的节点——尽管前述的团问题是NP-困难的,但在这里并不困难。因此,NP-完全并不意味着所有给定问题的实例都很难,而是随着问题规模的增长,很多问题变得难以解决。
P vs. NP 问题
如果你能在多项式时间内解决最难的 NP 问题,那么你也可以在多项式时间内解决 所有 NP 问题,因此 NP 将等于 P。这听起来荒谬;难道不明显吗,某些问题的解决方案容易验证却难以找到?例如,难道不明显,指数时间的暴力破解是恢复对称密码密钥的最快方法,因此这个问题不可能在 P 中吗?事实证明,尽管有高达百万美元的奖金,听起来多么荒谬,但至今没有人证明 P 不等于 NP。
克雷数学研究所将向任何证明 P ≠ NP 或 P = NP 的人颁发这笔奖金。这个问题,被称为 P 与 NP 问题,曾被著名的复杂性理论学家斯科特·阿伦森称为“人类有史以来提出的最深刻的问题之一”。想一想:如果 P 等于 NP,那么任何容易验证的解决方案也会很容易找到。所有实际使用的加密技术将不再安全,因为你可以有效地恢复对称密钥并反转哈希函数。

图 9-4:NP、P 类及 NP-完全问题集合
但不要恐慌:大多数复杂性理论学家相信 P 不等于 NP,因此 P 是 NP 的严格子集,正如图 9-4 所示,NP-完全问题是 NP 的另一个子集,与 P 不重叠。换句话说,看起来难的问题实际上确实很难。只是很难从数学上证明这一点。虽然证明 P = NP 只需要为 NP-完全问题提供一个多项式时间的算法,但证明不存在这样的算法从根本上来说更为困难。但这并没有阻止一些古怪的数学家提出简单的证明,尽管这些证明通常显然是错误的,但常常成为有趣的阅读材料;例如,参见“P 与 NP 页面” (www.win.tue.nl/~gwoegi/P-versus-NP.htm)。
如果我们几乎可以确定确实存在难题,那么如何利用这些难题来构建强大且可证明安全的加密呢?想象一下,如果某个加密算法的破解被证明是 NP-完全的,那么只要 P 不等于 NP,该加密算法就无法破解。但现实令人失望:NP-完全问题已经被证明在加密领域很难应用,因为正是使其在一般情况下困难的结构,在特定情况下可能使它们变得容易——这些情况有时会出现在加密中。相反,加密技术通常依赖于可能不是 NP-困难的问题。
因式分解问题
因式分解问题的目标是给定一个大数字 N = p × q,找到质数 p 和 q。广泛使用的 RSA 算法就基于因式分解数字的困难性。事实上,因式分解问题的难度正是使 RSA 加密和签名方案得以安全的原因。但在我们看到 RSA 如何在第十章中利用因式分解问题之前,我想先说服你,相信这个问题确实很难,但可能不是NP-完全的。
首先,来点幼儿园数学。一个质数是指不能被其他任何数字整除,除了它本身和 1。例如,数字 3、7 和 11 是质数;数字 4 = 2 × 2,6 = 2 × 3,和 12 = 2 × 2 × 3 则不是质数。数论中的一个基本定理表明,任何整数都可以唯一地表示为质数的乘积,这种表示被称为该数字的因式分解。例如,123456 的因式分解是 2⁶ × 3 × 643;1234567 的因式分解是 127 × 9721;依此类推。任何整数都有唯一的因式分解,或者说唯一的质数乘积表示。那么我们怎么知道给定的因式分解只包含质数,或者某个数字是否是质数呢?答案可以通过多项式时间质数性测试算法找到,这些算法使我们能够高效地测试一个给定的数字是否为质数。然而,从一个数字到其质因数的过程则是另一个问题。
大数因式分解的实践
那么,我们如何从一个数字出发,得到它的因式分解——即它作为质数乘积的分解呢?因式分解一个数字 N 最基本的方法是尝试将它除以所有小于它的数字,直到找到一个数字 x,使得 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的两个因数p和q都大于√N,那么它们的乘积将大于√N × √N = N,这是不可能的。例如,如果我们说N = 100,那么它的因数p和q不能都大于 10,因为这样会导致乘积大于 100。p或q必须小于√N。
那么,仅测试小于√N的质数的复杂度是多少?质数定理表明,小于N的质数大约有N/log N个。因此,小于√N的质数大约有√N/log √N个。表达这个值,我们得到大约 2(*n*/2)/*n*个可能的质因数,因此复杂度为*O*(2(n/2)/n),因为√N = 2^(n/2),且 1/log√N = 1/(n/2) = 2n。这种方法比测试所有质数要快,但仍然非常慢——对于一个 256 位数字,大约需要 2¹²⁰次操作。这是一个相当不切实际的计算量。
最快的因式分解算法是一般数域筛法(GNFS),我这里不描述它,因为它需要介绍几个高级数学概念。GNFS 的复杂度的大致估算是 exp(1.91 × n^(1/3) (log n)^(2/3)),其中 exp(…)只是指数函数e^x的不同表示法,e是指数常数,约等于 2.718。然而,很难准确估算 GNFS 对于特定数字大小的实际复杂度。因此,我们必须依赖启发式复杂度估算,它显示了随着n增大,安全性是如何增加的。例如:
-
对一个1024 位的数字进行因式分解,其中会有两个大约为 500 位的质因数,这将需要大约 2⁷⁰次基本操作。
-
对一个2048 位的数字进行因式分解,其中会有两个大约为 1000 位的质因数,这将需要大约 2⁹⁰次基本操作,比 1024 位数字要慢大约百万倍。
我们估计,至少需要 4096 位才能达到 128 位的安全性。请注意,这些值应该谨慎对待,研究人员对这些估算并不总是达成一致。可以查看这些实验结果,看看因式分解的实际成本:
-
2005 年,在约 18 个月的计算之后——并且得益于一个由 80 个处理器组成的集群,其计算总量相当于在单一处理器上进行 75 年的计算——一组研究人员成功地对一个663 位(200 十进制数字)的数字进行了因式分解。
-
2009 年,在大约两年的时间里,使用了数百个处理器,计算量相当于在单个处理器上进行约 2000 年的计算,另一个研究小组成功地因式分解了一个768 位(232 位十进制数字)的数字。
如你所见,学术研究者实际因式分解的数字比实际应用中的要短,后者至少是 1024 位,通常超过 2048 位。当我写这篇文章时,没有人报告过因式分解 1024 位的数字,但许多人猜测像 NSA 这样的资金充足的组织可能能够做到。
总之,1024 位 RSA 应视为不安全,RSA 至少应使用 2048 位的密钥——最好使用 4096 位的密钥以确保更高的安全性。
因式分解是 NP 完全吗?
我们不知道如何高效地因式分解大数,这表明因式分解问题不属于P类。然而,因式分解显然属于NP类,因为给定一个因式分解结果,我们可以通过检查所有因子是否为质数来验证解决方案,得益于上述的质数检测算法,并且当将这些因子相乘时,结果确实是期望的数字。例如,要检查 3 × 5 是否是 15 的因式分解,你需要检查 3 和 5 是否都是质数,并且 3 乘以 5 确实等于 15。
因此,我们有一个属于NP类且看起来很难的问题,但它真的像最难的NP问题一样难吗?换句话说,因式分解是NP完全的吗?剧透:很可能不是。
因式分解是否是NP完全问题没有数学证明,但我们有一些软证据。首先,所有已知的NP完全问题可能有一个解,但也可能有多个解,或者根本没有解。相比之下,因式分解总是只有一个解。并且,因式分解问题具有一种数学结构,使得 GNFS 算法能显著优于朴素算法,这种结构是NP完全问题所不具备的。如果我们有一台量子计算机,一种利用量子力学现象运行不同算法的计算模型,它能够高效地因式分解大数(不是因为它能更快地运行算法,而是因为它能够运行专门为因式分解大数设计的量子算法)。然而,量子计算机目前还不存在——并且可能永远也不会出现。无论如何,量子计算机在解决NP完全问题时也将毫无用处,因为它的速度与经典计算机并无不同(见第十四章)。
因此,在理论上,因式分解问题可能比NP完全问题稍微容易些,但就密码学而言,它已经足够难,而且比NP完全问题更可靠。事实上,在因式分解问题上构建密码系统比在NP完全问题上构建密码系统更容易,因为很难准确知道破解基于某些NP完全问题的密码系统有多难——换句话说,就是难以确定你能获得多少位的安全性。
因式分解问题只是密码学中作为困难假设使用的多个问题之一,困难假设是指某个问题在计算上是困难的。这个假设在证明破解密码系统的安全性至少与解决该问题一样困难时被使用。另一个作为困难假设使用的问题是离散对数问题(DLP),实际上它是一个问题家族,接下来我们将讨论它。
离散对数问题
DLP 在密码学的官方历史中早于因式分解问题。RSA 虽然出现在 1977 年,但第二个密码学突破——Diffie–Hellman 密钥交换(见第十一章)——出现在大约一年前,其安全性建立在 DLP 的困难性之上。与因式分解问题一样,DLP 也涉及大数,但它稍微不那么直接——理解它可能需要几分钟,而不是几秒钟,并且它需要比因式分解更多的数学知识。所以让我介绍一下在离散对数的上下文中,群的数学概念。
什么是群?
在数学上下文中,群是一个元素的集合(通常是数字),这些元素根据某些明确的规则彼此相关。群的一个例子是非零整数集合(介于 1 和p – 1 之间),模某个质数p运算,我们将其表示为Z[p]^。当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。在任何Z[p]^*中,单位元是 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,则称该群为 交换群。这对于任何整数的乘法群 Z[p]^* 也成立。特别地,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 都成立。然而,存在一个很大的区别,那就是这个整数群是无限大小的,而在加密中我们只处理 有限群,即具有有限个元素的群。通常,我们会使用群 Z[p]^,其中 p 是 成千上万 位长(即包含大约 2^(p*) 个数字的群)。
困难的事情
离散对数问题(DLP)是寻找 y,使得 g^(y) = x,给定一个群 Z[p]^* 中的基数 g,其中 p 是素数,并且给定一个群元素 x。离散对数问题之所以称为 离散,是因为我们处理的是整数,而不是实数(连续);它之所以称为 对数,是因为我们在寻找 x 关于 g 的对数。(例如,256 以 2 为底的对数是 8,因为 2⁸ = 256。)
人们常问我,因式分解和离散对数哪个更安全——换句话说,哪个问题更难?我的回答是,它们差不多一样难。实际上,解决离散对数问题的算法与因式分解算法相似,而且使用 n 位的难以因式分解的数字,得到的安全性与 n 位群中的离散对数相当。与因式分解的原因相同,离散对数问题不是 NP-完全问题。(请注意,有些群的离散对数问题更容易解决,但这里我仅指的是由素数模数构成的离散对数群。)
问题如何出现
40 多年过去了,我们仍然不知道如何高效地分解大数或解决离散对数问题。业余爱好者可能会辩称,总有一天某个人可能会破解因式分解——我们也没有证明它永远无法被破解——但我们也没有证明P ≠ NP。同样,你可以推测P可能等于NP;然而,根据专家们的说法,这种惊讶的可能性不大。所以不必担心。实际上,今天所有公开密钥加密技术都依赖于因式分解(RSA)或离散对数问题(Diffie–Hellman、ElGamal、椭圆曲线加密)。然而,尽管数学可能不会让我们失望,现实世界中的顾虑和人为错误却可能悄然潜入。
当因式分解很容易时
因式分解大数并不总是很难。例如,考虑这个 1024 位数字N,它等于以下形式:

对于在 RSA 加密或签名方案中使用的 1024 位数字,当N = pq时,我们预计最好的因式分解算法需要大约 2⁷⁰次操作,正如我们之前所讨论的。但你可以使用 SageMath(一个基于 Python 的数学软件)在几秒钟内分解这个示例数字。我在 2015 款 MacBook 上使用 SageMath 的factor()函数,花费不到五秒钟就找到了以下因式分解:

对的,我作弊了。这个数字并不是N = pq的形式,因为它不仅有两个大素因子,而是有五个,包括非常小的因子,这使得它容易分解。首先,你会通过从预计算的素数列表中尝试小素数来识别 2⁸⁰⁰ × 641 × 6700417 部分,剩下的则是一个比 1024 位数字容易分解的 192 位数字,它仅有两个大因子。
但是,因式分解不仅在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: q = random_prime(2**64)
sage: factor(p*q)
6822485253121677229 * 17596998848870549923
清单 9-2:通过选择两个随机素数并即时因式分解来生成 RSA 模数
清单 9-2 显示,作为两个 64 位素数的乘积,随机选取的一个 128 位数可以在一台典型的笔记本电脑上轻松分解。然而,如果我改为选择 1024 位的素数,通过使用 p = random_prime(2**1024),命令 factor(p*q) 永远也不会完成,至少在我的一生中是如此。
公平地说,现有工具并不能有效防止不安全的短参数的天真使用。例如,OpenSSL 工具包允许你生成最短 31 位的 RSA 密钥而不发出任何警告;显然,这样短的密钥是完全不安全的,如清单 9-3 所示。
$ 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 实现支持在一个组中使用 Diffie–Hellman,Z[p]^*,该组由一个仅有 512 位的素数 p 定义,而离散对数问题已经不再是一个实际不可计算的问题。
不仅服务器支持一个弱算法,攻击者还可以通过在客户端会话中注入恶意流量,强迫一个无辜的客户端使用这个算法。对攻击者来说更好的是,攻击的最大部分可以一次性执行,并重复用来攻击多个客户端。大约一周的计算用于攻击特定组,Z[p]^*,仅用了 70 秒就能破解不同用户的独立会话。
如果一个安全协议被削弱的算法所破坏,那么它毫无价值;而如果一个可靠的算法被弱参数所破坏,它也没有任何用处。在密码学中,你总是应该阅读细则。
要了解更多关于这个故事的细节,请查看研究文章《不完美的前向保密性:Diffie–Hellman 在实践中的失败》(weakdh.org/imperfect-forward-secrecy-ccs15.pdf)。
进一步阅读
我鼓励你深入探讨计算的基础方面,特别是在可计算性(哪些函数是可计算的?)和复杂性(以什么代价计算?)的背景下,以及它们与密码学的关系。我大部分谈论的是P类和NP类问题,但对于密码学家来说,还有许多其他类别和有趣的点。我强烈推荐 Scott Aaronson 的书《量子计算自德谟克里特以来》(剑桥大学出版社,2013 年)。这本书主要讲的是量子计算,但它的前几章精彩地介绍了复杂性理论和密码学。
在密码学研究文献中,你还会发现其他一些计算困难的问题。我将在后续章节中提到它们,但这里有一些例子可以说明密码学家所利用问题的多样性:
-
Diffie–Hellman 问题(给定 g^(x) 和 g^(y),求 g^(xy))是离散对数问题的一种变体,广泛应用于密钥协商协议中。
-
格问题,如最短向量问题(SVP)和带误差学习问题(LWE),是唯一成功应用于密码学的NP-困难问题。
-
编码问题依赖于解码纠错码时缺乏足够信息的困难性,自 1970 年代末以来一直是研究的重点。
-
多元问题涉及解非线性方程组,可能是NP-困难的,但由于其困难版本过大且过慢,且实践中的版本被发现不安全,未能提供可靠的加密系统。
在第十章中,我们将继续讨论困难问题,特别是因式分解及其主要变体——RSA 问题。
第十一章:RSA

Rivest–Shamir–Adleman (RSA) 加密系统在 1977 年问世时,作为第一个公钥加密方案,彻底改变了密码学;而经典的对称密钥加密方案使用相同的秘密密钥来加密和解密信息,公钥加密(也叫做非对称加密)则使用两个密钥:一个是你的公钥,任何想要为你加密信息的人都可以使用它,另一个是你的私钥,只有使用这个私钥才能解密用公钥加密的信息。这种“魔法”是 RSA 成为真正突破性技术的原因,40 年后,它仍然是公钥加密的典范,是互联网安全的核心工具。(在 RSA 发布的前一年,Diffie 和 Hellman 提出了公钥密码学的概念,但他们的方案无法进行公钥加密。)
RSA 本质上是一种算术技巧。它通过创建一个叫做陷门置换的数学对象来工作,这是一种将数字x转换为范围内的数字y的函数,使得通过公钥从x计算出y很容易,但除非你知道私钥——陷门,否则几乎不可能从y计算出x。(可以把x看作明文,把y看作密文。)
除了加密,RSA 还用于构建数字签名,其中私钥的拥有者是唯一能够签署信息的人,而公钥则使任何人都能验证签名的有效性。
在本章中,我将解释 RSA 的陷门置换是如何工作的,讨论 RSA 相对于因数分解问题的安全性(在第九章中讨论),并进一步解释为什么单靠 RSA 的陷门置换不足以构建安全的加密和签名。我还将讨论如何实现 RSA,并演示如何攻击 RSA。
我们从解释 RSA 背后的基本数学概念开始。
RSA 背后的数学
在加密一条信息时,RSA 将该信息视为一个大数字,加密本质上是对大数字进行乘法运算。因此,为了理解 RSA 如何工作,我们需要知道它处理的是什么类型的大数字,以及如何在这些数字上进行乘法运算。
RSA 将其加密的明文视为介于 1 和n – 1 之间的正整数,其中n是一个称为模数的大数字。更准确地说,RSA 处理的是小于n且与n互质的数字,这些数字与n没有共同的素因子。当这些数字相乘时,会得到另一个满足这些标准的数字。我们称这些数字组成一个群,记作Z[N]^,并称之为整数模n*的乘法群。(请参见“什么是群?”中第 174 页的群的数学定义。)
举个例子,考虑模 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 的整数乘法群。这个集合包含哪些数字呢?数字 5 是质数,且 1、2、3 和 4 都与 5 互质,因此 Z[5]^ 的集合是 {1, 2, 3, 4}。我们来验证一下:2 × 3 mod 5 = 1,因此,2 是 3 的逆元,3 是 2 的逆元;注意,4 是它自己的逆元,因为 4 × 4 mod 5 = 1;最后,1 在这个群中也是它自己的逆元。
为了计算当 n 不是质数时群 Z[n]^* 中的元素个数,我们使用 欧拉函数,用符号 φ(n) 表示,其中 φ 代表希腊字母 phi。这个函数给出了与 n 互质的元素个数,也就是 Z[n]^* 中的元素个数。通常来说,如果 n 是质数的乘积 n = p[1] × p[2] × … × p[m],那么群 Z[n]^* 中的元素个数为:
φ(n) = (p[1] − 1) × (p[2] − 1) × … × (p[m] − 1)
RSA 只处理 n 是两个大质数的乘积的数字,通常表示为 n = pq。相关的群 Z[N]^* 将包含 φ(n) = (p – 1)(q – 1) 个元素。通过展开这个表达式,我们得到等价的定义 φ(n) = n – p – q + 1,或者 φ(n) = (n + 1) – (p + q),这更加直观地表达了 φ(n) 相对于 n 的值。换句话说,除了 (p + q) 之外,所有 1 和 n – 1 之间的数字都属于 Z[N]^* 并且在 RSA 操作中是“有效的数字”。
RSA 陷门置换
RSA 陷门置换是 RSA 加密和签名背后的核心算法。给定模数 n 和数字 e,称为 公钥指数,RSA 陷门置换将集合 Z[n]^* 中的数字 x 转换为数字 y = x^(e) mod n。换句话说,它计算出与 x 自乘 e 次模 n 相等的值,并返回结果。当我们使用 RSA 陷门置换进行加密时,模数 n 和指数 e 组成 RSA 公钥。
为了从 y 中恢复出 x,我们使用另一个数字,记作 d,计算如下:
y^d mod n = (xe*)(d*) mod n = x^(ed) mod n = x
因为 d 是让我们能够解密的“陷门”,它是 RSA 密钥对中的一部分,和公钥不同,应该始终保密。数字 d 也称为 秘密指数。
显然,d 不是随便的数字;它是满足 e 与 d 相乘等于 1 的数字,因此满足 x^(ed) mod n = x 对任何 x 都成立。更准确地说,我们必须有 ed = 1 mod φ(n),才能确保 x^(ed) = x¹ = x 并正确解密消息。请注意,我们这里计算的是模 φ(n) 而不是模 n,因为指数行为类似于 Z[n]^* 的元素索引,而不是元素本身。由于 Z[n]^* 中有 φ(n) 个元素,索引必须小于 φ(n)。
数字 φ(n) 对 RSA 的安全性至关重要。事实上,求解 RSA 模数 n 的 φ(n) 等同于破解 RSA,因为秘密指数 d 可以通过计算 e 的逆元,轻松从 φ(n) 和 e 推导出来。因此,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 的逆元。为了演示这一过程,清单 10-1 使用了 SageMath (www.sagemath.org/),这是一个开源的类 Python 环境,包含了许多数学包。
❶ 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
36567230045260644
❺ sage: e = random_prime(phi); e
13771927877214701
❻ sage: d = xgcd(e, phi)[1]; d
15417970063428857
❼ sage: mod(d*e, phi)
1
清单 10-1:使用 SageMath 生成 RSA 参数
注意
为了避免输出多页内容,我在 清单 10-1 中使用了一个 64 位的模数 n ,但实际上,RSA 模数应至少为 2048 位。
我们使用random_prime()函数来选择小于给定参数的随机质数p ❶ 和 q ❷,然后将p和q相乘得到模数n ❸ 和φ(n),即变量phi ❹。接下来,我们生成一个随机的公有指数e ❺,通过选择一个小于phi的随机质数,确保e具有模phi的逆元。然后,我们通过使用 Sage 中的xgcd()函数来生成关联的私有指数d ❻。该函数通过扩展欧几里得算法计算出两个数a和b的数值s和t,使得as + bt = GCD(a, b)。最后,我们检查ed mod φ(n) = 1 ❼,以确保d可以正确地反转 RSA 置换。
现在我们可以应用陷门置换,如在 Listing 10-2 中所示。
❶ sage: x = 1234567
❷ sage: y = power_mod(x, e, n); y
19048323055755904
❸ sage: power_mod(y, d, n)
1234567
Listing 10-2: 计算 RSA 陷门置换的前后过程
我们将整数 1234567 赋值给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中计算出x,即计算e次方根模n,而不必因式分解n。这两种风险看起来紧密相关,尽管我们无法确定它们是否等价。
假设因式分解确实困难,而求解e次方根也同样困难,RSA 的安全性取决于三个因素:n的大小,p和q的选择,以及陷门置换的使用方式。如果n太小,可能会在实际时间内被因式分解,进而暴露私钥。为了安全起见,n至少应为 2048 位(约 90 位的安全级别,需要大约 2⁹⁰次操作的计算量),但最好是 4096 位(约 128 位的安全级别)。p和q的值应该是无关的随机质数,且大小相近。如果它们太小或过于接近,从n中确定它们的值就变得更容易。最后,RSA 的陷门置换不应直接用于加密或签名,正如我稍后将讨论的那样。
使用 RSA 加密
通常,RSA 与对称加密方案结合使用,其中 RSA 用于加密对称密钥,然后使用该密钥通过诸如高级加密标准(AES)等加密算法加密消息。但使用 RSA 加密消息或对称密钥比简单地将目标转换为数字x并计算x^(e) mod n更为复杂。
在接下来的各个小节中,我将解释为什么简单应用 RSA 陷门置换是不安全的,以及强 RSA 加密是如何工作的。
打破教科书式 RSA 加密的易变性
教科书式 RSA 加密是用来描述一种简化的 RSA 加密方案,其中明文仅包含你想要加密的消息。例如,要加密字符串 RSA,我们首先通过连接每个字母的 ASCII 编码(作为字节)来将其转换为数字:R(字节 52)、S(字节 53)和 A(字节 41)。得到的字节串 525341 转换为十进制后是 5395265,我们可以通过计算 5395265^(e) mod n 来加密它。如果不知道私钥,就无法解密该消息。
然而,教科书式 RSA 加密是确定性的:如果你两次加密相同的明文,你会得到相同的密文。这是一个问题,但更大的问题是——给定两个教科书式 RSA 密文 y[1] = x[1]^(e) mod n 和 y[2] = x[2]^(e) mod n,你可以通过将这两个密文相乘,推导出 x[1] × x[2] 的密文,就像这样:
y[1] × y[2] mod n = x[1]^(e) × x[2]^(e) mod n = (x[1] × x[2])^(e) mod n
结果是 (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 加密:OAEP
为了使 RSA 密文不易变,密文应包括消息数据和一些附加数据,这些附加数据被称为 填充,如图 10-1 所示。以这种方式加密 RSA 的标准方法是使用最优非对称加密填充(OAEP),通常称为 RSA-OAEP。该方案通过在应用 RSA 函数之前,用额外的数据和随机数填充消息,创建一个与模数相同大小的位串。

图 10-1:使用 RSA 加密对称密钥 K,以 (n, e) 作为公钥
注意
OAEP 在官方文档中被称为 RSAES-OAEP,如 RSA 公司和 NIST 的特殊出版物 800-56B 中的 PKCS#1 标准。OAEP 改进了现在被称为 PKCS#1 v1.5 的早期方法,这是一系列由 RSA 创建的公钥密码学标准(PKCS)中的第一个。它明显不如 OAEP 安全,但仍然在许多系统中使用。
OAEP 的安全性
OAEP 使用伪随机数生成器(PRNG)来确保密文的不可区分性和不可篡改性,使得加密具有概率性。只要 RSA 函数和 PRNG 是安全的,且哈希函数不太弱,它就被证明是安全的。每当你需要使用 RSA 加密时,都应使用 OAEP。
OAEP 加密工作原理
为了以 RSA-OAEP 模式加密,你需要一个消息(通常是一个对称密钥,K)、一个伪随机数生成器(PRNG)和两个哈希函数。为了生成密文,你使用一个给定的模数 n,它有 m 字节长(即 8m 位,因此 n 小于 2⁸^(m))。为了加密 K,编码消息 形成 M = H || 00 … 00 || 01 || K,其中 H 是由 OAEP 方案定义的 h 字节常量,后面跟着所需数量的 00 字节和一个 01 字节。然后,按照下文所述的方式处理这个编码后的消息 M,并如 图 10-2 所示。

图 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 标准文档指定使用 mask generating function 技术来创建能够从任何哈希函数返回任意大哈希值的哈希函数。
使用 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可以是介于 0 和n – 1 之间的任意数值。像教科书加密一样,教科书式 RSA 签名简单易于指定和实现,但在面对多种攻击时也容易受到攻击。一种攻击方式是伪造签名:当攻击者注意到 0^(d) mod n = 0,1^(d) mod n = 1,以及(n – 1)^(d) mod n = n – 1 时,不论私钥d的值如何,攻击者都可以伪造 0、1 或n – 1 的签名,而无需知道d。
更让人担忧的是盲签名攻击。举个例子,假设你想让第三方在某条有罪的消息M上签名,而你知道他们绝对不会主动签署此消息。要发起这种攻击,你可以先找到一个值R,使得R^(e)M 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 等于 R^(ed) = R(按定义),我们有 S = (R(*e*)*M*)(d) = RM^(d)。为了获得 M^(d),我们只需通过 R 将 S 除以,从而获得签名:
S/R = RM^d/R = M^d
如你所见,这是一个实际且强大的攻击方式。
PSS 签名标准
RSA 概率签名方案 (PSS) 就像 OAEP 对 RSA 加密的作用一样,是对 RSA 签名的扩展。它旨在通过增加填充数据来提高消息签名的安全性。
如 图 10-3 所示,PSS 将比模数更窄的消息与一些随机和固定的位结合,然后对这些填充后的结果进行 RSA 运算。

图 10-3:使用 RSA 和 PSS 标准签名消息 M,其中 (n, d) 是私钥
像所有公钥签名方案一样,PSS 基于消息的哈希值而非消息本身进行工作。只要哈希函数是抗碰撞的,签名 Hash(M) 就是安全的。PSS 的一个特别好处是,你可以用它来签名任何长度的消息,因为对消息进行哈希后,不管原始消息的长度如何,你都会得到一个相同长度的哈希值。哈希的长度通常是 256 位,使用的哈希函数是 SHA-256。
为什么不直接通过对 Hash(M) 运行 OAEP 来签名?不幸的是,你不能。虽然与 PSS 相似,但 OAEP 只被证明在加密中是安全的,而不是在签名中。
与 OAEP 类似,PSS 也需要一个 PRNG 和两个哈希函数。其中一个,Hash1,是一个典型的哈希函数,具有 h 字节的哈希值,如 SHA-256。另一个,Hash2,是一个宽输出哈希函数,类似于 OAEP 的 Hash2。
对于消息 M,PSS 签名过程如下(其中 h 是 Hash1 的输出长度):
-
使用 PRNG 选择一个 r 字节的随机字符串 R。
-
构造编码消息 M**′ = 0000000000000000 || Hash1(M) || R,长度为 h + r + 8 字节(前面加上八个零字节)。
-
计算 h 字节字符串 H = Hash1(M**′)。
-
设置 L = 00 … 00 || 01 || R,或者一串 00 字节,后跟一个 01 字节,然后是 R,使得 L 的长度为 m – h – 1 字节(模数的字节宽度 m 减去哈希长度 h 再减去 1)。
-
设置 L = L ⊕ Hash2(H),从而用新值替换 L 的旧值。
-
将 m 字节的字符串 P = L || H || BC 转换为一个小于 n 的数字 x。这里,字节 BC 是在 H 后附加的固定值。
-
给定刚获得的 x 值,计算 RSA 函数 x^(d) mod n 以获得签名。
要验证给定消息 M 的签名,你需要计算 Hash1(M),然后使用公钥指数 e 来恢复 L 和 H,再从签名中恢复 M′,并在每一步检查填充的正确性。
实际上,随机字符串 R(在 RSA-PSS 标准中称为 salt)通常与哈希值的长度相同。例如,如果你使用 n = 2048 位并选择 SHA-256 作为哈希算法,值 L 的长度为 m – h – 1 = 256 – 32 – 1 = 223 字节,而随机字符串 R 通常为 32 字节。
与 OAEP 相似,PSS 是经过证明的安全、标准化且广泛部署的。和 OAEP 一样,它看起来过于复杂,容易发生实现错误和角落情况的处理不当。但与 RSA 加密不同,有一种方法可以绕过这种额外的复杂性,采用一种甚至不需要伪随机数生成器(PRNG)的签名方案,从而减少由不安全的 PRNG 引起的不安全 RSA 签名的风险,下面将讨论这一点。
全域哈希签名
全域哈希(FDH) 是你能想象的最简单的签名方案。要实现它,你只需将字节串 Hash(M) 转换为一个数字 x,然后创建签名 y = x^(d) mod n,如 图 10-4 所示。

图 10-4:使用全域哈希技术签名消息
签名验证也很简单。给定一个数字签名 y,你计算 x = y^(e) mod n,然后将结果与 Hash(M) 进行比较。它简单、确定性且安全。那么,为什么还要使用复杂的 PSS 呢?
主要原因是,PSS 是在 1996 年发布的,晚于 FDH,并且它有一个比 FDH 更能令人信服的安全性证明。具体来说,它的证明提供了略高于 FDH 证明的安全保证,且其使用的随机性有助于增强这一证明。
这些更强的理论保证是密码学家偏好 PSS 而不是 FDH 的主要原因,但今天使用 PSS 的大多数应用可以在没有显著安全损失的情况下切换到 FDH。然而,在某些情况下,使用 PSS 而不是 FDH 的一个合理理由是,PSS 的随机性保护它免受一些针对其实现的攻击,比如我们将在 “事物如何出错”(第 196 页)中讨论的故障攻击。
RSA 实现
我真诚地希望你永远不必从零实现 RSA。如果有人要求你这么做,尽量跑得越快越好,并质疑要求你这么做的人的理智。密码学家和工程师花了几十年才开发出快速、足够安全且希望没有致命漏洞的 RSA 实现,因此你真的不想重新发明 RSA。即使有所有文档可用,完成这个艰巨任务也需要几个月的时间。
通常,在实现 RSA 时,你会使用一个提供执行 RSA 操作所需函数的库或 API。例如,Go 语言的 crypto 包中有以下函数(来自 www.golang.org/src/crypto/rsa/rsa.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 加密函数
清单 10-3 中显示的主要操作是c.Exp(m, e, pub.N),它将消息m提升到指数e并对pub.N取模,然后将结果赋值给变量c。
如果你选择实现 RSA,而不是使用现成的库函数,务必依赖一个现有的大数库,它是一组函数和类型,允许你定义和计算对数千位长的大数的算术运算。例如,你可以在 C 语言中使用 GNU 多精度(GMP)算术库,或者在 Go 语言中使用big包。(相信我,你不想自己实现大数算术。)
即使你在实现 RSA 时仅使用库函数,也务必了解其内部工作原理,以便衡量风险。
快速指数运算算法:平方并乘法
计算 x 的 e 次幂时的操作,称为指数运算。当我们处理大数时,如 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:伪代码中的一个简单指数运算算法
这个算法简单但效率极低。一种获得相同结果并显著加速的方法是,通过平方而不是乘法指数,直到达到正确的值。这类方法被称为平方并乘,或者通过平方的指数运算,或者二进制指数运算。
例如,假设我们要计算 3⁶⁵⁵³⁷ mod 36567232109354321(65537 是大多数 RSA 实现中使用的公共指数)。我们可以将数字 3 自己乘以 65536 次,或者我们可以通过理解 65537 可以写成 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)。
-
设置 y = y² mod n(现在 y = (3⁸)² = 3¹⁶ mod n)。
-
设置 y = y² mod n(现在 y = (3¹⁶)²= 3³² mod n)。
以此类推,直到 y = 3⁶⁵⁵³⁶,通过执行 16 次平方运算。
为了得到最终结果,我们返回 3 × y mod n = 3⁶⁵⁵³⁷ mod n = 26652909283612267。换句话说,我们只需要通过 17 次乘法来计算结果,而不是使用朴素方法的 65536 次乘法。
更一般来说,平方并乘法方法通过从左到右逐一扫描指数的每一位,计算每一位的平方以加倍指数的值,并对每个位为 1 时与原始数字相乘。在前面的例子中,指数 65537 在二进制中是 10000000000000001,我们为每个新位平方y,并且仅在第一个和最后一个位时才与原始数字 3 相乘。
Listing 10-5 展示了如何作为一个通用算法在伪代码中计算 x^(e) mod n,其中指数 e 包含位 e[m] [–] [1]e[m] [–] [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
}
Listing 10-5: 快速指数运算算法的伪代码
Listing 10-5 中显示的expMod()算法的运行时间为O(m),而朴素算法的运行时间为O(2^(m)), 其中 m 是指数的位长。这里的O()是第九章中引入的渐近复杂度符号。
真实系统通常实现这种最简单的平方并乘法方法的变种。其中一种变种是滑动窗口方法,它考虑一块块的位而非单个的位来执行给定的乘法操作。例如,参见 Go 语言中的expNN()函数,其源代码可以在* golang.org/src/math/big/nat.go*找到。
这些平方并乘法指数算法有多安全?不幸的是,虽然加速过程的技巧通常能提高速度,但往往会增加对某些攻击的脆弱性。让我们看看会出现什么问题。
这些算法的弱点在于指数运算对指数值的依赖性很强。Listing 10-5 中显示的if操作根据指数的某一位是 0 还是 1 来选择不同的分支。如果某一位是 1,那么for循环的迭代将比 0 时慢,攻击者通过监控 RSA 操作的执行时间可以利用这个时间差来恢复私有指数。这就是所谓的定时攻击。硬件攻击者可以通过监控设备的功耗来区分 1 位和 0 位,并观察哪些迭代执行了额外的乘法,从而揭示私有指数中哪些位为 1。
只有少数加密库实现了有效的防御机制,能够抵御定时攻击,更不用说应对像功率分析攻击这样的攻击了。
使用小指数以加速公钥操作
由于 RSA 计算本质上是指数运算,因此其性能取决于所用指数的值。较小的指数需要较少的乘法,因此可以使指数计算更快。
公共指数 e 理论上可以是介于 3 和 φ(n) - 1 之间的任何值,只要 e 和 φ(n) 互质。但实际上,您只会发现小的 e 值,而且大多数情况下 e = 65537,这是由于加密和签名验证速度的考虑。例如,Microsoft Windows CryptoAPI 只支持适合 32 位整数的公共指数。e 越大,计算 x^(e) mod n 的速度就越慢。
与公共指数的大小不同,私有指数 d 将与 n 大小相当,这使得解密比加密慢得多,签名也比验证慢得多。实际上,由于 d 是保密的,它必须是不可预测的,因此不能限制为一个小值。例如,如果 e 固定为 65537,那么相应的 d 通常与模数 n 的数量级相同,如果 n 是 2048 位长,那么它将接近 2²⁰⁴⁸。
正如在 “快速指数算法:平方乘法” 第 192 页中讨论的那样,将一个数字提高到 65537 次方只需要 17 次乘法,而将一个数字提高到某个 2048 位数字的次方则需要大约 3000 次乘法。
确定 RSA 实际速度的一种方法是使用 OpenSSL 工具包。例如,列表 10-6 显示了在我的 MacBook 上进行 512 位、1024 位、2048 位和 4096 位 RSA 操作的结果,该 MacBook 配备了 2.7 GHz 时钟频率的 Intel Core i5-5257U 处理器。
$ openssl speed rsa512 rsa1024 rsa2048 rsa4096
Doing 512 bit private rsa's for 10s: 161476 512 bit private RSA's in 9.59s
Doing 512 bit public rsa's for 10s: 1875805 512 bit public RSA's in 9.68s
Doing 1024 bit private rsa's for 10s: 51500 1024 bit private RSA's in 8.97s
Doing 1024 bit public rsa's for 10s: 715835 1024 bit public RSA's in 8.45s
Doing 2048 bit private rsa's for 10s: 13111 2048 bit private RSA's in 9.65s
Doing 2048 bit public rsa's for 10s: 288772 2048 bit public RSA's in 9.68s
Doing 4096 bit private rsa's for 10s: 1273 4096 bit private RSA's in 9.71s
Doing 4096 bit public rsa's for 10s: 63987 4096 bit public RSA's in 8.50s
OpenSSL 1.0.2g 1 Mar 2016
--snip--
sign verify sign/s verify/s
rsa 512 bits 0.000059s 0.000005s 16838.0 193781.5
rsa 1024 bits 0.000174s 0.000012s 5741.4 84714.2
rsa 2048 bits 0.000736s 0.000034s 1358.7 29831.8
rsa 4096 bits 0.007628s 0.000133s 131.1 7527.9
列表 10-6:使用 OpenSSL 工具包的 RSA 操作基准测试
验证与签名生成相比慢多少?为了获得一个概念,我们可以计算验证时间与签名时间的比率。列表 10-6 中的基准测试显示,对于 512 位、1024 位、2048 位和 4096 位模数大小,我得到的验证与签名速度比率分别约为 11.51、14.75、21.96 和 57.42。这个差距随着模数大小的增加而增大,因为 e 操作所需的乘法次数将与模数大小保持恒定(例如,当 e = 65537 时是 17),而私钥操作由于 d 随着模数增大而增加,始终需要更多的乘法。
但是,如果小指数这么好用,为什么不用 65537 而用 3 呢?实际上,在实现带有安全方案的 RSA(如 OAEP、PSS 或 FDH)时,使用 3 作为指数也是可以的,而且会更快。然而,加密专家避免这样做,因为当 e = 3 时,较不安全的方案会使某些数学攻击成为可能。数字 65537 足够大,可以避免这种低指数攻击,并且由于其低哈明重量,它只有一个位是 1,从而减少了计算时间。65537 对数学家也有特别意义:它是第四个费马数,或一种形式为
2((2(n))) + 1
因为它等于 2¹⁶ + 1,其中 16 = 2⁴,但这仅仅是一个好奇心,通常对加密工程师来说并不重要。
中国剩余定理
加速解密和签名验证(即计算 y^(d) mod n)的最常见技巧是中国剩余定理(CRT)。它使得 RSA 约四倍更快。
中国剩余定理通过计算两个指数,模 p 和模 q,而不是直接模 n,可以加快解密速度。因为 p 和 q 比 n 小得多,所以执行两个“较小”的指数运算比执行一个“较大”的要快。
中国剩余定理并不仅限于 RSA。它是一个一般的算术结果,最简单的形式指出,如果 n = n[1]n[2]n[3] …,其中 n[i]s 是两两互质的(即 GCD(n[i], n[j]) = 1 对于任何不同的 i 和 j),那么值 x mod n 可以从 x mod n[1]、x mod n[2]、x mod n[3] 等值计算出来。例如,假设我们有 n = 1155,我们将其写成素因数的乘积 3 × 5 × 7 × 11。我们想要确定满足 x mod 3 = 2、x mod 5 = 1、x mod 7 = 6 和 x mod 11 = 8 的数字 x。(我任意选择了 2、1、6 和 8。)
要使用中国剩余定理找到 x,我们可以计算 P(n[1]) + P(n[2]) + …,其中 P(n[i]) 定义如下:
P(n[i]) = (x mod n[i]) × n / n[i] × (1 / (n / n[i]) mod n[i]) mod n
注意,第二项 n/n[i] 等于除了这个 n[i] 之外所有其他因子的乘积。
为了将这个公式应用到我们的例子中并恢复 x mod 1155,我们取任意值 2、1、6 和 8;我们计算 P(3)、P(5)、P(7) 和 P(8);然后将它们加在一起,得到以下表达式:

在这里,我刚刚应用了前面定义的 P(n[i])。(每个数字如何找到的数学原理是直接的,但我不会在这里详细说明。)然后可以将这个表达式简化为 [770 + 231 + 1980 + 1680] mod n = 41,实际上 41 正是我为这个例子选择的数字,所以我们得到了正确的结果。
将 CRT 应用于 RSA 比之前的例子更简单,因为每个 n 只有两个因子(即 p 和 q)。给定一个密文 y 需要解密时,与你计算 y^(d) mod n 的方式不同,你使用 CRT 来计算 x[p] = y^(s) mod p,其中 s = d mod (p – 1),并且 x[q] = y^(t) mod q,其中 t = d mod (q – 1)。然后,你将这两个表达式结合起来,计算 x 得到如下结果:
x = x[p] × q × (1/q mod p) + x[q] × p × (1/p mod q) mod n
就是这样。它比平方乘法更快,因为乘法密集的操作是在模 p 和 q 上进行的,而这两个数的大小仅为 n 的一半。
注意
在最后的操作中,两个数 q × (1/q mod p) 和 p × (1/p mod q) 可以提前计算,这意味着只需要计算两次乘法和一次模 n 的加法来找到 x。
不幸的是,这些技术附带有一个安全隐患,接下来我将讨论这一点。
错误如何发生
比 RSA 方案本身更美妙的是,一系列攻击的方式,它们之所以有效,要么是因为实现泄漏了(或可以被诱导泄漏)算法内部信息,要么是因为 RSA 被不安全地使用。我将在接下来的章节中讨论两个经典的攻击示例。
Bellcore 攻击 RSA-CRT
Bellcore 攻击是 RSA 历史上最重要的攻击之一。1996 年首次发现时,它引起了广泛关注,因为它利用了 RSA 对 故障注入 的漏洞——故障注入攻击通过迫使算法的一部分出错,从而得到不正确的结果。例如,硬件电路或嵌入式系统可以通过突然改变电压供应或向芯片上精确选定的部分照射激光脉冲来临时扰动。攻击者可以通过观察最终结果的影响,利用算法内部操作的故障。例如,比较正确结果和错误结果,可以提供关于算法内部值(包括密钥)的信息。
Bellcore 攻击就是这样一种故障攻击。它作用于使用中国剩余定理的 RSA 签名方案,并且是确定性的——这意味着它作用于 FDH,但不作用于 PSS,因为 PSS 是概率性的。
要理解 Bellcore 攻击如何工作,请回想上一节内容,使用 CRT 时,等于 x^(d) mod n 的结果是通过以下计算获得的,其中 x[p] = y^(s) mod p 和 x[q] = y^(t) mod q:
x = x[p] × q × (1/q mod p) + x[q] × p × (1/p mod q) mod n
现在假设攻击者在 x[q] 的计算中引入了一个故障,使得你得到了一个错误的值,与实际的 x[q] 不同。我们将这个错误的值称为 x[q]′,并称最终得到的结果为 x′。攻击者可以通过将错误的签名 x′ 与正确的签名 x 相减,进而分解 n,得到如下结果:
x − x′ = (x[q] − x[q]′) × p × (1/p mod q) mod 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,我可以通过你的公钥 e 结合 p 和 q 计算出你的私钥。
如果我的私钥只是一个对 (n, d[1]),而你的私钥是 (n, d[2]),你的公钥是 (n, e[2]),假设我知道 n 但不知道 p 和 q,那么我不能直接从你的公钥指数 e[2] 计算出你的私有指数 d[2]。那么,你如何仅凭私有指数 d 计算出 p 和 q 呢?这个解决方案有点技术性,但非常优雅。
记住,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 下我们有如下关系:
a^(kφ(n)) = (a(φ(*n*)))(k) = 1^(k) = 1
第二个观察是,由于 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:一个计算私有指数 d 的质因子 p 和 q 的 Python 程序
该程序通过找到数字 t,使得 kφ(n) = 2^(s)t,来确定 kφ(n),其中 s ❷ 是某个值。然后它寻找 a 和 k,使得 (a^(k))² = 1 mod n ❸,使用 t 作为 k 的起始点 ❹。当此条件得到满足 ❺ 时,我们就找到了一个解。接着,程序确定因子 p ❻ 并验证 ❼ pq 的值是否等于 n 的值。最后,程序打印出结果 p 和 q 的值:
p = 2046223079
q = 17870599
该程序正确返回两个因子。
进一步阅读
RSA 本身值得一本书的篇幅。许多重要且有趣的话题我不得不省略,比如 Bleichenbacher 对 OAEP 前身(标准 PKCS#1 v1.5)进行的填充 oracle 攻击,这与第四章中对分组密码的填充 oracle 攻击在精神上相似。此外,还有 Wiener 对低私有指数 RSA 的攻击,以及使用 Coppersmith 方法对具有小指数的 RSA 进行的攻击,这些攻击可能也存在不安全的填充。
要查看与侧信道攻击和防御相关的研究结果,请查看自 1999 年以来举办的 CHES 研讨会论文集,访问 www.chesworkshop.org/。在编写本章时,最有用的参考文献之一是 Boneh 的“二十年 RSA 加密系统攻击”——一篇回顾并解释 RSA 最重要攻击的综述性文章。关于定时攻击的参考文献,Brumley 和 Boneh 的论文《远程定时攻击是可行的》是必读的,因其在分析和实验方面的贡献。要了解更多关于故障攻击的内容,请阅读 Boneh、DeMillo 和 Lipton 的 Bellcore 攻击论文《在密码学计算中消除错误的重要性》的完整版。
学习 RSA 实现如何工作,尽管有时痛苦且令人沮丧,但最好的方法是查看广泛使用的实现的源代码。例如,可以查看 OpenSSL 中的 RSA 及其底层大数运算实现,查看 NSS(Mozilla Firefox 浏览器使用的库)、Crypto++或其他流行软件中的实现,检查它们的运算操作以及它们对定时攻击和故障攻击的防御措施。
第十二章:DIFFIE–HELLMAN

1976 年 11 月,斯坦福大学的研究人员 Whitfield Diffie 和 Martin Hellman 发表了一篇名为《密码学的新方向》的研究论文,彻底改变了密码学。在他们的论文中,他们提出了公钥加密和签名的概念,尽管他们实际上没有这些方案;他们只是提出了他们所称的 公钥分配方案,这是一种协议,允许两个当事方通过交换可被窃听者看到的信息来建立共享的秘密。这个协议现在被称为 Diffie–Hellman(DH)协议。
在 Diffie–Hellman 之前,建立共享秘密需要执行繁琐的程序,例如手动交换密封信封。一旦通信方通过 DH 协议建立了共享的秘密值,这个秘密可以用于通过将秘密转换为一个或多个对称密钥来建立 安全通道,然后用这些密钥对后续通信进行加密和认证。因此,DH 协议及其变体被称为密钥协商协议。
在本章的第一部分,我回顾了 Diffie–Hellman 协议的数学基础,包括 DH 依赖于的计算问题,以便实现其神奇功能。然后,在本章的第二部分,我描述了用于创建安全通道的不同版本的 Diffie–Hellman 协议。最后,由于 Diffie–Hellman 方案只有在参数选择得当时才是安全的,我通过检查 Diffie–Hellman 可能失败的场景来结束本章。
注意
Diffie 和 Hellman 因发明了公钥密码学和数字签名而在 2015 年获得了声望卓著的图灵奖,但也有其他人应得的荣誉。早在 1974 年,即 Diffie–Hellman 论文发表的两年前,计算机科学家 Ralph Merkle 就提出了公钥密码学的概念,并创造了现在称为 Merkle 迷题的东西。大约在同一时期,GCHQ(政府通信总部,英国相当于美国国家安全局)的一些研究人员也发现了 RSA 和 Diffie–Hellman 密钥协商背后的原理,尽管这一事实直到数十年后才解密。
Diffie–Hellman 函数
为了理解 DH 密钥协商协议,你必须首先理解它们的核心操作,即 DH 函数。DH 函数通常与以 Z[p]^* 表示的群体一起工作。回想一下第九章,这些群体是由模素数 p 的非零整数构成的。另一个公共参数是 基数,g。所有算术运算都是在 p 模下进行的。
DH 函数涉及两个由通信双方从群体 Z[p]^* 随机选择的私有值,分别表示为 a 和 b。私有值 a 具有一个与之相关的公共值 A = g^(a) mod p,即 g 的 a 次方模 p。这个值通过一个可被窃听者看到的消息发送给另一方。与 b 相关的公共值是 B = g^(b) mod p,即 g 的 b 次方模 p,这个值通过一个公开可读的消息发送给 a 的拥有者。
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) 限制在一小部分可能的值之内,而你期望的应该是与 Z[p]^* 中的元素数量大致相同的可能值,从而共享秘密的可能值也应该是如此。为了确保最高的安全性,安全的 DH 参数应与一个素数 p 一起工作,使得 (p – 1) / 2 也是素数。这样的 安全素数 保证了该群体不会有小的子群,从而使 DH 更容易被破解。使用安全素数时,DH 可以特别地与 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 154.53s user 0.86s system 99% cpu 2:36.85 total
示例 11-1:使用 OpenSSL 工具包生成 2048 位 Diffie–Hellman 参数的执行时间测量
正如你在示例 11-1 中看到的,使用 OpenSSL 工具包生成 DH 参数花费了 154.53 秒。现在,为了进行比较,示例 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:生成 2048 位 RSA 参数并测量执行时间
生成 DH 参数的时间大约是生成相同安全级别的 RSA 参数的 1000 倍,主要是由于在生成用于创建 DH 参数的素数时施加的额外约束。
Diffie–Hellman 问题
DH 协议的安全性依赖于计算问题的难度,特别是依赖于第九章中介绍的离散对数问题(DLP)的难度。显然,通过从公共值 g^(a) 中恢复私有值 a,可以破解 DH 协议,这本质上就是解决 DLP 实例。但当我们使用 DH 计算共享秘密时,我们不仅关心离散对数问题。我们还关心两个与 DH 特定相关的问题,如下所述。
计算 Diffie–Hellman 问题
计算 Diffie–Hellman (CDH)问题是指在仅知道公共值 g^(a) 和 g^(b),而不知道任何秘密值 a 或 b 的情况下,计算共享秘密 g^(ab) 的问题。其动机显然是确保即使窃听者捕获了 g^(a) 和 g^(b),他们也无法确定共享秘密 g^(ab)。
如果你能够解决 DLP 问题,那么你也能够解决 CDH 问题;简而言之,如果你能够解决 DLP 问题,那么在给定 g^(a) 和 g^(b) 的情况下,你将能够推导出 a 和 b,从而计算出 g^(ab)。换句话说,DLP 问题至少和 CDH 问题一样难。但我们并不确定 CDH 问题是否至少和 DLP 问题一样难,这将使得这两个问题具有相同的难度。换句话说,DLP 问题对于 CDH 问题就像因式分解问题对于 RSA 问题一样。(回想一下,因式分解能够解决 RSA 问题,但不一定能够反过来解决。)
Diffie–Hellman 与 RSA 有一个相似之处,即对于给定的模数大小,DH 将提供与 RSA 相同的安全级别。例如,具有 2048 位素数 p 的 DH 协议将为你提供与具有 2048 位模数 n 的 RSA 大致相同的安全性,即大约 90 位。事实上,破解 CDH 的最快方法是通过一种称为数域筛法的算法来解决 DLP 问题,这种方法类似但不完全相同于破解 RSA 的最快方法:一般数域筛法(GNFS)。
决策 Diffie–Hellman 问题
有时候,我们需要比 CDH 难度假设更强的假设。例如,假设攻击者能够在给定 2048 位的 g^(a) 和 g^(b) 的情况下计算出 g^(ab) 的前 32 位,但无法计算出所有 2048 位。虽然由于 32 位不足以完全恢复 g^(ab),CDH 仍然是安全的,但攻击者仍然可能已经学到了一些关于共享秘密的信息,这可能会让他们突破应用的安全性。
为了确保攻击者无法得知任何关于共享密钥 g^(ab) 的信息,这个值只需要与一个随机群元素无法区分,就像加密方案在密文无法区分于随机字符串时才是安全的。正式化这一直觉的计算问题被称为 判定性 Diffie–Hellman(DDH) 问题。给定 g^(a), g^(b),以及一个值,它是 g^(ab) 或 g^(c),其中 c 是一个随机数(每个有 1/2 的概率),DDH 问题的任务是确定是否选择了 g^(ab)(对应于 g^(a) 和 g^(b) 的共享秘密)。假设没有攻击者可以高效地解决 DDH,这个假设被称为 判定性 Diffie–Hellman 假设。
如果 DDH 是困难的,那么 CDH 也同样困难,你无法从中学到任何关于 g^(ab) 的信息。但如果你能解决 CDH,那么你也能解决 DDH:给定一个三元组 (g^(a), g^(b), g^(c)), 你就可以从 g^(a) 和 g^(b) 中推导出 g^(ab),并检查结果是否与给定的 g^(c) 相等。底线是,DDH 在本质上比 CDH 更简单,但 DDH 的难度是加密学中的一个基本假设,并且是最研究的课题之一。我们可以确信,当 Diffie–Hellman 参数选择得当时,CDH 和 DDH 都很难。
更多的 Diffie–Hellman 问题
有时,加密学家设计新的方案,并证明它们至少与解决与 CDH 或 DDH 相关的问题一样困难,但并不完全与这两者相同。理想情况下,我们希望证明破解一个加密系统的难度与解决 CDH 或 DDH 的难度相同,但这并不总是可以通过先进的加密机制实现,通常是因为这些方案涉及比基本的 Diffie–Hellman 协议更复杂的操作。
例如,在一个类似于 DH 的问题中,给定 g^(a),攻击者会尝试计算 g^(1/a),其中 1/a是群中a的逆元(通常是 Z[p]^,其中 p 是某个素数)。在另一个问题中,攻击者可能会区分 (g^(a), g^(b)) 和 (g^(a), g^(1/a)) 这两对随机的 a 和 b。最后,在所谓的 双重 Diffie–Hellman 问题 中,给定 g^(a), g^(b) 和 g^(c),攻击者会尝试计算两个值 g^(ab) 和 g^(ac*)。有时这些 DH 变种与 CDH 或 DDH 一样困难,有时它们本质上更容易——因此提供的安全保障较低。作为练习,尝试找出这些问题的难度与 CDH 和 DDH 之间的关系。(双重 Diffie–Hellman 实际上与 CDH 一样困难,但这并不容易证明!)
密钥协议
Diffie–Hellman 问题旨在构建安全的密钥协议——这些协议旨在保护通过网络进行通信的两个或多个参与方之间的通信,并利用共享秘密进行保护。这个秘密被转化为一个或多个 会话密钥——用于加密和验证会话期间交换的信息的对称密钥。在研究实际的 DH 协议之前,你应该了解是什么使密钥协议安全或不安全,以及简单协议是如何工作的。我们将从一个广泛使用的密钥协议开始,它不依赖于 DH。
非 DH 密钥协议示例
为了让你了解密钥协议是如何工作的,以及它安全意味着什么,我们来看看在 3G 和 4G 电信标准中用于建立 SIM 卡与电信运营商之间通信的协议。这个协议通常被称为 AKA,即 认证密钥协议。它不使用 Diffie–Hellman 函数,而是仅使用对称密钥操作。细节可能有点枯燥,但本质上,这个协议如图 11-1 所示。

图 11-1:3G 和 4G 电信中的认证密钥协议
在这个协议的实现中,SIM 卡拥有一个秘密密钥 K,这是运营商所知道的。运营商通过选择一个随机值 R 开始会话,然后基于两个伪随机函数 PRF0 和 PRF1 计算两个值 SK 和 V[1]。接下来,运营商向 SIM 卡发送一个包含 R 和 V[1] 的消息,这些值对攻击者可见。一旦 SIM 卡获得 R,它就具备计算 SK 所需的信息,使用 PRF0 完成此操作。会话中的两个参与方最终共享一个密钥 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 卡。更大的风险是,攻击者可以记录通信内容以及在密钥协商过程中交换的任何消息,并且稍后可以使用捕获的R值解密这些通信。攻击者可以由此推算出过去的会话密钥,并使用它们解密录制的流量。
密钥协商协议的攻击模型
对于密钥协商协议,并没有单一的安全定义,无法在没有上下文和不考虑攻击模型以及安全目标的情况下说某个密钥协议是完全安全的。例如,你可以说之前的 3G/4G 协议是安全的,因为被动攻击者不会找到会话密钥,但你也可以说它不安全,因为一旦密钥K泄漏,那么所有以前和未来的通信都会受到威胁。
在密钥协商协议中有不同的安全概念,以及三个主要的攻击模型,这些模型取决于协议泄露的信息。从最弱到最强,这些模型分别是窃听者、数据泄漏和漏洞:
窃听者 这个攻击者观察两个合法方之间交换的消息,并可以记录、修改、丢弃或注入消息。为了防止窃听者,密钥协商协议必须确保不泄露任何关于共享秘密的信息。
数据泄漏 在这个模型中,攻击者通过一次或多次协议执行获取会话密钥和所有临时秘密(例如前面讨论的电信协议示例中的SK),但没有获取长期密钥(例如同一协议中的K)。
漏洞(或破坏) 在这个模型中,攻击者获得了一个或多个方的长期密钥。一旦发生漏洞,安全性就不再可得,因为攻击者可以在协议的后续会话中冒充一方或双方。然而,攻击者不应能够从收集密钥之前执行的会话中恢复秘密。
现在我们已经看过攻击模型并了解了攻击者可以做些什么,接下来我们来探讨安全目标——也就是协议应提供的安全保障。密钥协商协议可以设计来满足多个安全目标。这里描述了四个最相关的目标,按从简单到复杂的顺序排列。
身份验证 每一方都应该能够验证另一方的身份。也就是说,协议应允许进行相互身份验证。认证密钥协商(AKA)发生在协议认证了双方身份时。
关键控制 无论哪一方都不应该能够选择最终的共享密钥或将其强迫在某个特定子集内。前面讨论的 3G/4G 密钥协商协议缺乏这一特性,因为运营商选择了R的值,这完全决定了最终共享密钥。
前向保密性 这是指即使所有长期密钥都被暴露,之前执行协议时的共享密钥也无法被计算出来,即使攻击者记录了所有之前的执行,或能够注入或修改之前执行中的消息。前向保密协议保证,即使你必须将设备和它们的秘密交给某个机构或其他地方,它们也无法解密你之前加密的通信。(3G/4G 密钥协商协议不提供前向保密性。)
抗密钥泄露伪装(KCI) KCI 发生在攻击者泄露了一方的长期密钥,并能够用它冒充另一方。例如,3G/4G 密钥协商协议允许简单的密钥泄露伪装,因为双方共享相同的密钥K。理想的密钥协商协议应该防止这种攻击。
性能
要有用,一个密钥协商协议不仅应该是安全的,还应该是高效的。考虑到密钥协商协议的效率时,应该考虑多个因素,包括交换的消息数量、消息的长度和数量、实现协议的计算工作量,以及是否可以进行预计算以节省时间。如果交换的消息较少且较短,且最好保持最小的交互性,以便双方不必等待接收到消息后才发送下一个消息,那么协议通常会更高效。协议效率的常见衡量标准是以往返时间计算,或发送一条消息并接收回应的时间。
往返时间通常是协议中延迟的主要原因,但要执行的计算量也很重要;所需的计算越少越好,并且可以提前完成的预计算越多越好。
例如,前面讨论的 3G/4G 密钥协商协议交换了两条几百比特的消息,且必须按照特定顺序发送。此协议可以使用预计算来节省时间,因为运营商可以提前选择多个R值;预计算出匹配的SK、V[1]和V[2]值;并将它们存储在数据库中。在这种情况下,预计算的优势是减少了长期密钥的暴露。
迪菲-赫尔曼协议
Diffie–Hellman 函数是大多数已部署的公钥协议的核心。然而,并没有单一的 Diffie–Hellman 协议,而是多种方式使用 DH 函数来建立共享密钥。接下来的章节中,我们将回顾其中三种协议。在每次讨论中,我将坚持使用常见的加密占位符名称,称两方为 Alice 和 Bob,攻击者为 Eve。我将写g为用于算术运算的群体基数,这是一个固定值,Alice 和 Bob 事先都已知晓。
匿名 Diffie–Hellman
匿名 Diffie–Hellman是所有 Diffie–Hellman 协议中最简单的一种。之所以称为“匿名”,是因为它没有认证;参与者没有可以被另一方验证的身份,也没有持有长期密钥。Alice 无法向 Bob 证明她就是 Alice,反之亦然。
在匿名 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和接收到的值分别升到各自私有指数的幂次。简单纯粹,但仅对最懒惰的攻击者有效。
匿名 DH 协议可以通过中间人攻击被破解。窃听者只需要拦截消息并伪装成 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 (priv[A], pub[B])标签意味着 Alice 持有她自己的私钥priv[A],以及 Bob 的公钥pub[B]。这种priv/pub密钥对被称为长期密钥,因为它是事先固定的,并且在协议的连续运行中保持不变。当然,这些长期私钥应该保密,而公钥被认为是攻击者已知的。
Alice 和 Bob 首先像在匿名 DH 中那样选择随机指数,a和b。然后,Alice 计算出A和一个基于她的签名函数sign、她的私钥priv[A]以及A的签名sig[A]。现在,Alice 将A和sig[A]发送给 Bob,Bob 使用她的公钥pub[A]来验证sig[A]。如果签名无效,Bob 就知道消息不是来自 Alice,他会丢弃A。
如果签名正确,Bob 将从A和他的随机指数b计算出g^(ab)。然后,他将从sign函数、他的私钥priv[B]和B的组合中计算出B和他自己的签名。现在他将B和sig[B]发送给 Alice,Alice 将尝试使用 Bob 的公钥pub[B]来验证sig[B]。只有当 Bob 的签名被成功验证时,Alice 才会计算出g^(ab)。
防止窃听者攻击
经过认证的 DH 能够防止窃听者攻击,因为攻击者无法获取任何关于共享秘密 g^(ab) 的信息,因为他们忽略了 DH 指数。经过认证的 DH 还提供前向保密性:即使攻击者在某个时刻篡改了某一方,就如之前讨论的 breach 攻击模型那样,他们将只能获取私钥签名信息,而无法获得任何瞬时的 DH 指数;因此,他们无法获知任何先前共享的秘密。
经过认证的 DH 还可以防止任何一方控制共享秘密的值。Alice 无法通过构造一个特殊的 a 值来预测 g^(ab) 的值,因为她无法控制 b,而 b 对 g^(ab) 的影响和 a 一样大。(唯一的例外是如果 Alice 选择 a = 0,这样无论 b 为何,都会有 g^(ab) = 1。但 0 不是一个有效值,协议应拒绝此选择。)
尽管如此,经过认证的 DH 并不能防止所有类型的攻击。例如,Eve 可以记录 A 和 sig[A] 的先前值,并稍后将其重放给 Bob,从而伪装成 Alice。此时,Bob 会相信他正在与 Alice 分享一个秘密,尽管实际上他并没有,而 Eve 也无法得知这个秘密。通过添加一个名为 密钥确认 的过程,可以在实践中消除这一风险,在这个过程中,Alice 和 Bob 互相证明他们拥有共享的秘密。例如,Alice 和 Bob 可以通过发送 Hash(pub[A] || pub[B], g^(ab)) 和 Hash(pub[B] || pub[A], g^(ab)) 来进行密钥确认,使用某个哈希函数 Hash;当 Bob 收到 Hash(pub[A] || pub[B], g^(ab)),而 Alice 收到 Hash(pub[B] || pub[A], g^(ab)) 时,两者可以使用 pub[A]、pub[B] 和 g^(ab) 验证这些哈希值的正确性。公钥的不同顺序(pub[A] || pub[B] 和 pub[B] || pub[A])确保了 Alice 和 Bob 会发送不同的值,且攻击者不能通过复制 Bob 的哈希值伪装成 Alice。
数据泄露防护
经过认证的 DH 对数据泄露攻击的脆弱性更为令人关注。在这种类型的攻击中,攻击者会获得短期瞬时秘密的值(即指数 a 和 b),并利用这些信息冒充通信的一方。如果 Eve 能够获取指数 a 的值,并获得发送给 Bob 的 A 和 sig[A] 的匹配值,她就可以发起协议的新一轮执行,并冒充 Alice,如 图 11-5 所示。

图 11-5:经过认证的 Diffie–Hellman 协议的冒充攻击
在这个攻击场景中,Eve 获取了 a 的值,并重新播放对应的 A 及其签名 sig[A],伪装成 Alice。Bob 验证签名并从 A 计算 g^(ab),然后发送 B 和 sig[B],Eve 使用这些信息,利用偷来的 a 计算 g^(ab)。这样两者之间就有了一个共享的密钥。Bob 现在认为自己在和 Alice 交流。
一种使认证 DH 协议在泄露临时密钥时仍能保证安全的方法是将长期密钥整合进共享密钥的计算中,这样在不知道长期密钥的情况下,就无法确定共享密钥。
Menezes–Qu–Vanstone (MQV)
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 中的密钥不是签名密钥:MQV 中使用的密钥由私有指数 x 和公有值 g^(x) 组成。图 11-6 展示了 MQV 协议的操作过程。

图 11-6:MQV 协议
在 图 11-6 中的 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 中所定义。展开该表达式后,我们得到以下结果:
(B × YB*)((a* + xA)) = (g^b × (gy*)(B))^((a* + xA)) = (g^(b + yB))^((a + xA)) = g^((b + yB)(a + xA))
与此同时,Bob 计算 (A × X(*A*))((b + yB)) 的结果,我们可以验证它与 Alice 计算的值相等:
(A × XA*)((b* + yB)) = (g^a × (gx*)(A))^((b* + yB)) = (g^(a + xA))^((b + yB)) = g^((a + xA)(b + yB)) = g^((b + yB)(a + xA))
如你所见,我们得到了 Alice 和 Bob 相同的值,即 g^((b + yB)(a + xA))。这告诉我们 Alice 和 Bob 共享相同的秘密。
与认证 DH 不同,MQV 不能仅通过泄露临时秘密而被攻破。知道 a 或 b 不会让攻击者确定最终的共享秘密,因为他们还需要长期私钥来计算它。
在最强的攻击模型——泄密模型下,长期密钥被破解时会发生什么?如果 Eve 破解了 Alice 的长期私钥 x,先前建立的共享秘密是安全的,因为它们的计算也涉及了 Alice 的临时私钥。
然而,MQV 并不能提供 完美的前向保密性,因为存在以下攻击。例如,假设 Eve 拦截了 Alice 的 A 消息,并将其替换为她的 A = g^(a),其中 a 是 Eve 选择的一个值。与此同时,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⁴ = 13,依此类推。如果 g 的指数是随机的,你将得到 Z[13]^* 的一个随机元素,但 Z[13]^* 元素的 4 位字符串编码不会是均匀随机的:并不是所有的位都有相同的概率是 0 或 1。在 Z[13]^* 中,七个值的最重要的位是 0(即群中的 1 到 7 的数字),但只有五个值的最重要的位是 1(即 8 到 12)。也就是说,这个位是 0 的概率为 7 / 12 ≈ 0.58,而理想情况下,一个随机位应该有 0.5 的概率为 0。更糟的是,4 位序列 1101、1110 和 1111 永远不会出现。
为了避免来自 DH 共享秘密中生成的会话密钥的偏差,应该使用加密哈希函数,如 BLAKE2 或 SHA-3,或者更好的是,使用密钥派生函数(KDF)。KDF 构造的一个例子是 HKDF 或基于 HMAC 的 KDF(如 RFC 5869 中所指定),但如今 BLAKE2 和 SHA-3 都提供了专门的模式来充当 KDF。
TLS 中的遗留 Diffie–Hellman
TLS 协议是 HTTPS 安全网站以及安全邮件传输协议(SMTP)背后的安全机制。TLS 涉及多个参数,包括它将使用的 Diffie–Hellman 协议类型,尽管大多数 TLS 实现仍然出于兼容性原因支持匿名 DH,尽管它并不安全。
不安全的群参数
2016 年 1 月,OpenSSL 工具包的维护者修复了一个高严重性的漏洞(CVE-2016-0701),该漏洞允许攻击者利用不安全的 Diffie–Hellman 参数。漏洞的根本原因在于 OpenSSL 允许用户使用不安全的 DH 群参数(即不安全的素数 p),而不是在执行任何算术运算之前抛出错误并完全中止协议。
本质上,OpenSSL 接受了一个素数 p,其乘法群 Z[p]^(所有 DH 操作都在此进行)包含了小的子群。正如你在本章开始时所学,密码协议中较大群体内存在小的子群是有害的,因为它会将共享的秘密限制在一个比使用整个群体 Z[p]^ 时更小的可能值集合中。更糟糕的是,攻击者可以构造一个 DH 指数 x,当与受害者的公钥 g^(y) 结合时,会泄露私钥 y 的信息,最终泄露其全部内容。
虽然实际的漏洞出现在 2016 年,但该攻击所使用的原理可追溯到 1997 年 Lim 和 Lee 的论文《基于素数阶子群的离散对数方案的密钥恢复攻击》。修复该漏洞的方法很简单:在接受素数p作为群体模数时,协议必须检查p是否为安全素数,方法是验证(p – 1) / 2 也为素数,以确保群体Z[p]^*不会有小子群,并且对该漏洞的攻击会失败。
深入阅读
以下是一些我在本章中未涉及,但有助于进一步学习的内容。
你可以通过阅读一些标准和官方出版物进一步深入了解 DH 密钥交换协议,包括 ANSI X9.42、RFC 2631 和 RFC 5114、IEEE 1363,以及 NIST SP 800-56A。这些文献作为参考,确保互操作性,并提供群体参数的建议。
若要进一步了解高级 DH 协议(如 MQV 及其相关协议 HMQV 和 OAKE 等)及其安全概念(如未知密钥共享攻击和群体表示攻击),请阅读 Hugo Krawczyk 于 2005 年发布的文章《HMQV:一种高效的安全 Diffie-Hellman 协议》(eprint.iacr.org/2005/176/),以及 Andrew C. Yao 和 Yunlei Zhao 于 2011 年发布的文章《一种新的隐式认证 Diffie-Hellman 协议家族》(eprint.iacr.org/2011/035/)。你会发现这些文章中的 Diffie-Hellman 操作与本章中的表示方式不同。例如,代替g^(x), 你会看到共享密钥表示为xP。通常,你会看到乘法被加法替代,指数运算被乘法替代。原因是这些协议通常不是在整数群体上定义的,而是在椭圆曲线上定义的,如第十二章所讨论。
第十三章:椭圆曲线

1985 年引入的椭圆曲线密码学(ECC)彻底改变了我们进行公钥密码学的方法。与 RSA 和经典的 Diffie–Hellman 等替代方案相比,ECC 更强大且高效(例如,使用 256 位密钥的 ECC 比使用 4096 位密钥的 RSA 更强大),但它也更复杂。
像 RSA 一样,ECC 通过乘法运算处理大数字,但不同于 RSA,它是通过在数学曲线上结合点来完成的,这条曲线叫做椭圆曲线(顺便说一下,它与椭圆无关)。更复杂的是,椭圆曲线有很多不同类型——简单的和复杂的,高效的和低效的,安全的和不安全的。
尽管椭圆曲线密码学(ECC)在 1985 年首次被提出,但直到 2000 年代初,标准化组织才开始采纳它,并且直到更晚的时候,主要工具包才开始支持:OpenSSL 在 2005 年添加了 ECC,而 OpenSSH 安全连接工具则等到 2011 年才支持 ECC。但现代系统几乎没有理由不使用 ECC,你会在比特币和许多苹果设备的安全组件中看到它。实际上,椭圆曲线使你能够比传统算法更快地执行常见的公钥密码学操作,如加密、签名和密钥协商。大多数依赖离散对数问题(DLP)的加密应用在基于其椭圆曲线对手 ECDLP 时也能正常工作,唯一的显著例外是:安全远程密码(SRP)协议。
本章重点介绍 ECC 的应用,并讨论为什么选择 ECC 而不是 RSA 或经典的 Diffie–Hellman,以及如何为你的应用选择合适的椭圆曲线。
什么是椭圆曲线?
椭圆曲线是平面上的一条曲线——一组具有x和y坐标的点。曲线的方程定义了所有属于该曲线的点。例如,曲线y = 3 是一个横坐标为 3 的水平线,形式为y = ax + b的曲线是直线,x² + y² = 1 是一个半径为 1、以原点为中心的圆,依此类推。无论曲线的类型如何,曲线上的点都是满足曲线方程的(x, y)对。
用于加密的椭圆曲线通常是其方程形式为y² = x³ + ax + b(即魏尔斯特拉斯形式),其中常数a和b定义了曲线的形状。例如,图 12-1 展示了满足方程y² = x³ – 4x的椭圆曲线。

图 12-1:具有方程 y² = x³ – 4x 的椭圆曲线,显示在实数范围内
注意
在本章中,我将重点介绍最简单、最常见的椭圆曲线类型——即其方程类似于 y² = x³ + ax + b——但也有其他形式的椭圆曲线。例如,爱德华兹曲线是方程为 x² + y² = 1 + dx²y² 的椭圆曲线。爱德华兹曲线有时用于密码学(例如,在 Ed25519 方案中)。
图 12-1 显示了组成 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) 属于该曲线。
区分属于曲线的点和其他点非常重要,因为在使用椭圆曲线进行密码学时,我们将使用来自曲线的点,而曲线外的点往往会带来安全风险。然而,请注意,曲线的方程并不总是有解,至少在自然数平面中没有解。例如,要找到横坐标为 x = 1 的点,我们需要解 y² = x³ – 4x,对于 x³ – 4x = 1³ – 4 × 1,结果为 –3。但 y² = –3 没有解,因为没有一个数满足 y² = –3。(在复数中有解,但椭圆曲线密码学只处理自然数——更准确地说,是模素数的整数。)因为曲线方程在 x = 1 时没有解,所以在 x 轴上该位置没有点,如图 12-1 所示。
如果我们尝试求解 x = –1 呢?在这种情况下,我们得到方程 y² = –1 + 4 = 3,方程有两个解(y = √3 和 y = –√3),即三的平方根及其负值。平方一个数总是得到一个正数,因此对于任何实数 y,都有 y² = (–y)²,正如你所看到的,图 12-1 中的曲线对于所有解其方程的点相对于 x 轴是对称的(所有形式为 y² = x³ + ax + b 的椭圆曲线都是如此)。
整数上的椭圆曲线
现在,稍微有点意外:在椭圆曲线密码学中使用的曲线实际上看起来并不像图 12-1 中所示的曲线。它们更像是 图 12-2,这是一团点而不是曲线。这是怎么回事呢?
图 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 上显示的所有点都有满足方程 y² = x³ – 4x 的整数坐标 x 和 y,且这些坐标是模 191 的整数。例如,当 x = 2 时,y² = 0,y = 0 是一个有效解。这告诉我们点 (2, 0) 属于该曲线。

图 12-2:方程 y² = x³ – 4x 在 Z[191] 上的椭圆曲线,Z[191] 是模 191 的整数集合*
如果 x = 3 会怎么样?我们得到方程 y² = 27 – 12 = 15,这个方程有两个解 y² = 15(即 46 和 145),因为 46² mod 191 = 15 和 145² mod 191 = 15 都等于 15,属于 Z[191]。因此,点 (3, 46) 和 (3, 145) 属于该曲线,并如 图 12-2 所示(左侧突出显示的两个点)。
注意
图 12-2 考虑的是来自集合 Z[191] = {0, 1, 2, … , 190} 的点,这个集合包括零。这与我们在讨论 RSA 和 Diffie-Hellman 时提到的带星号上标的 Z[p]^(星号上标)集合不同。之所以有这种差异,是因为我们将进行加法和乘法运算,因此需要确保数集包含加法的单位元素(即 0,确保 x + 0 = x 对于 Z[191] 中的每个 x)。此外,每个数字 x 都有一个相对于加法的逆元,记作 –x,使得 x + (–x) = 0。例如,100 在 Z[191] 中的逆元是 91,因为 100 + 91 mod 191 = 0。这样的数集,其中加法和乘法都可行,并且每个元素 x 都有一个相对于加法的逆元(记作 –x)以及一个相对于乘法的逆元(记作 1* / x),被称为一个域。当一个域有有限个元素时,如 Z[191],并且所有用于椭圆曲线加密的域都是如此,它被称为一个有限域。
添加和乘法运算
我们已经看到,椭圆曲线上的点都是满足曲线方程 y² = x³ + ax + b 的坐标 (x, y)。在本节中,我们将讨论如何加椭圆曲线上的点,这条规则称为 加法定律。
加法两个点
假设我们要将椭圆曲线上的两点 P 和 Q 相加,得到一个新的点 R,它是这两点的和。理解点加法的最简单方法是根据几何规则确定 R = P + Q 在曲线上相对于 P 和 Q 的位置:画出连接 P 和 Q 的直线,找到这条直线与曲线相交的另一个点,并且 Q 是该点关于 x 轴的反射。例如,在图 12-3 中,连接 P 和 Q 的直线与曲线相交于 P 和 Q 之间的第三个点,点 P + Q 是与之具有相同 x 坐标,但 y 坐标相反的点。

图 12-3:在椭圆曲线上加点的一般几何规则
这个几何规则很简单,但它不会直接给出点 R 的坐标。我们使用点 P 的坐标 (x[P], y[P]) 和点 Q 的坐标 (x[Q], y[Q]),通过公式 x[R] = m² – x[P] – x[Q] 和 y[R] = m( x[P] – x[R]) – y[P],计算点 R 的坐标 (x[R], y[R]),其中 m = (y[Q] – y[P]) / (x[Q] – x[P]) 是连接点 P 和 Q 的直线的斜率。
不幸的是,这些公式以及图 12-3 中展示的画线技巧并不总是有效。例如,如果 P = Q,你无法在两点之间画一条直线(只有一条),而且如果 P = –P,这条线不会再次穿过曲线,因此没有与曲线相交的点可以进行镜像反射。我们将在下一节探讨这些情况。
加点与其负点
点 P = (x[P], y[P]) 的负点是点 –P = (x[P], –y[P]),即关于 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 处与曲线相切的线,2P 是这条线与曲线相交点的对称点,如图 12-5 所示。

图 12-5:使用加倍操作在椭圆曲线上加法的几何规则 P + P
用来确定 R = P + P 的坐标 (x[R],y[R]) 的公式与我们用来处理不同的 P 和 Q 的公式略有不同。同样,基本公式是 x[R] = m² – x[P] – x[Q] 和 y[R] = m(x[P] – x[R]) – y[P],但是 m 的值不同;它变为 (3x[P]² + a) / 2y[P],其中 a 是曲线的参数,如 y² = x³ + ax + b 中所示。
乘法
为了通过给定的整数 k 来乘椭圆曲线上的点,我们通过将 P 加到自身 k – 1 次来确定点 kP。换句话说,2P = P + P,3P = P + P + P,以此类推。为了获得 kP 的 x 和 y 坐标,我们反复将 P 加到自身并应用前述的加法法则。
然而,为了高效地计算 kP,使用通过应用加法法则 k – 1 次来加 P 的朴素技巧远非最佳。例如,如果 k 很大(比如 2²⁵⁶ 级别),如在基于椭圆曲线的加密方案中出现的那样,那么计算 k – 1 次加法是完全不可行的。
但有一个技巧:通过采用在“快速幂算法:平方加倍”第 192 页讨论的技巧,可以显著加速计算 x^(e) mod n。例如,为了用三次加法而不是七次计算 8P,你首先计算 P[2] = P + P,然后 P[4] = P[2] + P[2],最后 P[4] + P[4] = 8P。
椭圆曲线群
由于点可以相加,椭圆曲线上的点集形成一个群。根据群的定义(见“什么是群?”第 174 页),如果点 P 和 Q 属于给定的曲线,那么 P + Q 也属于该曲线。
此外,由于加法是结合律的,我们有 (P + Q) + R = P + (Q + R),对于任何点 P、Q 和 R 都成立。在椭圆曲线点的群体中,单位元称为无穷远点,记作 O,使得 P + O = P 对于任何 P 都成立。每个点 P = (x[P] , y[P]) 都有一个逆元素,–P = (x[P] , –y[P]),使得 P + (–P) = O。
在实践中,大多数基于椭圆曲线的加密系统使用的是模一个质数p的x和y坐标(换句话说,使用的是有限域Z[p]中的数)。就像 RSA 的安全性依赖于所用数字的大小一样,基于椭圆曲线的加密系统的安全性也依赖于曲线上的点的数量。但是,我们如何知道椭圆曲线上的点的数量,或者它的基数呢?嗯,这取决于曲线和p的值。
一个经验法则是,曲线上大约有p个点,但你可以使用 Schoof 算法来计算精确的点数,该算法用于计算有限域上的椭圆曲线的点数。你会发现这个算法已经内置在 SageMath 中。例如,列表 12-1 展示了如何使用该算法计算曲线y² = x³ – 4x上Z[191]的点数,具体见图 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。(与数字不同,椭圆曲线的问题作用于点,且使用乘法而不是指数运算。)
所有椭圆曲线密码学都是建立在 ECDLP 问题上的,这个问题就像 DLP 一样,被认为是困难的,并且自 1985 年引入密码学以来经受住了密码分析的考验。ECDLP 与经典的 DLP 之间一个重要的区别是,ECDLP 允许使用更小的数字,同时仍然享有类似的安全级别。
通常,当p是n位时,你会获得大约n/2 位的安全性。例如,取一个模p的椭圆曲线,其中p为 256 位,将提供大约 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 问题,我们需要找到满足以下条件的点:
c[1]P + d[1]Q = c[2]P + d[2]Q
为了找到这些点,我们将Q替换为值kP,得到以下结果:
c[1]P + d[1]kP = (c[1] + d[1]k)P = c[2]P + d[2]kP = (c[2] + d[2]k)P
这告诉我们,当对曲线上的点数取模时,(c[1] + d[1]k) 等于 (c[2] + d[2]k),这并不是什么秘密。
从中我们可以推导出以下内容:

而我们已经找到了k,即 ECDLP 的解。
当然,这只是宏观视角,细节要复杂且更有趣。在实际操作中,椭圆曲线的数字至少扩展到 256 位,这使得通过寻找碰撞来攻击椭圆曲线密码学变得不切实际,因为这需要最多 2¹²⁸次操作(这是找到 256 位数字的碰撞所需的代价,正如你在第六章中学到的)。
椭圆曲线 Diffie–Hellman 密钥协商
回想一下第十一章中提到的经典 Diffie–Hellman (DH) 密钥协商协议,其中两方通过交换非秘密的值来建立共享秘密。给定某个固定数值g,爱丽丝选择一个秘密的随机数a,计算出A = g^(a),然后将A发送给鲍勃,而鲍勃选择一个秘密的随机数b,计算出B = g(*b*),然后将*B*发送给爱丽丝。然后,两方将各自的私钥与对方的公钥结合,得到相同的共享密钥*A*(b) = B^(a) = g^(ab)。
椭圆曲线版本的 DH 与经典 DH 完全相同,只是符号不同。在 ECC 的情况下,对于某个固定点G,爱丽丝选择一个秘密的随机数d[A],计算出P[A] = d[A]G(即点G乘以d[A]),并将P[A]发送给鲍勃。鲍勃选择一个秘密的随机数d[B],计算出点P[B] = d[B]G,并将其发送给爱丽丝。然后,两方计算出相同的共享秘密,d[A]P[B] = d[B]P[A] = d[A]d[B]G。这种方法称为椭圆曲线 Diffie–Hellman,或ECDH。
ECDH 与 ECDLP 问题的关系,就像 DH 与 DLP 问题的关系一样:它在 ECDLP 难解的前提下是安全的。因此,依赖 DLP 的 DH 协议可以改编为使用椭圆曲线,并依赖 ECDLP 作为难度假设。例如,认证的 DH 和 Menezes–Qu–Vanstone (MQV)也将在使用椭圆曲线时保持安全。(事实上,MQV 最初就是定义为在椭圆曲线上工作的。)
使用椭圆曲线签名
用于使用椭圆曲线加密(ECC)签名的标准算法是 ECDSA,即 椭圆曲线数字签名算法。该算法已经在许多应用中取代了 RSA 签名和经典 DSA 签名。例如,它是比特币中唯一使用的签名算法,并且得到了许多 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。接着,验证者将 w 与 h 相乘,按以下公式计算 u:
wh = hk (h + rd) = u
然后,验证者将 w 与 r 相乘来计算 v:
wr = rk (h + rd) = v
给定 u 和 v,验证者按照以下公式计算点 Q:
Q = uG + vP
这里,P 是签名者的公钥,等于 dG,并且验证者只有在 Q 的 x 坐标等于签名中的 r 值时才接受该签名。
这一过程之所以有效,是因为在最后一步,我们通过将公钥 P 替换为其实际值 dG 来计算点 Q:
uG + vdG = (u + vd)G
当我们将 u 和 v 替换为其实际值时,得到以下结果:
u + vd = hk (h + rd) + drk / (h + rd) = (hk + drk) / (h + rd) = k (h + dr) / (h + rd) = k
这告诉我们(u + vd)等于在签名生成过程中选择的值 k,而 uG + vdG 等于点 kG。换句话说,验证算法成功计算了点 kG,这是在签名生成过程中计算出的同一点。一旦验证者确认 kG 的 x 坐标等于接收到的 r,验证完成;否则,签名将被视为无效并被拒绝。
ECDSA 与 RSA 签名
椭圆曲线密码学通常被视为 RSA 的替代方案,用于公钥密码学,但 ECC 和 RSA 没有太多相似之处。RSA 仅用于加密和签名,而 ECC 是一系列算法,可以用于执行加密、生成签名、进行密钥协商,并提供先进的加密功能,如基于身份的加密(这是一种使用从个人标识符(例如电子邮件地址)派生的加密密钥的加密方式)。
在比较 RSA 和 ECC 的签名算法时,请记住,在 RSA 签名中,签名者使用他们的私钥 d 来计算签名,公式为 y = x^(d) mod n,其中 x 是要签名的数据,y 是签名。验证使用公钥 e 来确认 y^(e) mod n 是否等于 x ——这一过程显然比 ECDSA 的过程简单。
RSA 的验证过程通常比 ECC 的签名生成过程更快,因为它使用了一个较小的公钥 e。但 ECC 相对于 RSA 有两个主要优势:更短的签名和更快的签名速度。因为 ECC 使用更短的数字,它生成的签名比 RSA 更短(只有几百位,而不是几千位),如果你需要存储或传输大量签名,这是一个明显的好处。使用 ECDSA 签名的速度也比 RSA 签名更快(尽管验证签名的速度差不多),因为 ECDSA 使用的数字比 RSA 小得多,且安全级别相似。例如,示例 12-2 显示,ECDSA 在签名时大约快 150 倍,在验证时稍微快一些。请注意,ECDSA 签名也比 RSA 签名更短,因为它们是 512 位(两个 256 位的元素),而 RSA 签名是 4096 位。
$ openssl speed ecdsap256 rsa4096
sign verify sign/s verify/s
rsa 4096 bits 0.007267s 0.000116s 137.6 8648.0
sign verify sign/s verify/s
256 bit ecdsa (nistp256) 0.0000s 0.0001s 21074.6 9675.7
示例 12-2:比较 4096 位 RSA 签名和 256 位 ECDSA 签名的速度
对这些不同大小的签名进行性能比较是公平的,因为它们提供了相似的安全级别。然而,在实践中,许多系统使用 2048 位的 RSA 签名,这比 256 位的 ECDSA 安全性低几个数量级。由于其较小的模数大小,2048 位 RSA 在验证时比 256 位 ECDSA 更快,但在签名时仍然较慢,如示例 12-3 所示。
$ openssl speed rsa2048
sign verify sign/s verify/s
rsa 2048 bits 0.000696s 0.000032s 1436.1 30967.1
示例 12-3:2048 位 RSA 签名的速度
结果是,除了签名验证至关重要且你不在乎签名速度的情况(例如,在一个 Windows 可执行应用程序被签名一次然后在所有执行该程序的系统中进行验证时),你应该选择 ECDSA 而非 RSA。
椭圆曲线加密
尽管椭圆曲线更常用于签名,但你仍然可以用它们进行加密。不过,由于可以加密的明文大小的限制,在实际应用中很少有人这么做:与 RSA 在相同安全等级下可加密约 4000 位的明文相比,椭圆曲线加密最多只能加密约 100 位的明文。
用椭圆曲线加密的一种简单方法是使用集成加密方案(IES),这是一种基于 Diffie-Hellman 密钥交换的混合非对称–对称密钥加密算法。本质上,IES 通过生成一个 Diffie-Hellman 密钥对,将私钥与接收者的公钥结合,从共享的秘密中派生出对称密钥,然后使用认证加密算法加密消息。
在椭圆曲线中使用时,IES 依赖于 ECDLP 的难度,并称为椭圆曲线集成加密方案(ECIES)。给定接收者的公钥 P,ECIES 按以下方式加密消息 M:
-
选择一个随机数,d,并计算点 Q = dG,其中基点 G 是一个固定的参数。在这里,(d, Q) 作为一个临时密钥对,仅用于加密 M。
-
通过计算 S = dP 来计算 ECDH 共享密钥。
-
使用密钥派生方案(KDF)从 S 派生出对称密钥 K。
-
使用 K 和对称认证加密算法加密 M,得到密文 C 和认证标签 T。
ECIES 密文由临时公钥 Q,然后是 C 和 T 组成。解密过程非常直接:接收者通过将 R 与其私钥指数相乘来计算 S,然后派生出密钥 K 并解密 C,同时验证 T。
选择曲线
用于评估椭圆曲线安全性的标准包括所用群的阶数(即其点的数量)、加法公式以及其起源。
椭圆曲线有多种类型,但并非所有曲线都同样适合加密用途。在选择时,务必小心选择曲线方程 y² = x³ + ax + b 中的系数 a 和 b;否则,你可能会选择一个不安全的曲线。在实际应用中,你会使用某种事实上的标准曲线进行加密,但了解什么构成安全的曲线将帮助你在多种曲线中做出选择,并更好地理解相关风险。以下是一些需要牢记的要点:
-
群的阶数不应为小数字的乘积;否则,解 ECDLP 问题会变得容易得多。
-
在“添加和乘法点”第 221 页中,你学到,添加点P + Q时,当Q = P时需要使用特定的加法公式。不幸的是,如果将这种情况与一般情况区别对待,可能会泄漏关键信息,尤其是当攻击者能够区分倍加和不同点之间的加法时。一些曲线之所以安全,因为它们对所有点加法使用相同的公式。(当曲线不需要特定的倍加公式时,我们说它符合统一的加法规则。)
-
如果曲线的创作者没有解释a和b的来源,他们可能会被怀疑有所隐瞒,因为我们无法知道他们是否选择了较弱的值,从而可能导致一些尚未发现的加密系统攻击。
让我们回顾一些最常用的曲线,特别是用于签名或 Diffie–Hellman 密钥协商的曲线。
注意
你可以在专门的网站上找到更多关于曲线的标准和细节 safecurves.cr.yp.to/。
NIST 曲线
2000 年,美国国家标准与技术研究院(NIST)在 FIPS 186 文档中对 NIST 曲线进行了标准化,文件标题为“联邦政府使用的推荐椭圆曲线”。五条 NIST 曲线在素数模(如《整数上的椭圆曲线》第 219 页所讨论)下工作,被称为素数曲线。另外十条 NIST 曲线使用二进制多项式,这是一种数学对象,它能使硬件实现更加高效。(我们不会进一步讨论二进制多项式,因为它们很少与椭圆曲线一起使用。)
最常见的 NIST 曲线是素数曲线。在这些曲线中,最常见的之一是 P-256,它在模 256 位数p = 2²⁵⁶ – 2²²⁴ + 2¹⁹² + 2⁹⁶ – 1 的数上工作。P-256 的方程是y² = x³ – 3x + b,其中b是一个 256 位的数字。NIST 还提供 192 位、224 位、384 位和 521 位的素数曲线。
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 为模的数字,这是一个 256 位素数,尽可能接近 2²⁵⁵。b 系数 486662 是满足 Bernstein 安全标准的最小整数。综合这些特性,使得 Curve25519 比 NIST 曲线及其可疑系数更值得信任。
Curve25519 已经被广泛使用:在 Google Chrome、Apple 系统、OpenSSH 和许多其他系统中。然而,由于 Curve25519 不是一个 NIST 标准,一些应用仍坚持使用 NIST 曲线。
注意
要了解 Curve25519 的所有细节和背后的原理,请查看 Daniel J. Bernstein 于 2016 年做的讲座“Curve25519 的前 10 年”,可通过此链接访问 cr.yp.to/talks.html#2016.03.09/。
其他曲线
在我写这篇文章时,大多数加密应用都使用 NIST 曲线或 Curve25519,但仍有其他遗留标准在使用,并且一些新曲线正在标准化委员会中被推广和推动。一些旧的国家标准包括法国的 ANSSI 曲线和德国的 Brainpool 曲线:这两种曲线不支持完整的加法公式,并且使用未知来源的常数。
一些更新的曲线比旧的曲线更高效,并且没有任何怀疑;它们提供了不同的安全级别和各种效率优化。例子包括 Curve41417,它是 Curve25519 的一个变种,使用更大的数字并提供更高的安全性(大约 200 位);Ed448-Goldilocks,它是一个 448 位曲线,首次提出于 2014 年,并被认为是互联网标准;以及 Aranha 等人在《A note on high-security general-purpose elliptic curves》中提出的六条曲线(见 eprint.iacr.org/2013/647/),尽管这些曲线很少被使用。这些曲线的具体细节超出了本书的讨论范围。
事情可能出错的方式
由于椭圆曲线的复杂性和较大的攻击面,它们也有一些缺点。它们使用比经典 Diffie–Hellman 更多的参数,这带来了更大的攻击面,更多的出错和滥用的机会——以及可能影响其实现的软件漏洞。由于其运算中使用了大量的数字,椭圆曲线软件也可能容易受到侧信道攻击。如果计算的速度依赖于输入,攻击者可能能够获得关于加密所用公式的信息。
在接下来的章节中,我将讨论即使实现是安全的,椭圆曲线仍然可能出现的两种漏洞。这些是协议漏洞,而不是实现漏洞。
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 可以通过以下计算轻松恢复:
(ks[1] − h[1]) / r = ((h[1] + rd) − h[1]) / r = rd / r = d
与 RSA 签名不同,如果使用弱的伪随机数生成器(PRNG),RSA 签名不会允许恢复密钥,但非随机数的使用可能导致 ECDSA 的 k 可以被恢复,就像 2010 年 PlayStation 3 游戏机遭遇的攻击那样,这次攻击由 fail0verflow 团队在德国柏林举办的第 27 届 Chaos Communication Congress 上展示。
使用另一条曲线破解 ECDH
如果你没有验证输入点,ECDH 可以被优雅地破解。主要原因是,给出点 P + Q 和坐标的公式从来不涉及曲线的 b 系数;它们仅依赖于 P 和 Q 的坐标以及 a 系数(当对一个点进行加倍时)。不幸的是,结果就是,当你加两个点时,你永远不能确定自己正在使用正确的曲线,因为你实际上可能在使用一个不同 b 系数的曲线。也就是说,你可以像下面的场景所描述的那样,通过所谓的 无效曲线攻击 来破解 ECDH。
假设 Alice 和 Bob 正在运行 ECDH,并且已经就一条曲线和一个基点 G 达成一致。Bob 将他的公钥 d[B]G 发送给 Alice。Alice 并没有在商定的曲线上发送公钥 d[A]G,而是发送了一个不同曲线上的点,无论是故意还是无意。遗憾的是,这条新曲线较弱,允许 Alice 选择一个点 P,使得解决 ECDLP 变得容易。她选择了一个低阶的点,对于这个点,存在一个相对较小的 k,使得 kP = O。
现在,Bob 认为自己拥有一个合法的公钥,他计算了自己认为的共享秘密 d[B]P,对其进行哈希处理,并使用得到的密钥加密发送给 Alice 的数据。问题在于,当 Bob 计算 d[B]P 时,他在不知情的情况下实际上是在较弱的曲线上进行计算。因此,由于 P 是从较大点群中的一个小子群中选择的,结果 d[B]P 也将属于这个小子群,这使得攻击者如果知道 P 的阶,就能有效地确定共享秘密 d[B]P。
防止这种情况的一种方法是确保点P和Q属于正确的曲线,通过确保它们的坐标满足曲线方程来实现。这样可以防止这种攻击,确保你只能在安全曲线上进行操作。
2015 年发现某些 TLS 协议实现存在无效曲线攻击,该协议使用 ECDH 来协商会话密钥。(详情请参阅 Jager、Schwenk 和 Somorovsky 的论文《TLS-ECDH 上的实用无效曲线攻击》)。
进一步阅读
椭圆曲线密码学是一个迷人且复杂的主题,涉及大量数学内容。我没有讨论一些重要的概念,如点的阶、曲线的余因子、射影坐标、扭曲点以及解决 ECDLP 问题的方法。如果你对数学有兴趣,你可以在 Cohen 和 Frey 编著的《椭圆曲线与超椭圆曲线密码学手册》(Chapman and Hall/CRC, 2005)中找到有关这些和其他相关主题的信息。2013 年由 Bos、Halderman、Heninger、Moore、Naehrig 和 Wustrow 编写的《椭圆曲线密码学实践中的应用》调查报告,也提供了一个很好的插图介绍,包含实际例子(eprint.iacr.org/2013/734/)。
第十四章:TLS

传输层安全性(TLS)协议,也叫做安全套接字层(SSL),这是其前身的名称,是互联网安全的支柱。TLS 保护服务器和客户端之间的连接,无论是网站与访客之间的连接、电子邮件服务器、移动应用与其服务器之间的连接,还是视频游戏服务器与玩家之间的连接。如果没有 TLS,就不会有安全的在线商务、安全的在线银行,或者说安全的任何在线事务。
TLS 是与应用无关的;它不关心加密内容的类型。这意味着你可以将其用于依赖 HTTP 协议的基于 Web 的应用程序,也可以用于任何需要客户端计算机或设备与远程服务器建立连接的系统。例如,TLS 广泛用于所谓的物联网(IoT)应用中的机器间通信。
本章为你提供了 TLS 的简要概览。如你所见,随着时间的推移,TLS 变得越来越复杂。不幸的是,复杂性和臃肿带来了多个漏洞,且其杂乱的实现中发现的错误屡次登上头条——比如 Heartbleed、BEAST、CRIME 和 POODLE,所有这些漏洞影响了数百万台 Web 服务器。
2013 年,工程师们厌倦了修复 TLS 中新出现的加密漏洞,于是对其进行了全面改革,并开始着手开发 TLS 1.3。如你将在本章中了解到的那样,TLS 1.3 摒弃了不必要和不安全的特性,并用先进的密码算法替代了旧的算法。结果是一个更简洁、更快速且更安全的协议。
但在我们探讨 TLS 1.3 如何工作之前,先让我们回顾一下 TLS 最初旨在解决的问题,以及它存在的理由。
目标应用和需求
TLS 最著名的是作为 HTTPS 网站中的S,以及浏览器地址栏中显示的锁形图标,表示该页面是安全的。创建 TLS 的主要驱动力是通过加密网站连接来保护信用卡号码、用户凭证和其他敏感信息,从而在电子商务或电子银行等应用程序中实现安全浏览。
TLS 还通过在客户端和服务器之间建立安全通道来帮助保护基于互联网的通信,确保传输的数据是机密的、经过身份验证的且未被篡改。
TLS 的一个安全目标是防止中间人攻击,其中攻击者拦截传输方的加密流量,解密流量以捕获明文内容,然后重新加密并发送给接收方。TLS 通过使用证书和受信任的证书颁发机构来验证服务器(并可选择验证客户端),从而防止中间人攻击,我们将在第 238 页的“证书和证书颁发机构”部分中详细讨论。
为了确保广泛采用,TLS 需要满足另外四个要求:它需要高效、互操作、可扩展且多功能。
对于 TLS,效率意味着尽量减少与未加密连接相比的性能损失。这对服务器(减少服务提供商硬件成本)和客户端(避免明显的延迟或移动设备电池寿命的减少)都有好处。该协议需要具有互操作性,以便能够在任何硬件和操作系统上工作。它还需要具有可扩展性,以支持额外的功能或算法。而且它必须是多功能的——也就是说,不绑定于特定的应用程序(这与传输控制协议类似,后者不关心其上方使用的应用协议)。
TLS 协议套件
为了保护客户端与服务器之间的通信,TLS 由多个版本的多个协议组成,这些协议共同构成了 TLS 协议 套件。尽管 TLS 代表 传输层安全性,它实际上并不是一种传输协议。TLS 通常位于传输协议 TCP 和应用层协议(如 HTTP 或 SMTP)之间,用于保护通过 TCP 连接传输的数据。
TLS 还可以通过 用户数据报协议(UDP) 传输协议工作,UDP 被用于“无连接”的传输,如语音或视频流量。然而,与 TCP 不同,UDP 不保证数据的传输或正确的包排序。因此,UDP 版本的 TLS 略有不同,称为 DTLS(数据报传输层安全协议)。关于 TCP 和 UDP 的更多内容,请参阅 Charles Kozierok 的 The TCP/IP Guide(No Starch Press,2005 年)。
TLS 和 SSL 协议族:简史
TLS 的诞生可追溯到 1995 年,当时 Netscape 浏览器的开发商 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 的密钥协商协议。它常常被误认为是“整个”TLS 协议,但记录协议和握手协议是无法分开的。
握手由客户端发起,用以与服务器建立安全连接。客户端发送一条初始消息,称为 ClientHello,其中包含它希望使用的加密算法参数。服务器检查此消息及其参数后,回应一条消息,称为 ServerHello。一旦客户端和服务器处理完对方的消息,它们就准备好通过握手协议建立的会话密钥来交换加密数据,如你将在“TLS 握手协议”第 241 页中看到的那样。
证书与证书颁发机构
TLS 握手中最关键的步骤,也是 TLS 安全性的核心,是证书验证步骤,其中服务器使用证书来向客户端验证其身份。
证书本质上是一个公钥,并附带该公钥的签名和相关信息(包括域名)。例如,当连接到 www.google.com/ 时,浏览器会从某个网络主机接收到证书,并随后验证该证书的签名,签名内容可能类似于“我就是 google.com,我的公钥是[key]”。如果签名被验证通过,则该证书(及其公钥)被认为是可信任的,浏览器可以继续建立连接。(关于签名的详细信息,请参见第十章和第十二章)
浏览器如何知道验证签名所需的公钥?这就是证书颁发机构(CA)概念的作用。CA 本质上是硬编码在你的浏览器或操作系统中的公钥。该公钥的私钥(即其签名能力)属于一个受信任的组织,该组织确保其颁发的证书中的公钥属于声明拥有该公钥的网站或实体。也就是说,CA 充当受信任的第三方。没有 CA,就无法验证由 google.com 提供的公钥是否属于 Google,而不是属于正在进行中间人攻击的窃听者。
例如,清单 13-1 中显示的命令展示了当我们使用 OpenSSL 命令行工具在端口 443(TLS 基于 HTTP 连接的网络端口,即 HTTPS)上启动与www.google.com的 TLS 连接时发生的情况:
$ openssl s_client -connect www.google.com:443
CONNECTED(00000003)
--snip--
---
Certificate chain
❶ 0 s:/C=US/ST=California/L=Mountain View/O=Google Inc/CN=www.google.com
i:/C=US/O=Google Inc/CN=Google Internet Authority G2
❷ 1 s:/C=US/O=Google Inc/CN=Google Internet Authority G2
i:/C=US/O=GeoTrust Inc./CN=GeoTrust Global CA
❸ 2 s:/C=US/O=GeoTrust Inc./CN=GeoTrust Global CA
i:/C=US/O=Equifax/OU=Equifax Secure Certificate Authority
---
Server certificate
-----BEGIN CERTIFICATE-----
MIIEgDCCA2igAwIBAgIISCr6QCbz5rowDQYJKoZIhvcNAQELBQAwSTELMAkGA1UE
BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
--snip--
cb9reU8in8yCaH8dtzrFyUracpMureWnBeajOYXRPTdCFccejAh/xyH5SKDOOZ4v
3TP9GBtClAH1mSXoPhX73dp7jipZqgbY4kiEDNx+hformTUFBDHD0eO/s2nqwuWL
pBH6XQ==
-----END CERTIFICATE-----
subject=/C=US/ST=California/L=Mountain View/O=Google Inc/CN=www.google.com
issuer=/C=US/O=Google Inc/CN=Google Internet Authority G2
--snip--
清单 13-1:与 www.google.com 建立 TLS 连接并接收证书以验证连接
我已将输出结果裁剪,只显示有趣的部分,即证书。请注意,在第一个证书(以 BEGIN CERTIFICATE 标签开头)之前,是证书链的描述,其中以 s: 开头的行描述了主题名称,以 i: 开头的行描述了签名的颁发者。在这里,证书 0 是从google.com接收到的 ❶,证书 1 ❷属于签署证书 0 的实体,证书 2 ❸属于签署证书 1 的实体。颁发证书 2(GeoTrust)的组织授权 Google Internet Authority 为域名 www.google.com 颁发证书(证书 1),从而将信任转移给了 Google Internet Authority。
显然,这些 CA 组织必须是可信的,并且只能向可信的实体颁发证书,它们还必须保护其私钥,以防止攻击者代表它们颁发证书(例如,冒充合法的 google.com 服务器)。
要查看证书的内容,我们在 Linux 终端中输入清单 13-2 中显示的命令,然后粘贴在清单 13-1 中显示的第一个证书。
$ openssl x509 –text –noout
-----BEGIN CERTIFICATE-----
--snip--
-----END CERTIFICATE-----
Certificate:
Data:
Version: 3 (0x2)
Serial Number: 5200243873191028410 (0x482afa4026f3e6ba)
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=US, O=Google Inc, CN=Google Internet Authority G2
Validity
Not Before: Dec 15 14:07:56 2016 GMT
Not After : Mar 9 13:35:00 2017 GMT
Subject: C=US, ST=California, L=Mountain View, O=Google Inc, CN=www.google.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:bc:bc:b2:f3:1a:16:3b:c6:f6:9d:28:e1:ef:8e:
92:9b:13:b2:ae:7b:50:8f:f0:b4:e0:36:8d:09:00:
--snip--
8f:e6:96:fe:41:41:85:9d:a9:10:9a:09:6e:fc:bd:
43:fa:4d:c6:a3:55:9a:9e:07:8b:f9:b1:1e:ce:d1:
22:49
Exponent: 65537 (0x10001)
--snip--
Signature Algorithm: sha256WithRSAEncryption
94:cd:66:55:83:f1:16:7d:46:d8:66:21:06:ec:c6:9d:7c:1c:
2b:c1:f6:4f:b7:3e:cd:01:ad:69:bd:a1:81:6a:7c:96:f5:9c:
--snip--
85:fa:2b:99:35:05:04:31:c3:d1:e3:bf:b3:69:ea:c2:e5:8b:
a4:11:fa:5d
清单 13-2:解码从 www.google.com 接收到的证书
你在 Listing 13-2 中看到的是命令 openssl x509 解码一个证书,该证书最初是作为一块 base64 编码的数据提供的。由于 OpenSSL 知道这块数据的结构,它可以告诉我们证书内部的内容,包括序列号和版本信息、标识信息、有效期(Not Before 和 Not After 行)、公钥(这里是一个 RSA 模数及其公钥指数)以及前述信息的签名。
尽管安全专家和密码学家常常声称整个证书系统在设计上存在漏洞,但它仍然是我们拥有的最佳解决方案之一,例如,SSH 采用的首次使用信任(TOFU)策略就是一个例子。
记录协议(Record Protocol)
所有通过 TLS 1.3 通信交换的数据都是作为 TLS 记录 传输的,TLS 使用的数据包。TLS 记录协议(记录层)本质上是一种传输协议,与传输数据的含义无关;这使得 TLS 适用于任何应用。
TLS 记录协议首先用于承载握手过程中交换的数据。一旦握手完成,双方共享一个秘密密钥,应用数据会被分割成多个块,作为 TLS 记录的一部分进行传输。
TLS 记录的结构
一个 TLS 记录是最多 16 千字节的数据块,其结构如下:
-
第一个字节表示传输数据的类型,设置为 22 表示握手数据,23 表示加密数据,21 表示警报。在 TLS 1.3 规范中,这个值被称为 ContentType。
-
第二个和第三个字节分别被设置为 3 和 1。这些字节由于历史原因是固定的,并且不特定于 TLS 1.3 版本。在规范中,这个 2 字节的值被称为 ProtocolVersion。
-
第四个和第五个字节编码了传输数据的长度,表示为一个 16 位整数,最大为 2¹⁴ 字节(16KB)。
-
剩下的字节是要传输的数据(也称为 负载),其长度等于记录的第四和第五个字节编码的值。
注意
TLS 记录有一个相对简单的结构。正如我们所见,TLS 记录的头部仅包含三个字段。作为对比,IPv4 包在其负载之前包含 14 个字段,TCP 段则包含 13 个字段。
当 TLS 1.3 记录的第一个字节(ContentType)被设置为 23 时,它的负载会使用经过认证的密码算法进行加密和认证。负载由密文和一个认证标签组成,接收方会解密该负载。那么,接收方如何知道使用哪个密码和密钥来解密呢?这就是 TLS 的魔力:如果你收到一个加密的 TLS 记录,你已经知道使用哪个密码和密钥,因为它们在 TLS 握手协议执行时已经建立。
随机数(Nonces)
与许多其他协议(例如 IPsec 的封装安全有效载荷(ESP))不同,TLS 记录并没有指定经过身份验证的密码算法所使用的随机数。
用于加密和解密 TLS 记录的随机数是从 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 规范中描述的握手过程中文件交换的方式。如你所见,在 TLS 1.3 握手中,客户端向服务器发送一条消息,内容为:“我想与你建立一个 TLS 连接。这里是我支持的加密 TLS 记录的密码套件,还有一个 Diffie–Hellman 公钥。”该公钥必须专门为此次 TLS 会话生成,且客户端保留相关的私钥。客户端发送的消息还包括一个 32 字节的随机值和可选信息(如附加参数等)。这条消息称为ClientHello,并且在以字节序列传输时必须遵循 TLS 1.3 规范中定义的特定格式。

图 13-1:连接到 HTTPS 网站时的 TLS 1.3 握手过程
但请注意,规范还描述了数据应以何种格式发送,以确保各个实现之间的互操作性,从而保证任何实现 TLS 1.3 的服务器都能够读取任何实现 TLS 1.3 的客户端发送的 TLS 1.3 数据,即使客户端可能使用不同的库或编程语言。
服务器接收到 ClientHello 消息后,验证消息格式是否正确,并以一条名为 ServerHello 的消息作出响应。ServerHello 消息包含大量信息:它包含将用于加密 TLS 记录的密码套件,一个 Diffie–Hellman 公钥,一个 32 字节的随机值(详见“降级保护”第 244 页),一个证书,一个对 ClientHello 和 ServerHello 消息中所有先前信息的签名(使用与证书公钥相关联的私钥计算),以及该信息和签名的 MAC。MAC 是使用从 Diffie–Hellman 共享密钥派生的对称密钥计算的,该密钥由服务器从其 Diffie–Hellman 私钥和客户端的公钥计算得出。
当客户端接收到 ServerHello 消息时,它会验证证书的有效性,验证签名,计算共享的 Diffie–Hellman 密钥并从中派生出对称密钥,同时验证服务器发送的 MAC。一旦所有内容都被验证,客户端就准备好向服务器发送加密消息。
然而,请注意,TLS 1.3 支持许多选项和扩展,因此它的行为可能与这里描述的不同(并且与图 13-1 所示的不同)。例如,你可以配置 TLS 1.3 握手以要求客户端证书,从而使服务器验证客户端的身份。TLS 1.3 还支持使用预共享密钥的握手。
注意
TLS 1.3 支持许多选项和扩展,因此它的行为可能与这里描述的有所不同(并且在 图 13-1 中有所展示)。例如,你可以配置 TLS 1.3 握手要求客户端证书,以便服务器验证客户端的身份。TLS 1.3 还支持使用预共享密钥的握手。
让我们来看一下实际应用。假设你已经部署了 TLS 1.3 来提供对网站 www.nostarch.com/ 的安全访问。当你将浏览器(客户端)指向这个网站时,浏览器向网站的服务器发送一个包含支持的密码套件的 ClientHello 消息。网站则用一个 ServerHello 消息和一个包含与域名 www.nostarch.com 相关的公钥的证书作出回应。客户端使用浏览器中嵌入的证书颁发机构之一验证该证书的有效性(收到的证书应该由一个受信任的证书颁发机构签名,该机构的证书应该包含在浏览器的证书库中,以便进行验证)。一旦所有检查通过,浏览器就会从 www.nostarch.com 服务器请求该网站的初始页面。
在成功完成 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 中定义的五个群体:2048、3072、4096、6144 和 8192 位的群体。
2048 位组可能是 TLS 1.3 的最弱环节。尽管其他选项至少提供 128 位安全性,但 2048 位 Diffie-Hellman 被认为提供不到 100 位的安全性。因此,支持 2048 位组可以被视为与 TLS 1.3 的其他设计选择不一致。
TLS 1.3 相较于 TLS 1.2 的改进
TLS 1.3 与其前身有很大不同。首先,它去除了像 MD5、SHA-1、RC4 以及 AES 在 CBC 模式下等弱算法。而且,虽然 TLS 1.2 通常使用加密算法和 MAC(例如 HMAC-SHA-1)的组合来保护记录,采用“先 MAC 再加密”的结构,TLS 1.3 只支持更高效且更安全的认证加密算法。TLS 1.3 还去掉了椭圆曲线点编码协商,并为每条曲线定义了单一的点格式。
TLS 1.3 的主要开发目标之一是去除 TLS 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 连接,32 字节中的前八个字节将被设置为 44 4F 57 4E 47 52 44 01,而如果请求的是 TLS 1.1 连接,则这些字节设置为 44 4F 57 4E 47 52 44 00。但是,如果客户端请求一个 TLS 1.3 连接,这前八个字节应该是随机的。例如,如果客户端发送一个 ClientHello 请求 TLS 1.3 连接,但网络中的攻击者将其修改为请求 TLS 1.1 连接,当客户端收到具有错误模式的 ServerHello 时,它将知道其 ClientHello 消息已被篡改。(攻击者无法随意修改服务器的 32 字节随机值,因为该值是通过加密签名的。)
单次往返握手
在典型的 TLS 1.2 握手中,客户端向服务器发送一些数据,等待响应,然后再发送更多数据并等待服务器的响应,然后才会发送加密消息。延迟是两个往返时间(RTT)。相比之下,TLS 1.3 的握手只需要一个往返时间,如 图 13-1 所示。节省的时间可能达到几百毫秒。听起来可能不多,但考虑到流行服务的服务器每秒处理成千上万的连接时,这个时间差异实际上非常重要。
会话恢复
TLS 1.3 比 1.2 更快,但通过完全消除加密会话之前的往返过程,可以使其更快(大约几百毫秒)。诀窍是使用 会话恢复,这是一种利用客户端和服务器在先前会话中交换的预共享密钥来启动新会话的方法。会话恢复带来了两个主要好处:客户端可以立即开始加密,而且在后续会话中无需使用证书。
图 13-2 显示了会话恢复是如何工作的。首先,客户端发送一个 ClientHello 消息,其中包括与服务器已共享的密钥标识符(称为 PSK,即 预共享密钥),以及一个新的 DH 公钥。客户端还可以在此消息中包含加密数据(这些数据称为 0-RTT 数据)。当服务器响应 ClientHello 消息时,它提供一个用于数据交换的 MAC。客户端验证该 MAC,并知道它与之前相同的服务器进行通信,从而使证书验证变得有些多余。客户端和服务器进行与正常握手一样的 Diffie–Hellman 密钥协商,后续的消息使用依赖于 PSK 和新计算的 Diffie–Hellman 共享密钥的加密密钥进行加密。

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

图 13-3:OpenSSL 中 TLS 实现的 Heartbleed 漏洞
问题在于服务器没有确认长度是否正确,而是会尝试读取客户端告诉它的字符数。因此,如果客户端提供的长度超过了字符串的实际长度,服务器会从内存中读取过多的数据,并将其返回给客户端,连同任何可能包含敏感信息的额外数据,例如私钥或会话 cookie。
你听到 Heartbleed 漏洞的消息时,应该不会感到惊讶。为了避免未来类似的漏洞,OpenSSL 和其他主要的 TLS 实现现在进行严格的代码审查,并使用自动化工具,如模糊测试工具(fuzzers),来识别潜在问题。
深入阅读
正如我在开头所提到的,本章并不是关于 TLS 的全面指南,你可能想要更深入地了解 TLS 1.3。首先,完整的 TLS 1.3 规范涵盖了协议的所有内容(尽管不一定包括其背后的理论依据)。你可以在 TLS 工作组(TLSWG)的主页上找到该规范,网址是:tlswg.github.io/。
此外,让我提到两个重要的 TLS 倡议:
-
SSL Labs TLS 测试(
www.ssllabs.com/ssltest/)是 Qualys 提供的一个免费服务,允许你测试浏览器或服务器的 TLS 配置,提供安全评分和改进建议。如果你设置了自己的 TLS 服务器,可以使用这个测试来确保一切安全,并获得“A”评级。 -
Let’s Encrypt(
letsencrypt.org/)是一个非盈利组织,提供“自动化”在你的 HTTP 服务器上部署 TLS 的服务。它包括自动生成证书和配置 TLS 服务器的功能,并且支持所有常见的 Web 服务器和操作系统。
第十五章:量子与后量子

之前的章节主要关注当今的密码学,但在本章中,我将探讨密码学的未来,时间跨度大约是一个世纪或更长——一个量子计算机存在的时代。量子计算机是利用量子物理现象来运行不同类型算法的计算机,区别于我们习惯的算法。量子计算机目前还不存在,看起来也很难建造,但如果有一天它们存在,那么它们将有潜力突破 RSA、Diffie–Hellman 和椭圆曲线密码学——也就是本文所提到的所有已经部署或标准化的公钥加密算法。
为了应对量子计算机带来的风险,密码学研究人员开发了替代性的公钥加密算法,这些算法被称为后量子算法,能够抵抗量子计算机的攻击。2015 年,NSA(美国国家安全局)呼吁转向量子抗性算法,这些算法旨在即使在面对量子计算机时也能保持安全,2017 年,美国标准化机构 NIST 开始了一个过程,最终将标准化后量子算法。
本章将为您提供量子计算机背后的原理的非技术性概述,并简要介绍后量子算法。虽然涉及一些数学内容,但仅限于基础算术和线性代数,因此不要被这些不常见的符号吓到。
量子计算机的工作原理
量子计算是一种利用量子物理进行不同计算的计算模型,它能够完成经典计算机无法做到的事情,比如高效破解 RSA 和椭圆曲线密码学。但量子计算机并不是一种超快的普通计算机。事实上,量子计算机无法解决任何经典计算机无法解决的问题,比如暴力破解或NP-完全问题。
量子计算机基于量子力学,量子力学是研究亚原子粒子行为的物理学分支,而这些粒子的行为是真正随机的。与操作 0 或 1 的经典计算机不同,量子计算机基于量子比特(或量子位),它们可以同时是 0 和 1——这种状态叫做叠加。物理学家发现,在这个微观世界中,电子和光子等粒子表现出一种极其反直觉的方式:在你观察电子之前,它并不位于空间的某个确定位置,而是同时位于多个位置(也就是处于叠加状态)。但是,一旦你观察它——这在量子物理中叫做测量——它就会停留在一个固定的、随机的位置,并不再处于叠加状态。这种量子魔力使得量子计算机能够创建量子位。
但量子计算机之所以能够工作,是因为一个更为疯狂的现象,称为纠缠:两个粒子可以以一种方式连接(纠缠),使得观察其中一个粒子的值能够得出另一个粒子的值,即使这两个粒子相距遥远(可能相隔数公里甚至光年)。这种行为通过爱因斯坦–波多尔斯基–罗森(EPR) 悖论得以说明,这也是阿尔伯特·爱因斯坦最初否定量子力学的原因。(详见 plato.stanford.edu/entries/qt-epr/,了解其深入解释。)
为了最好地解释量子计算机的工作原理,我们应当区分实际的量子计算机(由量子比特组成的硬件)和量子算法(在其上运行的软件,由量子门组成)。接下来的两部分将讨论这两个概念。
量子比特
量子比特(qubits)或其组合由称为幅度的数字来描述,这些幅度类似于概率,但并不完全是概率。概率是介于 0 和 1 之间的数字,而幅度是一个形如a + b × i(或简单地说是a + bi)的复数,其中a和b是实数,i是虚数单位。数字i用于形成虚数,其形式为bi,其中b为实数。当i与实数相乘时,我们得到另一个虚数,而当i与自己相乘时,结果为-1;也就是说i² = -1。
与实数不同,实数可以看作属于一条直线(见图 14-1),复数可以看作属于一个平面(一个二维空间),如图 14-2 所示。这里,图中的 x 轴对应于a + bi中的a,y 轴对应于b,虚线对应于每个数字的实部和虚部。例如,从点 3 + 2i垂直向下到 3 的虚线长为 2(即虚部 2i中的 2)。

图 14-1:实数作为无限直线上的点的展示

图 14-2:复数作为二维空间中的点的展示
如你在图 14-2 中所见,你可以利用毕达哥拉斯定理计算从原点(0)到点a + bi的线段长度,方法是将这条线看作一个三角形的对角线。这条对角线的长度等于该点坐标平方和的平方根,即√(a² + b²),我们称其为复数a + bi的模。我们用|a + bi|表示模,并可以将其作为复数的长度。
在量子计算机中,寄存器由 1 个或多个量子比特组成,这些量子比特处于由一组这样的复数组成的叠加状态中。但正如我们将看到的,这些复数——幅度——不能是任意的数字。
单个量子比特的幅度
一个单独的量子比特由两个振幅组成,分别称为 α(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/2 的机会看到 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)。
量子比特组的振幅
我们已经探讨了单个量子比特,但如何理解多个量子比特呢?例如,一个量子字节可以由 8 个量子比特组成,当这 8 个量子比特的量子态相互联系时(我们称这些量子比特是纠缠的,这是一种复杂的物理现象)。这样的量子字节可以如下描述,其中α是与这 8 个量子比特的 256 个可能值对应的幅度:

注意,我们必须有|α[0]|² + |α[1]|² + … + |α[255]|² = 1,确保所有概率的总和为 1。
我们的 8 个量子比特可以视为一组 2⁸ = 256 个幅度,因为它有 256 种可能的配置,每种配置都有其对应的幅度。然而,在物理现实中,你只有 8 个物理对象,而不是 256 个。256 个幅度是这 8 个量子比特的一个隐式特性;这些 256 个数字中的每一个可以取任意数量的不同值。泛化来说,一组n个量子比特由一组 2^(n)个复数组成,这个数字随着量子比特数量的增加而指数级增长。
这种对大量高精度复数的编码是经典计算机无法模拟量子计算机的核心原因:为了做到这一点,经典计算机需要一个无法估量的巨大内存量(大小大约为 2^(n)),来存储仅由n个量子比特所包含的相同信息。
量子门
幅度和量子门的概念是量子计算的独特之处。与经典计算机使用寄存器、内存和微处理器来执行一系列数据指令不同,量子计算机通过应用一系列量子门反向转换一组量子比特,然后测量一个或多个量子比特的值。量子计算机承诺提供更强的计算能力,因为仅用n个量子比特,它们就可以处理 2^(n)个数字(量子比特的幅度)。这一特性具有深远的意义。
从数学角度来看,量子算法本质上是一个量子门的电路,它在最终测量之前将一组复数(幅度)进行转换,在最终测量中会观察到一个或多个量子比特的值(见图 14-3)。你也会看到量子算法被称为量子门阵列或量子电路。

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

该矩阵–向量乘法的结果是另一个包含两个元素的列向量,其中顶部的值等于I矩阵的第一行与输入向量的点积(即第一元素 1 与i/√2 的乘积加上第二元素 0 与–1/√2 的乘积),底部值同理。
注意
实际上,量子计算机不会显式计算矩阵–向量乘法,因为矩阵会非常大。(这也是为什么量子计算不能被经典计算机模拟的原因。)相反,量子计算机会通过物理转换来变换量子比特,物理转换等价于矩阵乘法。困惑吗?理查德·费曼曾经说过:“如果你对量子力学不完全困惑,那你就不理解它。”
哈达玛量子门
到目前为止,我们看到的唯一量子门是单位矩阵I,它几乎没什么用,因为它不做任何操作,保持量子比特不变。现在我们将看到一个最有用的量子门,叫做哈达玛门,通常表示为H。哈达玛门的定义如下(请注意右下角的负值):

让我们看看如果我们将这个门应用于量子比特|
〉 = (1/√2, 1/√2)时会发生什么:

通过将哈达玛门H应用于|
〉,我们得到量子比特|0〉,其值|0〉的振幅为 1,|1〉的振幅为 0。这告诉我们该量子比特将表现出确定性:也就是说,如果你观察这个量子比特,你总是会看到 0,而不会看到 1。换句话说,我们已经失去了初始量子比特|
〉的随机性。
如果我们再次对量子比特|0〉应用哈达玛门,会发生什么?

这将我们带回到量子比特|
〉和一个随机化的状态。实际上,哈达玛门常用于量子算法中,用来将一个确定性状态转化为均匀随机的状态。
并非所有矩阵都是量子门
尽管量子门可以看作是矩阵乘法,但并不是所有的矩阵都对应于量子门。回想一下,量子比特由复数α和β组成,并且量子比特的振幅满足条件|α|² + |β|² = 1。如果在将量子比特与矩阵相乘后,我们得到的两个振幅不满足这个条件,那么结果就不能是量子比特。量子门只能对应于那些保持|α|² + |β|² = 1 性质的矩阵,这样的矩阵被称为单位矩阵。
单位矩阵(以及量子门的定义)是可逆的,这意味着给定一个操作的结果,你可以通过应用逆矩阵计算回原始的量子比特。这也是为什么量子计算被称为一种可逆计算的原因。
量子加速
量子加速指的是一个问题能通过量子计算机比经典计算机更快地解决。例如,在经典计算机上查找一个无序列表中的 n 个项目时,平均需要 n/2 次操作,因为你需要查看列表中的每一项,直到找到你要找的项。(平均来说,你会在搜索列表一半时找到目标项。)没有任何经典算法能够比 n/2 更快。然而,量子算法能够在大约 √n 次操作中完成搜索,这比 n/2 要小得多。例如,如果 n 为 1000000,则 n/2 为 500000,而 √n 为 1000。
我们试图通过 时间复杂度 来量化量子算法和经典算法之间的差异,时间复杂度用 O() 符号表示。在前面的例子中,量子算法的时间复杂度是 O(√n),而经典算法的时间复杂度则不可能快于 O(n)。由于这里时间复杂度的差异来自于平方指数,我们称这种加速为 二次 加速。虽然这种加速可能带来一定的差异,但还有更强大的加速方式。
指数加速与西蒙问题
指数加速是量子计算的圣杯。当在经典计算机上需要指数时间(如 O(2^(n))) 才能完成的任务,在量子计算机上却能以多项式复杂度完成——即以 O(n^(k)) 的时间复杂度完成,其中 k 为某个固定常数。这种指数加速能将一个几乎不可能完成的任务转变为可行的任务。(回忆一下第九章,密码学家和复杂性理论家将指数时间与不可能的任务相关联,而将多项式时间与实际可行的任务相关联。)
指数加速的代表性问题是 西蒙问题。在这个计算问题中,一个函数 f() 将 n 位的字符串转换为 n 位的字符串,使得 f() 的输出看起来是随机的,除了有一个值 m,使得任何满足 f(x) = f(y) 的两个值 x 和 y,都有 y = x ⊕ m。解决这个问题的方法是找到 m。
使用经典算法解决西蒙问题的路线归结为找到一个碰撞,这大约需要 2^(n/2) 次查询 f()。然而,量子算法(如图 14-4 所示)能够在大约 n 次查询内解决西蒙问题,且其极高效的时间复杂度为 O(n)。

图 14-4:解决西蒙问题的量子算法电路
如你在图 14-4 中看到的那样,你将 2n个量子比特初始化为|0〉,对前n个量子比特应用 Hadamard 门(H),然后对两组n量子比特应用Qf门。给定两个n量子比特组x和y,Qf门将量子态|x〉|y〉转换为|x〉|f(x) ⊕ y〉。也就是说,它对量子态f()进行可逆计算,因为你可以通过计算f(x)并与f(x*) ⊕ y进行异或运算来从新状态回到旧状态。(不幸的是,解释为什么这一切有效超出了本书的范围。)
对 Simon 问题的指数加速只能在非常特定的情况下对称加密算法起作用,但在接下来的部分,你将看到量子计算的真正“杀手级”加密应用。
Shor 算法的威胁
1995 年,AT&T 的研究员 Peter Shor 发表了一篇开创性的文章,题为“在量子计算机上进行素因数分解和离散对数的多项式时间算法”。Shor 的算法是一种量子算法,在解决因式分解、离散对数(DLP)和椭圆曲线离散对数(ECDLP)问题时能够实现指数级的加速。你无法使用经典计算机解决这些问题,但你可以用量子计算机来解决。这意味着你可以使用量子计算机解决任何依赖这些问题的密码算法,包括 RSA、Diffie-Hellman、椭圆曲线密码学以及所有当前部署的公钥密码学机制。换句话说,你可以将 RSA 或椭圆曲线密码学的安全性降低到凯撒密码的水平。(Shor 也可以将他的文章标题定为“在量子计算机上破解所有公钥密码学”)Shor 的算法被著名的复杂性理论学家 Scott Aaronson 称为“20 世纪末期的主要科学成就之一”。
Shor 的算法实际上解决了一类比因式分解和离散对数更为广泛的问题。具体来说,如果一个函数f()是周期性的——即,如果存在一个ω(周期),使得对于任何x都有f(x* + ω) = f(x),那么 Shor 的算法将有效地找到ω。(这看起来与前面讨论的 Simon 问题非常相似,事实上 Simon 的算法是 Shor 算法的一个重要灵感来源。)Shor 算法高效计算函数周期的能力对密码学家来说非常重要,因为这种能力可以用于攻击公钥密码学,正如我接下来会讨论的那样。
讨论 Shor 算法如何实现其加速的细节对于本书来说过于技术化,但在这一节中,我将展示如何使用 Shor 算法攻击公钥密码学。我们来看看 Shor 的算法如何用来解决因式分解和离散对数问题(如第九章所讨论的),这分别是 RSA 和 Diffie-Hellman 背后的难题。
Shor 算法解决因式分解问题
假设你想因式分解一个大数 N = pq。如果你能计算出 a^(x) mod N 的周期,那么分解 N 就变得容易了。这个任务在经典计算机上很难做到,但在量子计算机上却很容易。你首先选择一个小于 N 的随机数 a,然后让 Shor 算法找到函数 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^(ω / 2) – 1)(a^(ω / 2) + 1)。然后,你可以计算 (a^(ω / 2) – 1) 和 N 之间的最大公约数(GCD),并检查是否已经找到了 N 的一个非平凡因子(即,除了 1 或 N 之外的值)。如果没有,你可以使用另一个值 a 再次运行相同的算法。经过几次试验,你将找到 N 的一个因子。你现在已经从公钥恢复了私钥,这使得你可以解密消息或伪造签名。
那么,这个计算到底有多简单呢?请注意,经典算法中用于因式分解一个数 N 的最佳算法,运行时间是指数级别的,取决于 N 的位长 n(即,n = log[2] N)。然而,Shor 算法的运行时间是 n 的多项式时间——即 O(n²(log n)(log log n))。这意味着,如果我们有一台量子计算机,我们可以运行 Shor 算法,并在合理的时间内(几天?几周?也许几个月?)看到结果,而不是几千年。
Shor 算法与离散对数问题
离散对数问题的挑战在于给定 y = g^(x) mod p,找出 y 的值,其中 g 和 p 是已知的数值。解决这个问题在经典计算机上需要指数时间,但 Shor 算法通过其高效的周期查找技术使得你可以轻松找到 y。
例如,考虑函数 f(a, b) = g(*a*)*y*(b)。假设我们想找到该函数的周期,即数字 ω 和 ω′,使得 f(a + ω, b + ω′) = f(a, b) 对任意 a 和 b 成立。那么,我们寻求的解是 x = –ω / ω′ 模 q,其中 g 的阶 q 是已知的参数。等式 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 的位长。该算法可以推广到求解任何交换群中的离散对数,而不仅仅是质数模数的数值群。
Grover 算法
在 Shor 算法实现因式分解的指数级加速之后,量子加速的另一重要形式是能够在时间上与 n 的平方根成比例地在 n 个项目中进行搜索,而任何经典算法都需要与 n 成比例的时间。这种二次加速得益于 格罗弗算法,这是一种在 1996 年(Shor 算法之后)发现的量子算法。我不会深入讲解格罗弗算法的内部原理,因为它基本上就是一堆 Hadamard 门,但我会解释格罗弗算法能解决什么样的问题,以及它对加密安全性的潜在影响。我还将展示为什么通过将对称加密算法的密钥或哈希值大小翻倍,你可以从量子计算机中“拯救”它,而非对称算法则无法挽救。
将格罗弗算法看作是一种在 n 个可能值中找到值 x 的方法,使得 f(x) = 1,而对于其他大多数值,f(x) = 0。如果有 m 个 x 满足 f(x) = 1,格罗弗算法将以时间 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 位的密钥!这样,格罗弗算法将把搜索密钥的复杂度减少到“仅” 2^(256 / 2) = 2¹²⁸ 次操作。
格罗弗算法还可以找到哈希函数的前像(这是在第六章中讨论的一个概念)。为了找到某个值 h 的前像,f() 函数被定义为“f(x) = 1 当且仅当 Hash(x) = h,否则 f(x) = 0。”因此,格罗弗算法可以在大约 2^(n/2) 次操作的代价下找到 n 位哈希的前像。与加密类似,为了确保 2^(n) 的 后量子 安全性,只需使用大小为两倍的哈希值,因为格罗弗算法将在至少 2^(n) 次操作中找到 2n 位值的前像。
最终结论是,你可以通过将对称加密算法的密钥或哈希值大小翻倍,从量子计算机中“拯救”对称加密算法,而非对称算法则无法挽救。
注意
有一种量子算法可以在时间 O(2^(n/3)) 内找到哈希函数的碰撞, 而不是 O(2^(n/2)),* 就像经典的生日攻击那样。这表明量子计算机在寻找哈希函数碰撞方面可能优于经典计算机, 除非是 O(2^(n/3)) 时间的量子算法还需要 O(2^(n/3)) 的空间或内存来运行。给经典算法提供 O(2^(n/3)) 的计算空间,它可以运行一个并行碰撞搜索算法,碰撞时间仅为 O(2^(n/6)),* 比 O(2^(n/3)) *的量子算法快得多。 (有关此攻击的详细信息,请参见 Daniel J. Bernstein 的《哈希碰撞的成本分析》 cr.yp.to/papers.html#collisioncost。)
为什么构建量子计算机如此困难?
尽管量子计算机在理论上是可以构建的,但我们不知道它会有多难,或者它何时能够实现,甚至是否能实现。到目前为止,看起来真的非常困难。截止到 2017 年初,记录保持者是一台能够将 14(十四!)个量子比特稳定保持几毫秒的机器,而我们需要将数百万个量子比特稳定保持几周才能破解任何加密。关键是,我们还没有做到这一点。
为什么构建量子计算机如此困难?因为你需要极小的物体来充当量子比特——大约是电子或光子的大小。由于量子比特必须如此微小,它们也极其脆弱。
量子比特必须保持在极低的温度(接近绝对零度),以保持稳定性。但是即使在如此寒冷的温度下,量子比特的状态也会衰退,最终变得无用。到目前为止,我们还不知道如何制造能保持超过几秒钟的量子比特。
另一个挑战是量子比特可能会受到环境的影响,比如热量和磁场,这些因素会在系统中产生噪声,从而导致计算错误。理论上,我们可以处理这些错误(只要错误率不是太高),但实际上非常困难。修正量子比特的错误需要特定的技术,称为量子错误纠正码,这些技术又需要额外的量子比特和足够低的错误率。但我们不知道如何构建具有如此低错误率的系统。
目前,形成量子比特并因此构建量子计算机的主要方法有两种:超导电路和离子阱。使用超导电路是谷歌和 IBM 实验室主张的方法。它基于将量子比特形成为依赖于超导材料量子现象的微小电路,其中电荷载体是电子对。由超导电路制成的量子比特需要保持在接近绝对零度的温度下,并且它们的寿命非常短。截止目前,保持稳定的量子比特数量最多为九个,并且稳定时间仅为几微秒。
离子阱,即被困离子,由带电的原子组成,使用激光操控以将量子比特准备为特定的初始状态。使用离子阱是构建量子比特的最早方法之一,且它们通常比超导电路更稳定。截止目前的纪录是 14 个量子比特在几毫秒内保持稳定。但离子阱操作较慢,且在扩展性上似乎不如超导电路。
构建量子计算机真的是一项雄心勃勃的任务。挑战在于:1)构建一个具有少量量子比特的系统,该系统稳定、容错并能够应用基本的量子门操作;2)将这样的系统扩展到数千或数百万个量子比特,以使其变得实用。从纯物理学角度来看,根据我们目前的知识,并没有什么能阻止构建大型容错量子计算机的出现。但许多事情在理论上是可能的,却在实践中证明难以实现或成本过高(例如安全计算机)。当然,未来将证明谁是对的——量子乐观主义者(他们有时预测十年内会有大型量子计算机)还是量子怀疑论者(他们认为人类永远不会看到量子计算机的诞生)。
后量子加密算法
后量子密码学领域致力于设计无法被量子计算机破解的公钥算法;也就是说,它们将是量子安全的,并能够在量子计算机可以轻松破解 4096 位 RSA 模数的未来,取代 RSA 和基于椭圆曲线的算法。
这些算法不应依赖于已知可以通过 Shor 算法高效求解的难题,因为 Shor 算法破坏了因式分解和离散对数问题的难度。对称算法,如分组密码和哈希函数,在面对量子计算机时,理论安全性只会降低一半,但不会像 RSA 那样被彻底破解。它们可能构成后量子方案的基础。
在以下几节中,我将解释四种主要的后量子算法类型:基于编码的、基于格的、多变量的和基于哈希的。其中特别喜欢基于哈希的,因为它简单且具有强大的安全保障。
基于编码的加密学
基于编码的后量子加密算法基于纠错码,这是一种旨在通过噪声信道传输比特的技术。纠错码的基本理论可以追溯到 20 世纪 50 年代。第一个基于编码的加密方案(McEliece 加密系统)于 1978 年开发,至今未被攻破。基于编码的加密方案既可用于加密也可用于签名。它们的主要限制是公钥的大小,通常在几百千字节的数量级。但当网页的平均大小约为两兆字节时,这真的是个问题吗?
让我首先解释一下什么是纠错码。假设你想要将一串比特作为一系列(假设是)3 比特的字传输,但传输不可靠,你担心一个或多个比特可能会被错误传输:你发送的是 010,但接收方收到的是 011。解决这个问题的一种简单方法是使用一个非常基础的纠错码:你不直接传输 010,而是传输 000111000(每个比特重复三次),接收方通过取每组三个比特的多数值来解码接收到的字。例如,100110111 会被解码为 011,因为该模式出现了两次。但正如你所看到的,这种特定的纠错码只允许接收方在每个 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完全问题,因此超出了量子计算机的解码能力。
基于格点的加密学
格点是数学结构,基本上由一组位于n维空间中的点组成,并具有某种周期性结构。例如,在二维空间(n = 2)中,格点可以被视为图 14-5 所示的点集。

图 14-5:二维格点的点,其中 v 和 w 是格点的基向量, s 是最接近星形点的向量
格点理论催生了看似简单的加密方案。我将给你简要介绍一下。
基于格的加密中的第一个困难问题被称为 短整数解(SIS)。SIS 的问题是给定 (A,b),找出 n 个数字的秘密向量 s,使得 b = As mod q,其中 A 是一个随机的 m × n 矩阵,q 是素数。
基于格的加密中的第二个困难问题称为 带错误学习(LWE)。LWE 的问题是给定 (A,b),找出 n 个数字的秘密向量 s,其中 b = As + e mod q,A 是一个随机的 m × n 矩阵,e 是一个随机的噪声向量,q 是素数。这个问题看起来很像在基于编码的密码学中进行噪声解码。
SIS 和 LWE 在某种程度上是等价的,可以通过将一组基向量组合来重新表述为 最近向量问题(CVP) 在格上的实例,即在格中找到距离给定点最近的向量。图 14-5 中的虚线向量 s 显示了我们如何通过组合基向量 v 和 w 来找到最接近星形点的向量。
CVP 和其他格问题被认为对于经典计算机和量子计算机都很困难。但这并不直接转化为安全的加密系统,因为有些问题仅在最坏情况下(即它们的最难实例)才困难,而不是在平均情况下(这对于加密来说是需要的)。此外,虽然找到 CVP 的精确解很困难,但找到一个近似解可能要容易得多。
多元密码学
多元密码学 旨在构建加密方案,其破解难度与解决多元方程组或涉及多个未知数的方程组的难度相当。例如,考虑以下涉及四个未知数 x[1]、x[2]、x[3]、x[4] 的方程组:

这些方程包含的是单个未知数的和,例如 x[4](或一次项),或者是两个未知数的乘积,例如 x[2]x[3](二次项或 二次 项)。为了求解这个方程组,我们需要找到满足所有四个方程的 x[1]、x[2]、x[3]、x[4] 的值。方程可能是在所有实数、仅在整数范围内,或者在有限的数集上。在密码学中,方程通常是在某些素数的模数下,或者是在二进制值(0 和 1)下。
这里的问题是,在给定一个 随机 二次方程组的情况下,找到一个 NP-困难的解。这个困难的问题,称为 多元二次方程(MQ),因此是后量子系统的潜在基础,因为量子计算机无法有效解决 NP-困难问题。
不幸的是,在 MQ 基础上构建加密系统并非易事。例如,如果我们要使用 MQ 进行签名,私钥可能由三个方程组组成,L[1]、N 和 L[2],当按此顺序组合时,得到另一个方程组,我们称之为 P,即公钥。依次应用变换 L[1]、N 和 L[2](即按照方程组变换一组值)相当于通过变换 x[1]、x[2]、x[3]、x[4] 到 y[1]、y[2]、y[3]、y[4] 来应用 P,其定义如下:

在这样的加密系统中,L[1]、N 和 L[2] 的选择是这样的:L[1] 和 L[2] 是线性变换(即方程中只有加法,而没有乘法),且是可逆的;而 N 是一个二次方程系统,也具有可逆性。这使得三者的组合是一个可逆的二次方程系统,但其逆矩阵在不知道 L[1]、N 和 L[2] 的逆的情况下很难求出。
计算签名的过程就是计算 L[1]、N 和 L[2] 的逆矩阵,然后应用到某条消息 M 上,M 被视为一组变量 x[1]、x[2]、……。
S = L[2](−1)(*N*(−1)(L[1]^(−1)(M)))
验证签名的过程就是验证 P(S) = M。
攻击者如果能计算出 P 的逆矩阵,或者从 P 中确定 L[1]、N 和 L[2],就能够破坏这种加密系统。解决这些问题的实际难度取决于方案的参数,比如使用的方程数量、数字的大小和类型等。但选择安全的参数是困难的,且曾经有多个被认为安全的多变量方案被攻破。
多变量密码学在主要应用中并未得到广泛使用,原因是对该方案的安全性存在担忧,并且它通常速度较慢或需要大量内存。然而,多变量签名方案的一个实际好处是它能生成较短的签名。
基于哈希的密码学
与之前的方案不同,基于哈希的密码学依赖于密码学哈希函数的安全性,而不是数学问题的难度。由于量子计算机无法破解哈希函数,因此它们无法破解任何依赖于找到哈希冲突难度的系统,而这是基于哈希函数的签名方案的核心思想。
基于哈希的加密方案相当复杂,因此我们将仅关注它们最简单的构建块:一次性签名,这一技巧大约在 1979 年被发现,并以发明者命名为 Winternitz 一次性签名(WOTS)。这里的“一次性”意味着私钥只能用来签名一条消息;否则,签名方案将变得不安全。(WOTS 可以与其他方法结合使用,以签署多条消息,正如你将在后续部分看到的那样。)
但首先,让我们看看 WOTS 是如何工作的。假设你想签署一个被视为 0 到w – 1 之间的数字的消息,其中w是方案的某个参数。私钥是一个随机字符串,K。要签署消息M,其中 0 ≤ M < w,你需要计算Hash(Hash(…(Hash(K))),其中哈希函数Hash重复M次。我们将这个值表示为Hash(*M*)(*K*)。公钥是**Hash**(w)(K),或者说是从K开始的w次嵌套Hash迭代的结果。
一个 WOTS 签名,S,通过检查Hash^(w – M)(S)是否等于公钥Hash^(w)(K)来验证。注意,S是经过M次应用Hash后的K,因此如果我们再进行w – M次应用Hash,我们会得到一个等于K哈希M + (w – M) = w次的值,即公钥。
这个方案看起来相当愚蠢,而且有显著的局限性:
签名可能会被伪造
从Hash(*M*)(*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 的签名。通过这些值,攻击者可以计算出Hash(*x*)(*K*)和**Hash**(x)(K**′),其中x在[1;7]范围内,从而伪造代表K和K**′的签名。没有简单的方法来解决这个问题。
先进的基于哈希的方案依赖于更复杂版本的 WOTS,结合树形数据结构和复杂的技术,旨在用不同的密钥签署不同的消息。不幸的是,结果产生的签名很大(大约几十千字节,如 SPHINCS 一样,SPHINCS 是本文写作时的一个先进方案),而且有时会限制它们能够签署的消息数量。
错误可能发生的方式
后量子密码学可能比 RSA 或椭圆曲线加密更强大,但它并不是无懈可击或全能的。我们对后量子方案及其实现的安全性理解,远不如非后量子加密那么深入,这带来了增加的风险,以下部分将总结这些风险。
不清晰的安全级别
后量子方案可能看起来非常强大,但在量子攻击和经典攻击面前,仍然可能不安全。基于格的算法,如环-LWE 家族的计算问题(LWE 问题的多项式版本),有时存在问题。环-LWE 对密码学家具有吸引力,因为它可以用来构建加密系统,在原则上,这些系统的破解难度与解决最难的环-LWE 问题一样大,而环-LWE 问题可以是NP-难的。但当安全性看起来过于完美时,通常并非如此。
安全性证明的一个问题是,它们通常是渐近的,这意味着它们只对大数量的参数(如基础格的维度)有效。然而,在实际操作中,使用的参数数量要小得多。
即使一个基于格的方案看起来像是一个NP-难题一样难以破解,其安全性仍然难以量化。对于基于格的算法来说,我们很少能清楚地了解对它们的最佳攻击方式,以及这种攻击在计算或硬件方面的成本,因为我们对这些最新构造的理解不足。这种不确定性使得基于格的方案比那些更为理解的构造(如 RSA)更难进行比较,这让潜在的用户感到害怕。然而,研究人员在这一方面已有进展,且希望在几年内,基于格的问题能像 RSA 一样被理解。(有关 Ring-LWE 问题的更多技术细节,请阅读 Peikert 的精彩综述,见 eprint.iacr.org/2016/351/。)
快进:如果为时已晚,会发生什么?
想象一下这个 CNN 头条:2048 年 4 月 2 日:“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 的高级使用使得攻击者的工作更加困难,即使他拥有量子计算机。
实现问题
实际上,量子后方案将是代码,而不是算法;也就是说,它们是在某个物理处理器上运行的软件。无论这些算法在纸面上多么强大,它们都无法免于实现错误、软件漏洞或侧信道攻击。一个算法在理论上可能是完全的量子后方案,但仍然可能被一个简单的经典计算机程序破解,因为程序员忘记输入了一个分号。
此外,像基于代码和基于格的算法这样的方案在很大程度上依赖于数学运算,这些运算的实现使用了各种技巧,以使这些操作尽可能快速。但同样,这些算法中的代码复杂性使得实现更容易受到侧信道攻击的威胁,例如时序攻击,通过测量执行时间推断关于秘密值的信息。事实上,这种攻击已经应用于基于代码的加密(见 eprint.iacr.org/2010/479/)和基于格的签名方案(见 eprint.iacr.org/2016/300/)。
结果是,具有讽刺意味的是,量子后方案在实践中一开始将比非量子后方案更不安全,因为它们的实现存在漏洞。
进一步阅读
要学习量子计算的基础知识,可以阅读 Nielsen 和 Chuang 所著的经典教材《Quantum Computation and Quantum Information》(剑桥大学出版社,2000 年)。Aaronson 的《Quantum Computing Since Democritus》(剑桥大学出版社,2013 年)是一本更具娱乐性的非技术读物,内容涉及的范围不仅限于量子计算。
多款软件模拟器可以让你尝试量子计算。Quantum Computing Playground(www.quantumplayground.net/)设计尤为出色,采用简单的编程语言和直观的可视化效果。
有关后量子密码学的最新研究,请访问pqcrypto.org/以及相关的会议 PQCrypto。
未来几年将是后量子密码学特别令人兴奋的时期,这得益于 NIST 的后量子密码学项目,这是一个致力于开发未来后量子标准的社区合作项目。一定要查看该项目的官方网站csrc.nist.gov/groups/ST/post-quantum-crypto/,了解相关算法、研究论文和研讨会。


浙公网安备 33010602011771号