Python-密码学实践指南-全-

Python 密码学实践指南(全)

原文:Practical Cryptography in Python

协议:CC BY-NC-SA 4.0

一、密码学:不仅仅是保密

欢迎来到实用密码学的世界!这本书的目的是教你足够多的关于密码学的知识,使你能够推理它做什么,什么时候某些类型可以被有效地应用,以及如何选择好的策略和算法。每一章都有例子和练习,通常在开始会有后续练习来帮助你找到方向。这些例子往往伴随着一些虚构的舞台设置,以增加一些背景。在你有了一些接触和经验之后,那些例子后面的术语应该更有意义,也更容易记住。我们希望你喜欢它。

设置 Python 环境

为了深入研究,我们需要一个游泳的地方,那就是 Python 3 环境。如果您已经是 Python 3 专业版的用户,并且在安装您发现需要的模块时没有问题,请跳过这一节,做一些实际的尝试。否则,请继续阅读,我们将快速完成设置步骤。

本书中的所有例子都是使用 Python 3 和第三方“加密”模块编写的。

如果您不想弄乱您的系统 Python 环境,我们建议使用 venv 模块创建一个 Python 虚拟环境。这将使用 Python 解释器和相关模块配置一个选定的目录。通过使用“激活”脚本,shell 被指示使用 Python 的这个定制环境,而不是系统范围的安装。您安装的任何模块都只能在本地安装。

在这一节中,我们将介绍如何在 Ubuntu Linux 中安装系统。对于其他版本的 Linux 或 Unix,安装会稍有不同,对于 Windows,可能会有很大不同。

首先,我们需要安装 Python 3、Pip 和venv模块:

apt install python3 python3-venv python3-pip

接下来,我们使用 venv 在一个env目录中设置环境:

python3 -m venv env

这将在路径中设置解释器和模块。安装完成后,可以通过以下命令随时使用该环境:

source env/bin/activate

现在,您应该可以在您的 shell 提示符前面看到一个前缀,其中包含您的环境名称。一旦您的环境被激活,安装cryptography模块。如果您不想在系统范围内安装加密技术,请记住首先激活您的 Python 虚拟环境。

pip install cryptography

我们将在整本书中使用cryptography模块。很多时候我们会直接参考模块的文档,可以在 https://cryptography.io/en/latest/ 在线找到。

对于一些实践,我们还需要gmpy2模块。这个确实需要一些系统范围的软件包。

apt install libmpfr-dev libmpc-dev libgmp-dev python3-gmpy2

一旦安装了这些包,就可以在虚拟环境中安装 Python gmpy2模块

pip install gmpy2

请注意,在虚拟环境中,您可以使用“python”代替“python3”,使用“pip”代替“pip3”。这是因为当您使用 venv 创建环境时,您是使用 Python3 来完成的。在虚拟环境中,Python3 是唯一的解释器,不需要区分版本 2 和版本 3。如果您在系统范围内安装这些软件包中的任何一个,您可能需要使用 pip3,而不仅仅是 pip。否则,可能会为 Python 2 安装这些包。

如果您在使用gmpy2时遇到问题或者不希望安装所有系统范围的软件包,您可以跳过这一步。只有几个练习你不能完成。

现在让我们开始潜水吧!

凯撒的诡秘密码

东南极洲(EA)和西南极洲(WA)这两个(虚构的)国家彼此都不太喜欢对方,并且不停地互相刺探。在这个场景中,两个来自 EA 的间谍,代号“爱丽丝”和“鲍勃”,已经渗透到他们的西方邻居中,并通过秘密渠道来回发送消息。

他们不喜欢他们在西南极洲的对手阅读他们的信息,所以他们用密码交流。

不幸的是,东南极洲在加密领域并不特别先进。对于一个代码,东南极洲真相间谍机构(EATSA)创造了一个简单的替换,用字母表中后来的另一个字母替换每个字母。两个国家都使用标准的 ASCII 字母表,字母“A”到“z”

假设他们选择使用这种替换技术对他们的消息进行编码,将移位距离设置为 1。在这种情况下,字母“A”将被替换为“B”,字母“B”将被替换为“C”,依此类推。字母表的最后一个字母“Z”将绕到开头,并被替换为“a”。该表显示了从明文(原始的,未接触的)字母到密文(编码的)字母的完整(大写)映射。像空格和标点符号这样的非字母保持不变。

| A | B | C | D | E | F | G | H | 我 | J | K | L | M | | B | C | D | E | F | G | H | 我 | J | K | L | M | 普通 | | 普通 | O | P | Q | 稀有 | S | T | U | V | W | X | Y | Z | | O | P | Q | 稀有 | S | T | U | V | W | X | Y | Z | A |

使用该表,HELLO WORLD编码为IFMMP XPSME

现在试试距离 2,其中“A”到“C”,“B”到“D”,依此类推,直到“Y”映射到“A”,而“Z”映射到“B”

| A | B | C | D | E | F | G | H | 我 | J | K | L | M | | C | D | E | F | G | H | 我 | J | K | L | M | 普通 | O | | 普通 | O | P | Q | 稀有 | S | T | U | V | W | X | Y | Z | | P | Q | 稀有 | S | T | U | V | W | X | Y | Z | A | B |

现在,消息HELLO WORLD被编码为JGNNQ YQTNF

东南极洲真相调查机构(EATSA)对他们简单的移位密码感到满意,决定创建一个 Python 程序来处理信息的编码和解码。

提示:编写代码

这本书介绍了许多示例 Python 程序。在每一个的开始,我们将列出需求,也许还有一个密码 API 的提示或概述。你应该先试着自己写程序。如果你卡住了或者犯了错误,那也没关系。即使您不能自己解决所有问题,您尝试编写程序的经验将帮助您更好地理解所提供的示例。

练习 1.1。移位密码编码器

创建一个 Python 程序,使用本节中描述的移位密码对消息进行编码和解码。移位量必须是可配置的。

让我们一起来完成这个练习。我们在所有练习中都使用 Python 3。

首先,让我们创建一个简单的函数来创建我们的替换表。为了简单起见,我们将创建两个 Python 字典:一个包含编码表,另一个创建解码表。我们也将只编码和解码大写的 ASCII 字母,如清单 1-1 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   import string
 4
 5   def create_shift_substitutions(n):
 6       encoding = {}
 7       decoding = {}
 8       alphabet_size = len(string.ascii_uppercase)
 9       for i in range(alphabet_size):
10           letter       = string.ascii_uppercase[i]
11           subst_letter = string.ascii_uppercase[(i+n)%alphabet_size]
12
13           encoding[letter]       = subst_letter
14           decoding[subst_letter] = letter
15       return encoding, decoding

Listing 1-1Creating Substitution Tables

注意该功能在n(移位参数)上被参数化。我们在这个函数中没有任何错误检查;我们将在别处检查参数。但是请注意,n 的任何整数值都是有效的,因为 Python 以合理的方式处理负模数。甚至值 0 也可以:它只是产生一个从每个字符到其自身的映射!大于 26 的值也工作得很好,因为我们在索引到字母表之前应用了最终模数alphabet_size

现在,为了编码和解码,我们简单地用消息中的每个字母替换相应字典中的一个字母,如清单 1-2 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   def encode(message, subst):
 4       cipher = ""
 5       for letter in message:
 6           if letter in subst:
 7               cipher += subst[letter]
 8            else:
 9                cipher += letter
10       return cipher
11
12   def decode(message, subst):
13       return encode(message, subst)

Listing 1-2Shift Encoder

注意:紧凑与清晰

当它们之间有冲突时,我们倾向于支持普遍清晰而不是紧凑。如果有助于说明正在发生的事情,我们甚至会用不被广泛认为是惯用的方式来写东西。

清单 1-2 中的代码是一个比普通习惯用法更倾向于清晰的好例子。惯用的函数体可能是一行代码:

def encode(message, subst):

return "".join(subst.get(x, x) for x in message)

如果你已经习惯了,这是一个可爱的 Python,但是我们在这里尽量不做太多的假设。

在我们的实现中,encode函数接受一条传入消息和一个替换字典。对于消息中的每一个字母,如果可以替换的话,我们就替换它。否则,我们只包括字符本身,不进行任何转换(保留空格和标点符号)。

显然,这个清单中的decode操作是完全不必要的,但是我们包含它是为了强调在替代密码中编码和解码是完全一样的。只有字典需要改变。

这些函数足以构建一个应用,但是为了好玩,我们将在清单 1-3 中添加另一个函数,以获取一个替换字典并创建一个显示映射的字符串。这将允许我们打印出从不同的移位值创建的不同表格。

 1   # Partial Listing: Some Assembly Required
 2
 3   def printable_substitution(subst):
 4       # Sort by source character so things are alphabetized.
 5       mapping = sorted(subst.items())
 6
 7       # Then create two lines: source above, target beneath.
 8       alphabet_line = " ".join(letter for letter, _ in mapping)
 9       cipher_line = " ".join(subst_letter for _, subst_letter in mapping)
10       return "{}\n{}".format(alphabet_line, cipher_line)

Listing 1-3Printable Substitutions

使用这些函数,我们可以构建一个编码和解码消息的简单应用,如清单 1-4 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   if __name__ == "__main__":
 4       n = 1
 5       encoding, decoding = create_shift_substitutions(n)
 6       while True:
 7           print("\nShift Encoder Decoder")
 8           print("--------------------")
 9           print("\tCurrent Shift: {}\n".format(n))
10           print("\t1\. Print Encoding/Decoding Tables.")
11           print("\t2\. Encode Message.")
12           print("\t3\. Decode Message.")
13           print("\t4\. Change Shift")
14           print("\t5\. Quit.\n")
15           choice = input(">> ")
16           print()
17
18           if choice == '1':
19               print("Encoding Table:")
20               print(printable_substitution(encoding))
21               print("Decoding Table:")
22               print(printable_substitution(decoding))
23
24           elif choice == '2':
25               message = input("\nMessage to encode: ")
26               print("Encoded Message: {}".format(
27                  encode(message.upper(), encoding)))
28
29           elif choice == '3':
30               message = input("\nMessage to decode: ")
31               print("Decoded Message: {}".format(
32                   decode(message.upper(), decoding)))
33
34           elif choice == '4':
35               new_shift = input("\nNew shift (currently {}): ".format(n))
36               try:
37                   new_shift = int(new_shift)
38                   if new_shift < 1:
39                       raise Exception("Shift must be greater than 0")
40               except ValueError:
41                   print("Shift {} is not a valid number.".format(new_shift))
42               else:
43                   n = new_shift
44                   encoding, decoding = create_shift_substitutions(n)
45
46           elif choice == '5':
47               print("Terminating. This program will self destruct in 5 seconds .\n")
48               break
49
50           else:
51               print("Unknown option {}.".format(choice))

Listing 1-4Shift Cipher Application

编码和解码程序完成后,东南极洲真相间谍机构(EATSA)将爱丽丝和鲍勃送到他们的秘密目的地,希望他们的通信如果被截获,不会被西南极洲中央骑士办公室(WACKO)读取。

问题是这个代码很容易被破解。你能看出为什么吗?通过巧妙的猜测,有各种各样的方法可以想出来。比如,试着打破这个:

FA NQ AD ZAF FA NQ FTMF UE FTQ CGQEFUAZ

使用几个简单的两个字母的单词,如“if”、“or”、“in”、“to”等等,很快就可以看出这个短语是

TO BE OR NOT TO BE THAT IS THE QUESTION

保留的空间很容易辨认。因此,在现代加密技术出现之前,真正的间谍通常会删除消息中的所有空格,就像这样:

FANQADZAFFANQFTMFUEFTQCGQEFUAZ

有了这样的改变,至少在哪里尝试简单的单词替换并不明显。但即使爱丽丝和鲍勃去掉所有空格和标点符号,破解他们的代码仍然是微不足道的。虽然这段代码非常琐碎,可以用笔和纸来破解,但我们将编写一个 Python 程序来破解它。你已经明白了吗?如果是这样,那就自己动手吧。如果没有,继续读下去!

EATSA 使用的替代密码的问题是只有 25 个唯一有效的移位。您可以轻松构建一个 Python 程序来尝试所有可能的 25 种组合。

我们如何知道我们何时与 Alice 和 Bob 使用相同的班次?当我们看到它的时候就会知道,因为它是可读的。

让我们在这场南极冷战中交换立场,为西南极洲中央骑士办公室(WACKO)工作。他们知道间谍已经渗透到他们的国家,他们正在监控这些间谍和 EATSA 之间的通信。他们的一名代号为“Eve”的反情报人员刚刚收到了以下信息:

FANQADZAFFANQFTMFUEFTQCGQEFUAZ

通过这条消息,Eve 得到情报,EA 特工正在使用替代密码。她决定构建一个程序来编码和解码这样的信息。一个惊人的巧合是,她像 EATSA 一样构造了一个 Python 程序!

运行程序时,她尝试用移位 1 解码消息,结果如下:

EZMPZCYZEEZMPESLETDESPBFPDETZY

这看起来不对劲。因此 Eve 再次尝试第二、第三班,以此类推。

1:  EZMPZCYZEEZMPESLETDESPBFPDETZY
2:  DYLOYBXYDDYLODRKDSCDROAEOCDSYX
3:  CXKNXAWXCCXKNCQJCRBCQNZDNBCRXW
4:  BWJMWZVWBBWJMBPIBQABPMYCMABQWV
5:  AVILVYUVAAVILAOHAPZAOLXBLZAPVU
6:  ZUHKUXTUZZUHKZNGZOYZNKWAKYZOUT
7:  YTGJTWSTYYTGJYMFYNXYMJVZJXYNTS
8:  XSFISVRSXXSFIXLEXMWXLIUYIWXMSR
9:  WREHRUQRWWREHWKDWLVWKHTXHVWLRQ
10: VQDGQTPQVVQDGVJCVKUVJGSWGUVKQP
11: UPCFPSOPUUPCFUIBUJTUIFRVFTUJPO
12: TOBEORNOTTOBETHATISTHEQUESTION

使用移位 12,Eve 看到一串明显的英语文本。这显然是信息。

这种替代密码通常被称为凯撒密码,因为朱利叶斯·凯撒用它来传递秘密信息。这个密码已经有 2000 多年的历史了。显然,自那时以来,我们已经走过了漫长的道路。这项技术已经过时了。

尽管如此,使用凯撒密码可以讨论许多现代密码学的原理,包括

  1. 密钥大小

  2. 块大小

  3. 保留的结构(编码后仍然存在的结构)

  4. 暴力攻击

在本书中,我们将在现代密码学的背景下学习所有这些概念。数学的进步使得新的密码成为可能,如果正确使用的话,几乎不可能破解。不过,在我们继续之前,这里有几个额外的练习供求知欲者参考。

练习 1.2。自动解码

在我们的例子中,Eve 尝试解码各种信息,直到她看到看起来像英语的东西。尝试自动化这一点。

  • 得到一个包含几千个英文单词的数据结构。 1

  • 创建一个程序,接受一个编码字符串,然后尝试用所有 25 个移位值解码它。

  • 使用字典来尝试自动确定最有可能是哪一个班次。

因为您必须处理没有空格的消息,所以您可以简单地记录解码输出中出现了多少字典单词。偶尔,一两个单词可能会偶然出现,但正确的解码应该有更多的命中。

练习 1.3。一种强替代密码

如果不是移动字母表,而是随机打乱字母,会怎么样?创建一个使用这种替代编码和解码消息的程序。

一些报纸刊登类似这样的谜题,叫做密码

练习 1.4。清点字典

对于上一个练习中的密文样式的替换,有多少个替换字典是可能的?

练习 1.5。识别字典

修改你的密码程序,这样你就可以识别和挑选带有数字的乱糟糟的字符替换图。也就是说,每个映射都有一个唯一的编号来标识它:每次选择替换 n 都应该创建相同的替换映射。这个练习比其他的稍微难一点。尽力而为!

练习 1.6。蛮力

试着让你的密码解码程序暴力破解一条信息。测试每个可能的映射需要多长时间?你能写一个程序用任何一种“聪明的猜测”来加速这个吗?

密码学的简明介绍

示例结束后,我们就可以开始真正的密码学了。欢迎光临!希望你对替代密码感兴趣。如前所述,这种特殊形式的加密被称为“凯撒密码”,因为它被凯撒大帝用于保护重要文件。

像凯撒一样,我们大多数人都有我们想要保密的信息。用密码学的术语来说,我们希望保密。加密是数据机密性的基石。

你对凯撒密码有什么看法?即使没有电脑,你认为你要花多长时间才能打破这样的东西?也许在凯撒的时代,如果凯撒的敌人没有受过良好的教育,这是相当有效的。这是密码学和计算机安全的重要一课。密码术的有效性通常依赖于上下文。无论你的对手受过多少教育,他们有多少台计算机,他们是否知道你使用的算法,或者他们有多大的动机,好的加密技术都是有效的。

简而言之,当你不太依赖环境时,你会过得更好,至少环境是在你控制之外的。

然而,良好的安全性将永远取决于你的选择(??)。本书的目标是帮助密码初学者了解一些特定密码算法的工作原理以及它们的设计环境。这本书是针对程序员的,因此使用了大量的源代码来教授和阐释概念。当我们使用 Python 编程语言时,Python 程序员会特别喜欢这些练习。然而,这些概念并不依赖于语言。

因此,我们假设对编程有些熟悉。Python 很容易学习和阅读,任何人都应该很容易至少理解示例,为了方便起见,我们尽量远离非常特殊的 Python 习惯用法。

然而,我们不也不假设读者事先熟悉密码学。如果你对密码学略知一二,请耐心阅读书中可能针对绝对初学者的一些解释。如果你是初学者,这本书适合你。我们希望你喜欢弄湿你的脚。

密码学的用途

您可能已经意识到,在当今互联的现代世界中,加密技术无处不在。世界上的人们正在以令人难以置信的数量和速度交换信息。2018 年福布斯的一篇文章报道了以下统计数据[10]:

  1. 每天都会产生 2.5 万亿字节的数据,而且这个数字还在加速增长。

  2. 谷歌每天处理 35 亿次搜索。

  3. Snapchat 用户每秒分享 50 万张照片

** 每秒钟发送超过 1600 万条短信。

 *   每秒钟发送超过 1.5 亿封电子邮件。

 *

从信息安全的角度来看,令人惊讶的是,这些传输中的绝大多数都应该受到某种方式的保护。在我写这篇文章的时候,有将近 40 亿互联网用户,但是几乎所有传输的数据都是给他们中极小的一部分人的。甚至当有人在社交媒体上公开发帖给全世界看时,他们也是在特定的平台上发帖。这种交流首先是给脸书、Twitter、Snapchat 或 insta gram的,然后这个平台会让它公开。*

*密码学是保护信息的主要工具。加密可用于帮助提供以下保护:

  • 保密:只有授权方才能读取受保护的信息。当你想到加密或密码时,这可能是你想到的第一件事。

  • 认证:你知道你正在与正确的实体/个人交谈,并且他们没有委派他们的身份(他们“在场”)。许多人知道他们浏览器上的小锁图标意味着他们的数据被加密,但是很少有人知道这也意味着服务的身份(例如,你的银行)已经被可信的权威机构验证。毕竟,这一点非常重要:将数据加密给错误的一方并没有真正的帮助。

  • 完整性:消息在发送方和接收方之间没有被改变。这同样适用于明文和加密消息。在某些情况下,这可能看起来不直观,但是在无法阅读的情况下更改加密的消息是可能的,即使是以对接收者“有意义”的方式。

虽然有很多关于密码学的书,但是没有几本书把编程作为教授算法和相关原理的主要方法。我们的目标是通过动手练习帮助计算机程序员理解和使用这些概念。

什么会出错?

不幸的是,有很多方法可以错误地使用加密技术。事实上,错误地使用它的方法比正确地使用它的方法要多得多。造成这种情况的原因有很多,但这里我们将重点讨论两个。

首先,密码学基于许多非常深奥的数学,大多数程序员和 IT 专业人员都没有什么经验。您不必了解使用加密技术的数学知识,但有时不了解背后的数学知识会使您很难对什么可行、什么不可行有正确的直觉。

第二,也可能是最大的问题是,正确的用法也依赖于上下文。很难找到一个通用的“在任何情况下你都应该这样做”的算法。学习密码学的很大一部分是学习各种参数设置如何影响操作。

我们会在书中多次谈到这一点。事实上,你的许多练习都是为了破解错误设置的加密系统。观察事物的变化是理解其工作原理的好方法。这也很有趣。

亚那克:你不是密码学家

警告

这一部分至关重要。请仔细阅读

重复一遍,搞乱密码学的方法多得超乎你的想象。密码学的历史充满了非常聪明的人无意中创造出易受攻击的算法和系统的故事。很多时候,非专业人士了解到足够危险,于是拼凑出一个基于密码术的模块,提供了一种虚假的安全感。即使是一些最优秀的加密专家在发现他们忽略了一个微妙的边缘情况后,也不得不修正他们的协议。

如果这本书是你第一次接触密码学,当你读完的时候,你仍然不会是一个专家。这本书不会让你准备好创建算法和协议来提供工业强度保护。请,,不要读完这本书,然后认为你已经准备好为一个真正的应用拼凑你自己的定制密码。

即使对于专家来说,目前密码学社区最好的想法是而不是创建新的或定制的机制。这通常被表述为,“不要使用你自己的密码”相反,找到并使用现有的库、协议和算法,这些库、协议和算法已经过大量测试,并且都有很好的文档记录和一致的维护。当真正需要新算法时,这些算法通常由专家委员会创建并测试,然后提交给同行审查和公众评论,最后才被信任来保护敏感数据。

那么,为什么要读这本书呢?如果只有专家才应该开发密码学,为什么非专家要学习这种东西?

首先也是最重要的,密码学很有趣!无论您对保护您编写的应用和后端服务器之间的数据通信做了多少准备,学习密码学都是有趣、愉快和值得的。此外,也许在你尝到甜头后,你会想要努力工作,让自己成为专家!也许这本书将是你成为密码学奇才的第一步!

第二,我们生活在一个不完美的世界。您可能正在从事一个项目,其中以前的贡献者(不幸的是)确实推出了他们自己的加密技术。如果您处于这种情况,您需要鼓励您的组织尽快更换它。这种情况就像一个随时会爆炸的地雷,可能需要大量的财政投资来修复。您的组织可能需要雇用一名加密顾问来调查和评估风险。在没有提前通知坏人的情况下,您可能需要向所有客户发送强制安全补丁。尽管这种情况很糟糕,但你自己去发现总比等着坏人来找你要好。阅读这本书可以帮助你认识到这些问题,并对你正在处理的问题做出初步评估。

第三,即使您使用的是著名的算法(或者更好的是第三方库),至少了解一点底层的加密原理也是有帮助的。知道如何使用加密技术,尤其是如何设置各种加密方法的参数,会很方便。密码学社区中的一些人大力推动用 API 创建库,这些 API 只需要最少的配置,而且几乎不可能被错误地使用(我们将在本书的后面讨论一个例子)。然而,即使对于这些来说,如果在这些黑盒中发现了弱点,知情的用户可以更好地理解该弱点如何影响系统的安全性,从而更好地选择缓解策略。

最后,有见识的用户能够更好地识别好的建议和值得信赖的专家。让我们在接下来的几节中进一步讨论这一点。

“跳下悬崖”——互联网

我们大多数写代码的人都非常依赖互联网。搜索 API 文档、示例代码甚至最佳实践是很常见的。但是在网上搜索关于密码术的建议时请小心。很多答案是好的,但更多的是可怕的。如果你不是专家,可能很难识别出其中的区别。

例如,有研究人员在 2017 年发表了一篇题为“栈溢出被认为有害?复制粘贴对 Android 应用安全性的影响”[5]。他们详细介绍了 Stack Overflow 网站上的 4000 多个帖子,其中包括与安全相关的代码片段。在对 130 万个 Android 应用进行法医检查后,他们发现整整 15%的应用包含了从这些帖子中复制的代码,其中大多数都在某种程度上不安全。

你可以做的第一件事就是在实践中自学密码学,这也是我们写这本书的目的之一。你不一定要成为专家才能见多识广。阅读本书的大多数人对计算机硬件都有足够的了解,即使你没有亲自设计电路板,也不会被咄咄逼人的推销员利用。类似地,多了解一点密码学基础知识可以帮助你辨别好的建议和坏的建议。它可以帮助你知道什么时候你可以自己解决,什么时候你应该寻求专家的帮助。

cryptodoneright.org 项目

作者之一是 Crypto Done Right 项目的创始成员。这个项目的目标是在一个地方汇集最好的实用密码指南。在 cryptodoneright.org 网站上,我们正在创建和维护一个为软件开发人员、IT 专业人员和管理人员设计的加密推荐集。目标是在了解所有疯狂数学的加密专家和只需要一个应用与基于云的服务器安全通信的加密用户之间架起一座桥梁。

任何人都可以向 Crypto Done Right 提交或建议一个条目,但由最优秀的专家组成的编辑委员会可以确保正确的内容。在撰写本文时,编辑控制权仍在约翰·霍普金斯大学,但将它转移到一个独立的、由社区驱动的组织正在进行中。

我们鼓励您将此网站用作加密最佳实践的权威来源,我们认可其内容。作为一个通用知识库,它永远不会拥有每个人需要的一切,也不会回答每个应用的每个问题。但是,理解加密算法如何工作、哪些参数很重要以及应该避免哪些常见问题是一个良好的开端。如果您试图弄清楚在您的开发项目中如何使用加密技术,那么从那里开始,然后扩展到其他来源,以获得适用于您的情况的更详细的建议。Crypto Done Right 可以让你对相关问题敏感,这样你就可以识别哪些来源是可信的。

说够了,让我们总结一下

这本书是一本 Python 编程的书。我们会写很多非常好玩、非常有趣的代码来学习密码学。为了保持趣味性,我们将在整本书中依靠爱丽丝、鲍勃和伊芙。计算机安全人员实际上是这样谈论场景的,其中“Alice”代表“甲方”,Bob 代表“乙方”,Eve 代表“窃听者”。有时还有其他常见的名字,但这将是我们最常见的三个演员。

我们将使用一个假设的东西南极洲之间的冷战来激发我们的许多例子,这是完全虚构的。请不要对此事进行任何政治解读。我们用南极洲是因为它是我们能想到的最没有政治色彩的地方。如果我们无意中冒犯了您,我们提前道歉。

虽然示例代码是为了娱乐而编写的,但它也是为了相关和启发而编写的。花点时间研究一下这些例子。尝试你自己的实验。从正反两方面的例子中学习。

请小心不要在你的项目中使用“坏”的代码样本。即使是“好”的代码也不应该在没有仔细确定它是否合适的情况下就复制并粘贴到应用中。

本书的其余部分组织如下:

在第二章,我们将从哈希开始。你可能已经在某种程度上熟悉了哈希,但我们将在针对哈希算法的暴力攻击中做一些有趣的实验,甚至谈一谈工作证明,如比特币中使用的工作证明。从安全角度来看,哈希对于密码保护极其重要。它们对文件完整性也很有用,在后面的章节中当我们谈到消息完整性和数字签名时,它们会再次出现。

在第三章中,我们通过讨论对称加密真正进入加密领域。如果你听说过 AES,那就是对称加密方案的一个例子。它被称为“对称的”,因为加密数据的同一密钥被用来解密数据。这些算法速度很快,几乎专门用于加密大多数数据,无论是传输中的数据还是磁盘上的数据。

与对称算法相反,第四章深入到非对称加密。这种加密涉及两个协同工作的密钥。一个加密,另一个解密。这些类型的算法用在证书和数字签名中,尽管在那一章中我们将集中讨论算法本身。

虽然大多数人一听到密码学就会想到加密,但是它还有其他用途。第五章关注完整性和认证。完整性是确保消息在发送方和接收方之间不会改变。你可能会惊讶地发现,即使你不能阅读一条信息,你仍然可以用有用和有意义的方式改变它。当我们读到那一章时,我们将会探讨一些简洁的例子。此外,我们将看看数字签名和证书,将第四章中的非对称工具和第二章中的哈希工具结合起来。

第六章介绍了如何同时使用不对称和对称加密以及为什么要这样做,第七章探讨了对称加密的其他现代算法。

在第八章中,我们将特别关注用于保护 HTTPS 流量的 TLS 协议。这一章将把我们在整本书中看到的几乎所有东西都集中在一起,因为 TLS 是一个建立在所有这些工具之上的复杂协议。不过不要担心复杂的东西;你会发现这是对这本书的一个很好的评论,也是一个很有帮助的方式来了解所有的事情。

向前

我们现在已经快速介绍了密码学的基础知识,包括简单的密码和它不仅仅是关于保密的事实:还有其他重要的因素。理想情况下,您现在已经建立了一个良好的 Python 环境,自己尝试了一些代码,并准备学习更多内容。

我们走吧!

**

二、哈希

哈希是加密安全的基石。它涉及到一个单向函数指纹的概念。哈希函数只有在满足以下几个条件时才能正常工作:

  • 它们为每个输入产生可重复的唯一值

  • 输出值没有提供关于产生它的输入的线索

有些哈希函数比其他函数更能满足这些要求,我们将讨论一些好的函数(SHA-256)和一些不太好的函数(MD5,SHA-1 ),以展示它们是如何工作的,以及为什么选择一个好的函数如此重要。

hashlib随意哈希

警告:MD5 是不好的

在本章的前半部分,我们将使用一种叫做 MD5 的算法。MD5 已被弃用,不应用于任何安全敏感的操作,或者实际上根本不应用于任何操作,除非您必须与遗留系统交互。

这个讨论是为了介绍哈希的概念和提供历史背景。MD5 很好,因为它产生短哈希,有丰富的历史,并且给我们一些东西来破解

当我们最后一次从东南极洲离开我们最喜欢的两个间谍时,爱丽丝和鲍勃正在使用简单的替代密码来编制一些代码。尽管这种密码非常脆弱,但它提供了一种基本的信息保密形式。

然而,它对消息的完整性毫无作用。如果你还没猜到,消息保密意味着除了授权方没有人可以阅读消息。消息完整性意味着没有未授权方能够改变消息而不被注意到。

理解其中的区别很重要。即使有了现代密码,信息不能被阅读并不意味着它不能被篡改,即使是以解密后有意义的方式。

另外,当 Alice 和 Bob 在 WA 边境通过海关时,有时他们的笔记本电脑会被检查。如果知道在这个过程中没有任何文件被篡改,那就太好了。

对 Alice 和 Bob 来说幸运的是,他们的新技术官员向他们介绍了一种叫做“消息摘要”的东西,以“指纹”他们的文件和消息传输。他解释说,他们可以将消息的内容与消息的摘要结合起来,然后一起使用这两者,他们就可以知道任何消息的一部分是否被修改了。听起来正是这样!

由于他们对摘要一无所知,是时候进行一些培训了。让我们跟随他们的指导者使用我们自己的 Python 解释器,从清单 2-1 开始。

>>> import hashlib
>>> md5hasher = hashlib.md5()
>>> md5hasher.hexdigest()
'd41d8cd98f00b204e9800998ecf8427e'

Listing 2-1Intro to hashlib

导入名为hashlib的库看起来足够简单,但是什么是md5

教师解释说 MD5 中的“MD”代表“消息摘要”我们稍后将讨论一些有趣的细节,但是现在,像 MD5 这样的摘要可以将任意长度的文档(甚至是空文档)转换成占用固定空间的大数。它至少应具备以下特征:

  • 相同的文档总是产生相同的摘要。

  • 摘要“感觉”是随机的:如果你有一个摘要,它不会给你任何关于文档的线索。

这样,一个摘要就像一个指纹,有时被称为指纹:它是代表文档身份的少量数据;我们可能关心的每个文档都应该有一个完全独特的摘要。

人类的指纹在其他方面也很相似。如果你手边有一个人,很容易产生一个(相对)一致且唯一的指纹;但是如果你只有一个指纹,就不那么容易找到是谁的了。摘要以同样的方式工作:给定一个文档,很容易计算它的摘要;但是只给出一个摘要,很难找出是哪个文档产生的。非常辛苦。事实上,越难越好。

MD5 摘要创建一个总是占用 16 字节内存的数字。在我们的示例解释器会话中,我们要求它为空文档生成一个摘要,这就是为什么我们在要求它为我们生成一个摘要之前没有向md5hasher添加任何数据。使用hexdigest是为了演示一种更易于阅读的数字格式,其中摘要中的 16 个字节中的每一个都显示为两个字符的十六进制值。

急于继续学习的教师要求 Alice 和 Bob 将他们的名字哈希(用字节表示)。到解释器,并列出 2-2 !

>>> md5hasher = hashlib.md5(b'alice')
>>> md5hasher.hexdigest()
'6384e2b2184bcbf58eccf10ca7a6563c'
>>> md5hasher = hashlib.md5(b'bob')
>>> md5hasher.hexdigest()
'9f9d51bc70ef21ca5c14f307980a29d8'

Listing 2-2Hash Names

对于像这样的短字符串,组合操作并不少见,比如清单 2-3 。

>>> hashlib.md5(b'alice').hexdigest()
'6384e2b2184bcbf58eccf10ca7a6563c'
>>> hashlib.md5(b'bob').hexdigest()
'9f9d51bc70ef21ca5c14f307980a29d8'

Listing 2-3Combine Operations

“那么,爱丽丝、鲍勃,你们从中学到了什么?”教员问道。当没有人回答时,她建议他们再做一些实验。让我们跟着走。

Python 区分 Unicode 字符串和原始字节字符串。对这些差异的完整解释超出了本书的范围,但是对于几乎所有的加密目的,你必须使用字节。否则,当解释器试图(或拒绝)为您将 Unicode 字符串转换成字节时,您可能会遇到一些令人讨厌的意外。我们使用b''字符串语法强制我们的字符串文字为字节。在其他用户输入要求我们从 Unicode 字符串开始的例子中,我们将把它们编码成字节,以确保这样做是安全的。

练习 2.1。欢迎使用 MD5

计算更多摘要。尝试计算以下输入的 MD5 和:

  • b'alice'(再次)

  • b'bob'(再次)

  • b'balice'

  • b'cob'

  • b'a'

  • b'aa'

  • b'aaaaaaaaaa'(字母“a”的十个副本)

  • b'a'*100000(100000 份字母“a”)

关于练习 2.1 中的 MD5 和,您学到了什么?我们将在本章中进一步讨论这些,但让我们跳回我们无畏的南极人。

“好的,爱丽丝和鲍勃,”老师说。“有几件事。这些摘要对象不需要一次输入全部内容。可以使用update方法一次插入一大块,如清单 2-4 所示。

>>> md5hasher = hashlib.md5()
>>> md5hasher.update(b'a')
>>> md5hasher.update(b'l')
>>> md5hasher.update(b'i')
>>> md5hasher.update(b'c')
>>> md5hasher.update(b'e')

Listing 2-4Hash Update

教员问爱丽丝和鲍勃:“你认为md5hasher.hexdigest()指令的输出会是什么?”尝试一下,看看你是否做对了!

“太好了,”当他们完成后,老师说。“你的入门训练快结束了。就多一个练习!”

练习 2.2。谷歌知道!

使用以下哈希进行快速谷歌搜索(在谷歌搜索栏中输入哈希):

  1. 5f 4 DCC 3b 5aa 765d 61d 8327 deb 882 cf 99

  2. d 41 D8 CD 98 f 00 b 204 e 9800998 ECF 8427 e

  3. 6384 和 2b2184bcbf58eccf10ca7a6563c

把教育搞得一团糟

在计算机安全领域,术语“哈希”或“哈希函数”总是指加密哈希函数,除非另有说明。还有一些非常有用的非加密哈希函数。事实上,你在小学会学到一个非常简单的方法:计算一个数是奇数还是偶数。让我们看看这个简单、熟悉的函数是如何阐释适用于所有哈希函数的原理的。

哈希函数从根本上试图将大量(甚至无限)的事物映射到一个(相对)小的事物集合上。例如,当使用 MD5 时,无论我们的文档有多大,我们最终都会得到一个 16 字节的数字。在离散代数术语中,这意味着哈希函数的比它的范围大得多。给定数量非常非常大的文档,它们中的许多可能会产生相同的哈希。

哈希函数因此有损。从源文档到摘要或哈希,我们丢失了信息。这实际上对它们的功能至关重要,因为在不丢失信息的情况下,有一种方法可以从哈希返回到文档。我们真的不希望这样,我们很快就会知道为什么。

因此,计算一个数字是奇数还是偶数非常符合这个描述。不管这个(整数)数有多大或者有多有趣,我们都可以把它压缩到一个比特的空间里:1 代表奇数,0 代表偶数。那是杂碎!给定任何大小的任何数字,我们可以有效地产生它的“奇怪”值,但是考虑到它的奇怪,我们将很难找出是哪个数字产生了它。我们可以创建非常非常多的个可能的输入,但是我们无法知道哪个特定的被用来做出那个答案。

“偶数或奇数”位有时被称为“奇偶”位,通常被用作基本的检错码。

奇偶哈希示例说明了将输入“压缩”成固定大小值的原理。这个值是一致的,这意味着如果你两次输入相同的数字,你不会得到不同的值。它大量输入压缩到一个固定大小的空间中(只有一位!),而且是有损:只看输出是无法告诉我哪个数字被用作输入的。

所有的哈希函数,包括非密码哈希函数,都具有一致性压缩有损性的基本性质,在计算机科学中有各种重要的应用。然而,仅仅这些品质还不足以让哈希函数成为加密的安全的:为此,哈希函数还需要更多的属性。9]:

  • 原像电阻

  • 第二原像电阻

  • 耐碰撞性

我们将依次讨论这些重要的品质。

原像电阻

通俗地说,原像是一个哈希函数的输入集合,它产生一个特定的输出。如果我们把它应用到前面的奇偶校验例子中,奇数奇偶校验位的原像是(无穷大!)所有奇整数的集合。类似地,偶数奇偶校验位的原像是所有偶数整数的集合。

这对加密哈希意味着什么?之前,我们计算出 MD5 哈希值6384e2b2184bcbf58eccf10ca7a6563c可以由输入b'alice'生成。因此,的原像

  • MD5( x ) = 6384e2b2184bcbf58eccf10ca7a6563c

包含元素x == b'alice'

这很重要,所以让我们用更精确的术语来陈述(使用我们的域和范围中的整数—记住,文档是有序的位,因此只是一个大整数):

前像:哈希函数 H前像和哈希值 kx组值,其中 H(x) = k

对于加密哈希函数,原像的概念很重要。如果我给你一个摘要值,可能(应该)有无限多的输入数字可以用来产生它。这些数字是那个摘要的原像。请记住,从计算机的角度来看,每个文档都只是一个大整数。都只是字节,我们只是在对它们进行数学运算。因此,原像只是一组无穷多的整数。 1

原像抗性的想法基本上是这样的:如果你递给我一个摘要,而我还不知道你是怎么得到的,我甚至不能在原像中找到一个元素,除非我做了大量的工作。理想情况下,我必须完成不可能完成的工作量。

(一般来说)找到整个原像已经很难了;它太大了。我们真正感兴趣的是让在原像中找到任何元素变得困难,除非你碰巧已经知道一个。这就是损失的来源:摘要不应该给我们任何关于产生它的文件的信息。在没有任何信息指导我们的情况下,我们能做的最好的事情就是随机猜测或尝试一切,直到我们意外地找到一个产生正确摘要的方法。那个是原像抗性。

试图在给定输出的原像中找到一个元素的过程称为反转哈希:试图反向运行它以获得给定输出的输入。原像阻力意味着很难找到任何逆像。

这就是为什么奇偶函数是一个潜在的有用的哈希函数,而不是一个安全的哈希函数。如果我给你一个偶/奇值,你可以很容易地得出匹配的东西。例如,我说“偶数”,你说“2”。这不是很抗原像,因为你刚刚告诉我一个产生给定输出的输入,你不需要很努力地去做。事实上,您可以毫不费力地描述整个原像:“所有偶数整数。”对于密码哈希函数,如果我告诉你MD5 ( x ) = ca8a0fb205782051bd49f02eae17c9ee,你(理想情况下)无法告诉我 x 是什么,除非你能找到已经知道并且愿意告诉你的人。MD5 很难反转。

现在,您可以尝试随机(或有序)的文档,看看它们中是否有任何一个产生ca8a0fb205782051bd49f02eae17c9ee,您可能会得到(非常!)幸运。这种方法是一种蛮力攻击,因为你必须在干草堆中的每一根稻草中寻找你要找的针。你所能做的就是盯着一大堆稻草,依靠原始的耐力来度过难关。

因为一致性是哈希的一个属性,如果您已经有了一个映射到给定输出的输入,或者您可以通过搜索 Google 找到它,那么这个特定的输出就会被平凡地反转。无论如何,当运行 MD5 时,ASCII 文本“alice”总是映射到“6384 e2b 2184 bcbf 58 eccf 10 ca 7a 6563 c ”,因此,如果您碰巧知道这两个东西在一起,您可以很容易地从摘要中找到“alice”。对于那个特定的输出,MD5 被简单地反转。但是,这并不意味着 MD5 不能抵抗原像攻击:要打破这一点,你需要找到一种简单的方法,在事先不知道一个输入的情况下总是找到一个给定输出的输入

*这又给我们留下了蛮力。使用强力技术(随机猜测或顺序搜索)猜测 MD5 哈希的原图像元素需要多长时间?要回答这个问题,我们首先需要看看有多少可能的哈希值。我们知道 MD5 总是产生一个 16 字节的摘要,我们可以用它来计算出反转 MD5 的理想难度。为此,我们需要了解二进制(基数为 2)、十进制(基数为 10)和十六进制(基数为 16)正整数(加上 0,但我们通常只说“非负”)。

如果您已经很好地理解了这些,请随意跳到下一部分。

字节转换成一些非负整数

大多数计算机使用二进制来表示一切。二进制数字系统是以 2 为基数表示的。了解它的一个好方法是通过数数。这里,左边是我们熟悉的十进制数,右边是相应的二进制数:

0     0
1     1
2    10
3    11
4   100
5   101
6   110
7   111
8  1000
9  1001

在这个系统中,计数是如何工作的?我们从 0 开始,很好听也很熟悉。加 1 得到 1,这是意料之中的。目前为止,一切顺利。但是,由于我们的基数是 2,所以当我们再次尝试的时候,就没有位数了!就像我们的十进制中没有代表数字“10”的一位数一样,二进制中也没有代表“2”的一位数!

当十进制的位数用完了,我们该怎么办?我们使用位置值。“10”这个数字说明了这一点:这个数字中有“1 个 10”和“0 个 1”。是“9”后面的数字

二进制也差不多。当我们从“1”向上移动一个数字时,我们用完了所有的数字,所以我们在“2”列中放一个“1”,在“1”列中从“0”开始。

似乎值得注意的是,你可以用这种方式表示每个非负整数,就像你可以用 decimal 表示一样。基值(“基数-2”、“基数-10”、“基数-16”等)。)告诉您需要处理多少位数字,以及位值的含义。这里有几个不同数系的位值。注意,人们对这些东西有点粗心,用十进制来谈论它们,但实际上数字系统是任意的。说到那个,世界上有十种人:懂二进制的和不懂的。 2

这是教授数系时的一个大问题:在不知道我们在什么基数上运算的情况下,“10”意味着什么?默认情况下,假设它表示“10”,除非基数被明确说明,或者确实是明显的,就像十六进制一样,我们看到“a”—“f”以及更常见的十进制数字。我们在这里也要这样做:如果你看不到一个基数,或者你不能轻易说出它是什么,你看到的是十进制。

|   |

第三名

|

地方 2

|

地点 1

|

放置 0

|
| --- | --- | --- | --- | --- |
| 二进制的 | eight | four | Two | one |
| 小数 | One thousand | One hundred | Ten | one |
| 十六进制的 | Four thousand and ninety-six | Two hundred and fifty-six | Sixteen | one |

或者,换句话说:

|   |

第三名

|

地方 2

|

地点 1

|

放置 0

|
| --- | --- | --- | --- | --- |
| 二进制的 | 2 3 | 2 2 | 2 1 | 2 0 |
| 小数 | 10 3 | 10 2 | 10 1 | 10 0 |
| 十六进制的 | 16 3 | 16 2 | 16 1 | 16 0 |

所有这些数字系统都以同样的方式工作:位置值是通过在底数的指数上加 1 来确定的。

所以,在十进制中,数字 237 真正的意思是 2⋅102+3⋅101+7⋅100= 200+30+7。

同样的数字在十六进制中(我们会用xh 来表示“ x 在十六进制中”)是 ed h ,表示$ {e}_h\cdot {10}_h¹+{d}_h\cdot {10}_h⁰ $。但是那个是什么意思呢?嗯,eh=十进制的 14 * d ,以及dh= 13d。由于 10 h * 在十六进制列中有一个 1,我们得到(十进制)14 ⋅ 16 + 13 = 237。

为什么我们首先关心十六进制,而不是它的相对紧凑性?十六进制(或“hex”)很有用,因为它的位置值是 2 的倍数(确切地说,它们是 2 4 的倍数),所以它与二进制很好地匹配。考虑下表,左边是十六进制,右边是二进制:

0     0
1     1
2    10
3    11
4   100
5   101
6   110
7   111
8  1000
9  1001
A  1010
B  1011
C  1100
D  1101
E  1110
F  1111

在我们需要从四列增加到五列的同时,我们用完了十六进制的数字!这真的很有帮助,因为这意味着我们可以在计算机的本机和杂乱无章的二进制数字之间来回转换,转换为更加友好和紧凑的十六进制数字。人们甚至很擅长翻译,一看到就能翻译出来。下面是一个上面是二进制,下面是十六进制的例子:

101 1100 1010 0011 0111
5   c    a    3    7

不管一个二进制数有多大,你都可以把每四位写成一个十六进制数字。

回顾二进制的要点是再次强调计算机中的每一个位序列都是一个 ?? 数。如果这些比特是一个文件呢?那是一个数字。如果它们代表一个图像呢?这只是一个很大的数字。

这些比特的“意义”不在计算机中,而是在我们的头脑中。

我们可能以某种方式显示这些位,但是我们人类选择这样做是基于我们认为它们的意思。计算机不知道它们真正的意思。它们只是数字。我们能以某种方式存储本身的含义吗?嗯,当然,但那会迫使我们将含义编码成数字,因为数字是计算机所能理解的一切。甚至他们的指令也只是数字。

我们很哲学化,是吗?如果你真的想知道计算机是如何工作的,理解这一点实际上是非常重要的,而且我们确实需要这样的人。数据和代码只是大数字,计算机基本上只是对它们进行提取、存储和运算。

多难的一次哈希啊!

有了这个小小的补充,我们现在可以回答我们首先想要回答的问题了:一般来说,使用暴力破解 MD5 有多难?我们可以通过查看其输出的大小来尝试一下。MD5 输出 16 字节的值,即 16 ⋅ 8 = 128 位。用 n 位我们可以表示 2n 个单独的值,所以 MD5 可以输出很多不同的摘要。这许多,其实(十进制): 3

340,282,366,920,938,463,463,374,607,431,768,211,456.

即使你每秒检查 100 万个值(并且保证你检查的任何东西都不会产生你以前见过的输出),它仍然会花费你大约 10 26 年(1000 亿亿亿!)通过蛮力找到一个合适的输入。相比之下,我们的太阳预计最多只能再维持地球生命 50 亿年;你的电脑需要运行很多很多次。

如果你有一个密码算法,它的唯一破解手段是暴力破解,那么你就有一个好算法。麻烦的是,你不一定知道它好*。但是这给了我们一个上限,即找到一个在 MD5 中产生特定哈希的输入需要多长时间。至少用不了多长时间!

第二原像和碰撞阻力

一旦理解了原像抗性,其他两个属性就相对容易掌握了。在最后一节快结束时,我们进入了蛮力和二进制,所以让我们快速回顾一下:

  • 原像阻力意味着很难找到一个能产生特定摘要的文档,除非你已经知道了。

第二原像电阻

第二原像阻力意味着如果你已经有一个文档产生特定的摘要,仍然很难找到一个不同的文档产生相同的摘要。

换句话说,仅仅因为你知道

  • 384e2b2184bcbf58eccf10ca7a6563c(爱丽丝)= 384e2b2184bcbf58eccf10ca7a6563c

这并不意味着你可以找到另一个值输入到$$ MD5\left(\cdot \right) $$中,它会给你同样的值。你将不得不再次诉诸暴力。

将它与它的名字联系起来,如果您已经有了前映像的一个成员,找到前映像的第二个成员并不容易:前映像中没有可利用的模式。

耐碰撞性

碰撞阻力比我们刚刚提到的任何一个原像特征都要微妙一些。碰撞阻力意味着很难找到任何两个产生相同输出的:不是一个特定的输出,只是相同的输出。

描述这一点的经典方式是用生日。 4 假设你在一个挤满人的房间里,你想找到其中两个人的生日是 2 月 3 日。这种可能性有多大?不一定很有可能,如果你真的是随便挑的。

但是现在假设你想做别的事情。你想知道是否有两个人的生日是同一天。你不关心它是一年中的哪一天,你只想知道是否有人的生日和其他人的有重叠。这种可能性有多大?原来,一般来说,远,远的可能性更大。毕竟,我们刚刚取消了某一天的限制,现在我们想要的只是在的任何一天发生碰撞。

这是碰撞阻力背后的基本思想。当哈希算法抵抗冲突时,它抵抗有目的地创建或挑选产生相同摘要的任何两个输入,而不预先决定该摘要应该是什么。

MD5 似乎相当抗冲突。有助于这一点的一个属性是,输入的小变化可以产生非常大的输出变化。考虑练习 2.1,其中您为非常相似的值生成了哈希,比如“a”和“aa”,或者“bob”和“cob”。对这些值执行 MD5 得到的摘要不仅不同,而且非常不同:

bob: 9f9d51bc70ef21ca5c14f307980a29d8
cob: 386685f06beecb9f35db2e22da429ec9

没有明显的模式可以将两者联系起来。这是由于许多哈希和加密算法共有的属性,称为雪崩属性:输入的变化,无论多小,都会在输出中产生巨大且不可预测的变化。理想情况下,50%的输出位应因微小的输入变化而改变。7].我们用“鲍勃”和“cob”做到了吗?让我们使用一些 Python 来看看二进制摘要,以帮助我们的探索(注意,我们的位串相当长,所以在清单 2-5 中它被分成两行)。

>>> hexstring = hashlib.md5(b'bob').hexdigest()
>>> hexstring
'9f9d51bc70ef21ca5c14f307980a29d8'
>>> binstring = bin(int(hexstring, 16))
>>> print("{}\n{}".format(binstring[2:66], binstring[66:]))
1001111110011101010100011011110001110000111011110010000111001010
0101110000010100111100110000011110011000000010100010100111011000

Listing 2-5Avalanche

下图显示了给定输入b'bob'b'cob'时位的变化,

MD5(bob):
   9   f   9   d   5   1   b   c   7   0   e   f   2   1   c   a
1001111110011101010100011011110001110000111011110010000111001010
   5   c   1   4   f   3   0   7   9   8   0   a   2   9   d   8
0101110000010100111100110000011110011000000010100010100111011000

MD5(cob):
   3   8   6   6   8   5   f   0   6   b   e   e   c   b   9   f
0011100001100110100001011111000001101011111011101100101110011111
   3   5   d   b   2   e   2   2   d   a   4   2   9   e   c   9
0011010111011011001011100010001011011010010000101001111011001001

Changed Bits:

X_X__XXXXXXXX_XXXX_X_X___X__XX_____XX_XX_______XXXX_X_X__X_X_X_X
_XX_X__XXX__XXXXXX_XXX_X__X__X_X_X____X__X__X___X_XX_XXX___X___X

在本例中,“bob”和“cob”的哈希之间的差异影响了 128 位中的 64 位。还不错!雪崩是一个重要的性质,我们将在第三章的密码中再次看到它。

练习 2.3。观察雪崩

比较各种输入值之间的位变化。

Avalanche 有助于防止冲突,因为很难生成一个文档,然后进行可预测的更改,这仍然会导致它生成相同的摘要。如果文档中的小变化导致摘要中不可预测的大变化,那么故意制造冲突很可能是一个困难的问题,迫使我们再次使用暴力来解决它。

还记得之前的生日类比吗?寻找碰撞并不像在原像中寻找一个值那样困难。n 位摘要的原像抵抗意味着攻击者在尝试 2 次 n 后会期望损害你的哈希,其中只需要 2 次 ( n /2) 尝试就可以找到冲突。这不是一半的尝试次数,而是一半的尝试次数中有一半的零。这种差异令人震惊。具体地说,对于 MD5,为给定的摘要寻找一个文档应该需要大约 2 次 128 次尝试,而寻找两个冲突的文档应该只需要 2 次 64 次尝试。

碰巧的是,MD5 的抗碰撞性实际上远没有那么好。它已经被“打破”,这意味着发现碰撞的技术比预期的 2 64 尝试要少得多。简而言之,这个问题可以通过某种而不是蛮力在不到一个小时内解决【17】。请记住这一点,我们稍后将回到这一点。

易消化的哈希

至此,您应该知道如何创建一个 Python 程序来计算文件的 MD5 摘要 5 。这是哈希的一个常见用法,也是一个很好的练习。请记住,您必须使用 Python 字节,而不是 Python Unicode 字符串作为输入。如果您尝试使用默认模式打开 Python 文件,它可能会将其作为文本文件打开,并将数据作为字符串读取,进行隐式解码。相反,您应该以“rb”模式打开文件,以便所有读取都产生原始字节。对于文本文件,您可能想将数据作为字符串读取,然后使用字符串的encode方法转换为字节,但是取决于配置,这种编码可能不是您所期望的,并导致令人讨厌的意外

练习 2.4。文件的 MD5

编写一个 python 程序,计算文件中数据的 MD5 和。您不需要担心文件的任何元数据,比如最后修改时间,甚至是文件名,只需要担心它的内容。

你应该检查你的练习 2.4。如果您使用的是 Ubuntu Linux 系统,那么已经安装了md5sum实用程序。使用一个文件作为输入,从命令行运行这个实用程序,看看它是否会生成与您的实用程序相同的十六进制摘要。

说到 Ubuntu,这是一个使用哈希实现文件完整性的完美例子。访问 Ubuntu 版本的网站。在写这篇文章的时候,这个网站是 https://releases.ubuntu.com 。例如,如果你看一下“仿生海狸”发行版,你会发现有许多文件可供下载。具体来说,有两个 iso,但它们可以直接获得或通过 BitTorrent 等其他下载技术获得。

还有一个文件叫 MD5SUMS。看一看。对于该发行版,该文件的内容应该如下所示:

f430da8fa59d2f5f4262518e3c177246 *ubuntu-18.04.1-desktop-amd64.iso
9b15b331455c0f7cb5dac53bbe050f61 *ubuntu-18.04.1-live-server-amd64.iso

下载后,您可以通过在 ISO 上运行 MD5 sum 来验证数据是否被破坏。

MD5 哈希值有什么帮助?它不会保护你免受危害 Ubuntu 网站的人的攻击。如果他们上传一个假的 Ubuntu 到网络服务器,他们也可以上传一个假的 MD5 总和。

然而,MD5 sum 确实让你更容易从其他来源获得 Ubuntu ISO 并知道它是可信的。例如,假设您正准备直接从 Ubuntu 网站下载 ISO 文件,这时一位同事过来告诉您,您可以使用他们在 USB 驱动器上已经下载的文件。你可以从 Ubuntu 的官方网站下载 MD5 总和的(相对较小的)文件,并在信任它们之前对照驱动器上的(大得多的)文件进行检查。

在 Ubuntu 目录中,您还会看到一个名为 SHA1SUMS 和 SHA256SUMS 的文件。这些是什么?

到目前为止,我们只讨论了 MD5 作为教授一些哈希原理的一种方法。MD5 在很长一段时间内也是加密哈希的标准方法,但它已经被打破了:人们已经发现了比暴力引发冲突更快的方法,因此它正被淘汰,取而代之的是其他哈希函数。

有趣的是,“崩溃”通常意味着“某人可以用比蛮力少一个数量级的时间解决问题”例如,这可能意味着平均在 2 次 127 次尝试中可以找到原像值,而不是 2 次 128 次。这仍然很难,只是没有想象中那么难。当看到文章指出某些东西已经损坏时,找出到底是什么意思很重要。这是否意味着基本属性之一不再成立?这是不是意味着它能保持住,但不那么难绕过?如果不止一处房产呢?这些事情很重要。

通过 MD5,研究人员找到了一种“打破”原像抗性的方法[12]。他们展示了他们可以比 2 次 128 次尝试更快地找到 MD5 哈希的前像。快了多少?嗯,他们的算法比 2 次 123 次尝试花费的时间稍长,或者用十进制表示,10,633,823,966,279,326,983,230,456,482,242,756,608 次尝试。这种攻击被认为是理论上的,因为它在实践中仍然没有用处:2 123 仍然巨大

另一方面,MD5 已经被证明在碰撞阻力方面非常、非常不可靠。创建两个产生相同 MD5 输出的输入相当容易。这已被证明能够进行实际的攻击,以获得在 TLS 中使用的假证书,TLS 用于所有类型的安全互联网通信。我们不会在这里深入讨论细节,因为我们还没有谈到证书,但是我们会在本书结尾谈到 TLS 时再讨论这个问题。

另一方面,碰撞阻力不同于第二原像阻力。请记住,当你已经有了第一个原像时,第二个原像阻力会阻止你为输出找到第二个原像。尽管 MD5 的抗碰撞能力被破坏了,但它的前像抵抗能力没有被破坏。回到我们的 Ubuntu 发行版的例子,如果你从中介那里得到你的发行版,他们不能用相同的 MD5 摘要创建一个替代发行版。

然而,Ubuntu 组织可以利用 MD5 抗冲突弱点来创建两个具有相同 MD5 和的独立发行版。或许,他们可以与一个政府合作,向另一个对前者怀有敌意的政府出售一个包含各种跟踪软件的发行版。MD5 总和不能用于确保相同的 ISO 被分发给所有各方。

此外,一旦加密算法以一种方式被破解,就越来越有可能以其他方式被破解。因此,即使没有人证明 MD5 的原像或二次原像抵抗的实际攻击,许多密码学家担心这样的漏洞存在。

重申一下本章开头的警告,不要使用 MD5 。它已经被弃用了 10 多年(十进制),它的一些安全缺陷在 20 年前就已经为人所知。

SHA-1 哈希是另一种算法,被广泛认为是 MD5 的替代品。然而,SHA-1 的碰撞阻力最近也被打破了,因为研究人员已经表明,创建两个输入哈希到同一个输出相对容易。所以,和 MD5 一样,也不要使用 SHA-1

在撰写本文时,最佳实践是使用 SHA-256。幸运的是,如果您使用的是hashlib,这对您来说意义不大:只需更改哈希函数,如清单 2-6 所示。

>>> import hashlib
>>> hashlib.md5(b'alice').hexdigest()
'6384e2b2184bcbf58eccf10ca7a6563c'
>>> hashlib.sha1(b'alice').hexdigest()
'522b276a356bdf39013dfabea2cd43e141ecc9e8'
>>> hashlib.sha256(b'alice').hexdigest()
'2bd806c97f0e00af1a1fc3328fa763a9269723c8db8fac4f93af71db186d6e90'

Listing 2-6Change to SHA-256

您应该注意到,这些不同的哈希算法有不同的长度。当然,MD5 输出 16 个字节(128 位)。如果不明显的话,SHA-1 的输出是 20 字节(160 位)。更简单地说,SHA-256 的输出是 32 字节(256 位)。

如果您认为反转 MD5(为给定的输出找到一个原像)需要很长时间,那么看看 SHA-1 吧。因为输出是 160 位,所以需要 2 次 160 次尝试,或者

1,461,501,637,330,902,918,203,684,832,716,283,019,655,932,542,976

试图找到一个前像。SHA-256 要求 2 次 256 次尝试,或者

115,792,089,237,316,195,423,570,985,008,687,907,853,269,984,665,640,564,039,457,584,007,913,129,639,936.

祝你好运!

传递哈希字...嗯...哈希密码

哈希函数的另一个常见用途是密码存储。例如,当你在一个网站上创建一个账户时,他们几乎不会存储你的密码。通常,它们存储密码的一个哈希。这样,如果网站遭到破坏,密码文件被盗,攻击者就无法恢复任何人的密码。

这是什么意思?当你发送你的密码(通过安全通道,经由 HTTPS),服务器不需要存储它来检查它。当你注册时,你的密码被哈希,并且哈希被存储。我们称之为 H (密码)。当您稍后登录时,您发送一个我们称之为“建议”的密码:您建议这是您的真实密码,服务器需要验证这一点。

因此,您尝试通过安全连接发送您建议的密码来登录,服务器现在为您提供了两件事情:它可以从您的用户名中查找 H (密码),并且它有您刚刚提交的建议。它所要做的就是检查 H (提议)= H (密码),如果相同就让你通过。

如果您不信任该服务不会实际存储您的密码,该怎么办?这可能是一个合理的担忧,特别是因为近年来我们已经看到如此多的网站被盗密码。为什么不使用 JavaScript 的力量在浏览器中哈希你的密码,然后将那个发送给服务器呢?那么服务器甚至不会在内存中看到你的密码,更不用说在数据库中了!

这有几个大问题:

  • 在你的浏览器中哈希密码的代码首先来自那个服务器,所以你仍然必须信任这个服务。

  • 如果你没有一个安全的密码通道,那么有人可以在传输中读取它。如果你有一个安全的通道,那么你也可以只发送密码。你必须已经信任这项服务。

  • 如果你成功发送了一个哈希,它就成为了你的密码。是,您可以从其他一些容易记住的东西中生成它,但是现在您还必须保护哈希值。无论如何,服务器必须对您的哈希进行哈希,这样,窃取数据库的攻击者就不能仅仅使用存储在那里的内容进行登录。

简而言之,如果你要使用一个哈希作为你的密码,正确的方法是使用一个独立于你的浏览器的工具,从你的密码和感兴趣的网站名称生成哈希,然后使用结果作为你的密码。这本质上与创建一个全新的密码并在一个安全的地方记住它是一样的,比如密码管理器。

就这么做吧。那么服务器将永远看不到你在其他地方使用的密码,因为你为它创建了一个全新的随机密码。

比起试图用哈希来解决安全问题,更好的方法是使用多种形式的认证,这些认证被证明可以使在线窃取您的身份变得更加困难,通常涉及连接到您计算机的硬件令牌。

大多数常见形式的双因素身份认证没有帮助,实际上使事情变得更糟。秘密问题就是其中之一。通常很容易得到这些问题的答案,如果不是这样,除非写下来,否则它们只是又一件很难记住的事情。另外,现在你有了几个可以用作网站密码的东西,这意味着攻击者有更多的机会通过猜测进入网站。短信已经被证明是非常脆弱的,也很容易被劫持,所以通过短信向你的手机发送代码是不好的。

正确部署的挑战响应硬件令牌不存在任何这些问题。它们是您拥有的东西,而不仅仅是您知道的另一个东西,它们不会被监听连接或伪装成其他站点的登录表单的人猜到或欺骗。它们不可能是偶然通过电话得到的,也不可能是伪造的。

最终你需要两个或更多的认证因素无论如何为了真正的安全。“修复密码”是寻找完整解决方案的错误地方。

如果正确使用了服务器端哈希,并且攻击者窃取了密码文件,他们将会看到类似这样的内容。从看能说出 smithj 的密码吗?

...
smithj,5f4dcc3b5aa765d61d8327deb882cf99
...

仔细看。你以前见过那个哈希值吗?

眼尖的读者会记得本章练习开始时的哈希值。你被要求在网上寻找这个值。你发现了什么?

该哈希值是“password”的 MD5 哈希值,是的,该密码仍然被频繁使用。但是这里更深层次的问题是哈希值是确定性的:相同的输入总是哈希到相同的输出。如果攻击者见过一次“密码”的 MD5 总和,他就能够在每个被盗的密码文件中寻找相同的摘要。我们如何解决这个问题?

首先,我们不要假设我们可以让人们停止使用愚蠢的密码。

让我们假设他们会,我们需要修复它。我们将从文摘本身开始。

回想一下,MD5 在原像抗性或二次原像抗性方面是而不是(实际上)被破坏的。因此,目前不存在将这个哈希值转化为密码的实际攻击。尽管如此, MD5 被破解,不应该使用!让我们来看看新的密码文件。

...
smithj,5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8
...

知道史密斯的密码是什么吗?是的,它仍然是“密码”,但现在在 SHA-1 下被哈希化了。好多了,对吧?哦耶, SHA-1 坏了,不能用了!我们再试一次吧!

...
smithj,5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
...

那里!终于!我们用的是没有已知漏洞的哈希算法。这样更好,但是确定性哈希的问题仍然是一个问题。如果攻击者知道这个哈希映射到“密码”的 SHA-256,那么 smithj 仍然受到威胁。

这就是“盐”的概念发挥作用的地方。salt 是一个众所周知的值,在哈希之前与用户的密码混合在一起。通过混合一个 salt 值,用户的密码将不会像现在这样立即可辨。

这种盐必须选择正确。它需要是唯一的,并且需要足够长。这样做的一种方法是使用os.urandombase64.b64encode生成一个强的、随机的 6 salt:

>>> import hashlib
>>> hashlib.md5(b'alice').hexdigest()
'6384e2b2184bcbf58eccf10ca7a6563c'
>>> hashlib.sha1(b'alice').hexdigest()
'522b276a356bdf39013dfabea2cd43e141ecc9e8'
>>> hashlib.sha256(b'alice').hexdigest()
'2bd806c97f0e00af1a1fc3328fa763a9269723c8db8fac4f93af71db186d6e90'

显然,您的 salt 输出将与代码清单中显示的不同,并且每次调用它时都会有所不同。

一旦你有了盐,你就存储它,然后把密码和盐连接起来。例如,在哈希之前在密码前面加上盐。现在,如果攻击者获得了您的密码文件,就不可能从任何类型的预先计算的表中“识别”密码。

不过,他们仍然可以尝试哈希 salt 加“密码”来查看是否有匹配的内容。猜测总是一种策略,对于大多数人的密码选择来说,这是一种特别好的策略。

很容易看出,每次检查用户密码时都必须使用相同的 salt。但是同一种盐应该被多个用户使用吗?您能为整个网站生成一次 salt 并重用它吗?

答案是一个非常强烈的“不!”你能想到为什么吗?如果两个用户使用相同的盐会有什么影响?至少,这意味着如果两个用户共享同一个密码,很容易识别出来。因此,最佳实践是将用户名和 salt 与密码哈希一起存储。

如果我们的朋友 smithj 有可怕的密码,“密码”,至少它会正确地存储在我们的系统中:

...
smithj,cei6LtJVQYSM+n6Cty0O2w==,
    bd51dac1e2fca8456069f38fcce933f1ff30a656320877b596a14a0e05db9567
...

我们现在已经走过了密码存储的基础,但还有更好的算法。它们建立在相同的原理上,但是做了额外的步骤,使得攻击者更难颠倒密码。科林·帕西瓦尔强烈推荐的一种密码存储算法叫做 scrypt ,在 RFC 7914 [16]中有描述。其他流行的还有较新的bcrypt7(https://pypi.org/project/bcrypt/)以及被一些人认为是其继承者的算法: Argon2 ( https://pypi.org/project/argon2/ )。

幸运的是,使用第一章中设置的cryptography模块,使用scrypt很容易。清单 2-7 是一个来自cryptography模块在线文档的例子。该清单导出了要存储在文件系统上的密钥(哈希)。

 1   import os
 2   from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
 3   from cryptography.hazmat.backends import default_backend
 4
 5   salt = os.urandom(16)
 6
 7   kdf = Scrypt(salt=salt, length=32,
 8                   n=2**14, r=8, p=1,
 9                   backend=default_backend())
10
11   key = kdf.derive (b"my great password")

Listing 2-7Scrypt Generate

密钥和 salt 都必须存储到磁盘上。scrypt参数必须是固定的,或者也必须被存储。我们稍后将介绍这些参数,但是首先,清单 2-8 中描述了验证(假设 salt 和 key 是从磁盘恢复的)。

1   kdf = Scrypt(salt =salt, length =32,
2                n=2**14, r=8, p=1,
3                backend=default_backend())
4   kdf.verify(b"my great password", key)
5   print("Success! (Exception if mismatch)")

Listing 2-8Scrypt Verify

选择完美的参数

关于scrypt参数,先说一下backendcryptography模块主要是一个低级引擎的包装器。例如,该模块可以利用 OpenSSL 作为这样的引擎。这使得系统更快(因为计算不是在 Python 中完成的)和更安全(因为它依赖于一个健壮的、经过良好测试的库)。纵观这本书,我们将永远依靠default_backend()

其他参数是针对scrypt的。length参数是该过程完成后密钥的长度。在这些示例中,密码被处理成 32 字节的输出。参数rnp是影响计算时间和所需内存的调优参数。为了更好地保护您的密码,您希望该过程需要更长的时间和更多的内存,以防止攻击者一次破坏大块的数据库(每次破坏都需要很长时间)。

幸运的是,推荐的参数是可用的。r参数应该是 8,p参数应该是 1。n参数可能会有所不同,这取决于您是在做一个需要给出相对快速响应的网站,还是做一个不需要快速响应的更安全的存储。无论哪种方式,它都必须是 2 的幂。对于交互式登录,建议使用 2 14 。对于更敏感的文件,高达 2 20 的数字更好。

这实际上是进入关于参数的更一般讨论的一个很好的继续。密码术中的许多安全性取决于参数是如何设置的。除非你是密码学专家,知道算法的确切细节,明白它们为什么是它们的样子,否则可能很难正确选择。至少在高层次上,熟悉这些参数的含义,以及在不同的上下文中应该如何使用它们是很重要的。参考可靠的消息来源,如 https://cryptodoneright.org ,获取意见和建议。也要留意这些来源。随着新的攻击和计算资源的出现,被认为是安全的东西可能会改变。

破解弱密码

让我们来看看攻击者是如何试图破解密码的。对 smithj 来说不幸的是,选择这样一个糟糕的密码意味着如果密码文件被盗,他很可能会受到威胁,因为攻击者无论如何都会针对所有哈希尝试常用词(包括其他被盗数据库中的词)。但是,即使是不太复杂的方法也可能猜出密码。

在本节中,我们将练习使用最简单的方法破解弱密码:暴力破解。这个练习旨在强调为什么好的密码如此重要。

场景是这样的:攻击者有一个包含用户名、salts 和密码哈希的密码文件。他们能做什么?嗯,他们可以尝试一定长度的所有小写字母组合,例如,从“a”、“b”、“c”等等开始。

为了使这些练习更容易开始,清单 2-9 显示了一些简单的代码,用于生成最大长度的字母表的所有可能组合。

1   def generate(alphabet, max_len):
2       if max_len <= 0: return
3       for c in alphabet:
4           yield c
5       for c in alphabet:
6           for next in generate(alphabet, max_len-1):
7               yield c + next

Listing 2-9
Alphabet Permutations

调用 generate( 'ab',2)会生成'a''b''aa''ab''ba''bb'。在内置字符串模块中使用有用的集合,例如

  • string.ascii_lowercase

  • string.ascii_uppercase

  • string.ascii_letters

使下面的练习变得相当容易。回想一下,哈希算法需要字节作为输入,所以在将这些生成的字符串传递给哈希函数之前,不要忘记执行一个encode操作,如下所示:

string.ascii_letters.encode('utf-8').

ASCII 字母正确地编码成字节,所以这不会导致不正确的哈希或意外的行为。

练习 2.5。一个人的力量

编写一个程序,执行以下操作 10 次(因此,10 次完整的循环,计算时间):

  • 随机选择一个小写字母。这是“原像种子”

  • 使用 MD5 计算这个首字母的哈希值。这就是“测试哈希”

  • 在一个循环中,遍历所有可能的小写单字母输入。

    • 以与之前相同的方式哈希每个字母,并与测试哈希进行比较。

    • 找到匹配的就停下来。

  • 计算找到匹配所需的时间。

平均来说,找到一个随机原像种子的匹配需要多长时间?

练习 2.6。一个人的力量,但更大!

重复前面的练习,但是使用越来越大的输入字母集。尝试用小写和大写字母进行测试。然后用小写字母、大写字母、数字试一下。最后,尝试所有可打印字符(string.printable)。

  • 每个输入集中总共有多少个符号?

  • 每次跑步需要多长时间?

练习 2.7。密码长度对攻击时间的影响

重复前面的练习,但这次是针对双符号输入。然后一次用三个和四个符号试试。反转随机选择的输入需要多长时间?

您会注意到,增加密码的长度和增加字母表的长度都会增加反转哈希所花费的时间。让我们看看数学。

当只使用小写字母时,有多少种可能的单符号输入?很简单,ASCII 中有 26 个小写字母,所以有 26 个单符号输入。在最坏的情况下,将需要 26 次哈希计算来反转一个单字母密码。但是,如果我们既有小写字母又有大写字母,这将需要的哈希数增加到 52。添加数字会使其增加到 62。string.printable有 100 个字符。这比进行强力反转所需的最坏情况下的哈希数增加了近四倍。

当我们将输入符号的大小增加到两个时会怎样?仅使用小写字母的双符号密码有多少个?如果第一个符号有 26 个字符,第二个符号有 26 个字符,那么总共有 26÷26 = 676 种组合。那是相当大的一跳!

现在看看如果你使用从 52 个大写和小写字母中抽取的两个符号会发生什么。数学计算结果是 52÷52 = 2704!对于双符号输入,将输入集的大小加倍会使复杂性增加四倍!如果加上数字,最坏的情况是 3844 个哈希,对于所有可打印的 ASCII 字符,大约是 10,000 个哈希。

计算一下三个、四个和五个符号,你会很容易明白为什么较长的密码很重要。拥有支持 GPU 的设备的黑客能够转换小于 6 个字符的任何内容,大多数密码小于 8 个字符,因此至少密码应该有那么长。出于这里展示的原因,从中选择所有可打印的字母大大增加了复杂性。

练习 2.8。更多哈希,更多时间

选择复杂的密码是用户的责任,但存储密码的系统也可以通过使用更复杂的哈希函数来减缓攻击者的速度。重复前面使用 MD5 的任何练习,但现在使用 SHA-1 和 SHA-256。记录完成蛮力操作需要多长时间。最后,使用 scrypt 尝试蛮力。你可能走不远!

最后一点。仅仅因为一个密码并不意味着它安全。攻击者还会使用大型字典来查找已知的单词和短语,即使有各种常见的数字或符号替换。像“巧克力蛋糕”这样的密码很长,但是仍然很容易被破解。随机选择的字母或单词仍然是最好的选择。关键是它们是“随机的”,这意味着你永远也不会在任何真实的作品或真实作品的普通转换中找到它们。通常,选择由常见话语组成的密码短语会将一次成功的攻击减少到,而不是

工作证明

哈希被广泛使用的另一个领域是区块链技术中所谓的“工作证明”方案。为了介绍这一点,我们需要非常快速地概述一下区块链是如何工作的。

区块链的基本思想是“分布式账本”该系统是一个分类账,因为它记录了参与者之间交易的相关信息。它还可以存储附加信息,但主要操作是事务。这是一个分布式分类账,因为它的内容存储在参与者的集合中,而不是在任何一个中心位置。

问题是没有一个中心位置来执行系统的正确性。用户如何才能避免(有意或无意地)损坏分类帐?请注意,我们不会在这里详细讨论分类帐,但是我们确实想讨论一下分类帐是由哪些块组成的。

每个事务必须存储在一个块中。一个“块”没什么特别的;它只是一个数据的集合。块内的每个交易都必须由交易者进行数字签名(我们将在第五章中更详细地讨论签名,但是现在,简单地接受这意味着没有人可以在没有他们的私钥的情况下为其他人创建交易)。整个块结构受到哈希的保护。块被复制到整个参与者集;如果有人试图对数据块的内容“撒谎”,数据将无法正确验证,他们的信息将被拒绝。

一个新的块是如何创建的,它又是如何获得保护性哈希的?在这部分讨论中,我们将使用比特币网络区块链来浏览这些概念。被称为“中本聪”的比特币设计者(或设计者,来源实际上是未知的)希望控制新区块创建的速度,也希望系统能够激励参与。解决方案是将比特币奖励给生产新区块的“矿工”,同时让新区块的生产变得非常困难。

基本上,在任何给定的时间,被称为矿工的各方都在寻找区块链的下一个区块。区块链的任何用户都可以请求交易。他们在整个区块链网络中广播他们想要的交易,矿工们会去接他们。挖掘器获取一些请求的事务集(每个块的数量有限)并创建一个候选块。这个候选块具有所有正确的信息。它有事务、元数据等等。但这并不是区块链的下一个区块,直到矿工能够解决一个密码难题。

这个难题是找到一种特殊的 SHA-256 哈希值,特别是小于某个阈值的值。正如我们之前所讨论的,找到一个产生特定输出的输入将会花费非常非常长的时间,但是找到任何小于某个值的输出将花费非常少的时间(??)。降低这个阈值会减少有效哈希的数量,需要更多的工作来找到一个合适的值,这就是比特币如何随着时间的推移调整难度,以适应更快的硬件或更大的计算池。最终,整个比特币网络需要大约 10 分钟才能找到一个合适的哈希。如果花费的时间少于几周内的平均时间,则允许的最大哈希值会减少。图 2-1 显示了两个不同的示例块,一个具有合适的 nonce(矿工试图找到的随机值,以产生可接受的哈希),另一个没有,其中最大允许哈希值是 2236–1(需要 20 个前导零)。对于比特币,允许出现问题的最简单的方法是由最大值 2224–1 决定的,这将使我们的小程序平均花费比以前多 2 12 倍的时间。这相当于 11.3 个小时,难度比今天的要难多了。

img/472260_1_En_2_Fig1_HTML.png

图 2-1

具有相同内容但不同 nonce 值的两个块哈希。产生具有 20 个前导二进制零(十六进制中的 5 个前导零)的哈希的随机数是有效的。要求 20 个前导零等同于要求哈希数小于 2∫236。

我们的节目肯定不会很快超过电视网 10 分钟的平均预期。

顺便说一下,说前几个位必须为零与说哈希值(哈希只是一个数字,就像任何其他位串一样)应该小于某个阈值(恰好是 2 的幂)是一样的。因为好的哈希函数(如 SHA-256)产生基本上随机的哈希值,你强加给哈希的结构越多,找到一个合适的就需要越长的时间。你可以通过思考零的数量来定义搜索空间的大小,从而获得一些直觉:如果你必须有一个前导零,那么这基本上是一个掷硬币的过程;平均只需要两次尝试就可以找到一个从零开始的合适的哈希。另一方面,如果你需要找到一个有 8 个前导零的哈希,这是一个更困难的问题:256 个不同的数字可以用 8 位来表示,所以平均来说需要 256 次尝试才能找到一个合适的值。

这就是为什么这种策略被称为“工作证明”:如果你在阈值以下找到了一个合适的哈希,你必须做一些工作(或者你破坏了哈希函数,这被认为是极不可能的,但对你来说可能很棒)。

这提出了一个有趣的问题:每个网络参与者如何决定问题应该有多难?举例来说,并不是有一个中央权威告诉每个人,难度只是从 11 增加到 12。这将违背整个网络的宗旨。网络中的“权威”是参与者之间的默契,使用相同的算法来确定这些事情。当网络上有人以不同的方式做事时,他们的阻碍会被其他人拒绝,因此他们没有动机去做错事。少数服从多数。

在哈希困难的特定情况下,每个参与者都知道计算前导零的数量的标准算法,并使用该算法来进行挖掘(或者拒绝任性的参与者提出的想要计算简单哈希的糟糕建议)。

但是,您可能会问,当输入数据实际上没有变化时,如何计算不同的 hash 值。这是一个很好的问题,因为哈希是确定性的:给定相同的输入,它们总是产生相同的输出(否则它们不会很有用!).答案是他们改变了一小部分输入,称为“现时”它只是一个数字,并不是实际块数据的一部分:它的唯一目的是启用工作证明概念。当搜索合适的哈希时,参与者尝试用不同的随机数值对块进行哈希,通常是随机搜索,或者每次尝试只对最后一个值加 1。最终找到一个合适的哈希值,并将该块发送给所有其他参与者进行验证。

然后,每个参与者通过自己执行哈希来验证该块,根据他们的算法检查前导零,并确保他们的答案与提交的哈希值匹配。如果它是好的,他们接受它,链条就变长了。

练习 2.9。工作证明

编写一个程序,将一个计数器输入 SHA-256,获取输出哈希并将其转换为整数(在转换为二进制之前,我们已经这样做了)。让程序重复运行,直到找到一个小于目标数的哈希值。目标数字一开始应该很大,比如 2 255 。为了使这更像区块链,包括一些任意字节与计数器结合。

是时候重复了

我们已经介绍了很多关于什么是哈希以及如何使用它们的信息,包括为什么你永远不应该使用 MD5,除非你告诉人们它是坏的,以及如何使用它们来实现更安全的密码存储甚至加密货币。哈希是密码学中一个强大而重要的部分,随着我们的发展,我们会一次又一次地看到它。

既然我们已经了解了如何将一个文档分解成一个安全的有代表性的值,那么是时候回顾一下加密了。

**

三、对称加密:双方,一个密钥

对称加密是所有现代安全通信的基础。我们用它来“扰乱”信息,这样,只有当人们能够使用加密信息的同一密钥时,他们才能解密信息。这就是“对称”在这种情况下的含义:在通信信道的两端使用一个密钥来加密和解密消息。

我们抢吧!

不出所料,东南极的反派 1 又来了,给他们的邻居带来各种麻烦。这一次,爱丽丝和鲍勃正在监视西边的敌军,侦察他们雪球的大小和投掷的准确性。

在早期的任务中,爱丽丝和鲍勃使用第一章中的凯撒密码来保护他们的信息。正如你所发现的,这个密码很容易破解。因此,东南极洲真相间谍机构(EATSA)为他们配备了现代加密技术,使用一把钥匙来编码和解码秘密信息。这项新技术属于一种被称为对称密码的加密算法,因为加密和解密过程使用相同的共享密钥。他们在这个后外星人时代使用的特定算法是高级加密标准(AES)。22

Alice 和 Bob 不太了解如何正确护理和处理不良事件。他们有足够的文档来进行加密和解密。

“医生说我们必须创建 AES 密钥,”爱丽丝拿着一本手册说。“显然,这相当容易。我们这里有样本代码。”

import os
key = os.urandom(16)

“等等...真的吗?”鲍勃问道。“就这样?”

爱丽丝是对的:这就是全部!AES 密钥只是随机位:在本例中有 128 位(相当于 16 个字节)。这将允许我们使用 AES-128。

创建了随机密钥后,我们如何加密和解密消息呢?前面,我们使用 Python cryptography模块来创建哈希。它还做许多其他事情。让我们看看 Bob——受到创建密钥的便利性的鼓舞——现在如何使用它通过 AES 加密消息。

Bob 从 Alice 那里拿过文档,看了下一节,注意到 AES 计算有许多不同的模式。必须在它们之间做出选择听起来有点让人不知所措,所以 Bob 挑选了看起来最容易使用的一个。

“我们用欧洲央行模式吧,爱丽丝,”他从文件上抬起头说。

“欧洲央行模式?那是什么?”

“我真的不知道,但这是高级加密标准。应该一切正常,对吧?”

警告:欧洲央行:不适合你

我们稍后会发现欧洲央行模式很糟糕,应该永远不使用 ?? 模式。但我们现在只能跟着走。

清单 3-1 有他们用来创建“加密器”和“解密器”的代码

 1   # NEVER USE: ECB is not secure!
 2   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 3   from cryptography.hazmat.backends import default_backend
 4   import os
 5
 6   key = os.urandom(16)
 7   aesCipher = Cipher(algorithms.AES(key),
 8                      modes.ECB(),
 9                      backend=default_backend())
10   aesEncryptor = aesCipher.encryptor()
11   aesDecryptor = aesCipher.decryptor()

Listing 3-1AES ECB Code

“那还不算太坏,”爱丽丝说。“现在发生了什么?”

显然,加密器和解密器都有一个update方法。差不多就是这样。加密器的更新返回密文。

练习 3.1。秘密消息

不看其他文档,试着弄清楚aesEncryptor.update()aesDecryptor.update()方法是如何工作的。提示:您将会得到一些意想不到的行为,所以尝试大量的输入。考虑从b"a secret message"开始,然后解密结果。

爱丽丝和鲍勃开始尝试找出update的方法。也许是受到上一章哈希的启发,在那里他们哈希自己的名字,他们尝试在一个交互式 Python shell 中加密自己的名字。爱丽丝先来。

这里的 AES 示例代码使用了键b"\x81\xff9\xa4\x1b\xbc\xe4\x84\xec9\x0b\x9a\xdbu\xc1\x83",以防您想要获得相同的结果。

>>> aesEncryptor.update(b'alice')
b''

“我没有收到任何密文,”爱丽丝抱怨道。“我做错了什么?”

“我不知道。让我试试,”鲍勃回答。

>>> aesEncryptor.update(b'bob')
b''

“我也是,”他困惑地说。出于沮丧,他又试了几次。

>>> aesEncryptor.update(b'bob')
b''
>>> aesEncryptor.update(b'bob')
b''
>>> aesEncryptor.update(b'bob')
b'\xe7\xf9\x19\xe3!\x1d\x17\x9f\x80\x9d\xf5\xa2\xbaTi\xb2'

“等等!”爱丽丝阻止了他。“你有东西!”

“诡异!”鲍勃惊呼道。“我没有做任何不同的事情。发生了什么事?”

“现在试着解密它,”爱丽丝建议道。

>>> aesDecryptor.update(_)
b'alicebobbobbobbo'

Alice 和 Bob 又玩了一会儿,并重新阅读了文档,了解到您已经从练习中发现的东西:用于加密和解密的update函数总是一次处理 16 个字节。调用少于 16 字节的 update 不会立即产生结果。相反,它会累积数据,直到至少有 16 个字节可以处理。一旦 16 个或更多字节可用,就产生尽可能多的 16 字节密文块。如图 3-1 所示。

img/472260_1_En_3_Fig1_HTML.png

图 3-1

update方法的两次调用。前 8 个字节不返回任何内容,因为还没有完整的数据块要加密。

练习 3.2。最新技术

从第一章升级 Caesar cipher 应用以使用 AES。与其指定一个移位值,不如弄清楚如何让进出程序。您还必须处理 16 字节消息大小的问题。祝你好运!

到底什么是加密?

对于那些听说过密码学的人来说,加密可能是他们听说得最多的。网站和在线服务通常会提到加密,以向您保证您的信息是“安全的”它们通常会包含类似“所有通过互联网传输的数据都受到 128 位加密保护,防止被盗”的语句

你不是已经感觉好多了吗?

像这样的声明实际上只是营销。它们听起来不错,但通常没有多大意义。这是因为“加密”包括像凯撒密码这样容易破解的东西,它本身也不足以保证通信安全。在密码学中,有几个属性有助于不同方面的安全,它们需要协同工作。1].这些特性通常被认为是最重要的:

  1. 机密

  2. 完整

  3. 证明

我们在本章探索的加密都是关于机密性的。保密性意味着只有拥有正确密钥的人才能够读取数据。我们使用加密来保护信息,这样外人就无法阅读。

同样重要的是诚信完整性意味着数据不能在你没有注意到的情况下被更改。理解这一点很重要,因为某些东西不能被读取并不意味着它不能被有效地改变。为了说明这一点,我们将在这一章中进行这种恶作剧。

最后,认证与了解你与之通信的一方的身份有关。认证通常包括一些机制来建立身份和存在、 3 以及将通信绑定到已建立的身份的能力。

很明显,这三个属性在许多交流形式中都是必不可少的。如果伊芙可以在他们不知道的情况下改变消息的内容,保密对爱丽丝和鲍勃没什么好处:伊芙不需要阅读消息来引起真正的问题。同样,如果 Alice 和 Bob 不确定在信道的另一端是否有合适的人,他们的秘密通信也不会成功。

当你阅读这一章时,请记住这些想法!我们对保密性的关注有助于展示,它确实是安全性的一个重要组成部分,但这还不够。花一些时间在保密本身上,将有助于我们证明,没有它的朋友,保密是多么的不充分。

AES:一种对称分组密码

如前所述,对称加密背后的思想是加密和解密使用相同的密钥。在现实世界中,几乎所有物理锁的钥匙都可以被认为是“对称的”:锁门的同一把钥匙也可以开锁。还有其他非常重要的加密方法,对每个操作使用不同的密钥,但是我们将在后面的章节中讨论这些方法。

对称密钥加密算法通常分为两个子类型:分组密码流密码。块密码的名字来源于它对数据块的工作:在它能做任何事情之前,你必须给它一定量的数据,并且较大的数据必须被分解成块大小的块(而且,每个块必须是满的)。另一方面,流密码可以一次加密一个字节的数据。

AES 基本上是一种对称密钥、分组密码算法。无论如何,这都不是唯一的一个,但却是我们在这里关注的唯一一个。它用于许多常见的互联网协议和操作系统服务,包括 TLS(由 HTTPS 使用)、IPSec 和文件级或全磁盘加密。鉴于其无处不在,它可以说是知道如何正确使用的最重要的密码。更重要的是,AES 的正确使用原则很容易转移到其他密码的正确使用。

最后,尽管 AES 本质上是一种分组密码,但它(像许多其他分组密码一样)可以像流密码一样使用,因此我们不会因为将本机流密码排除在讨论之外而失去任何教学机会。在过去,RC4 是一种常用的流密码,但是已经发现它容易受到各种攻击,并且正在被 AES 的流模式所取代。

还有,就像 Bob 说的,“这是高级!“这对任何人来说都足够了,对吧?

练习 3.3。历史课

在网上做一些关于 DES3DES 的研究。DES 的块大小是多少?它的密钥大小是多少?3DES 如何强化 DES?

练习 3.4。其他密码

做一些关于 RC4 和两条鱼的研究。它们用在哪里?RC4 有什么样的问题?Twofish 比 AES 有哪些优势?

因为 AES 是一个很好的起点,所以让我们深入了解一些背景知识。我们知道这是一种对称密钥分组密码。根据我们看到的 Alice 和 Bob 使用它的尝试,您能猜出块的大小吗?

如果你在想“16 字节!”(128 位),你得到一颗金星。告诉你所有的朋友! 4

AES 有几种工作模式,允许我们实现不同的加密属性:

  1. 电子代码簿(ECB) ( 警告!危险! )

  2. 密码分组链接(CBC)

  3. 计数器模式

这些并不是 AES 的唯一工作模式。7].事实上,虽然 CBC 和 CTR 仍在使用,但现在推荐使用一种称为 GCM 的新模式在许多情况下取代它们,我们将在本书后面详细研究 GCM。然而,这三种模式非常有指导意义,它们一起涵盖了最重要的概念。它们将提供一个坚实的基础,在此基础上建立对分组密码,特别是 AES 的更好理解。

ECB 不适合我

请注意,依赖欧洲央行模式来保障安全是不负责任的危险行为,?? 永远不应该使用这种模式。请将它视为仅用于测试和教育目的。请不要在您的应用或项目中使用它!说真的。你已经被警告了。别让我们过去。

顺便说一句,你看到这里的发展模式了吗?有时候解释一件事情的最佳方法根本不适合在实践中使用它。这似乎特别适用于密码学,这也是我们敦促人们总是使用成熟的库而不是构建自己的库的一个原因。基本的原则很简单,但是如果没有成熟库所具有的复杂特性和对如何使用它们的深刻理解,仅仅这些原则将会给你非常差的安全性,而不仅仅是“稍微不完美”的安全性。很少有中间立场;保险箱一旦被破解,它的壁有多厚都没用。密码概念通常很简单,但是安全和正确的实现通常很复杂。

随着所有这些警告的消失(事实上,还会有更多),欧洲央行是什么?在某种程度上,ECB 是“原始”AES:它独立处理每个 16 字节的数据块,使用提供的密钥以完全相同的方式加密每个数据块。正如我们将在计数器模式和密码块链接模式中看到的,有很多有趣的方法可以将这种方法用作更高级(更安全)密码的构建块,但这真的不是加密本身的好方法

“电子密码本”这个名字可以追溯到早期的密码本,你可以拿着你的(小)钥匙,找到书上正确的那一页,使用那一页上的表格来查找与你的输入(明文)的每一部分相对应的输出(密文)。AES ECB 模式可以这样想,但要用一本大得令人难以置信的书。关键相似度(哈!)就是一旦有了密钥,每个可能的块的加密值都是已知的,解密也是如此;就像我们正在查找它们一样,如图 3-2 所示。

img/472260_1_En_3_Fig2_HTML.png

图 3-2

ECB 模式类似于一个从明文到密文的大字典。每 16 字节的明文都有一个相应的 16 字节输出。

正如我们将看到的,确定性和独立性的属性对于消息安全性是有用的,但不是充分的属性。ECB 模式非常有用,因为它可以用于测试,例如,确保 AES 算法按预期运行。一些系统会选择一个特殊的密钥,比如全零,作为“测试密钥”作为自检的一部分,系统将使用测试密钥在 ECB 模式下运行 AES,以查看它是否如预期的那样加密。你有时会看到这种被称为“KATs”(已知答案测试)的测试。

美国国家标准和技术研究所(NIST)发布了一个用于实现验证的 kat 列表。你可以从 https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Algorithm-Validation-Program/documents/aes/KAT_AES.zip 下载这些 kat 的 zip 文件。该档案包含响应(。rsp)文件,为给定的输入标识预期的输出。例如,在ECBGFSbox128.rsp文件中,前四个加密条目是

COUNT = 0
KEY = 00000000000000000000000000000000
PLAINTEXT = f34481ec3cc627bacd5dc3fb08f273e6
CIPHERTEXT = 0336763e966d92595a567cc9ce537f5e

COUNT = 1
KEY = 00000000000000000000000000000000
PLAINTEXT = 9798c4640bad75c7c3227db910174e72
CIPHERTEXT = a9a1631bf4996954ebc093957b234589

COUNT = 2
KEY = 00000000000000000000000000000000
PLAINTEXT = 96ab5c2ff612d9dfaae8c31f30c42168
CIPHERTEXT = ff4f8391a6a40ca5b25d23bedd44a597

COUNT = 3
KEY = 00000000000000000000000000000000
PLAINTEXT = 6a118a874519e64e9963798a503f1d35
CIPHERTEXT = dc43be40be0e53712f7e2bf5ca707209

这似乎很有用。让我们使用清单 3-2 来测试这个理论。

 1   # NEVER USE: ECB is not secure!
 2   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 3   from cryptography.hazmat.backends import default_backend
 4
 5   # NIST AES ECBGFSbox128.rsp ENCRYPT Kats
 6   # First value of each pair is plaintext
 7   # Second value of each pair is ciphertext
 8   nist_kats = [
 9       ('f34481ec3cc627bacd5dc3fb08f273e6', '0336763e966d92595a567cc9ce537f5e'),
10       ('9798c4640bad75c7c3227db910174e72', 'a9a1631bf4996954ebc093957b234589'),
11       ('96ab5c2ff612d9dfaae8c31f30c42168', 'ff4f8391a6a40ca5b25d23bedd44a597'),
12       ('6a118a874519e64e9963798a503f1d35 ', 'dc43be40be0e53712f7e2bf5ca707209')
13   ]
14
15   # 16–byte test key of all zeros.
16   test_key = bytes.fromhex('00000000000000000000000000000000')
17
18   aesCipher = Cipher(algorithms.AES(test_key),
19                      modes.ECB(),
20                      backend=default_backend())
21   aesEncryptor = aesCipher.encryptor()
22   aesDecryptor = aesCipher.decryptor()
23
24   # test each input
25   for index, kat in enumerate(nist_kats):
26       plaintext, want_ciphertext = kat
27       plaintext_bytes = bytes.fromhex(plaintext)
28       ciphertext_bytes = aesEncryptor.update(plaintext_bytes)
29       got_ciphertext = ciphertext_bytes.hex()
30
31       result = "[PASS]" if got_ciphertext == want_ciphertext else "[FAIL]"
32
33       print("Test {}. Expected {}, got {}. Result {}.".format(
34           index, want_ciphertext, got_ciphertext, result))

Listing 3-2AES ECB KATs

假设您的处理器工作正常,您应该会看到 4/4 的及格分数。

练习 3.5。所有 NIST kat

编写一个程序来读取这些 NIST KAT“RSP”文件之一,并解析出加密和解密 KAT。在几个 ECB 测试文件的所有向量上测试和验证您的 AES 库。

这一切似乎都很合理。那么,ECB 怎么了?除非你已经完全睡着了,否则你会注意到我们关于它的可怕警告。为什么呢?简单地说,因为它的独立性。

让我们回到爱丽丝、鲍勃和他们在南极洲的死对头夏娃。爱丽丝和鲍勃在南极洲西部边界执行秘密任务。他们会通过伊芙能监听到的无线电频道互相发送秘密信息。在他们离开之前,他们生成一个共享密钥来加密和解密他们的消息,并且他们在旅行中保持这个密钥的安全。

我们也能做到。我们将从生成一个密钥开始。通常情况下,密钥是随机的,但我们将只选择一个容易记住的,然后我们也可以完美地重现以下结果。这是关键:

key = bytes.fromhex('00112233445566778899AABBCCDDEEFF')

爱丽丝和鲍勃是政府特工,他们使用标准的 EATSA 表格互相发送信息。例如,要安排会议:

FROM: FIELD AGENT<codename>
TO: FIELD AGENT<codename>
RE: Meeting
DATE: <date>

Meet me today at <location> at <time>

如果 Alice 告诉 Bob 晚上 11 点在码头见她,那么消息应该是

FROM: FIELD AGENT ALICE
TO: FIELD AGENT BOB
RE: Meeting
DATE: 2001-1-1

Meet me today at the docks at 2300.

我们将使用之前设定的密钥对该消息进行加密。我们需要填充消息以确保它是 16 字节长度的倍数。我们可以在末尾添加额外的字符,直到它的长度是 16 的倍数,就像这样。 5

 1   # NEVER USE: ECB is not secure!
 2   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 3   from cryptography.hazmat.backends import default_backend
 4
 5   # Alice and Bob's Shared Key
 6   test_key = bytes.fromhex('00112233445566778899AABBCCDDEEFF')
 7
 8   aesCipher = Cipher(algorithms.AES(test_key),
 9                      modes.ECB(),
10                      backend=default_backend())
11   aesEncryptor = aesCipher.encryptor()
12   aesDecryptor = aesCipher.decryptor()
13
14   message = b"""
15   FROM: FIELD AGENT ALICE
16   TO: FIELD AGENT BOB
17   RE: Meeting
18   DATE: 2001-1-1
19
20   Meet me today at the docks at 2300."""
21
22   message += b"E" * (-len(message) % 16)
23   ciphertext = aesEncryptor.update(message)

Listing 3-3AES

ECB Padding

清单 3-3 显示了一个简单但可能不是最佳的填充。我们将在下一节使用更多的标准方法。然而,就目前而言,这已经足够好了。当 Bob 解码他的消息时,它只是在结尾有几个额外的“E”字符。

练习 3.6。给鲍勃发信息

使用前面程序的修改版或本章开头的 AES 加密器,创建两条从 Alice 到 Bob 的 meetup 消息。也创造一些从鲍勃到爱丽丝。确保您可以正确地加密和解密消息。

有了新的加密技术,爱丽丝和鲍勃开始在西南极洲进行监视。他们偶尔会面,分享信息,协调行动。

与此同时,伊夫和她的反情报同事了解到渗透,并很快开始识别编码信息。从 Eve 的角度看一下 Alice 给 Bob 的几条消息,她能看到的只有密文。你注意到什么了吗?

考虑这两条消息:

FROM: FIELD AGENT ALICE
TO: FIELD AGENT BOB
RE: Meeting
DATE: 2001-1-1

Meet me today at the docks at 2300.

FROM: FIELD AGENT ALICE
TO: FIELD AGENT BOB
RE: Meeting
DATE: 2001-1-2

Meet me today at the town square at 1130.

并排查看这些消息的两个密文输出。注意:即使是间距和换行符也很重要,所以要确保使用所示的格式。

| 消息 1,块 1 | `a3a2390c0f2afb700959b3221a95319a` | | 消息 2,块 1 | `a3a2390c0f2afb700959b3221a95319a` | | 消息 1,块 2 | `0fd11a5dcfa115ba89630f93e09312b0` | | 消息 2,块 2 | `0fd11a5dcfa115ba89630f93e09312b0` | | 消息 1,块 3 | `87597bf7f98759410ae3e9a285912ee6` | | 消息 2,块 3 | `87597bf7f98759410ae3e9a285912ee6` | | 消息 1,块 4 | `8430e159229e4bf5c7b39fe1fb72cfab` | | 消息 2,块 4 | `8430e159229e4bf5c7b39fe1fb72cfab` | | 消息 1,块 5 | `a5c7412fda6ac67fe63093168f474913` | | 消息 2,块 5 | `c9b3ccefda71f286895b309d85245421` | | 消息 1,块 6 | `dbd386db053613be242c6059539f93da` | | 消息 2,块 6 | `699f1cd5adbeb94b80980a0860ead320` | | 消息 1,块 7 | 800 D3 ECE 3b 12931 be 974 f 36 ef 5 da 4342 | | 消息 2,块 7 | S7-1200 可编程控制器 |

16 字节块中有多少是相同的?为什么呢?

请记住,原始模式下的 AES 就像一本代码书。对于每个输入和键,都只有一个输出,独立于任何其他输入。因此,因为大部分消息头是在消息之间共享的,所以大部分输出也是相同的。

Eve 和她的同事注意到他们日复一日看到的信息中的重复元素,并很快开始理解它们的含义。他们是怎么做到的?他们可能会以猜测作为一个良好的开端。如果你看到同样的信息被重复发送,你可以开始猜测它的一些内容。

另一种取得进展的方法可能是利用敌人组织中的逃兵或内奸。他们可以想象得到 Eve 一份表格的拷贝或者一个被丢弃的解码信息。总的来说,对手有很多种方法可以了解加密消息的结构和组织,你永远也不应该假设不是这样。那些试图保护信息的人所犯的一个常见错误是假设敌人无法知道系统如何工作的一些细节。

相反,永远按照克霍夫的原则生活。这位 19 世纪(远在现代计算机之前)的密码学家教导说,即使除了密钥之外的所有信息都是已知的,密码系统也必须是安全的。这意味着,如果敌人知道我们系统的所有信息,只是缺少获取密钥的途径,我们应该找到一种方法来确保我们的信息安全。

我们用过度官僚化的形式做了这个愚蠢的例子,但是即使在真实的消息中,也经常有大量可预测的结构。考虑 HTML、XML 或电子邮件。这些通常有大量可预测的定位相同的数据。对于窃听者来说,仅仅因为一条消息与其他所有消息共享协议头就开始了解消息中的内容,这将是一件可怕的事情。

更糟糕的是,想象一下如果 Eve 的团队能够想出一种方法来进行所谓的“选择明文”攻击。在这次攻击中,他们想出了一个让 Alice 或 Bob 代表他们加密的方法。想象一下,例如,他们发现爱丽丝总是在西南极洲的总理发表公开演讲后召集鲍勃开会。一旦他们知道了这一点,他们就可以利用政治演讲来引发一个信息,其中大部分内容都是已知的。或者他们设法塞给鲍勃一些假信息,加密后发给爱丽丝。一旦他们可以控制部分或全部明文,他们就可以查看加密并开始创建他们自己的密码本

Eve 也可以很容易地通过将旧信息的片段组合在一起来创建新信息。如果 Eve 知道密文的第一个块是带有当前日期的标题,她可以获取一个旧的消息体,将 Bob 指向一个旧的会议站点,并将其附加到新的标题。然后鲍勃在错误的时间出现在了错误的地方。

练习 3.7。给鲍勃发了一条假消息

从 Alice 到 Bob 拿两份不同的密文,上面有不同日期的不同会议指示。将第一条消息正文中的密文拼接到第二条消息正文中。也就是说,首先用前一个消息的最后一个块(如果更长,则为多个块)替换新消息的最后一个块。消息解密了吗?你改变鲍勃去哪里见爱丽丝了吗?

所有这一切可能看起来仍然只是一点假设。或许欧洲央行模式并不真的那么糟糕。也许只有在极端的情况下或者类似的情况下才是不好的。为了以防万一,让我们再做一个测试(一个非常有趣的测试)来说服我们自己,ECB 模式永远不应该用于真正的消息保密。

在这个实验中,您将构建一个非常基本的 AES 加密程序。用什么键不重要;随意生成一个随机的,或者使用一个固定的测试密钥。读入一个二进制文件,加密除前 54 个字节以外的所有内容,然后写出到一个新文件。它可能看起来像清单 3-4 。66

1   # Partial Listing: Some Assembly Required
2
3   ifile, ofile = sys.argv[1:3]
4   with open(ifile, "rb") as reader:
5       with open(ofile, "wb+") as writer:
6           image_data = reader.read()
7           header, body = image_data[:54], image_data[54:]
8           body += b"\x00"*(16-(len(body)%16))
9           writer.write(header + aesEncryptor.update(body))

Listing 3-4AES Exercise Example

我们不加密前 54 个字节的原因是因为这个程序要加密位图文件(BMP)的内容,而文件头的长度是 54 个字节。一旦你写好了这个清单,在你选择的图像编辑器中,创建一个大的图像,文本占据了大部分的空间。在图 3-3 中,我们的图像简单地写着“绝密”它是 800x600 像素。

img/472260_1_En_3_Fig3_HTML.jpg

图 3-3

带有文本“绝密”的图像加密后应该就不可读了,对吧?

获取您新创建的文件,并通过您的加密程序运行它,将输出保存到类似于encrypted_image.bmp的地方。完成后,在图像浏览器中打开加密文件。你看到了什么?

我们的加密图像如图 3-4 所示。

img/472260_1_En_3_Fig4_HTML.jpg

图 3-4

这张图像是用 ECB 模式加密的。这个消息不是很机密。

这里发生了什么?为什么图片的文字还是那么易读?

AES 是一种分组密码,一次处理 16 个字节。在这个图像中,许多 16 字节的块是相同的。一块黑色像素在任何地方都用相同的比特编码。每当有一个全黑或全白的 16 字节块时,它们就编码成相同的加密输出。因此,即使单个的 16 字节块被加密,图像的结构仍然可见。

真的。千万不要用 ECB。把这种事情留给东南极洲真相调查机构的“专业人士”吧。

通缉:自发独立

为了获得有效的密码,我们需要

  • 每次以不同的方式加密相同的消息。

  • 消除块之间的可预测模式。

为了解决第一个问题,我们使用了一个简单但有效的技巧来确保我们永远不会发送相同的明文两次,这意味着我们也永远不会发送相同的密文两次!我们用一个“初始化向量”或 IV 来做这件事。

IV 通常是一个随机字符串,用作加密算法中除密钥和明文之外的第三个输入。具体如何使用取决于模式,但其思想是防止给定的明文加密成可重复的密文。与密钥不同,IV 是公共的。也就是说,假设攻击者知道或者能够获得 IV 的值。静脉注射的存在并不能帮助事情保密,反而有助于防止它们被重复,避免暴露共同的模式。

至于第二个问题,即能够消除块之间的模式,我们将通过引入新的方法来解决这个问题,将消息作为一个整体*进行加密,而不是像 ECB 模式那样将每个块视为单独、独立的迷你消息。

每个解决方案的细节都特定于所使用的模式,但是原则可以很好地概括。

不是那个区块链

回想一下第二章,好的哈希算法应该具有雪崩属性。也就是说,一个输入位的单一变化将导致大约一半的输出位发生变化。分组密码应该有类似的特性,谢天谢地,AES 有。然而,在 ECB 模式下,雪崩的影响仅限于块的大小:如果明文有十个块长,则第一个位的变化只会改变第一个块的输出位。其余九个街区将保持不变。

如果一个块的密文的改变会影响所有的后续的块呢?嗯,可以,而且很容易实现。例如,在加密时,可以将一个块的加密输出与下一个块的未加密输入进行异或运算。为了在解密时反转这一点,对密文进行解密,然后对先前的密文块再次应用 XOR 运算,以获得明文。这被称为密码块链接(CBC)模式

让我们在这里暂停片刻,回顾一下称为 XOR 的运算,通常象征性地写成⊕.我们将在整本书中不断使用 XOR,因此有必要回顾一下。XOR 是一个二进制布尔运算符,具有下面的真值表(这里我们用 0 和 1 代替“假”和“真”)。

|

输入 1

|

输入 2

|

输出

|
| --- | --- | --- |
| Zero | Zero | Zero |
| Zero | one | one |
| one | Zero | one |
| one | one | Zero |

真值表很有用,它精确地显示了像 XOR 这样的函数在所有输入组合下的行为,但是实际上你不需要在这个层次上考虑 XOR。重要的是 XOR 有一个惊人的反转性质:XOR 运算是它自己的逆!也就是说,如果你从某个二进制数 A 开始,并与 B 进行异或运算,你可以通过再次与 B 进行异或运算来恢复 A 。数学上看起来是这样的:(abb=a

为什么会这样?如果你把“输入 1”看成一个控制位,当它是 0 时,出来的就是简单的“输入 2”另一方面,当“输入 1”为 1 时,输出的是“输入 2”的倒数如果您获取输出并再次对“输入 1”应用 XOR,它会保持之前未更改的内容不变(再次对 0 进行 XOR),同时将反转的内容翻转回原来的样子(再次对 1 进行 XOR)。

我们经常不是对单个比特进行异或运算,而是对比特序列同时进行异或运算。这就是我们在本书中使用 XOR 的方式:作为位块之间的运算,就像这样:

$$ \frac{\begin{array}{cc}& 11011011\ {}\oplus & 10110001\end{array}}{\frac{\begin{array}{cc}& 01101010\ {}\oplus & 10110001\end{array}}{\kern2.5em 11011011}} $$

你可以在这里看到如何应用⊕10110001 两次到 11011011 导致它再次出现。

练习 3.8。XOR 练习

因为我们会经常使用 XOR,所以熟悉 XOR 运算是个好主意。在 Python 解释器中,对几个数字进行异或运算。Python 支持直接使用^作为运算符的异或运算。例如,5⁹ 的结果是 12。当你尝试 12⁹ 时,你会得到什么?当你尝试 12⁵ 时,你会得到什么?用几个不同的数字试试这个。

练习 3.9。XOR-O 的面具?

虽然这个练习在计数器模式下更重要,但是理解 XOR 如何被用来屏蔽数据还是很有用的。创建 16 字节的明文(16 个字符的消息)和 16 字节的随机数据(例如,使用os.urandom(16))。将这两条消息进行异或运算。没有内置的对一系列字节进行异或运算的操作,所以你必须单独对每个字节进行异或运算,比如使用一个循环。完成后,看一下输出。它的“可读性”如何?现在,再次将这个输出与相同的随机字节进行 XOR 运算。现在输出是什么样子的?

从我们的 XOR 中断返回到 CBC,在这种模式下,我们将一个密文块的输出与下一个明文块进行 XOR 运算。更准确地说,如果我们称明文的 P [ nn 和“加密前明文”的P′[nn (使用 XOR 运算来完成非常科学地命名为“加密”的过程),我们首先从先前加密的块C创建P′[n创建P′[n的公式如下:**

$$ {P}^{\prime}\left[n\right]=P\left[n\right]\oplus C\left[n-1\right], $$

从那里,我们可以将 AES 加密应用于P'[n],这是一个 AES 块的长度,以获得 C [ n 。当解密时,我们得不到明文,我们得到的是“加密前的明文”P′[n。要获得实际的明文,我们需要颠倒前面的过程,我们可以通过对它与前面的加密块进行 XOR 运算来做到这一点(回想一下 XOR 是它自己的逆运算)。通过执行一些基本的代数运算,您可以明白为什么这样做:

$$ {\displaystyle \begin{array}{l}\kern7.75em {P}^{\prime}\left[n\right]=P\left[n\right]\oplus C\left[n-1\right]\ {}\kern3.875em {P}^{\prime}\left[n\right]\oplus P\left[n\right]=P\left[n\right]\oplus P\left[n\right]\oplus C\left[n-1\right]\ {}\kern3.875em {P}^{\prime}\left[n\right]\oplus P\left[n\right]=C\left[n-1\right]\ {}{P}^{\prime}\left[n\right]\oplus {P}^{\prime}\left[n\right]\oplus P\left[n\right]={P}^{\prime}\left[n\right]\oplus C\left[n-1\right]\ {}\kern7.875em P\left[n\right]={P}^{\prime}\left[n\right]\oplus C\left[n-1\right].\end{array}} $$

因此,为了在解密时得到原始明文,我们只需要将解密的块与先前加密的块进行异或运算。没有前身的第一个块在解密后简单地与初始化向量进行异或运算。这就是 CBC 模式的本质:每个块都依赖于之前的块。这个过程在图 3-5 中被形象化了,或许更直观一点。

img/472260_1_En_3_Fig5_HTML.png

图 3-5

CBC 加密和解密的可视化描述。请注意,在加密中,第一个明文块在 AES 之前与 IV 进行异或运算,而在解密中,密文首先经过 AES,然后与 IV 进行异或运算,以正确反转加密过程。

在 CBC 模式下,任何输入模块的变化都会影响所有后续模块的输出模块。这不会产生完整或完美的雪崩属性,因为它不会影响任何在块之前的,但是即使向前移动雪崩效应也会防止暴露我们在 ECB 模式中观察到的各种模式。

CBC 模式的配置是最熟悉的:我们生成一个密钥,然后采取额外的步骤生成一个初始化向量(IV)。因为 IV 与第一个块进行异或运算,所以 AES-CBC IV8总是 128 位长(16 字节),即使密钥大小更大(通常为 196 或 256 位)。在下面的例子中,密钥是 256 位,IV 是 128 位,这是必须的(清单 3-5 )。

 1   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 2   from cryptography.hazmat.backends import default_backend
 3   import os
 4
 5   key = os.urandom(32)
 6   iv = os.urandom(16)
 7
 8   aesCipher = Cipher(algorithms.AES(key),
 9                      modes.CBC(iv),
10                      backend=default_backend())
11   aesEncryptor = aesCipher.encryptor()
12   aesDecryptor = aesCipher.decryptor()

Listing 3-5AES-CBC

注意,在这个例子中,algorithms.AES将密钥作为参数,而modes.CBC将 IV 作为参数;AES 总是需要一个密钥,但是 IV 的使用取决于模式。

适当的填充

当我们在做改进的事情时,让我们引入一个更好的填充机制。cryptography模块提供了两种方案,一种遵循所谓的 PKCS7 规范,另一种遵循 ANSI x . 923。pkcs 7 追加 n 个字节,每个填充字节保存值 n :如果需要 3 个字节的填充,则追加\x03\x03\x03。类似地,如果需要 2 个字节的填充,它会追加\x02\x02

ANSI X.923 略有不同。所有追加的字节都是 0,除了最后一个字节,它是总填充的长度。在这个例子中,3 个字节的填充是\x00\x00\x03,两个字节的填充是\x00\x02

cryptography模块提供类似于 AES 密码上下文的填充上下文。在下一个代码清单中,创建了padderunpadder对象来添加和移除填充。注意,这些对象也使用了updatefinalize,因为调用update()方法不会产生任何填充。然而,它会返回完整的块,为下一次调用update()finalize()操作存储剩余的字节。当调用finalize()时,所有剩余的字节连同足够的填充字节一起返回,以形成一个完整的块大小。

尽管这个 API 看起来很简单,但它的行为并不一定像人们预期的那样。

 1   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 2   from cryptography.hazmat.backends import default_backend
 3   from cryptography.hazmat.primitives import padding
 4   import os
 5
 6   key = os.urandom(32)
 7   iv = os.urandom(16)
 8
 9   aesCipher = Cipher(algorithms.AES(key),
10                      modes.CBC(iv),
11                      backend=default_backend())
12   aesEncryptor = aesCipher.encryptor()
13   aesDecryptor = aesCipher.decryptor()
14
15   # Make a padder/unpadder pair for 128 bit block sizes.
16   padder = padding.PKCS7(128).padder()
17   unpadder = padding.PKCS7(128).unpadder()
18
19   plaintexts = [
20       b"SHORT",
21       b"MEDIUM MEDIUM MEDIUM",
22       b"LONG LONG LONG LONG LONG LONG",
23   ]
24
25   ciphertexts = []
26
27   for m in plaintexts:
28       padded_message = padder.update(m)
29       ciphertexts.append(aesEncryptor.update(padded_message))
30
31   ciphertexts.append(aesEncryptor.update(padder.finalize()))
32
33   for c in ciphertexts:
34       padded_message = aesDecryptor.update(c)
35       print("recovered", unpadder.update(padded_message))
36
37   print("recovered", unpadder.finalize())

Listing 3-6
AES-CBC Padding

运行清单 3-6 中的代码并观察输出。这是你所期望的吗?它应该是这样的:

recovered b''
recovered b''
recovered b'SHORTMEDIUM MEDIUM MEDIUMLONG LO'
recovered b'NG LONG LONG LON'
recovered b'G LONG '

为什么它没有完全按照指定生成原始消息?

从技术上讲,这段代码没有任何错误,但是在代码的表面意图和实际输出之间肯定存在不匹配。这段代码表明作者打算将三个字符串中的每一个作为独立的消息进行加密。换句话说,该代码的可能意图是加密三个不同的消息,并在解密后得到三个等效的消息。

这不是我们得到的。清单 3-6 报告了五个输出,其中两个为空。

让我们再说一次update()finalize() API。由于这些方法在某些模式下(如 ECB 模式)的表现方式,很容易将update()视为一个独立的加密器,其中明文块作为输入,密文块作为输出。

实际上,API 被设计成调用update()的次数是不相关的。也就是说,被加密的不是到\lstinline{update()}but \emph{the concatenation of every input}到一些\lstinline{update()}调用的输入,当然,也不是来自最后一个finalize()调用的输出(如果有的话)。

因此,清单 3-6 中的程序不是加密三个输入并产生五个输出,而是处理一个连续输出并产生一个连续输出。

理解update()finalize() API 对于我们已经介绍的填充操作尤其重要。如果你试着把update()看作一个独立的操作,填充行为会显得不寻常。图 3-6 展示了填充如何处理来自列表 3-6 的输入。注意,对update()的单独调用不会产生填充。只有finalize()行动会做到这一点。

img/472260_1_En_3_Fig6_HTML.jpg

图 3-6

PKCS7 填充在完成操作之前不会添加任何填充

解绑会更加刺耳。与填充操作不同,您可以向解填充器提交一个完整的块,但仍然不会得到任何 ??。这是因为解填充器必须保留在update()调用中接收的最后一个块,以防它是最后一个块。因为解包需要检查最后一个块,所以解包器必须确定它已经收到了所有的块,才能知道它有最后一个块。

再次浏览清单 3-6 说明了当 padder 和 encryptor 一起使用时,这些操作的效果是如何复合的。在第一次通过消息加密循环时,输入是SHORT。五个字符比一个块少。padder 的update()方法不添加任何填充,所以 padder 缓冲这五个字符,update()方法返回一个空字节字符串。当它被传递给加密器时,显然没有一个完整的块,所以加密器的 update 方法也返回一个空的字节字符串。这将被附加到密文列表中。

在我们第二次通过循环时,输入是MEDIUM MEDIUM MEDIUM。这 20 个字符被传递到 padder 的内部缓冲区,并添加到之前的 5 个字符中。UPDATE方法现在返回这 25 个字节中的前 16 个(一个完整的块),剩下的 9 个字节留在内部缓冲区中。padder 中的 16 个字节被加密并存储在密文列表中。

在最后一遍中,LONG LONG LONG LONG LONG LONG输入被添加到 padder 的内部缓冲区。这 29 个字节与缓冲区中当前的 9 个字节相加,总共为 38 个字节。padder 返回 2 个完整的块(每个块 16 字节),将最后 6 个字节留在其缓冲区中。这两个块被加密,并且这两个块的输出被存储在密文列表中。

一旦循环退出,就会调用 padder 的 finalize 方法。它获取输入的最后一个字节,附加必要的填充,并将其传递给加密操作。密文被添加到列表中,加密结束。现在有四条密文信息需要解密。您可能还记得,与这个过程相反,第一条消息是空缓冲区。它只是直接穿过所有东西,然后作为一个空消息出来。

但是下一个恢复的文本也是空的。这是因为去填充器的第一个完整块是出于我们解释过的原因而保留的。它产生一个空输出,输入到 AES 解密器的update()方法中。这生成了我们的第二个空输出。

剩下的三个更简单。

既然演练已经完成,您是否注意到我们仍在使用不正确的术语?我们将来自update()方法的单个输出称为单个密文,而不是密文的片段。类似地,我们将解密器更新方法的输出称为恢复文本,而不是单个恢复消息的一部分。

这是故意的。关键的原则是语义很重要。我们对代码的思考方式可能与它的运行方式不同,这可能会导致意想不到的、通常是不安全的结果。当你使用一个库(总是比创建你自己的库好!),您必须了解 API 的方法和设计。关键是你认为*API 的设计使用方式是正确的。

对于cryptography库,总是把提交给一系列加密update()调用和一个finalize()调用的所有东西都看作一个单独的输入。类似地,把从一系列解密update()调用和一个finalize()调用中恢复的所有东西都看作一个输出。

解密是怎么回事?我们如何得到五个输出而不是四个?列表中的第一个密文只是空字符串,所以第一个“恢复”的明文为空是有道理的。但是为什么第二个也是空的呢?

让我们看看另一种错误的做法。 9 假设我们决定创建自己的 API,它实际上将在消息级上工作。也就是说,每条消息都可以单独和独立地加密和解密。代码如清单 3-7 所示。

 1   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 2   from cryptography.hazmat.backends import default_backend
 3   from cryptography.hazmat.primitives import padding
 4   import os
 5
 6   class EncryptionManager:
 7       def __init__(self):
 8           self.key = os.urandom(32)
 9           self.iv = os.urandom(16)
10
11       def encrypt_message(self, message):
12           # WARNING: This code is not secure!!
13           encryptor = Cipher(algorithms.AES(self.key),
14                              modes.CBC(self.iv),
15                              backend=default_backend()).encryptor()
16           padder = padding.PKCS7(128).padder()
17
18           padded_message = padder.update(message)
19           padded_message += padder.finalize()
20           ciphertext = encryptor.update(padded_message)
21           ciphertext += encryptor.finalize()
22           return ciphertext
23
24       def decrypt_message(self, ciphertext):
25           # WARNING: This code is not secure!!
26           decryptor = Cipher(algorithms.AES(self.key),
27                              modes.CBC(self.iv),
28                              backend=default_backend()).decryptor()
29           unpadder = padding.PKCS7(128).unpadder()
30
31           padded_message = decryptor.update(ciphertext)
32           padded_message += decryptor.finalize()
33           message = unpadder.update(padded_message)
34           message += unpadder.finalize()
35           return message
36
37   # Automatically generate key/IV for encryption.
38   manager = EncryptionManager()
39
40   plaintexts = [
41       b"SHORT",
42       b"MEDIUM MEDIUM MEDIUM",
43       b"LONG LONG LONG LONG LONG LONG"
44   ]
45
46   ciphertexts = []
47
48   for m in plaintexts:
49       ciphertexts.append(manager.encrypt_message(m))
50
51   for c in ciphertexts:
52       print("Recovered", manager.decrypt_message(c))

Listing 3-7
Broken AES-CBC Manager

运行代码并观察输出。这次你收到每条信息了吗?很好!你可能更喜欢这个版本!

这一次,API 可能在语义上更加一致,但是实现非常不完整,非常危险。在我们告诉你它有什么问题之前,你能自己试试看吗?在本章中,我们是否违反了任何安全原则?如果不明显,请继续阅读!

卫生静脉注射的关键

清单 3-7 的问题是对不同的消息重用相同的键和 IV 。看看创建 key 和 IV 的构造函数。使用这个单一的 key/IV 对,违规的代码在每次调用encrypt_messagedecrypt_message时重新创建 encryptor 和 decryptor 对象。记住,每次加密时,IV 应该是不同的,以防止相同的数据被加密成相同的密文!这不是可选的。

同样,理解 API 是如何构建的以及与之相关的安全参数也很重要。回去看看图 3-5 。请记住,在 CBC 加密中,该算法在应用 AES 运算之前,使用 XOR 运算将第一个明文块与 IV 相结合。在 AES 加密之前,使用 XOR 将每个后续明文块与前一个密文块合并。使用 Python API,对update()的每次调用都会向这个链中添加块,在内部缓冲区中为后续调用留下不到一个完整块的数据。finalize()方法实际上并不做更多的加密,但是如果还有不完整的数据等待加密,就会产生一个错误。

反复调用update()方法是而不是重用一个键和 IV,因为我们追加到了 CBC 链的末尾。另一方面,如果你创建了新的加密器和解密器对象,就像我们在清单 3-7 中所做的那样,你将从头开始重新创建这个链。如果你在这里重用一个键和 IV,你会用同样的键和 IV!这导致每次对相同的输入产生完全相同的输出!

相应地,在使用 Python 的cryptography模块的 API 时,千万不要多次给一个加密器相同的密钥和 IV 对(很明显,你给了对应的解密器相同的密钥和 IV)。事实上,最好不要再重复使用同一个密钥。

在清单 3-8 中,我们纠正了之前的错误,只使用了一次 key/IV 对。加密器和解密器对象被移到构造函数中,我们使用由cryptography模块使用的更新/完成模式,而不是使用一个单独的encrypt_message()decrypt_message()调用。

 1   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 2   from cryptography.hazmat.backends import default_backend
 3   from cryptography.hazmat.primitives import padding
 4   import os
 5
 6   class EncryptionManager:
 7       def __init__(self):
 8           key = os.urandom(32)
 9           iv = os.urandom(16)
10           aesContext = Cipher(algorithms.AES(key),
11                               modes.CBC(iv),
12                               backend=default_backend())
13           self.encryptor = aesContext.encryptor()
14           self.decryptor = aesContext.decryptor()
15           self.padder = padding.PKCS7(128).padder()
16           self.unpadder = padding.PKCS7(128).unpadder()
17
18       def update_encryptor(self, plaintext):
19           return self.encryptor.update(self.padder.update(plaintext))
20
21       def finalize_encryptor(self):
22           return self.encryptor.update(self.padder.finalize()) + self.encryptor.finalize()
23
24       def update_decryptor(self, ciphertext):
25           return self.unpadder.update(self.decryptor.update(ciphertext))
26
27       def finalize_decryptor(self):
28           return self.unpadder.update(self.decryptor.finalize()) + self.unpadder.finalize()
29
30   # Auto generate key/IV for encryption
31   manager = EncryptionManager()
32
33   plaintexts = [
34       b"SHORT",
35       b"MEDIUM MEDIUM MEDIUM",
36       b"LONG LONG LONG LONG LONG LONG"
37   ]
38
39   ciphertexts = []
40
41   for m in plaintexts:
42       ciphertexts.append(manager.update_encryptor(m))
43   ciphertexts.append(manager.finalize_encryptor())
44
45   for c in ciphertexts:
46       print("Recovered", manager.update_decryptor(c))
47   print("Recovered", manager.finalize_decryptor())

Listing 3-8
AES-CBC Manager

清单 3-8 没有重用 key/IV 对,但是您可能已经注意到我们不再将单个消息视为单个消息。现在我们回到了update() finalize()模式,我们必须将传递给单个上下文的所有数据视为单个输入。如果我们希望每条消息被单独处理,每个输入有一系列的update()调用和finalize()调用。或者,从加密和解密的角度来看,我们可以将所有三个消息作为单个输入提交,并拥有一个独立的机制来将单个解密输出拆分成消息。

总之,仔细理解您使用的任何加密 API、它们如何工作以及它们的要求(尤其是安全要求)是很重要的。理解创建一个看起来做了正确的事情,但实际上却让您容易受到攻击的 API 有多容易也很重要。

记住,YANAC(你不是一个密码学家...还没有!).不要像我们在这些教育例子中所做的那样使用你自己的密码。

那么为什么cryptography模块使用更新/完成模式呢?在许多实际的加密操作中,数据经常需要分块处理。假设您正在通过网络传输数据。您真的想等到拥有全部内容后再加密吗?即使您正在加密硬盘上的本地文件,对于一次性加密来说,它可能太大了,不切实际。update()方法允许您在数据可用时将其提供给加密引擎。

finalize()操作对于强制要求很有用,比如 CBC 操作没有留下未加密的不完整块,以及会话已经结束。

当然,只要一个键和 IV 没有被重用,那么每个消息的 API 就没有问题。我们稍后将研究这方面的策略。

练习 3.10。确定性输出

使用相同的密钥和 IV 通过 AES-CBC 运行相同的输入。您可以使用清单 3-7 作为起点。每次输入都要相同,并打印出相应的密文。你注意到了什么?

练习 3.11。加密图像

加密之前用 ECB 模式加密的图像。加密后的图像现在是什么样子?不要忘记保持前 54 个字节不变!

练习 3.12。手工制作的 CBC

ECB 模式只是原始 AES。您可以使用 ECB 作为构建块来创建自己的 CBC 模式。 10 对于这个练习,看看你能否构建一个与cryptography库兼容的 CBC 加密和解密操作。对于加密,请记住在加密之前获取每个块的输出,并将其与下一个块的明文进行 XOR 运算。逆转解密过程。

穿过小溪

与 CBC 模式相比,计数器模式有许多优点,而且在我们看来,比 CBC 模式更容易理解。此外,虽然 CTR 是传统的缩写,但“CM”是一组非常好的首字母缩写。

虽然很简单,但这种模式背后的概念一开始可能有点反直觉(没错)。在 CTR 模式下,实际上永远不会使用 AES 对数据进行加密或解密。相反,这种模式生成一个与明文长度相同的密钥流,然后使用 XOR 将它们组合在一起。

回想一下本章前面的练习,通过将明文数据与随机数据相结合,XOR 可以用来“屏蔽”明文数据。前面的练习用 16 字节的随机数据掩盖了 16 字节的明文。这是一种名为“一次性密码本”(OTP)的真实加密形式。6].它工作得很好,但是要求密钥与明文的大小相同。我们在这里没有足够的空间来进一步探讨 OTP 重要的概念是,使用 XOR 来组合明文和随机数据是一种创建密文的好方法。

AES-CTR 模拟了 OTP 的这一方面。但它不要求密钥与明文大小相同(加密 1TB 文件时这是一个真正的痛苦),而是使用 AES 和计数器从小到 128 位的 AES 密钥生成几乎任意长度的密钥流。

为此,CTR 模式使用 AES 加密一个 16 字节的计数器,从而生成 16 字节的密钥流。为了获得 16 个字节的密钥流,该模式将计数器加 1 并加密更新的 16 个字节。通过不断增加计数器和加密结果,CTR 模式可以产生几乎任意数量的密钥流材料。 11

虽然计数器每次都有少量的变化(通常只变化一位!),AES 具有良好的每块雪崩特性。因此,每个输出块看起来与上一个完全不同,并且流作为一个整体看起来是随机数据。

注:随想

随机性在密码学中是非常重要的。许多其他可接受的算法如果没有足够的密钥随机性来源,就会在实践中受到损害。我们简单提到的 OTP 算法需要一个与明文大小相同的密钥(不管它有多大),并且整个密钥是真正的随机数据。AES-CTR 模式只要求 AES 密钥是真正随机的。AES-CTR 产生的密钥流看起来随机,实际上是伪随机。这意味着,如果您知道 AES 密钥,您就知道整个密钥流,不管它看起来有多随机。

确保您有足够随机的数据源超出了本书的范围。出于我们的目的,我们将假设认为os.urandom()能够返回满足我们需求的可接受的随机数据。在生产加密环境中,您需要更加仔细地分析这一点。

随机性是如此重要,我们将不止一次地提到它。事实上,我们将在这一章快结束时回到这个问题上来。

虽然 AES-CTR 是一个流密码,但我们仍然可以一次考虑一个块。要加密任何给定的明文块,为该块的索引生成密钥流,并将其与(可能是部分)块进行异或运算。用另一种方式表达(其中下标 k 表示“用密钥 k 加密”):

$$ C\left[n\right]=P\left[n\right]\oplus {n}_k. $$

差不多就是这样!唯一的另一个小变化是,我们不想每次都从相同的计数器值开始。因此,我们的 IV(我们称之为“nonce”)被用作起始计数器值。更新我们的定义:

$$ C\left[n\right]=P\left[n\right]\oplus {\left( IV+n\right)}_k. $$

XOR 是一种非常通用的数学运算。你可以把它想象成“受控比特翻转”:为了计算 AB ,你一前一后地向下移动它们的比特;当你在 B 中遇到 1 时,你反转 A 中相应的位,当你在 B 中遇到 0 时,你把那个位单独留在 A 中。这样想的话,很容易理解为什么做两次就能简单地将 A 恢复到之前的状态。

更正式地说,如前所述,异或是它自己的逆运算:(ab)⊕b=a。因为我们通过对密钥流中的适当值应用 XOR 来创建加密块流,所以我们简单地做完全相同的事情*来解密:对加密块及其对应的密钥应用 XOR:

$$ P\left[n\right]=C\left[n\right]\oplus {\left( IV+n\right)}_k. $$

当然,如果你只是与 0 进行异或运算,什么都不会发生(因为 A ⊕ 0 = A ,这就是逆属性的来源),所以流中的密钥需要由看起来随机的位组成,但这正是 AES 产生的密钥流的类型。

图 3-7 提供了 AES-CTR 操作的直观表示。

img/472260_1_En_3_Fig7_HTML.jpg

图 3-7

CTR 加密和解密的可视化描述。注意加密和解密是同一个过程!

幸运的是,流密码不需要填充!很简单,只对部分块进行异或运算,丢弃不需要的密钥的后面部分。

一般来说,这种方法要简单得多。填充消失,块可以再次彼此独立地被加密。

让我们看看它在cryptography模块中的运行情况(列表 3-9 )。

 1   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 2   from cryptography.hazmat.backends import default_backend
 3   import os
 4
 5   class EncryptionManager:
 6       def __init__(self):
 7           key = os.urandom(32)
 8           nonce = os.urandom(16)
 9           aes_context = Cipher(algorithms.AES(key),
10                                modes.CTR(nonce),
11                                backend=default_backend())
12           self.encryptor = aes_context.encryptor()
13           self.decryptor = aes_context.decryptor()
14
15       def updateEncryptor(self, plaintext):
16           return self.encryptor.update(plaintext)
17
18       def finalizeEncryptor(self):
19           return self.encryptor.finalize()
20
21       def updateDecryptor(self, ciphertext):
22           return self.decryptor.update(ciphertext)
23
24       def finalizeDecryptor(self):
25           return self.decryptor.finalize()
26
27   # Auto generate key/IV for encryption
28   manager = EncryptionManager()
29
30   plaintexts = [
31       b"SHORT",
32       b"MEDIUM MEDIUM MEDIUM",
33       b"LONG LONG LONG LONG LONG LONG"
34   ]
35
36   ciphertexts = []
37
38   for m in plaintexts:
39       ciphertexts.append(manager.updateEncryptor(m))
40   ciphertexts.append(manager.finalizeEncryptor())
41
42   for c in ciphertexts:
43       print("Recovered", manager.updateDecryptor(c))
44   print("Recovered", manager.finalizeDecryptor())

Listing 3-9AES-CTR

因为不需要填充,所以除了“关闭”对象,finalize 方法实际上是不必要的。它们是为了对称和教学而保留的。

如何在 CTR 和 CBC 模式之间选择?几乎在所有情况下,都建议使用计数器模式(CTR)。 12 不仅更容易,在某些情况下也更安全。似乎这还不够,计数器模式也更容易并行化,因为密钥流中的密钥是根据它们的索引计算的,而不是根据之前的计算。

那为什么还要谈论 CBC 呢?至少它还在广泛使用,所以当你在野外遇到它时,了解它会让你受益匪浅。

我们将在本书的后面介绍其他模式,它们建立在计数器模式的基础上,使事情变得更好。现在,理解 CBC 和 CTR 模式的基本特征以及每种模式如何从底层分组密码构建更好的算法就足够了。

练习 3.13。写一个简单的计数器模式

与 CBC 一样,从 ECB 模式创建计数器模式加密。这应该比 CBC 更容易。通过获取 IV 块并对其加密来生成密钥流,然后将 IV 块的值加 1 以生成密钥流材料的下一个块。完成后,将密钥流与明文进行异或运算。用同样的方式解密。

练习 3.14。并行计数器模式

扩展您的计数器模式实现,使用线程池来并行生成密钥流。记住,为了生成密钥流分组,所需要的只是起始 IV 和正在生成密钥流的哪个分组(例如,0 用于第一个 16 字节分组,1 用于第二个 16 字节分组,等等)。).首先创建一个可以生成任何特定密钥流分组的函数,可能类似于keystream(IV, i)。接下来,通过在独立的进程之间任意划分计数器序列,并行生成多达 n 的密钥流,并让它们都独立地生成密钥流块。

密钥和 IV 管理

正如您所看到的,拥有一个像cryptography这样的库可以使各种加密变得方便和简单。不幸的是,这种简单性可能具有欺骗性并导致错误;有很多方法会出错。我们已经简单地提到了其中的一个:重用密钥或 iv。

这种错误属于“密钥和 IV 管理”这一更广泛的类别,不正确地做是问题的常见来源。

重要的

您必须永远不要重用密钥和 IV 对。这样做严重损害了安全性,并让密码学书籍的作者失望。就是不做。当加密任何东西时,总是使用新的密钥/IV 对。

为什么不想重用一个 key 和 IV 对?对于 CBC,我们已经提到了一个潜在的问题:如果你重用一个 key 和 IV 对,你将得到可预测头的可预测输出。你可能倾向于根本不去想的信息部分,因为它们是样板文件或包含隐藏的结构,将成为一种负担;对手可以使用可预测的密文来了解您的密钥。

例如,考虑一个 HTML 页面。前几个字符在多页中通常是相同的(例如,"<!DOCTYPE html>\n")。如果 HTML 页面的前 16 个字节(一个 AES 块)是相同的,并且您使用相同的 key/IV 对对它们进行加密,那么每个页面的密文将是相同的。您刚刚将数据泄露给了您的敌人,他们可以开始分析您的加密数据的模式。

如果您的网站有大量相同生成的静态内容或动态结果,则每个加密页面都具有唯一的可识别性。敌人可能不知道每一页说了什么,但他们可以确定使用频率,并跟踪哪一方收到了相同的页面。

在 CBC 模式下重用一个键和 IV 是

另一方面,在计数器模式下重用一个键和 IV 会更糟糕。因为计数器模式是一种流密码,所以明文只是与密钥流进行异或运算。如果刚好知道明文,可以恢复密钥:kpp=k

“那又怎样?”你可能会想。“谁在乎他们能不能得到密钥流?如果他们已经知道了明文,我们为什么还要在乎?”

问题是,在许多情况下,攻击者可能知道一条明文消息的部分或全部内容。如果其他消息用相同的密钥流加密,攻击者也可以恢复那些消息!

糟糕,糟糕,糟糕。

让我们进一步探讨这个想法。假设你在商店用信用卡买了 100 美元的东西。让我们假设一个简化版本的世界,其中读卡器向您的银行发送一条消息,以授权仅受 AES-CTR 加密保护的购买。

假设从信用卡读卡器发送到银行的消息是如下所示的 XML:

1   <XML>
2     <CreditCardPurchase>
3       <Merchant>Acme Inc</Merchant>
4       <Buyer>John Smith</Buyer>
5       <Date>01/01/2001</Date>
6       <Amount>$100.00</Amount>
7       <CCNumber>555-555-555-555</CCNumber
8     </CreditCardPurchase>
9   </XML>

商店创建这一消息,对其加密,并将其发送给银行。为了进行通信,商店和银行必须共享一个密钥。如果编写代码的程序员懒惰和疏忽,他们可能已经创建了一个在每个消息中重复使用的具有常量键和 IV 的系统,就像我们在清单 3-10 中发现的那样。

 1   # ACME generates a purchase message in their storefront.
 2   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 3   from cryptography.hazmat.backends import default_backend
 4
 5   # WARNING! Never do this. Reusing a key/IV is irresponsible!
 6   preshared_key = bytes.fromhex('00112233445566778899AABBCCDDEEFF')
 7   preshared_iv = bytes.fromhex('00000000000000000000000000000000')
 8
 9   purchase_message = b"""
10   <XML>
11     <CreditCardPurchase>
12       <Merchant>Acme Inc</Merchant>
13       <Buyer>John Smith</Buyer>
14       <Date>01/01/2001</Date>
15       <Amount>$100.00</Amount>
16       <CCNumber>555-555-555-555</CCNumber
17     </CreditCardPurchase>
18   </XML>
19   """
20
21   aesContext = Cipher(algorithms.AES(preshared_key),
22                       modes.CTR(preshared_iv),
23                       backend=default_backend())
24   encryptor = aesContext.encryptor()
25   encrypted_message = encryptor.update(purchase_message)

Listing 3-10AES-CTR

for a Store

为简单起见,购买消息包含在前面的代码中。您可以随意更改它,以接受一个文件或命令行标志来设置购买者的姓名、购买价格等等。您可能还应该将加密的消息写入文件。

回到我们的场景,如果你试图破解这个系统,你可以在这个商店花 100 美元,然后窃听线路,拦截传输到银行的购买消息。如果你这样做,你知道多少明文信息?你全都知道了!你知道是谁买的,你知道购买的数量,你知道日期,你知道你自己的信用卡号码。

这意味着您可以重新创建明文消息,将其与密文进行异或运算,并恢复密钥流材料。因为商家正在为下一个客户重用相同的密钥和 IV,所以您可以轻松地解密消息并读取内容。哎呀。我们感到一个关于数据泄露的新闻故事即将发生。

练习 3.15。乘坐密钥流

实施这个密钥流窃取攻击。也就是说,使用相同的密钥和 IV 加密两条不同的购买消息。“截取”两条消息中的一条,并将密文内容与已知明文进行异或运算。这会给你一个密钥流。接下来,将密钥流与其他消息进行 XOR 运算,以恢复该消息的明文。消息大小可能略有不同,但是如果您缺少一些密钥流字节,请尽可能恢复。

即使攻击者不知道明文的任何内容,也无法恢复密钥流,他或她仍然可以利用用相同的密钥和 IV 对加密的消息。如果你有两条用相同密钥流加密的消息,你可以做下面的技巧(其中 K 是密钥流):

$$ {\displaystyle \begin{array}{l}\kern2.25em {c}_1={m}_1\oplus K\ {}\kern2.25em {c}_2={m}_2\oplus K\ {}{c}_1\oplus {c}_2=\left({m}_1\oplus K\right)\oplus \left({m}_2\oplus K\right)\ {}{c}_1\oplus {c}_2={m}_1\oplus {m}_2\oplus K\oplus K\ {}{c}_1\oplus {c}_2={m}_1\oplus {m}_2\end{array}} $$

对两个明文消息进行异或运算会得到什么?可读吗?看情况。因为明文消息通常是有结构的,私有数据通常是可提取的或可猜测的。以我们的例子中的这些虚构的购买消息为例。如果你把两条这样的消息异或在一起,你能学到什么?

首先,任何完全重叠的部分都简化为 0。很快,你就知道哪些信息是相同的,哪些是不同的。如果攻击者足够幸运,两条消息中买方的姓名长度相同,那么金额字段也会排成一行。当两者进行异或运算时,该字段会产生大量信息,因为该字段的合法字符很少(“0”-“9”和)。”).ASCII 字符与数字的异或运算只留下了几种可能性。

例如,只有两对数字的 ASCII 值的异或为 15。它们是“7”和“8”(ASCII 值 55 和 56),以及“6”和“9”(ASCII 值 54 和 57)。因此,如果我们知道我们有两个购买金额字段数字的 XOR,并且 XOR 值是 15,那么这两个消息每个都有这两对数字中的一对。这只是四种可能性,对于攻击者来说,在大多数情况下都不难发现。

如果不小心,您可能会惊讶于这种漏洞出现的频率。一个简单的例子是全双工消息。如果有两方希望向对方发送加密消息,他们不能使用相同的密钥和 IV 来加密连接的每一端。每一方的加密必须独立于另一方。如果你想想 CBC 和 CTR 模式是如何工作的,这将是非常明显的。如果你要双向写消息,每一面都需要一个单独的读密钥和写密钥。 13 第一方的读取密钥将是第二方的写入密钥,反之亦然。这样,不同的消息就不会写在同一个键和 IV 对下。

练习 3.16。通过 XOR 筛选

对一些明文消息进行异或运算,并寻找模式和可读数据。这不需要使用任何加密,只需要一些常规的、人类可读的消息,然后对字节进行异或运算。尝试人类可读的字符串、XML、JSON 和其他格式。你可能找不到很多可以立即解读的东西,但这是一个有趣的练习。

利用延展性

起初,密码学的某些方面是不直观的。例如,一个敌人可能无法读取一条机密信息,但仍然能够以有意义的、欺骗性的方式改变它。在本节中,我们将尝试在无法读取加密消息的情况下对其进行修改。

出于前面描述的所有原因,计数器模式是一种非常好的加密模式。然而,冒着过于重复的风险,它只能保证机密性。事实上,因为它是一个流密码,所以改变消息的一小部分而不改变其余部分是微不足道的。例如,在计数器模式下,如果攻击者修改了密文的一个字节,它只会影响对应的明文字节。虽然这一个字节的明文无法正确解密,但其余的字节将保持不变。

密码块链接模式是不同的,因为对密文的一个字节的改变将影响所有后续的块。

练习 3.17。可视化密文更改

为了更好地理解计数器模式和密码块链接模式之间的区别,回到您以前编写的图像加密实用程序。将其修改为首先加密,然后解密图像,使用 AES-CBC 或 AES-CTR 作为模式。解密后,原始图像应该完全恢复。

现在在密文中引入一个错误,并解密修改后的字节。例如,尝试选取加密图像数据中间的字节,并将其设置为 0。损坏数据后,调用解密函数并查看恢复的图像。编辑对 CTR 有多大影响?编辑对 CBC 有多大影响?

提示:如果你看不到任何东西,尝试一个全白的图像。如果还是看不出来,就改 50 个字节左右,算出哪里发生了变化。一旦您找到了发生变化的地方,返回到改变单个字节来查看 CTR 和 CBC 之间的差异。你能解释发生了什么吗?

为了说明可延展性的概念,我们将让攻击者知道加密消息的一些明文。这些知识将允许他们在途中改变信息。这次不同的是,这个漏洞是而不是依赖于重用的密钥流。

如果攻击者知道密钥流加密消息背后的明文,就很容易从密文中提取密钥流。如果密钥流被重用,攻击者可以解密所有使用它的消息。即使是而不是被重用,攻击者也可以修改一条已知明文的消息。

让我们重温一下加密的购买信息。假设 Acme 的竞争对手 Evil LLC 希望将这笔付款转给他们自己。他们可以监听来自 Acme 商店的网络连接,并可以拦截和修改消息。当这种消息的加密形式出现时,即使他们没有密钥并且不能解密,他们也可以去掉已知的原始消息部分,并用他们自己选择的部分替换它们。

Evil LLC 想要改变的部分是这个部分:

1   <XML>
2     <CreditCardPurchase>
3       <Merchant>Acme Inc</Merchant>

该数据在每个支付消息中都是已知和固定的。为了获得密钥流,Evil LLC 所要做的就是将该数据与密文进行 XOR 运算。一旦这部分被异或,他们就有了这么多字节的密钥流。然后,他们创建修改后的消息:

1   <XML>
2     <CreditCardPurchase>
3       <Merchant>Evil LLC</Merchant>

此消息与真实消息的大小完全相同。因为 AES-CTR 的可塑性很强,所以很容易将这部分消息与提取的密钥流进行异或运算,并将其加入到仍然加密的消息的其余部分中。该过程如图 3-8 所示。

img/472260_1_En_3_Fig8_HTML.jpg

图 3-8

如果攻击者知道 CTR 模式密文中的明文,她可以提取密钥流来加密自己的邪恶消息!

练习 3.18。拥抱邪恶

你为(或自己)工作!)Evil LLC。是时候从 Acme 偷些钱了。从您在前面的练习中创建的一条加密付款消息开始。通过商家的标识来计算报头的大小,并提取加密数据的多个字节。将明文头与密文头进行异或运算,得到密钥流。一旦你有了这个,XOR 提取的密钥流与识别 Evil LLC 为商家的报头。这是“邪恶”的密文。将其复制到加密文件的字节上,以创建一个新的支付消息,将您的公司标识为接收方。通过解密修改后的文件来证明它是有效的。

这里的关键教训是,加密本身不足以保护数据。在随后的章节中,我们将使用消息认证码、认证加密和数字签名来确保在不中断通信的情况下数据不会被修改。

凝视衬垫

虽然 CBC 模式比计数器模式更不容易被更改,但在这方面它绝不是完美的。事实上,正是 CBC 的可塑性使 SSL 的早期版本之一变得脆弱。请记住,CBC 模式是基于块的模式,需要填充。填充规范中的一个有趣错误和 AES-CBC 的延展性使得攻击者能够执行“填充 oracle 攻击”并解密机密数据。

让我们现在就发起攻击。它非常有趣而且有教育意义。

对于这个小练习,您需要编写自己的填充函数;cryptography模块里的太安全了。您的函数将遵循非常不完善的 SSL 3.0 规范(我们将在最后一章中更多地讨论 SSL/TLS)。基本上,N–1 字节的任何东西后跟一个字节,表示填充的总长度。因为在该规范中总是需要填充,所以即使明文是块大小的倍数,也要添加填充。这一点以后会很重要。

1   def sslv3Pad(msg):
2       padNeeded = (16 - (len(msg) % 16)) - 1
3       padding = padNeeded.to_bytes(padNeeded+1, "big")
4       return msg+padding
5
6   def sslv3Unpad(padded_msg):
7       paddingLen = padded_msg[-1] + 1
8       return padded_msg[:-paddingLen]

Listing 3-11SSLv3 Padding

先说说我们目前掌握的情况(列举 3-11 )。除了最后一个字节,该方案中的填充字节完全被忽略。字节是什么并不重要,只要最后一个字节是正确的。填充在信息的结尾,对吗?猜猜 CBC 信息的哪一部分最有延展性。

CBC 消息的最后部分更具延展性的原因是它对任何后续块没有影响。它可以在不弄乱其他任何东西的情况下被改变。回想一下,CBC 解密开始时对每个块都是一样的,不管它在哪里。AES 使用密钥对密文块进行解密。只有在解密之后,它才会与前一个块的密文进行异或运算。

这意味着你可以在链的最末端替换 CBC 链中的任何块。它将在最后被解密,就像在中间或开始时一样。解密后,它与前一个块的密文进行异或运算。

这有什么帮助?假设我们足够幸运,原始明文消息的长度是 16 字节的倍数,即 AES 分组长度。因为我们使用的填充方案是总是使用填充,所以最后会有一个完整的填充块。由于除了最后一个字节之外,我们不关心填充中有哪些字节,所以即使我们替换了最后一个块,只要最后一个字节解码为 15 (有完整填充块时的填充长度),我们也可以正确地恢复整个消息。

换句话说,当末尾有一个完整的填充块时,16 个字节中的 15 个被完全忽略。他们是什么并不重要。如果我们要尝试“愚弄”解密,这是一个很好的地方,因为我们只需要得到正确的一个字节!

这个小小的改变,只关心最后一个字节的值,改变了一切!它将蛮力猜测减少到合理的程度。通常,如果您想要“猜测”一个正确的 AES 块,您必须尝试所有 16 个字节的所有可能组合。您可能还记得之前的讨论,这是一个非常大的数字,不可能尝试所有实际用途的每种组合。

但是现在我们只关心最后一个字节,我们只需要正确猜测一个字节的数据。重复一遍,只要最后一个字节解密为 15,我们的填充就是“正确的”一个字节的数据有 256 个可能的值,所以如果我们的最后一个字节是随机选择的,那么 256 次中有 1 次将正确解密为 15!

你可能会抗议说数据不是随机的。我们正试图解密一个特定的字节。非常正确!但是请记住,在 CBC 中,我们将真正的明文与前一个块的密文进行异或运算!密文,至少对我们来说,就像随机数据一样。对于任何给定的 key/IV 对,密文的最后一个字节将与我们的明文字节进行异或运算,它有相等的机会成为 256 个可能的 1 字节值中的任何一个。如果我们幸运的话,密文的“随机”字节与我们的明文字节异或将是 15!

如果填充符被接受并解密到 15,我们可以使用我们对先前密文块的了解来获得真正的明文字节。

实际上,恢复明文字节是一个小技巧,需要我们仔细考虑 CBC 解密。请记住,最后一个明文块(例如,原始消息中的真实填充)与倒数第二个块中的密文进行异或运算。这个中间数据由 AES 算法加密。因此,反向工作,如果我们覆盖最终的密文块,CBC 操作将首先通过 AES 解密操作运行该块,以产生中间值,然后与前面的密文进行 xor 运算。如果这很难做到,回头参考图 3-5 。

如果接受填充(例如最后一个字节是 15),我们知道 AES 解密的中间值的最后一个字节是 15 和前一密文块的最后一个字节的异或。当然,我们有密文。现在,即使没有 AES 密钥,我们也可以简单地直接计算中间字节(例如,通过取 15 和倒数第二密文块的最后一个字节的异或)。

但是中间值不是明文字节。记住,我们正在解密一个更早的密文块。该密文块是实际明文与实际的前一密文(或者 IV,如果它是第一个明文块)异或的 AES 加密。因此,当我们恢复中间的最后一个字节时,我们仍然需要通过适当的 XOR 运算来移除混合的数据。

让我们努力把它写成代码。首先,我们需要定义我们的“甲骨文”在现实生活中,oracle 是 SSLv3 服务器。如果你给它发送一个填充错误的消息,它会给你发送一个错误消息,告诉你填充错误。这些信息是完成这次攻击的必要条件。对于清单 3-12 中的代码,我们将在Oracle类中有一个accept()方法来指示填充是否有效,执行与服务器相同的目的。

 1   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 2   from cryptography.hazmat.backends import default_backend
 3
 4   class Oracle:
 5       def __init__(self, key, iv):
 6           self.key = key
 7           self.iv = iv
 8
 9       def accept(self, ciphertext):
10           aesCipher = Cipher(algorithms.AES(self.key),
11                              modes.CBC(self.iv),
12                              backend=default_backend())
13           decryptor = aesCipher.decryptor()
14           plaintext = decryptor.update(ciphertext)
15           plaintext += decryptor.finalize()
16           return plaintext[-1] == 15

Listing 3-12
SSLv3 Padding Oracle

这可能看起来有点奇怪:我们有钥匙,并用它来创建先知。请记住:我们正在模拟一个易受攻击的远程服务器,该服务器拥有自己的密钥。我们下面写的攻击将在不知道这里使用的密钥的情况下进行。

一旦我们有了神谕,就很容易看到我们能否幸运地解码密文中任意块的最后一个字节,如清单 3-13 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   # This function assumes that the last ciphertext block is a full
 4   # block of SSLV3 padding
 5   def lucky_get_one_byte(iv, ciphertext, block_number, oracle):
 6       block_start = block_number * 16
 7       block_end = block_start + 16
 8       block = ciphertext[block_start: block_end]
 9
10       # Copy the block over the last block.
11       mod_ciphertext = ciphertext[:-16] + block
12       if not oracle.accept(mod_ciphertext):
13           return False, None
14
15       # This is valid! Let's get the byte!
16       # We first need the byte decrypted from the block.
17       # It was XORed with second to last block, so
18       # byte = 15 XOR (last byte of second-to-last block).
19       second_to_last = ciphertext[-32:-16]
20       intermediate = second_to_last[-1]¹⁵
21
22       # We still have to XOR it with its *real*
23       # preceding block in order to get the true value.
24       if block_number == 0:
25           prev_block = iv
26       else:
27           prev_block = ciphertext[block_start-16: block_start]
28
29       return True, intermediate ^ prev_block[-1]

Listing 3-13
Lucky SSLv3 Padding Byte

重复一遍:我们指望倒数第二个街区是幸运的!如图 3-9 所示,我们必须足够幸运,倒数第二个块的最后一个字节刚好与我们的中间字节异或为 15。我们所依赖的运气取决于所选择的键和 IV。同样,对于任何给定的 key/IV 对,倒数第二个块有 1/256 的机会“意外”与我们的中间明文块进行 XOR 运算,得到 15。

img/472260_1_En_3_Fig9_HTML.png

图 3-9

如果填充块的前 15 个字节被忽略,我们可以在倒数第二个块中进行替换,看看 oracle 是否告诉我们填充是正确的。如果是这样,我们可以算出前一个块的最后一个字节。

那真的那么有用吗?首先,我们必须足够幸运地拥有一整块衬垫。其次,我们只有 1/256 的机会解码单个字节。这似乎没什么帮助。

是吗?

再说一次,密码学可能非常反直觉。计算机的行为不像我们期望的那样,这就是我们遇到麻烦的地方。

虽然 SSLV3 忙于保护 web 流量,但事实证明恶意广告可以通过多种方式为 SSL 加密的网站带来流量。但是因为广告产生了流量,它的作者可以控制加密信息的长度。因此,如果攻击者试图解密一个加密的 cookie,触发不同长度的 GET 请求可以控制整个消息的长度。

在这种情况下,获取完整的填充块并不十分困难,因为恶意的请求者可以在 GET 请求中放入任意数据。

对于一台计算机来说,通过网络发出 256 个请求并不算什么。注意,在 SSLV3 上下文中,客户机和服务器将对每个连接使用不同的密钥(正如我们所看到的,这是有充分理由的!).这意味着在每个连接上,密文将是不同的!因此,如果攻击者发送 256 个请求,倒数第二个块每次都会不同,这提供了一个新的幸运机会,可以获得正确的“随机”数字,提供所需的 15 个。

img/472260_1_En_3_Fig10_HTML.jpg

图 3-10

为了解密一个重要的字节,攻击者控制 GET 请求的大小,以便 cookie 位于正确的位置。这需要能够在 TLS 安全的上下文中插入任意请求,例如广告客户。

它仍然只是一个字节,对吗?如图 3-10 所示,攻击者可以控制消息的长度。一旦解码了一个字节,通过在消息的前面插入一个字节,将新的字节推入任意块的最后一个槽,可以很简单地将消息长度增加 1。再尝试 256 次,第二个字节也将被解码!清洗,冲洗,然后重复!

练习 3.19。反抗是徒劳的

完成填充预言攻击的代码。我们已经给了你主要的部分,但是仍然需要一些工作来把所有的东西放在一起。我们将做一些事情来尽可能地简化。首先,选择一个长度正好是 16 字节(AES 块大小)的倍数的消息,并创建一个固定的填充来追加。固定填充可以是任何 16 个字节,只要最后一个字节是 15(这就是整个练习的要点,对吗?).加密此消息并将其传递给 oracle,以确保代码正常工作。

接下来,测试恢复消息的第一个块的最后一个字节。在一个循环中,创建一个新的 key 和 IV 对(以及一个包含这些值的新 oracle),加密消息,并调用lucky_get_one_byte()函数,将 block number 设置为 0。重复该循环,直到该函数成功,并验证恢复的字节是否正确。注意,在 Python 中,单个字节不被视为字节类型,而是被转换为整数。

解码整个消息的最后一步是能够使任何字节成为一个块的最后一个字节。同样,为了简单起见,将消息加密为 16 的整数倍。要将任何一个字节推到一个块的末尾,在开头添加一些额外的字节,在结尾去掉一个相等的数字。现在,您可以一次一个字节地恢复整个消息!

练习 3.20。统计也是徒劳的

在上一个练习中,测试您的填充 oracle 攻击,以计算完全解密整个消息需要多少次猜测,并计算每个字节的平均尝试次数。理论上,每字节应该有 256 次尝试。但是你可能处理的数字很小,以至于变化很大。在我们对 96 字节消息的测试中,我们的平均值在每字节 220 次猜测和每字节 290 次猜测之间变化。

再说一次,加密是关于保密性的,而保密性根本不足以解决所有的安全问题。在接下来的章节中,我们将学习如何结合机密性和完整性来解决一大类问题。

脆弱的钥匙,糟糕的管理

为了结束本章,让我们简单讨论一下。希望你已经很清楚钥匙有多重要了。

在几乎所有的密码系统中,密钥管理是最难的部分。生成好的密钥、共享密钥以及事后管理密钥(例如,保密、更新或撤销密钥)可能会很困难。现在,我们将关注密钥生成。

密钥必须从好的随机来源中抽取。我们已经在本章的一个简短的旁白中提到了随机性,但是让我们再看一看。比如下面这段代码真的错了

import random
key = random.getrandbits(16, "big")

随机包是一个伪随机数发生器,甚至不是一个好的发生器。伪随机数发生器是确定性的,产生对人类来说看似随机的数字,但给定一个已知的种子值,这些数字总是相同的。默认种子曾经基于系统时间。这看起来似乎是合理的,但这意味着如果攻击者能够猜出随机数生成器何时被播种,他们就可以完全预测所有产生的随机数。使情况变得更糟的唯一方法是硬编码密钥或种子(这实际上是一回事)。

import random

# Set the random number generator seed to 0.

r = random.Random(0)
key = r.getrandbits(16, "big")

这段代码将在程序的每次运行中产生相同的“随机”数字。这有时对测试很有用,但是你不能把它留在产品代码中!

尽管 Python 的默认播种不再那么容易预测,但它不适合生成密码之类的秘密。相反,总是从os.urandom()拉,或者,如果使用 Python 3.6 或更高版本,从secrets.SystemRandom()拉。在大多数情况下,这是足够的随机性。如果你需要更强的东西,你可能需要使用不同的硬件,并且应该咨询一个专业的密码学家。

在某些部署中,密钥不是从随机数中提取的。相反,它是从密码中派生出来的。如果你要从一个密码中得到一个密钥,这个密码需要非常安全!在前一章中,您学习了暴力攻击,所有这些课程在这里都适用。

让我们感受一下在这些场景中猜出一个键的难度有什么不同。尝试所有可能的 128 位(随机)密钥需要多长时间?那是多少次尝试?

有 2 个 128 个不同的 128 位密钥。有这么多不同的键:

340,282,366,920,938,463,463,374,607,431,768,211,456.

但是,如果您的密钥是由一个五位数的 pin 码得到的,那么您已经将它减少到了 99,999!的确,很少有密码像一个真正随机的 128 位密钥那样难以破解。毕竟,你需要一个由大约 20 个随机字符组成的密码,才能像一个 128 位的密钥一样需要强力破解。但是,99,999 只是在乞求一台计算机接受你的挑战。你可以做得更好!

提醒一下,有一些经过验证的算法可以从密码中导出密钥。一定要用好的。在前一章中,我们使用了 scrypt。还有一些人觉得更好的(比如 bcrypt 或者 Argon2)。什么是好的求导函数?一个特点是需要多长时间。如果有人选择了一个弱密码(例如,“puppy1”),攻击者不会花很长时间就能猜出它。然而,如果求导函数很慢,可能会花费太长时间。

简而言之,不要麻烦使用一个好的密码和一个坏的密钥。确保您的密钥是安全生成的,并且能够充分抵御坚定的对手滥用。

练习 3.21。预测基于时间的随机性

编写一个使用 Python 随机数生成器生成密钥的 AES 加密程序(或者修改您为本章编写的其他程序)。使用 seed 方法,使用四舍五入到最近的秒的time.time(),根据当前时间明确配置发生器。然后使用这个生成器创建一个密钥并加密一些数据。编写一个单独的程序,将加密的数据作为输入,并尝试猜测密钥。它应该将最小时间和最大时间作为一个范围,并尝试在这两点之间迭代,作为 random 的种子值。

其他加密算法

在本章中,我们专门关注 AES 加密。这是有充分理由的。AES 是目前使用的最流行的对称密码。它被用于网络通信以及在磁盘上存储数据。正如我们将在第七章中看到的,它是一些高级 AEAD(关联数据认证加密)的基础。

但是,也可以使用其他对称密钥加密算法。下面是一些受cryptography库支持的例子:

  • 山茶

  • 查查 20

  • 三重度

  • CAST5

  • 种子

尽管我们总是鼓励您使用经过充分测试、备受尊敬的第三方库,但是要知道,这些库通常包含对不太理想的算法的支持,以支持遗留系统。在这个由cryptography支持的算法列表中,一些密码已经被认为是不安全的,正在被淘汰。例如,虽然 DES 不包含在cryptography库的密码中(好!DES 很烂!),模块确实包含 3DES (TripleDES)。虽然 3DES 不像 DES 那么破,但应该尽快退役。CAST5 也属于这一类。

cryptography支持的另一个密码是 Blowfish。这种算法也不推荐使用,其更强的继任者 Twofish 在当前的cryptography实现中不可用。

最终确定()

这一章涵盖了大量的材料,我们仅仅触及了表面。也许你能从这一章学到的最重要的原则是,密码学通常比乍看起来要复杂得多。我们讨论的不同操作模式有不同的优点和缺点,其中一些我们通过示例进行了探讨。我们发现,即使我们如何处理加密操作的 API,也会对安全性产生重大影响。

希望这一课强化了 YANAC 原则(你不是一个密码学家...还没!).请记住,这些练习是介绍性的,有教育意义的。请不要将这些代码复制到产品中,也不要使用您已经获得的入门知识来编写安全关键操作。你真的想拿别人的个人信息、财务信息或其他敏感数据去冒险吗?

同时,在学习了一章关于加密的内容后,你会对这个词的含义有更广泛的理解。下次你听到“受 AES 128 位加密保护”时,你可能想知道他们是在使用 CTR、CBC 还是(但愿不会!)ECB 模式。您可能还想知道他们是否正确地使用了他们的加密,因为您已经经历了对称加密被破解的一些方式(通常是意想不到的)。

是的,你已经向密码世界迈出了第一步。你准备好再拍几张了吗?那我们来说说非对称加密吧!

***

四、不对称加密:公钥/私钥

非对称加密是加密安全领域有史以来最重要的进步之一。它是网络、Wi-Fi 连接、安全电子邮件和其他各种通信安全的基础。它无处不在,但也很微妙,很容易被错误地实现或使用,缺乏正确性意味着有时安全性会大大降低。

也许你听说过“公钥”、“公钥基础设施”和/或“公钥加密”实际上,在非对称加密和许多不同的算法中有多种操作。在本章中,我们将专门关注非对称加密,特别是使用一种被称为 RSA 的算法。我们将把其他非对称操作,如签名和密钥交换,留到后面的章节。

事实上,RSA 加密几乎完全过时了。为什么要研究它?因为 RSA 是经典的非对称算法之一,而且在我们看来,它很好地引入了一些核心概念,这些概念将有助于学习更现代的方法。

两把钥匙的故事

东南极洲真相间谍机构(EATSA)给爱丽丝和鲍勃一个新的任务。鲍勃将留在东南极洲(EA)作为爱丽丝的负责人,爱丽丝将在西南极洲政府的小餐馆(WAGGS)得到一个秘密职位。爱丽丝将向鲍勃报告西南极洲(WA)的政客们在吃什么。EATSA 计划要挟这些政客吃多少热食物,而他们的选民却只能吃冷冻晚餐。

然而,EATSA 担心通信受到影响。如果爱丽丝被用对称密钥捕获,西南极洲中央骑士办公室(WACKO)将能够用它来解密他们截获的她发给 EATSA 的任何信息。那会毁了整个计划!

EATSA 决定实施一项新技术:非对称加密。当他们发现有两个密钥的加密方案:用一个密钥加密的东西只能被另一个解密时,他们的集体头脑都炸了!

使用这项新技术,Bob 只需使用两把钥匙中的一把(“公共”钥匙)就可以将 Alice 发送到现场。爱丽丝将能够加密回给鲍勃的消息,即使她也无法解密!只有在 EA 领域内安全并且拥有相应的“私有”密钥的 Bob 可以解密消息。这听起来很完美——如果她的密钥被泄露,至少不会允许她的捕获者解密她写的东西,这比以前严格地说要好。 1 会出什么差错呢?

为了完成这个方案,EATSA 选择使用 RSA 加密,这是一种非对称算法,使用非常大的整数作为密钥和消息,并使用“模幂运算”作为加密和解密的主要数学运算符。该算法易于理解,并且使用现代编程语言,相对容易实现。从各方面来看,这都是烹饪花招的完美配方。

变得紧张

在 RSA 中生成密钥有点棘手,因为它需要找到两个非常大的整数,这两个整数很有可能是互质。对 EATSA 的代理人来说,这看起来像是一大堆数学,所以他们选择只使用现有的库来完成这一部分。清单 4-1 显示了他们放入 Python 3 的包以及他们编写的利用它的代码。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.asymmetric import rsa
 3   from cryptography.hazmat.primitives import serialization
 4
 5   # Generate a private key.
 6   private_key = rsa.generate_private_key(
 7        public_exponent=65537,
 8        key_size=2048,
 9        backend=default_backend()
10   )
11
12   # Extract the public key from the private key.
13   public_key = private_key.public_key()
14
15   # Convert the private key into bytes. We won't encrypt it this time.
16   private_key_bytes = private_key.private_bytes(
17       encoding=serialization.Encoding.PEM,
18       format=serialization.PrivateFormat.TraditionalOpenSSL,
19       encryption_algorithm=serialization.NoEncryption()
20   )
21
22   # Convert the public key into bytes.
23   public_key_bytes = public_key.public_bytes(
24       encoding=serialization.Encoding.PEM,
25       format=serialization.PublicFormat.SubjectPublicKeyInfo
26   )
27
28   # Convert the private key bytes back to a key.
29   # Because there is no encryption of the key, there is no password.
30   private_key = serialization.load_pem_private_key(
31       private_key_bytes,
32       backend=default_backend(),
33       password=None)
34
35   public_key = serialization.load_pem_public_key(
36       public_key_bytes,
37       backend=default_backend())

Listing 4-1
RSA Key Generation

一旦你知道如何使用它,那还不算太坏。这种模式对于任何私钥/公钥的生成都是一样的,所以即使有一些长名字的常量,看起来这个库确实让 EATSA 变得更容易了。

看看 RSA 中的私钥是如何决定一切的吧?公钥就是从它派生出来的。虽然其中一个密钥可用于加密(另一个可用于解密),但由于这个属性,私钥是特殊的。RSA 密钥不仅是不对称的,因为一个加密,另一个解密,它们也是不对称的,因为您可以从私钥导出 RSA 公钥,而不是相反。

private_bytespublic_bytes方法将大整数键转换成标准网络和磁盘编码的字节,称为 PEM。在从磁盘中读取这些字节后,可以使用相应的序列化“load”方法来解码这些字节,这样它们看起来就像加密和解密算法的密钥。

加密私钥本身是可能的(也是一个非常好的想法),但是我们选择不在这里这样做,这就是为什么没有使用密码。

RSA 做错了:第一部分

Alice 和 Bob 将通过探索错误使用 RSA 的所有方法来帮助我们了解 RSA。

对于 EATSA 来说,实际的加密和解密部分看起来非常简单,他们查看的每个库似乎都有许多不必要的额外内容,这使得它更难理解,甚至(气喘吁吁地)降低了速度。由于没有学习过 YANAC 原理,他们决定自己实现加密和解密。他们没有像写的那样使用第三方库,而是选择省略填充。这就产生了一个非常“原始”或基本形式的 RSA,它对我们学习内部机制很有用,即使结果很不完整。

警告:不要滚动您自己的加密

同样,实现你自己的 RSA 加密/解密,而不是使用一个库,根本不是一个好主意。使用没有填充的 RSA 是非常不安全的,原因有很多,我们将在本节中探讨其中的一些。尽管我们将出于教育目的在这里编写我们自己的 RSA 函数,在任何情况下都不要在真实的交流中使用这些代码

下面是加密的数学公式,其中 c 是密文, m 是消息,其余的参数形成公钥和私钥,稍后解释:

$$ c\equiv {m}^e\kern1em \left(\operatorname{mod}\ n\right) $$

(4.1)

同样,下面是解密:

$$ m\equiv {c}^d\kern1em \left(\operatorname{mod}\ n\right) $$

(4.2)

看起来不算太糟,对吧?模幂运算在大型整数数学库中是一个相当标准的运算, 2 所以这个真的没有太多。

如果你是这方面的新手,不要被。为了简单起见,你通常可以认为它是一个等号。

(4.1)和(4.2)中的运算可以使用gmpy2(一个大数数学库)用 Python 简洁地编写。powmod函数执行必要的模幂运算,如清单 4-2 所示。

 1   #### DANGER ####
 2   # The following RSA encryption and decryption is
 3   # completely unsafe and terribly broken. DO NOT USE
 4   # for anything other than the practice exercise
 5   ################
 6   def simple_rsa_encrypt(m, publickey):
 7       # Public_numbers returns a data structure with the 'e' and 'n' parameters.
 8       numbers = publickey.public_numbers()
 9
10       # Encryption is(m^e) % n.
11       return gmpy2.powmod(m, numbers.e, numbers.n)
12
13   def simple_rsa_decrypt(c, privatekey):
14       # Private_numbers returns a data structure with the 'd' and 'n' parameters.
15       numbers = privatekey.private_numbers()
16
17       # Decryption is(c^d) % n.
18       return gmpy2.powmod(c, numbers.d, numbers.public_numbers.n)
19   #### DANGER ####

Listing 4-2GMPY2

如前所述,现在可能更明显了,RSA 操作的是整数,而不是消息字节。我们如何将消息转换成整数?Python 使这变得很方便,因为它的int类型有to_bytesfrom_bytes方法。让我们让它们在清单 4-3 中使用起来更好一些。

1   def int_to_bytes(i):
2       # i might be a gmpy2 big integer; convert back to a Python int
3       i = int(i)
4       return i.to_bytes((i.bit_length()+7)//8, byteorder="big")
5
6   def bytes_to_int(b):
7       return int.from_bytes(b, byteorder="big")

Listing 4-3
Integer/Byte Conversion

重要的

因为 RSA 处理的是整数,而不是字节,所以默认实现会丢失前导零。就整数而言,01 和 1 是同一个数。如果您的字节序列以任意数量的零开头,它们将无法通过加密/解密。对于我们的例子,我们发送文本,所以它永远不会是一个问题。然而,对于二进制数据传输,它可能是。这个问题用填充就解决了。

EATSA 现在拥有了创建一个简单的 RSA 加密/解密应用所需的所有组件。在查看清单 4-4 中的代码之前,尝试创建自己的版本。

  1   # FOR TRAINING USE ONLY! DO NOT USE THIS FOR REAL CRYPTOGRAPHY
  2
  3   import gmpy2, os, binascii
  4   from cryptography.hazmat.backends import default_backend
  5   from cryptography.hazmat.primitives.asymmetric import rsa
  6   from cryptography.hazmat.primitives import serialization
  7
  8   #### DANGER ####
  9   # The following RSA encryption and decryption is
 10   # completely unsafe and terribly broken. DO NOT USE
 11   # for anything other than the practice exercise
 12   ################
 13   def simple_rsa_encrypt(m, publickey):
 14       numbers = publickey.public_numbers()
 15       return gmpy2.powmod(m, numbers.e, numbers.n)
 16
 17   def simple_rsa_decrypt(c, privatekey):
 18       numbers = privatekey.private_numbers()
 19       return gmpy2.powmod(c, numbers.d, numbers.public_numbers.n)
 20   #### DANGER ####
 21
 22   def int_to_bytes(i):
 23       # i might be a gmpy2 big integer; convert back to a Python int
 24       i = int(i)
 25       return i.to_bytes((i.bit_length()+7)//8, byteorder="big")
 26
 27   def bytes_to_int(b):
 28       return int.from_bytes(b, byteorder="big")

 29
 30   def main():
 31       public_key_file = None
 32       private_key_file = None
 33       public_key = None
 34       private_key = None
 35       while True:
 36           print("Simple RSA Crypto")
 37           print("--------------------")
 38           print("\tprviate key file: {}".format(private_key_file))
 39           print("\tpublic key file: {}".format(public_key_file))
 40           print("\t1\. Encrypt Message.")
 41           print("\t2\. Decrypt Message.")
 42           print("\t3\. Load public key file.")
 43           print("\t4\. Load private key file.")
 44           print("\t5\.  Create and load new public and private key files.")
 45           print("\t6\. Quit.\n")
 46           choice = input(" >> ")
 47           if choice == '1':
 48               if not public_key:
 49                   print("\nNo public key loaded\n")
 50               else:
 51                   message = input("\nPlaintext: ").encode()
 52                   message_as_int = bytes_to_int(message)
 53                   cipher_as_int = simple_rsa_encrypt(message_as_int, public_key)
 54                   cipher = int_to_bytes(cipher_as_int)
 55                   print("\nCiphertext (hexlified): {}\n".format(binascii.hexlify(cipher)))
 56           elif choice == '2':
 57               if not private_key:
 58                   print("\nNo private key loaded\n")
 59               else:
 60                    cipher_hex = input("\nCiphertext (hexlified): ").encode()
 61                   cipher = binascii.unhexlify(cipher_hex)
 62                   cipher_as_int = bytes_to_int(cipher)
 63                   message_as_int = simple_rsa_decrypt(cipher_as_int, private_key)
 64                   message = int_to_bytes(message_as_int)
 65                   print("\nPlaintext: {}\n".format(message))
 66           elif choice == '3':
 67               public_key_file_temp = input("\nEnter public key file: ")
 68               if not os.path.exists(public_key_file_temp):
 69                   print("File {} does not exist.")
 70               else:
 71                   with open(public_key_file_temp, "rb") as public_key_file_object:
 72                       public_key = serialization.load_pem_public_key(
 73                                        public_key_file_object.read(),
 74                                        backend=default_backend())
 75                       public_key_file = public_key_file_temp

 76                       print("\nPublic Key file loaded.\n")
 77
 78                       # unload private key if any
 79                       private_key_file = None
 80                       private_key = None
 81           elif choice == '4':
 82               private_key_file_temp = input("\nEnter private key file: ")
 83               if not os.path.exists(private_key_file_temp):
 84                   print("File {} does not exist.")
 85               else:
 86                   with open(private_key_file_temp, "rb") as private_key_file_object:
 87                       private_key = serialization.load_pem_private_key(
 88                                        private_key_file_object.read(),
 89                                        backend = default_backend(),
 90                                        password = None)
 91                       private_key_file = private_key_file_temp
 92                       print("\nPrivate Key file loaded.\n")
 93
 94                       # load public key for private key
 95                       # (unload previous public key if any)
 96                       public_key = private_key.public_key()
 97                       public_key_file = None
 98           elif choice == '5':
 99               private_key_file_temp = input("\nEnter a file name for new private key: ")
100               public_key_file_temp = input("\nEnter a file name for a new public key: ")
101               if os.path.exists(private_key_file_temp) or os.path.exists(public_key_file_temp):
102                   print("File already exists.")
103               else:
104                   with open(private_key_file_temp, "wb+") as private_key_file_obj:
105                       with open(public_key_file_temp, "wb+") as public_key_file_obj:
106
107                           private_key = rsa.generate_private_key(
108                                             public_exponent =65537,
109                                             key_size =2048,
110                                             backend = default_backend()
111                                         )
112                           public_key = private_key.public_key()
113
114                           private_key_bytes = private_key.private_bytes(
115                               encoding=serialization.Encoding.PEM,
116                               format=serialization.PrivateFormat.TraditionalOpenSSL,

117                               encryption_algorithm=serialization.NoEncryption()
118                           )
119                           private_key_file_obj.write(private_key_bytes)
120                           public_key_bytes = public_key.public_bytes(
121                               encoding=serialization.Encoding.PEM,
122                               format=serialization.PublicFormat.SubjectPublicKeyInfo
123                           )
124                           public_key_file_obj.write(public_key_bytes)
125
126                           public_key_file = None
127                           private_key_file = private_key_file_temp
128           elif choice == '6':
129               print("\n\nTerminating. This program will self destruct in 5 seconds.\n")
130               break
131           else:
132               print("\n\nUnknown option {}.\n".format(choice))
133
134   if __name__ == '__main__':
135       main()

Listing 4-4RSA Done Simply

在我们一起练习之前,花几分钟时间自己尝试一下这个练习。顺便注意,因为公钥可以从私钥派生出来,所以加载私钥的同时也加载了公钥。

当你准备好了,继续读!你可能想不时地回头参考清单 4-4 。我们随后的许多清单将重用这些导入和函数定义。为了节省空间,我们一般不会重印它们,所以这个列表也是一个有用的模板。

练习 4.1。简单 RSA 加密

使用前面的应用,建立从 Alice 到 Bob 的通信,然后从 Alice 向 Bob 发送一些加密的消息进行解密。

填充发件箱

一旦 EATSA 建立了 RSA 加密应用,他们就把它交给 Alice 和 Bob,并命令他们开始这项任务。爱丽丝将渗透到 WAGGS,并发送更新给鲍勃。爱丽丝和鲍勃首先需要做什么?

公钥/私钥对的神奇之处在于,为了让 Alice 向 Bob 发送安全消息,它们在分开之前不需要就任何事情达成一致! 3 只要爱丽丝知道去哪里找,鲍勃就可以在任何地方向她的发布公钥。他可以把它登在报纸上,在电话里背诵给她听,或者在环绕西南极洲飞行的固特异飞艇上宣传它。关键是。如果西南极洲反情报部门看到了也没关系:他们将无法解密爱丽丝的信息。

正确

爱丽丝离开 EATSA 总部,穿过边境,来到西南极洲城市,在那里她渗透到 WAGGS。当她从事秘密烹饪活动时,Bob 生成了一对公钥/私钥。他保留私钥并公布公钥给 Alice 看。

让我们跟着走。启动代表 Bob 版本的应用实例,并选择选项 5,这会生成新的密钥对并将它们保存到磁盘。完成后,您将有两个可以在编辑器中检查的文件。

看一下公钥文件(在出现提示时为它选择了名称)。它的内容应该是这样的:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuGFr+NV3cMu2pdl+i52J
XkYwwSHgZvA0FyIPsZ/rp6Ts5iBTkpymt7cf+cQCQro4FSw+udVt4A8wvZcppnBZ
h+17ZZ6ZZfj0LCr/3sJw8QfZwuaX5TZxFbJDxWWwsR4jLHsiGsPNf7nzExn7yCSQ
sXLNqc+mLKP3Ud9ta14bTQ59dZIKKDHVGlQ1iLlhjcE1dhOAjWlsdCVfE+L/bSQk
Ld9dWKCM57y5tiMsoqnVjl28XcsSuiOd4QPGITprsX0jb7/p/rzXc9OQHHGyAQzs
WTAbZNaQxf9AY1AhE4wgMVwhnrxJA2g+DpY1yXUapOIH/hpD0sMH56IGcMx9oV/y
SwIDAQAB
-----END PUBLIC KEY-----

那是一个 PEM 格式的公钥。恭喜你!鲍勃可以拿着这把钥匙,在分类广告栏里把它发表给西南极洲的一家报纸。

与此同时,爱丽丝一直在仔细观察西南极洲的政客们喜欢吃什么。多么不像南极人!当她看着他们吃着热狗热巧克力时,她心想。然后,回头看了一眼手中的报纸,她找到了她一直在寻找的分类广告!公钥已经到了!她小心翼翼地将它复制到一个文件中,现在已经能够为 Bob 的眼睛加密信息了。

接下来,让我们将刚刚生成的公钥复制到一个新文件中。这代表 Alice 从分类广告中复制文本后创建的文件。现在启动应用的一个新实例,它代表程序的 Alice 副本。选择选项 3 来加载她的公钥。

爱丽丝需要给鲍勃发回一条消息。这是我们计划中的选项 1。运行它,选择选项 1,并在明文字段中输入文本“热狗”。加密信息突然出现。 4 如果您使用前面的公钥,您将得到以下输出:

Plaintext: hot dogs

Ciphertext (hexlified): b'56d5586cab1764fae575bc5815115f1c5d759
daddccbd6c9cb4a077026e2616dfca756ffa7733538e66997f06ebbbb853028
3926383a6bb80b7145990a29236d042048eed8eb7607bd35fcafe3dadd5d60a
1f8694192bddedac5728061234ffbb7a407155844a7e79b3dbc9704df0de818
d24acad32ccd6d2afe2d0734199c76e5c5c770fa8c3c208eceae00554aa2f29
9a8510121d388d85f35fa49c08f3e9d7540f22fe5eb4ea15da5f387dbdd0e00
6710aa9031b885094773ef3329cde91dbede53ed77b96483d34daa4fedbf5bc
d95e95b6b482a7decbf47fe2df0e309d706ab9c73ce73a2bdef33b786dd12e9
8a9ce34bbc1847f36e13ae9eea4007b616'

我们再来一次,但这次是“热巧克力”如果您使用我们向您展示的前面的公钥这样做,您将得到这个输出(但是继续使用您自己生成的公钥):

Plaintext: hot chocolate

Ciphertext (hexlified): b'4d1e544e71c4cb15636ef4b0d629294538a05
979db762952cc5f0fc494f71535dff326dbb8543d0f2ace51a2279f65c2a76b
2a5ca5a3ee151e65e516afcb1d4da9ca9871dc7ce1dd4361a3b49def05c5089
99f5fab81b869b251ba8694fb171ab56ca1cde7cef0ac3934da4c28f7bfbb65
b03afa9cff30db974f0bd4fb8dee7fac75c99cd4def94ca8de83d46fffa092a
90642c9cfbfbf07c371f5aa3a62dc997d20e9959fcbec7dd0b434709b679619
ea195008a9a12eaa7462ffdbe8e6f765dd86b21f0f1d9b8b2b523ca7f11785e
fc6da84ec717bd1f0e2191e5a3bef74e489b5e396c49bd8f222ccd89984dbec
8b5e4cbb23ba739637d3307bca4e9f57e7'

同样,Alice 不能解密这些消息,即使她自己加密了它们:她没有私钥。至少,理论是这么告诉他们的。

她对自己的“可食用间谍”充满信心,带着这些信息,通过一只不安全的企鹅把它们发送给鲍勃。Bob 收到消息并重新加载他的应用。首先,他使用选项 4 加载私钥文件,然后选择选项 2 尝试解密。果不其然,当他把消息复制给爱丽丝时,它正确地解密了:

Ciphertext (hexlified): 56 d5586cab1764fae575bc5815115f1c5d759da
ddccbd6c9cb4a077026e2616dfca756ffa7733538e66997f06ebbbb85302839
26383a6bb80b7145990a29236d042048eed8eb760735fcafe3dadd5d60a1f86
94192bddedac5728061234ffbb7a407155844a7e79b3dbc9704df0de818d24a
cad32ccd6d2afe2d0734199c76e5c5c770fa8c3c208eceae00554aa2f299a85
10121d388d85f35fa49c08f3e9d7540f22fe5eb4ea15da5f387dbdd0e006710
aa9031b885094773ef3329cde91dbede53ed77b96483d34daa4fedbf5bcd95e
95b6b482a7decbf47fe2df0e309d706ab9c73ce73a2bdef33b786dd12e98a9c
e34bbc1847f36e13ae9eea4007b616

Plaintext: b'hot dogs'

“热狗!”鲍勃惊呼道。“不光彩!”

Ciphertext (hexlified): 4d1e544e71c4cb15636ef4b0d629294538a05979
db762952cc5f0fc494f71535dff326dbb8543d0f2ace51a2279f65c2a76b2a5c
a5a3ee151e65e516afcb1d4da9ca9871dc7ce1dd4361a3b49def05c508999f5f
ab81b869b251ba8694fb171ab56ca1cde7cef0ac3934da4c28f7bfbb65b03afa
9cff30db974f0bd4fb8dee7fac75c99cd4def94ca8de83d46fffa092a90642c9
cfbfbf07c371f5aa3a62dc997d20e9959fcbec7dd0b434709b679619ea195008
a9a12eaa7462ffdbe8e6f765dd86b21f0f1d9b8b2b523ca7f11785efc6da84ec
717bd1f0e2191e5a3bef74e489b5e396c49bd8f222ccd89984dbec8b5e4cbb23
ba739637d3307bca4e9f57e7

Plaintext: b'hot chocolate'

鲍勃的眼睛眯了起来。“热巧克力?!他们没有羞耻心吗?!"

到目前为止,一切顺利!鲍勃收到了爱丽丝的信息。它们被特工伊芙·瓦克截获了,但她应该读不出来,尽管她也有公钥。如果爱丽丝不能阅读她自己的信息,为什么夏娃可以?

爱丽丝和鲍勃不知道的是,夏娃即将造成各种破坏。在本章的其余部分,我们将介绍 RSA 可能受到攻击的一些方式以及如何正确应对。但是首先,练习!

练习 4.2。谁鲍勃。是你吗?

假设 Eve 的角色,想象你知道 Alice 和 Bob 操作的一切,除了私钥。也就是说,假设你知道分类广告,载体企鹅,甚至加密程序。他们的方案通过使用非对称加密得到了加强,但是仍然容易受到 MITM(中间人)攻击。伊芙如何定位自己,让她可以欺骗爱丽丝发送伊芙可以解密的信息,而鲍勃只能从伊芙而不是爱丽丝那里收到假信息?

练习 4.3。生命,宇宙,一切的答案是什么?

我们已经在前一章讨论过选择明文攻击。这里可以使用相同的攻击。再次承担伊夫的作用,古怪的代理人。你在报纸上截获了鲍勃的公开密钥,你可以进入 RSA 加密程序。如果你怀疑你知道爱丽丝在她的加密信息中发送了什么,解释或演示你将如何验证你的猜测。

非对称加密有何不同?

正如您在本节中已经了解到的,RSA 是非对称加密的一个例子。如果您之前没有听说过非对称加密,希望您刚刚完成的练习已经让您了解了关键概念。现在让我们明确一些事情。

在对称加密中,有一个单独的共享密钥对消息进行加密和解密。这意味着任何有能力创建加密消息的人都有同样的能力解密相同的消息。给某人解密对称加密的消息的权力而不给他们加密同类消息的能力是不可能的,反之亦然。

在非对称加密中,总有一个绝对不能公开的私钥和一个可以广泛公开的公钥。密钥对到底能做什么取决于算法。在本章中,我们一直关注 RSA 加密。我们将在本节中作为一个具体的例子来回顾 RSA 的运算,但是请记住,它们可能不适用于其他非对称算法和运算。

具体来说,RSA 支持非对称加密方案,在该方案中,您可以使用一个密钥来加密消息,而使用另一个密钥来解密消息。通常,任一密钥都可以充当任一角色:私钥可以加密可以被公钥解密的消息,反之亦然。当然,对于 RSA,一个密钥显然是私有密钥,因为公共密钥可以从私有密钥中导出,而不是相反。有 RSA 私钥而没有与之匹配的公钥是不可能的。因此,一个密钥被明确地指定为“私有的”,另一个是“公共的”

受适当保护的 RSA 私钥和足够健壮的协议的拥有者可以出于两个目的使用非对称加密:

  1. 加密收存箱:任何有公钥的人都可以加密一条消息,并将其发送给私钥的所有者。只有拥有私钥的人才能解密这条消息。

  2. 签名:任何有公钥的人都可以解密用私钥加密的消息。这显然无助于保密(任何人都可以解密该消息),但它有助于证明发送者的身份,或者至少发送者拥有私钥;否则,他们就不能加密一个可用公钥解密的消息。这是一个加密签名的例子,我们稍后会谈到。

注意:RSA 加密小东西

我们现在正在学习的加密 dropbox 操作几乎从未被用来以这种方式发送完整的消息。RSA 加密最常用的方式(同样,它正在被淘汰)是加密一个对称密钥,以便从一方传输到另一方。这是另一个概念,我们将留到下一章讨论。

RSA 加密的非对称本质的真正奇妙之处在于,双方不需要见面就可以开始交换消息。在我们的例子中,Alice 和 Bob 不需要一起创建任何共享密钥。爱丽丝甚至不需要认识鲍勃。只要 Alice 有 Bob 的公钥,她就可以加密只有 Bob 能读懂的消息。

不幸的是,只为一个人加密的能力并不是现实生活中唯一重要的事情。如练习中所示,非对称加密的优势也是其弱点。没有任何先前互动的交流能力也意味着,在没有额外信息的情况下,没有办法知道你正在与正确的人交流。

如果你已经完成了前面的练习,你会发现对于疯子来说,通过截取信息和密钥欺骗双方来读取和修改爱丽丝和鲍勃之间的通信是非常简单的。

  1. 他们可以通过截取和修改报纸上公布的公钥来欺骗爱丽丝。通过插入他们自己的公钥——爱丽丝现在误认为是鲍勃的——他们可以读取爱丽丝发送给鲍勃的所有信息。没有附加信息,Alice 无法知道公钥已经被泄露。

  2. 然后,他们可以通过阻止 Alice 不正确加密的消息到达 Bob,并向他发送用正确的公钥加密的假消息来欺骗 Bob,他们截获了这些消息。没有附加信息,Bob 无法知道是谁在发送消息。

这是对称密钥和非对称密钥之间的一个关键区别。事实上,一些密码学家区分“秘密的”对称密钥和“私有的”非对称密钥。两个人可以共享一个秘密,但是只有一个人知道他们自己的私钥。这在实践中意味着,如果对称密钥对双方都是保密的,那么它可以用来确定你正在与正确的人(即,与你创建共享密钥的人)交谈,而非对称密钥则不能。 6

让我们暂时回避这个问题,留待以后解决,因为在证书的上下文中确实讨论了这个问题的解决方案。

传递填料

回想一下前面的内容,EATSA 选择在没有任何填充的情况下实现 RSA。他们真的不应该那样做;这是一个相当严重的错误。事实上,它是如此严重,以至于cryptography模块甚至不允许你用 RSA 无填充加密!

那么,什么是填充,为什么填充如此重要?

解释这一点的最佳方式是演示如何读取用公钥加密的消息,即使您没有私钥,只要这些消息没有被填充。另一个很好的练习是在互联网上搜索 RSA 填充攻击。使用无填充明文有许多问题。

确定性输出

先说最基本的问题。RSA 本身就是一种确定性算法。这意味着,给定相同的密钥和消息,您将总是得到相同的密文,一个字节一个字节地。回想一下,我们在 AES 之类的对称密钥加密算法上也有同样的问题。有必要使用初始化向量(IV) 来防止确定性输出。你还记得为什么确定性输出如此糟糕吗?

确定性输出的问题是,它们使被动窃听者(如 Eve)能够进行一些密码逆向工程。因为加密是确定性的,如果伊芙知道 m 加密到 c ,那么任何时候伊芙看到 c 她就知道明文是什么。

img/472260_1_En_4_Fig1_HTML.png

图 4-1

如果 RSA 的输出是确定性的,那么发现明文和对应密文之间映射的对手可以将其记录到查找表中以备后用。这个图看起来眼熟吗?

Eve 既有公钥又有算法(你永远不能假设一个密码算法是秘密的)。她可以加密任意数量的潜在消息,并存储预加密值的查找表。图 4-1 看着眼熟吗?我们在第三章中展示了同样的图像,来讨论对称密码的 ECB 模式及其存在的问题。

但是确定性非对称加密会更糟。与对称加密不同,我们必须假设对手拥有(公共)密钥。在我们假设的南极冲突中,Eve 可能会发现,或者简单地猜测,Alice 正在根据她对自助餐厅的监视发送信息。如果她试图通过列出房间内发现的东西来加密几百个单词(例如,在自助餐厅吃饭的政治家的名字、谈话的主题和正在吃的食物),一旦她加密了“热狗”或“热巧克力”,加密的值就会与在返回给 Bob 的消息中截取的内容完全匹配。对于像这样的短消息,尤其是如果 EA Intelligence 总是用小写字母写单词,那么只有不到 3 亿条 8 个字符长的消息可以尝试。创建这么多消息的密文表并不太麻烦。使用这个查找表,Eve 可以相对快速地识别“热狗”。

即使夏娃不能猜出这个信息,仍然有各种各样的分析可以做。假设爱丽丝继续日复一日地发送同样的信息。虽然 Eve 可能无法解密这条消息,但她仍然能够自信地声明这是同一条消息。在前几章中,我们已经考虑了许多利用这种“信息泄露”的例子。

练习 4.4。强力 RSA

编写一个程序,使用蛮力解密一个 RSA 加密的全小写(无空格)少于四个字符的字。该程序应该将公钥和 RSA 加密的密文作为输入。使用 RSA 加密程序生成四个或更少字母的几个单词,并用您的暴力程序破解这些代码。

练习 4.5。等待是最难的部分

修改蛮力程序,尝试五个或更少字母的所有可能单词。测量暴力破解一个四个字母的单词和一个五个字母的单词所花费的时间(最坏情况下)。大约需要多长时间,为什么?尝试所有可能的六个字母单词需要多长时间?

练习 4.6。字典攻击

很明显,尝试所有可能的长度远大于四或五的小写 ASCII 单词将花费比你可能的注意力跨度更长的时间。但是我们在前面的章节中已经看到了同样的问题。让我们尝试相同的解决方案。修改你的强力程序,将字典作为输入来尝试任意的英语单词。

选择密文攻击

没有填充的 RSA 也容易受到所谓的“选择密文攻击”当你能让受害者代表你解密你选择的一些密文时,这种类型的攻击就起作用了。这听起来可能违背直觉。为什么有人会为你解密任何东西?例如,为什么鲍勃要为伊芙解密任何东西?

请记住,许多计算机安全都与心理学、诡计和人类思维有关。2].鲍勃在找什么?Bob 假设他正在解密来自 Alice 的可读信息。如果他收到了人类无法阅读的信息呢?例如,假设在解密一条消息(假设来自 Alice)时,他得到以下输出:

b'\xe8\xca\xe6\xe8'

完全有可能,这只是假设由于传输错误。这些事情在现实生活中无时无刻不在发生。这可能是一个小错误,或者是一只载体企鹅弄脏了墨水。Bob 可能会看到许多无法正确解密的消息。

鲍勃是做什么的?如果他没有很好的安全控制,他可能会把它扔掉。但是如果爱丽丝能渗透到敌人内部,它也能以另一种方式工作。你觉得哪个更容易被 Eve 弄到手?被发送到指挥链进行分析的绝密信息,还是被扔进垃圾桶的“不正确”信息?如果 Eve 在门卫工作人员中有自己的秘密特工,很有可能会得到丢弃的纸张或未完全销毁的数据。

让我们假设这个场景:Eve 可以向 Bob 发送任意的密文。出于我们的目的,Eve 看不到任何人类可读的消息,但是可以恢复被 Bob 丢弃的假定错误的消息,因为它们看起来毫无意义。

不幸的是,对于爱丽丝和鲍勃来说,伊芙可以用这个技巧解密爱丽丝发送回她基地的几乎所有信息。这个技巧背后的数学知识非常酷,在本章的多个例子中都有使用。所以让我们暂停一分钟来谈谈加密中的同态

加密同态的基本概念是,如果您对密文执行某种计算,结果会反映在明文中。不是所有的密码系统都具有同态性质,但 RSA 在一定程度上具有同态性质。在 RSA 中,我们将看到对密文进行乘法运算的方法会导致对明文进行乘法运算。目前还有其他一些特殊的同态加密技术正在开发中,这些技术使第三方能够在无法读取数据的情况下提供数据服务。你可能听说过其中的一些;如果没有,可以试着在网上搜索“同态加密”。这是非常有趣的东西。

虽然 RSA 不是一个同态加密方案,但这种乘法特性非常有趣(也造成了许多漏洞)。还记得代数课上说的(ac)(bc)=(abc)?模幂运算也是如此,如下式所示:

$$ {\left({m}_1\right)}e{\left({m}_2\right)}e\ \left(\operatorname{mod}\ n\right)={\left({m}_1{m}_2\right)}^e\ \left(\operatorname{mod}\ n\right) $$

(4.3)

这个等式的任何部分看起来熟悉吗?回头看看(4.1)。你现在明白了吗?

任何时候我们在 RSA 中加密一个值( m ),最终都会得到memodn。在(4.3)的左侧,我们有两个加密,一个是 m 1 一个是 m 2 ,两者都使用相同的公共指数 e 并且两者都取相同的模数 n

在右手边,我们有一个单次加密的值 m 1m2。这个等式告诉我们的是,如果将这些单独加密的值相乘(mod n ),就可以得到相乘的加密结果!

换句话说,两个密文(在同一公钥下加密)的乘积解密为两个明文的乘积。在我们开始之前,请尝试自己完成以下练习。

练习 4.7。无填充 RSA 的同态性质

使用(4.3)将两个 RSA 加密的数字相乘,并解密结果以验证等式。

这个练习的代码非常简单,所以一定要先自己尝试一下。当你准备好了,我们的解决方案就在清单 4-5 中。

 1   # FOR TRAINING USE ONLY! DO NOT USE THIS FOR REAL CRYPTOGRAPHY
 2
 3   import gmpy2, sys, binascii, string, time
 4   from cryptography.hazmat.backends import default_backend
 5   from cryptography.hazmat.primitives import serialization
 6   from cryptography.hazmat.primitives.asymmetric import rsa
 7
 8   #### DANGER ####
 9   # The following RSA encryption and decryption is
10   # completely unsafe and terribly broken. DO NOT USE
11   # for anything other than the practice exercise
12   ################
13   def simple_rsa_encrypt(m, publickey):
14       numbers = publickey.public_numbers()
15       return gmpy2.powmod(m, numbers.e, numbers.n)
16
17   def simple_rsa_decrypt(c, privatekey):
18       numbers = privatekey.private_numbers()
19       return gmpy2.powmod(c, numbers.d, numbers.public_numbers.n)

20
21   private_key = rsa.generate_private_key(
22         public_exponent=65537,
23         key_size=2048,
24         backend=default_backend()
25   )
26   public_key = private_key.public_key()
27
28   n = public_key.public_numbers().n
29   a = 5
30   b = 10
31
32   encrypted_a = simple_rsa_encrypt(a, public_key)
33   encrypted_b = simple_rsa_encrypt(b, public_key)
34
35   encrypted_product = (encrypted_a * encrypted_b) % n
36
37   product = simple_rsa_decrypt(encrypted_product, private_key)
38   print("{} x {} = {}".format(a,b, product))

Listing 4-5Solution

如果这种数学没有太大的意义,在这一点上不要太担心它。即使你不完全确定它是如何工作的,也要试着理解它是如何被使用的。

回到我们当前的例子,假设 Eve 有一个通过 m 的 RSA 公钥加密获得的密文 c 。没有私钥,Eve 应该无法解密。想必鲍勃也不会为她解密。然而,如果他将解密它的一个倍数,伊芙就能恢复原来的。

对于我们的例子,让我们选择我们的倍数为 2。Eve 首先使用(4.1)和公钥加密 2,得到cr。

为清楚起见,我们称原始密文为c0。如果我们将 c 0c r (模 n )相乘,我们将得到一个新的密文,我们称之为 c 1

$$ {c}_1={c}_0{c}_r\kern0.125em \left(\operatorname{mod}n\right). $$

从(4.3)式可以看出,这是

$$ {\displaystyle \begin{array}{l}{c}_1={c}_0{c}_r\kern0.125em \left(\operatorname{mod}n\right)\ {}={m}e{r}e\kern0.125em \left(\operatorname{mod}\kern0.125em n\right)\ {}={(mr)}^e\kern0.125em \left(\operatorname{mod}n\right).\end{array}} $$

那么 Eve 怎么用这个呢?假设伊芙截获了爱丽丝的一个密文 c 。Eve 将她计算的cr(同样,这只是在公钥下加密的值 2)然后将两个加密值相乘(模 n )。Eve 将这个新的密文发送给 Bob。

鲍勃接收到 c 1 并将其解密给 mr 并将整数转换成字节。他发现它不能解密成任何清晰可辨的东西,并认为某些东西在运输过程中被损坏了。他耸耸肩,把纸揉成一团,扔进了废纸篓。那天晚上晚些时候,伊芙的经纪人在垃圾桶里找到了那张皱巴巴的纸。她快速复制了一份,通过秘密的载体送回给伊芙。

夏娃现在有了先生,需要提取 m 。没问题。她选择 r 为 2。在熟悉的算法中,你将除以 r 得到 m。但是在用模运算做这个算术的时候,你必须使用一个不同的逆运算:r1(modn)。幸运的是,有一些库可以为我们计算这类数字,比如gmpy2

r_inv_modulo_n = gmpy2.powmod(r, -1, n)

练习 4.8。夏娃的门徒

重现夏娃选择的密文攻击。像前面一样,用 Python 创建一个示例消息,使用公钥对其进行加密。然后,加密一个值 r (比如 2)。将密文的两个数字版本相乘,不要忘记取模 n 的答案。解密这个新的密文,并尝试将其转换为字节。它不应该是人类可读的东西。取这个解密的数字版本,乘以 r (mod n )的倒数。你应该回到原来的数字。将其转换为字节以查看原始消息。

共模攻击

没有填充的 RSA 的另一个问题是“共模”攻击。回想一下, n 参数是模数,包含在公钥和私钥中。出于超出本书范围的数学原因,如果相同的 RSA 消息由两个不同的公钥加密,并且具有相同的 n 模数,那么该消息可以在没有私钥的情况下被解密。

在选择密文的例子中,我们详细地研究了数学,因为它可以相对容易地描述,也因为它对于多重攻击是至关重要的。对于这个例子,为了简单和节省空间,我们不会进入数学细节。相反,使用清单 4-6 中的代码来测试和探索攻击。如果你对数学的细节感兴趣,你可以阅读 Hinek 和 Lam 的“对小私有指数 RSA 和一些快速变体的共模攻击(实践)”。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Derived From: https://github.com/a0xnirudh/Exploits-and-Scripts/tree/master/RSA At tacks
 4   def common_modulus_decrypt(c1, c2, key1, key2):
 5       key1_numbers = key1.public_numbers()
 6       key2_numbers = key2.public_numbers()
 7
 8       if key1_numbers.n != key2_numbers.n:
 9           raise ValueError("Common modulus attack requires a common modulus")

10       n = key1_numbers.n
11
12       if key1_numbers.e == key2_numbers.e:
13           raise ValueError("Common modulus attack requires different public exponents")
14
15       e1, e2 = key1_numbers.e, key2_numbers.e
16       num1, num2 = min(e1, e2), max(e1, e2)
17
18       while num2 != 0:
19           num1, num2 = num2, num1 % num2
20       gcd = num1
21
22       a = gmpy2.invert(key1_numbers.e, key2_numbers.e)
23       b = float(gcd - (a*e1))/float(e2)
24
25       i = gmpy2.invert(c2, n)
26       mx = pow(c1, a, n)
27       my = pow(i, int(-b), n)
28       return mx * my % n

Listing 4-6Common Modulus

注意,为了测试这种攻击,您需要两个具有相同模数( n 值)和不同公共指数( e 值)的公钥。回想一下 e 建议总是 65537。但是很明显,在这个例子中,你不会对两个键都使用它。

如何创建公钥?到目前为止,在我们所有的例子中,我们要么生成新的键,要么从磁盘加载它们。

回想一下, ne定义了公钥。其他一切都只是为了方便而包装。cryptography模块提供了一个 API,用于直接从这些值创建一个键。RSA 私钥对象有一个名为private_numbers的方法,RSA 公钥对象有一个名为public_numbers的方法。这些方法返回带有数据元素的数据结构,如 nde 。这些“数字”对象也可以用来创建关键对象。

在清单 4-7 中,我们生成一个私钥,然后手动创建另一个具有相同模数和不同公共指数的密钥。

 1   # Partial Listing: Some Assembly Required
 2
 3   private_key1 = rsa.generate_private_key(
 4       public_exponent =65537,
 5       key_size=2048,
 6       backend = default_backend()
 7   )
 8   public_key1 = private_key1.public_key()
 9
10   n = public_key1.public_numbers().n
11   public_key2 = rsa.RSAPublicNumbers(3, n).public_key(default_backend())

Listing 4-7Common Modulus Key Generation

现在,您应该有了测试这种攻击所需的所有 Python 代码。

此时,您可能会问自己,“这种攻击有多实际?”为了实现它,你必须有相同的消息两个具有相同模数的密钥下加密。为什么同一条消息会在两个不同的密钥下被加密两次,为什么两个不同的密钥会有相同的模数?

在处理密码学的时候,千万不要依赖这种思维。如果有办法利用加密技术,坏人就会想出办法来利用它。让我们首先考虑如何用两个不同的密钥加密相同的消息。

一种可能性是让 Alice 相信已经创建了新的公钥,并且她需要进行交换。如果我们控制了新的公钥,我们可以给她一个我们选择的具有 ne 值的密钥。

但是如果我们能控制她的密钥,为什么我们需要使用共模攻击呢?为什么不直接给她一个我们创建的公钥,并且我们有配对的私钥呢?

的确,一个新的私钥/公钥对将允许 Eve 在将来解密 Alice 发送的任何消息。但是通用模数攻击将允许 Eve 潜在地确定在过去发送的一些消息。在我们的例子中,爱丽丝渗透进自助餐厅,食物服务可能有规律地重复。事实上,正如我们之前所讨论的,即使 Eve 不能解密,她也已经可以知道相同的消息是否被重发。如果 Eve 观察到相同的消息被一遍又一遍地发送,则共模攻击提供了关于发送内容的历史以及关于将来发送的消息的信息的更大视图。

练习 4.9。共模攻击

通过创建一个通用模数攻击演示来测试本节中的代码。

练习 4.10。常见模数用例

写出一个附加场景,说明使用共模攻击可能对攻击者有用。

证据就在衬垫里

正如我们刚刚演示的,这种非常原始的 RSA 形式,有时被称为“教科书式 RSA”,相对容易被破解。有两个关键问题。正如我们已经看到的,教科书 RSA 的一个问题是输出是确定的。这使得需要对同一消息加密两次的普通模数攻击变得更加容易。

也许更大的问题是这些信息的可塑性有多大。我们在前一章讨论了对称加密的可扩展性。对于 RSA,我们有类似的问题,例如,将 RSA 密文相乘并得到一个可解密的值。

尝试加密微小的消息也有潜在的问题,比如我们在练习中加密的一些小消息。除了练习中的强力方法之外,还有一些方法可以破解较小的消息,特别是使用较小的公共指数(例如, e = 3)。

为了减少或消除这些问题,RSA 的实际使用总是利用填充有随机元素的填充。RSA 填充在加密前通过我们一直在处理的原始 RSA 计算应用于明文消息。填充确保消息不会太小,并提供一定的结构来降低延展性。此外,随机化元素的操作与对称加密的 IV 没有什么不同:良好的随机化填充确保 RSA 加密操作生成的每个密文(即使对于相同的明文)都是唯一的(概率非常高)。

没有填充的 RSA 足够危险,cryptography模块甚至没有无填充的 RSA 操作。你应该非常清楚,你不能在没有填充的情况下使用 RSA 进行加密。虽然cryptography模块不允许这样做,但其他库允许。值得注意的是,这包括 OpenSSL。

在撰写本文时,通常使用两种填充方案。旧的方案被称为 PKCS #1 v1.5,另一个是 OAEP,代表最佳非对称加密填充。清单 4-8 中所示的cryptography模块可以使用这些填充方案中的任何一种。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.asymmetric import rsa
 3   from cryptography.hazmat.primitives import serialization
 4   from cryptography.hazmat.primitives import hashes
 5   from cryptography.hazmat.primitives.asymmetric import padding
 6
 7   def main():
 8       message = b'test'
 9
10       private_key = rsa.generate_private_key(
11             public_exponent =65537,
12             key_size=2048,
13             backend=default_backend()
14         )
15       public_key = private_key.public_key()
16
17       ciphertext1 = public_key.encrypt(
18           message,
19           padding.OAEP(
20               mgf = padding.MGF1(algorithm = hashes.SHA256()),

21               algorithm = hashes.SHA256(),
22               label = None # rarely used. Just leave it 'None'
23           )
24       )
25
26       ###
27       # WARNING: PKCS #1 v1.5 is obsolete and has vulnerabilities
28       # DO NOT USE EXCEPT WITH LEGACY PROTOCOLS

29       ciphertext2 = public_key.encrypt(
30           message,
31           padding.PKCS1v15()
32       )
33
34       recovered1 = private_key.decrypt(
35       ciphertext1,
36       padding.OAEP(
37           mgf=padding.MGF1(algorithm=hashes.SHA256()),
38           algorithm=hashes.SHA256(),
39           label=None # rarely used.Just leave it 'None'
40       ))
41
42       recovered2 = private_key.decrypt(
43       ciphertext2,
44        padding.PKCS1v15()
45     )
46
47       print("Plaintext: {}".format(message))
48       print("Ciphertext with PKCS #1 v1.5 padding(hexlified): {}".format(ciphertext1.hex()))
49       print("Ciphertext with OAEP padding (hexlified): {}".format(ciphertext2.hex()))
50       print("Recovered 1: {}".format(recovered1))
51       print("Recovered 2: {}".format(recovered2))
52
53   if __name__=="__main__":
54       main()

Listing 4-8RSA Padding

如果您重复运行这个演示脚本,您会发现两种填充方案的密文都会导致输出在每次时发生变化。因此,像 Eve 这样的对手既不能执行选择密文攻击,也不能执行本章前面演示的共模攻击。她也无法使用 RSA 的确定性加密来分析消息模式、频率等等。

填充还解决了加密过程中丢失前导零的问题。填充确保输入总是固定的大小:模数的比特大小。因此,例如,使用填充,模数大小为 2048 的 RSA 加密的输入将始终是 256 字节(2048 位)。因为输出的大小是已知的,所以它还允许明文以前导零开始。不管组合消息是否以 0 开始,已知的大小意味着可以附加零,直到达到正确的大小。

所以现在一切都好了,对吗?Alice 和 Bob 将切换到使用填充,Eve 将被关在他们的通信之外?

首先,请注意填充不能解决中间人或认证问题。Eve 仍然可以截获并更改公钥,从而完全解密 Alice 的消息。鲍勃仍然不知道是谁在给他发信息。这些问题将在下一章讨论。

其次,敏锐的读者可能注意到了源代码清单中的警告。以防你没有注意就浏览了一遍,我们将再次强调它。

警告:对 PKCS 1 号说“不”

不要使用 PKCS #1 v1.5,除非你必须这样做以兼容传统协议。它已经过时并且存在漏洞(包括我们将在下一节测试的一个漏洞)!对于加密,尽可能使用 OAEP。

在离开这一节之前,关于 OAEP 的使用,还有两个评论是适当的:

  1. 你可能已经注意到 OAEP 的“标签”参数。这很少使用,通常可以保留为None。使用标签不会增加安全性,所以现在忽略它。

  2. OAEP 要求使用哈希算法。在这个例子中,我们使用了 SHA-256。为什么不是 SHA-1?这与 SHA-1 已知的弱点有关吗?不。事实上,没有已知的针对 OAEP 的攻击依赖于 SHA-1 的弱点。因为 SHA-1 被认为是过时的,所以在编写自己的代码时最好不要使用它,但是如果出于兼容性原因或者为了维护别人的代码而不得不将 OAEP 与 SHA-1 一起使用,那么在撰写本文时,它还不知道是否不如 SHA-256 安全。

练习 4.11。获得升级

帮助爱丽丝和鲍勃。重写 RSA 加密/解密程序,使用cryptography模块代替gmpy2操作。

利用 PKCS #1 v1.5 填充的 RSA 加密

这一部分将会令人兴奋和有趣!Eve 不是密码学家,而你——因为你正在读这本书——可能也不是密码专家。然而,你和伊芙将要实施一个由杰出的密码学家设计的攻击,并用它来破解爱丽丝和鲍勃的密码。

这次攻击不仅好玩,而且非常真实。它不仅在过去是一种真正的攻击,而且今天仍然被用来攻击配置不佳的 TLS 服务器。它既是历史的又是当代的。

这篇论文是由 Daniel Bleichenbacher 撰写的“针对基于 RSA 加密标准 PKCS #1 的协议的选择密文攻击”[2]。您可以在网上找到这篇论文,一些读者可能对攻击背后的数学原理感兴趣。在接下来的章节中,我们将通过这篇文章创建一个攻击的实现。同时,我们会尝试给出某些关键概念背后的一些直觉。如果您发现深入的细节令人沮丧或不感兴趣,您应该能够忽略大部分解释,只需从源代码清单中整理出一个可用的 RSA 破解程序。我们不会被冒犯。

这个例子会有很多代码片段。您应该从清单 4-9 开始,它初始化了一些导入。不要忘记本章中我们已经看到的对其他函数的依赖。当我们处理新的片段时,将它们添加到这个框架中。

 1   from cryptography.hazmat.primitives.asymmetric import rsa, padding
 2   from cryptography.hazmat.primitives import serialization
 3   from cryptography.hazmat.primitives import hashes
 4   from cryptography.hazmat.backends import default_backend
 5
 6   import gmpy2
 7   from collections import namedtuple
 8
 9   Interval = namedtuple('Interval', ['a','b'])
10   # Imports and dependencies for RSA Oracle Attack
11   # Dependencies: simple_rsa_encrypt(), simple_rsa_decypt()
12   #                bytes_to_int()

Listing 4-9RSA Padding Oracle Attack

爱丽丝和鲍勃又在吵架了。不过,这一次,他们使用了带填充的 RSA。但是 EATSA 仍然在做错误的决定。他们决定使用 PKCS # 1 1.5 版,因为它不需要参数。最初他们打算使用 OAEP,但东南极洲工作队为现代操作 RSA 就业和更好的加密,特别是在外地(EATMOREBEEF)显然争论了几个星期的工作队名称。时间紧迫,无法就哪种哈希算法应该用于 OAEP,以及“EATMOREBEEF”是否应该用于标签达成一致,他们举手说,“我们非常确定 PKCS #1 v1.5 足够好了。”

我们再一次发现爱丽丝在西南极洲监视她的邻居。然而,这一次,爱丽丝假扮成一家制冰公司的首席执行官,在南极洲西部城市的一次会议上与制冰行业的其他高管会面。在过去的几年里,冰毒的销售已经融化,而政府面临着自身的资产冻结和流动性下降的问题,既不能也不愿意提供补贴。爱丽丝的任务是继续明确反对当前执政党的不同意见,试图在下次选举中巩固影响力。

会议结束后,Alice 需要向 Bob 发送一份她已说服向反对党大量捐款的首席执行官的报告。爱丽丝使用 PKCS #1 v1.5 的 RSA 传输以下消息:“简·温特斯、f·罗·曾和约翰·怀特。”

爱丽丝迅速拿出一部翻盖手机(他们在技术上正慢慢赶上来...还没有智能手机,但他们最终摆脱了企鹅)。她把信息输入给 Bob,它自动把它转换成一个数字,加密,然后发送出去。几秒钟后,她的手机震动了,出现了一条新信息:

Received: OK

在城市的其他地方,伊芙观察着这种交流。她从穿越边境开始就一直在追踪爱丽丝。但是她不能解密这些信息。爱丽丝甚至带着已经安装在手机里的公钥来了,所以伊芙也不能给她一个假密钥。她能做什么?

对 Eve 来说幸运的是,她通过自己的情报机构发现 Alice 和 Bob 正在使用 PKCS #1 v1.5 进行 RSA 填充。伊芙很惊讶。在经历了本章前面的所有事件之后,Eve 已经对 RSA 有了相当多的了解,她知道这种填充方案有已知的漏洞。她想知道他们为什么要用它。他们没收到备忘录吗?

伊芙拿了一份布莱肯巴赫的报纸,开始阅读。该白皮书解释说,PKCS #1 v1.5 填充可以被类似于我们在上一章中看到的 oracle 攻击破坏。

在这种情况下,Eve 需要一个神谕来告诉她一个给定的密文(一个数字)是否可以解密成具有适当填充的东西。神谕当然不会告诉她密文解密到了什么;关于填充,它只需要说“是”或“否”。

幸运的是,Eve 一直在监控 EA 的通讯,看起来他们在他们的技术中建立了一个错误报告系统。当 Alice 发送有效消息时,她会返回

Received: OK

但是当 Eve 发送一个随机数(密文)时,她几乎总能得到回复

Failed: Padding

在发送了成千上万个随机数之后,她最终还是收到了一个回复“OK”的消息。据她所知,这不是一个“真正的”消息(人类可读的,或者 Bob 理解的),但它确实有自动处理系统报告的正确填充。

这是夏娃的神谕。这是她完全解密密文信息所需要的。

为了方便编写她的攻击程序,Eve 将首先用自己生成的私钥破解本地加密的消息。Eve 将使用可插拔的 oracle 配置,以便在攻击 Bob 时,她可以简单地关闭用于支持攻击的 oracle。测试 oracle 使用真实私钥解密消息,并检查消息是否具有正确的格式。

伊芙开始阅读 PKCS 1.5 版,并开始尝试自己的实验。她创建了自己的密钥对,用填充符加密消息,然后检查输出。她加密消息“test ”,然后在不移除填充符的情况下解密消息。清单 4-10 显示了她使用的代码的关键片段。

 1   # Partial Listing: Some Assembly Required
 2
 3   from cryptography.hazmat.primitives.asymmetric import rsa, padding
 4   from cryptography.hazmat.primitives import hashes
 5   from cryptography.hazmat.backends import default_backend
 6   import gmpy2
 7
 8   # Dependencies: int_to_bytes(), bytes_to_int(), and simple_rsa_decrypt()
 9
10   private_key = rsa.generate_private_key(
11         public_exponent=65537,
12         key_size=2048,
13         backend=default_backend()
14     )
15   public_key = private_key.public_key()
16
17   message = b'test'
18
19   ###
20   # WARNING: PKCS #1 v1.5 is obsolete and has vulnerabilities
21   # DO NOT USE EXCEPT WITH LEGACY PROTOCOLS
22   ciphertext = public_key.encrypt(
23       message,
24       padding.PKCS1v15()
25   )
26
27   ciphertext_as_int = bytes_to_int(ciphertext)
28   recovered_as_int = simple_rsa_decrypt(ciphertext_as_int, private_key)
29   recovered = int_to_bytes(recovered_as_int)
30
31   print("Plaintext: {}".format(message))
32   print("Recovered: {}".format(recovered))

Listing 4-10Encrypt with Padding

您可以看到她正在使用cryptography模块创建加密。但是她使用自己的simple_rsa_decrypt操作进行解密,以便保留填充。

这是她所看到的:

Plaintext: b'test'
Recovered: b'\x02@&\x1cC\xb1\xe4\x0f\x14\xd9\x93oU
\x07\x1b\xfdC\xe1\xe2K\xeeP\xdd\x8b\x10\xf9cZJ\x0c
42\x8e\xbblZ\xfb\x80\x8b\xfcA?p\xac\xba\xf7I\x9e\x
11\x1cn&t\xb8\x15\xbfo\xfe\xcc\xdf\xe7=\xc2\x9e\x
ca<v\xcd\x9ep\xd8\x1c\xf6b2"\x8c\xc0\x1e\xb8\xdb\x
97\x89\xfauj\x8f``\x99m~,\x18h\xc2k6d~qr-\x0c\xb9\
xfe?\xf9\xf9\xa6o\x05\\ZV\xfd4?\x0e;y\xf3\xd3q\xb2
\x94\xf6\xf8~a\xc1eA\xe4\x14\xce\x82\xdcc\xbf4e\xa
e\xa3<"\xcb,L\xd8\xed\xca}\xeb\x82\xa67\x1a\xd1\xc
7)\x13\xc1D)\xe8\x05h\xbe/\x97\xdf>\xf0\xef\xeb\xe
4Q\xc2\x85(*\xdcE\x9ct\x08c0\xb1\x80la\x94_/2\xd4y
\xc7\x95\x01\x90@\xea\x92\xaa\xb8\x18!\xc7\xff\xab
\x03\xea\x8b\xa3\xb4\xf6\xf2\xd6GH\x98-fM\x1c\x99\
x84\x8d4\xaf"\x95\xa7XR(M\x836\xd4\x17\x99m\xa8\x1
a\xb3\x00test'

Eve 注意到实际的消息在填充的末尾,符合 PKCS #1 v1.5 标准。(在本节的其余部分,我们将只说“PKCS”)

她注意到恢复文本的第一个字节是 2。她觉得这很奇怪,因为标准规定填充应该以 0 和 2 开始。最初的 0 去哪了?

然后夏娃记得!当然可以!因为 RSA 处理的是整数而不是字节,所以任何前导零都会被删除。幸运的是,当使用 RSA 填充时,字节的大小固定为密钥的大小。Eve 决定用清单 4-11 中所示的可选参数 8 来更新她的转换函数。

 1   # Partial Listing: Some Assembly Required
 2
 3   # RSA Oracle Attack Component
 4   def int_to_bytes(i, min_size = None):
 5       # i might be a gmpy2 big integer; convert back to a Python int
 6       i = int(i)
 7       b = i.to_bytes((i.bit_length()+7)//8, byteorder="big")
 8       if min_size != None and len(b) < min_size:
 9           b = b'\x00'*(min_size-len(b)) + b
10       return b

Listing 4-11Integer to Bytes

现在适当地更新,Eve 写了她的“假”神谕,她将只用于测试。清单 4-12 中的代码执行简单的 RSA 解密,将结果转换为字节(使用我们刚刚实现的最小大小参数),并检查第一个和第二个字节是否分别为 0 和 2。确保新的int_to_bytes正常工作。旧版本将总是删除前导零,oracle 将总是报告错误。

 1   # Partial Listing: Some Assembly Required
 2
 3   # RSA Oracle Attack Component
 4   class FakeOracle:
 5       def __init__(self, private_key):
 6           self.private_key = private_key
 7
 8       def __call__(self, cipher_text):
 9           recovered_as_int = simple_rsa_decrypt(cipher_text, self.private_key)
10           recovered = int_to_bytes(recovered_as_int, self.private_key.key_size //8)
11           return recovered [0:2] == bytes([0, 2])

Listing 4-12
Fake Oracle

有了神谕,Eve 准备攻击论文中描述的算法。该算法分四步描述。我们将逐个审查每一个,并逐步开发代码。

第一步:失明

Bleichenbacher 的算法要求设置和“隐蔽”消息的隐蔽步骤。但是,算法末尾的备注部分解释说,对于我们的情况来说,这大部分是不必要的:

  • 如果 c 已经是符合 PKCS 的(即当 c 是加密的消息时),可以跳过步骤 1。在这种情况下,我们设置s0←1。

在这一步中有三个值需要配置。因为我们正在处理一个已经用 PKCS 填充的加密消息,所以我们只需要将这些值设置为规定的默认值:

$$ {\displaystyle \begin{array}{c}\kern3.5em {c}_0\leftarrow c{\left({s}_0\right)}^e\kern0.5em \left(\operatorname{mod}\ n\right)\ {}\kern2.5em {M}_0\leftarrow \left[2B,3B-1\right]\ {}i\leftarrow 1.\end{array}} $$

因为s0= 1,我们可以将第一次赋值简化为

$$ {c}_0\leftarrow c $$

显然,1 的任何次方仍然只是 1,所以无论是幂还是模都没有任何影响。

M 参数将会是一个区间列表的列表(稍后会有更多关于区间的内容)。该算法包括由 i 标识的重复步骤。 M 0 记录由 i = 1 标识的步骤中标识的区间列表。在这种情况下,只有单个区间[2 B ,3B–1]。

什么是 B ?正如本文前面所解释的, B 是具有适当填充的合法值的数量。它被定义为

$$ B={2}^{8\left(k-2\right)}. $$

基本上, k 是以字节为单位的密钥大小。因此,如果我们使用 2048 位密钥,k = 256。但是为什么要减去 2 呢?

让我们这样分解它。对于带填充的 RSA,我们的明文大小(以字节为单位)应该总是与密钥大小相同。如果我们使用 2048 位的密钥,我们的填充明文也必须是 2048 位(256 字节)。这意味着有 2 个 2048 个可能的明文值。

不过,这不是真的,对吧?我们知道,前两个字节必须是 0 和 2,这将合法值的数量减少了 2 × 8 = 16 位。因此, B 是在考虑前两个固定字节时该密钥大小的最大值。

回到区间,2 B 和 3 B 是什么?该数据结构中的区间代表实际明文消息所在的 PKCS 数的合法值。因为开头的字节是最高有效字节,所以 0 对整数没有影响(例如,0020 = 20)。但是 2 意味着任何合法的数字必须至少是 2 B 但是必须小于 3 B

这么想吧。如果我告诉你一个两位数的数字必须在 20 和 30 之间,你会知道它可能有 10 个可能的值。而且,你知道最小值是 2 × 10。这是同样的想法。

这种算法的工作方式是通过缩小合法区间,直到它只是一个单一的数字。这个数字就是明文信息!

Eve 决定为算法的每一步创建一个函数。假设有状态数据需要在这些函数之间共享(例如, BM 等)。),她决定使用一个类来存储状态。构造函数接受一个公钥和一个预言。请记住,oracle 只是将密文作为输入,如果密文解密为适当的 PKCS 填充明文,则返回 true。

现在,Eve 为算法的这一步(步骤 1)编写代码。该步骤需要一个密文作为输入( c ,并初始化c0、 BsM 的值。Eve 还在一个名为_step1_blinding的便利函数中从公钥中复制出 n ,如清单 4-13 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   class RSAOracleAttacker:
 4       def __init__(self, public_key, oracle):
 5           self.public_key = public_key
 6           self.oracle = oracle
 7
 8       def _step1_blinding(self, c):
 9           self.c0 = c
10
11           self.B = 2**(self.public_key.key_size-16)
12           self.s = [1]
13           self.M = [ [Interval(2*self.B, (3*self.B)-1)] ]
14
15           self.i = 1
16           self.n = self.public_key.public_numbers().n

Listing 4-13RSA Oracle Attack: Step 1

B 的值直接从位计算,而不是从字节转换。其他一切都是按照论文中描述的那样精确计算的。

这段代码中的Interval数据结构是使用collections.namedtuple工厂创建的。它的两个值是 a (下限)和 b (上限)。

步骤 2:搜索符合 PKCS 的邮件

对于这一节,我们需要从乘法 RSA 密文中重新学习数学。花一点时间复习(4.3)。

从概念上讲,步骤 2 是在MI–1间隔内搜索新的符合 PKCS 的消息,这些消息是原始明文消息 m 和某个其他整数 s i 的倍数。

图 4-2 描绘了所有可能的 RSA 密文值内的 PKCS 一致性空间的(简化)视图。RSA 加密的输出范围从 0 到 2k–1,其中 k 是以位为单位的密钥大小。不管密钥大小如何,每个数字(十六进制)都以 0 到f的 16 位数字中的 1 开始。2 和 3 之间突出显示的部分表示具有适当 PKCS 填充的 RSA 密文值。(这个视图过于简化,因为在现实中,正确的切片应该是从00ff范围内的从 02 到 03,所以它实际上只是 256 个切片中的 1 个。)

消息空间显示为一个环的原因是因为我们正在处理模块化(回绕)算法。如果你在这个空间内取两个数,并把它们相乘(取模 n ),如果乘积大于 n ,它就绕回。

img/472260_1_En_4_Fig2_HTML.jpg

图 4-2

PKCS 共形空间的简化视图

这让我们回到将明文消息 m 乘以另一个数字。在图 4-2 的简化视图中, m 一定在高亮区域的某处。如果我们使用模乘,用特定的数乘以 m 会产生同样在同一区域内的其他数。

当然,我们不知道确切的在哪里 m 因为我们所有的都是加密版本 c 。我们所知道的是,因为它符合 PKCS,所以它在这个区域内的某个地方。同样,因为我们不知道 m 在哪里,我们也不知道 m 的倍数会落在环中的什么地方。当然,例外的是,使用我们的神谕,我们可以确定多次波是否落在 PKCS 整合区域内!

然后,使用神谕,我们将搜索一个 s i 值,当该值乘以 m (模 n )时,是 PKCS 一致的,因此在 RSA 消息空间的 PKCS 一致区域内。我们仍然不知道 m 在哪里,但是知道它有一个倍数落在某个区域内会引入对包含它的区间的额外约束。我们将在第 3 步中详细讨论这些约束以及如何使用它们。不过现在,还是先找 s i

Bleichenbacher 将寻找 s i 分成三个子步骤:

  1. 2 a 开始搜索是我们第一次做这个操作(即当 i = 1)。

  2. 2 b

  3. 2 c 剩余一个区间搜索用于只有一个区间且 i 不为 1 时。其他情况应该都是这样。

这些子步骤中的每一个都需要搜索一系列可能的 s i 值,以查看它是否产生一致的密文。

具体来说,对于每个候选人sI,我们用 RSA 加密产生 c i

$$ {c}_i={s}_i^e\kern1em \left(\operatorname{mod}\ n\right). $$

我们将加密的 s i 值乘以我们的原始密文 c 0 来创建一个测试密码 c t 。因为 c 0 是未知明文的加密m09,我们得到

$$ {\displaystyle \begin{array}{l}{s}_t={c}_i{c}_0\kern1em \left(\operatorname{mod}\kern0.375em n\right)\ {}\kern1.625em ={s}_ie{m}_0e\kern1em \left(\operatorname{mod}\kern0.375em n\right).\end{array}} $$

我们向 oracle 发送 c t 来测试它是否符合。对于我们的假甲骨文,它只是简单地用私钥解密 c t 并检查明文是否以字节 0 和 2 开头。(记住,要破解 Alice 的消息,我们不会有一个支持私钥的 oracle。相反,我们将把密文发送给 Bob,并检查填充错误消息响应。)

因为每个子步骤都需要能够以这种方式检查一系列的 s i 值,所以 Eve 决定创建一个助手函数来执行搜索。它接受一个起始值和一个可选的包含上限(如清单 4-14 )。

 1   # Partial Listing: Some Assembly Required
 2
 3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
 4       def _find_s(self, start_s, s_max = None):
 5           si = start_s
 6           ci = simple_rsa_encrypt(si, self.public_key)
 7       while not self.oracle((self.c0 * ci) % self.n):
 8           si += 1
 9           if s_max and (si > s_max):
10               return None
11           ci = simple_rsa_encrypt(si, self.public_key)
12       return si

Listing 4-14Find “s”

使用这个助手函数,前两个子步骤非常简单。步骤 2 a 需要测试sIn/(3B)的所有值,直到其中一个值符合为止。Eve 对这个步骤进行编码,如清单 4-15 所示。

1   # Partial Listing: Some Assembly Required
2
3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
4       def _step2a_start_the_searching(self):
5           si = self._find_s(start_s=gmpy2.c_div(self.n, 3*self.B))
6           return si

Listing 4-15Step 2a

请注意,使用来自gmpy2模块的c_div函数,起始 s 值被计算为 n /(3 B )。因为我们正在处理如此大的数字,所以我们不能信任 Python 的内置浮点。我们正在计算的许多值只是范围,并不保证是整数,因此小数值是可能的。gmpy2模块为我们提供了对非常大的数字的快速运算,包括浮点。

c_div函数本身提供了向上舍入到上限的除法。因此,例如,c_div(3,4)计算 3/4 并向上舍入,返回 1。

使用这些 RSA 概念,该步骤搜索将 c 乘以另一个 PKCS 一致性值的 s i 的值。具体来说,对于一个 s i 的候选值,我们 RSA 加密,然后乘以原始密文。我们使用上限是因为 s i 必须是一个整数,并且必须大于或等于初始值。无论起始值是否为整数,下一个整数(即上限)都是 s i 的起点。

子步骤 2 b 也相当容易做到。该子步骤处理罕见的情况,其中 m 0 的间隔被一分为二。当这种情况发生时,我们向前迭代 s i ,直到我们找到另一个符合的值(清单 4-16 )。

1   # Partial Listing: Some Assembly Required
2
3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
4       def _step2b_searching_with_more_than_one_interval(self):
5       si = self._find_s(start_s=self.s[-1]+1)
6       return si

Listing 4-16Step 2b

我们将保存在self.s数组中找到的每个值,以便能够访问这些值。事实上,我们只需要前一个值,但是我们使用这个习语来匹配论文的写作方式。

最后,最后一个子步骤,2 c ,稍微复杂一点。它需要在一系列可能的值中搜索 s 。回想一下,在上一步中只找到了一个区间,我们将下限作为 a ,上限作为 b 。接下来,我们必须迭代通过 r i 值:

$$ {r}_i\ge 2\frac{b{s}_{i-1}-2B}{n}. $$

我们用这些rI 的值来绑定两边的 s i 搜索:

$$ \frac{2B+{r}_in}{b}\ge {s}_i<\frac{3B+{r}_in}{a}. $$

我们在这里所做的是挑选特定范围内的 s i 值,这将帮助我们继续缩小解决方案的范围。Bleichenbacher 在他的论文中解释了为什么这些界限有效,我们在这里不再重复他的评论。当我们谈到第 3 步时,我们将给出整个算法的一些进一步的直觉,这将有助于澄清正在发生的事情。

同时,Eve 将这个算法编码为清单 4-17 。

 1   # Partial Listing: Some Assembly Required
 2
 3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
 4       def _step2c_searching_with_one_interval_left(self):
 5           a,b = self.M[-1][0]
 6           ri = gmpy2.c_div(2*(b*self.s[-1] - 2*self.B),self.n)
 7           si = None
 8
 9           while si == None:
10               si = gmpy2.c_div((2*self.B+ri*self.n),b)
11
12               s_max = gmpy2.c_div((3*self.B+ri*self.n),a)

Listing 4-17Step 2c

13               si = self._find_s(start_s=si, s_max=s_max)
14               ri += 1
15           return si

img/472260_1_En_4_Fig3_HTML.jpg

图 4-3

描述布莱肯巴赫的攻击

与之前的计算一样,除法是使用gmpy2.c_div来处理的。这一点非常重要。如果只是使用 Python 的除法运算符,很可能会得到不完整的结果。

步骤 3:缩小解决方案的范围

一旦从步骤 2 中找到了一个 s i 值,我们就更新我们在 m 的位置上的界限。在讨论数学之前,我们先来讨论一下这个算法是怎么回事。

在图 4-3 中,我们再次可视化了包含合法 PKCS 填充值的 RSA 消息空间环的切片。这个空间的下限是从 000200 开始的数字...00,并且包含上限是 0002 FF...FF。明文消息 m 0 在这里的某个地方。在算法开始时,我们不知道在哪里。

然而,对于我们发现一致的每个 s i 值,我们了解到新的值m0sI也在该区域内(因为模运算而绕回)。我们知道m0sI*(模 n )落在特定范围内的事实引入了关于 m 0 可以在哪里的新约束。我们能够使用这些约束来计算新的区间 ab ,在该区间内 m 0 必须是

一旦我们更新了界限,我们可以使用进一步收紧界限的sI的新值来重复该过程。最终,边界会将m0 限制为单一值。那个就是我们要找的明文!

希望这种直觉会有所帮助,即使下面的公式没有多大意义。或者,如果你真的试着去理解 Bleichenbacher 的论文,那会很有帮助。在任何情况下,我们计算新的上限和下限如下。

对于前一个M0 中的每个 ab (通常会有一个,但有时会有两个),找出 r 的所有整数值,使得

$$ \frac{a{s}_i-3B+1}{n}\ge r\le \frac{b{s}_i-2B}{n}. $$

对于 abr 中的每一个值,我们计算一个新的间隔。首先,我们如下计算下限候选者:

$$ {a}_i=\frac{2B+ rn}{si}. $$

和上限候选者

$$ {b}_i=\frac{3B-1+ rn}{si}. $$

我们定义一个新的区间为[ max ( aa i ), min ( bb**I)]。

将所有音程的集合插入到MI 中。同样,通常只有一个间隔。

Eve 按照清单 4-18 对算法的这一步进行编码。

 1   # Partial Listing: Some Assembly Required
 2
 3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
 4       def _step3_narrowing_set_of_solutions(self, si):
 5           new_intervals = set()
 6           for a,b in self.M[-1]:
 7               r_min = gmpy2.c_div((a*si - 3*self.B + 1),self.n)
 8               r_max = gmpy2.f_div((b*si - 2*self.B),self.n)
 9
10               for r in range(r_min, r_max+1):
11                   a_candidate = gmpy2.c_div((2*self.B+r*self.n),si)
12                   b_candidate = gmpy2.f_div((3*self.B-1+r*self.n),si)
13
14                   new_interval = Interval(max(a, a_candidate), min(b, b_candidate))
15                   new_intervals.add(new_interval)
16           new_intervals = list(new_intervals)
17           self.M.append(new_intervals)
18           self.s.append(si)
19
20           if len(new_intervals) == 1 and new_intervals[0].a == new_intervals[0].b:
21               return True
22           return False

Listing 4-18Step 3

在这段代码中,注意r_max是使用f_div计算的。这将计算舍入到地板而不是天花板的除法。我们使用这个值是因为 r 是一个整数,并且必须小于或等于这个值。

一旦计算出间隔,代码就将它们添加到self.M数据结构中,并将sI值添加到self.s中。

最后,它检查我们是否找到了解决方案。伊芙在这里想得太多了。这是第 4 步的一部分,但是放在这里更方便。

步骤 4:计算解决方案

正如前面几节所暗示的,这个算法有终止条件。希望,考虑到前面的讨论,这是相当明显的。也

  • MI 只包含一个音程,或者

  • M i 的区间上下限相同。

简而言之,当限制 m 的位置的区间减少到一个数字时,我们终止。

我们已经在步骤 3 的末尾看到了 Eve 检查这个条件的代码。Bleichenbacher 的步骤 4 也处理了一个比我们的更普遍的问题,包括当 s 0 为 1 时不必要的步骤。回想一下,为了处理明文已经被 PKCS 填充的 RSA 加密消息, s 0 被设置为 1。

尽管有些不必要,但为了完整性和一致性,Eve 确实为步骤 4 创建了一个方法(清单 4-19 )。

1   # Partial Listing: Some Assembly Required
2
3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
4       def _step4_computing_the_solution(self):
5           interval = self.M[-1][0]
6           return interval.a

Listing 4-19Step 4

就这样!这就是整个算法!Eve 将这些步骤组合成清单 4-20 的攻击方法。

 1   # Partial Listing: Some Assembly Required
 2
 3   # RSA Oracle Attack Component, part of class RSAOracleAttacker
 4       def attack(self, c):
 5           self._step1_blinding(c)
 6
 7           # do this until there is one interval left
 8           finished = False
 9           while not finished:
10               if self.i == 1:
11                   si = self._step2a_start_the_searching()
12               elif len(self.M[ -1]) > 1:
13                   si = self._step2b_searching_with_more_than_one_interval()
14               elif len(self.M[-1]) == 1:
15                   interval = self.M[-1][0]
16                   si = self._step2c_searching_with_one_interval_left()
17
18               finished = self._step3_narrowing_set_of_solutions(si)
19               self.i += 1
20
21           m = self._step4_computing_the_solution()
22           return m

Listing 4-20Attack!

请注意,attack()方法的输入是密文,但它必须已经是整数形式。别忘了先调用密文上的bytes_to_int()

练习 4.12。发动进攻!

使用前面的代码,运行一些用 PKCS 填充破解 RSA 加密的实验。你应该使用cryptography模块来创建加密的消息,将加密的消息转换成整数,然后使用你的攻击程序(和假 oracle)来破解加密。首先,在大小为 512 的 RSA 密钥上测试你的程序。这将更快地中断,并使您能够更快地验证您的代码。

练习 4.13。花时间

攻击需要多长时间?使用计时检查和 oracle 函数调用次数的计数来检测您的代码。对一组输入运行攻击,并确定破解大小为 512、1024 和 2048 的密钥所需的平均时间。

练习 4.14。保持最新状态

尽管这种攻击已经有 20 多年的历史了,但它仍继续困扰着互联网。做一些谷歌搜索,找出这种攻击的当前状态,包括预防和更新的变种。一定要弄清楚机器人袭击的事。这个我们讨论 TLS 的时候再讲。

关于 RSA 的附加说明

在这一章中,我们在 RSA 上花了很多时间,我们甚至还没有深入了解它在实践中的实际应用。像大多数非对称密码一样,RSA 几乎从未被用来加密信息,就像我们在本章中让 Alice 和 Bob 做的那样。当使用它时,它通常用于加密一个对称密码或签名的会话密钥。

然而,理解非对称密码的工作原理以及如何破解它们是至关重要的。尽管有这些缺点,RSA 仍然被广泛使用,而且经常是不正确的。浏览本章中的漏洞应该有助于您走上正确的道路。

这里有几个其他项目需要考虑。

密钥管理

和所有的密码一样,它们的安全性很大程度上取决于正确地创建和保护密钥。

创建 RSA 密钥时,请确保使用库。不要试图自己生成公钥和私钥。同时,留意你所使用的库的任何错误报告。例如,已经发现一些库在没有足够随机性的情况下生成 RSA 私钥,从而产生易受各种攻击的私钥。您不可能预料到所有会出错的事情,或者您使用的库或算法何时会暴露为易受攻击的,因此您必须通过更新已知的漏洞来“维护”您的加密技术。

漏洞可能是特定于系统的。例如,ROCA 漏洞主要局限于某些硬件芯片。

创建 RSA 密钥时,使用正确的参数也很重要。密钥大小通常应该至少为 2048 位,除非遗留约束迫使您选择更小的值。并且公共指数 e 的值应该总是 65537。

您还必须小心地守护和保护私钥及其秘密。显然,私钥本身应该安全地存储,并具有适当的权限。您的私钥至少应该以绝对最小的权限存储在文件系统中。非常敏感的密钥可能需要脱机存储。

您还应该考虑以加密形式存储私钥。这将需要一个密码来解密密钥,这在全自动系统中有其自身的困难。但是,如果使用得当,如果攻击者获得主机系统的访问权限,它可以降低私钥泄露的风险。

此外,私钥由许多组成值组成。在我们的例子中,我们可以认为 d 是私钥,因为这是我们用来实际解密的值。但是除了 d 之外,还必须小心不要暴露用来生成它的秘密。例如,模数 n 本身并不是秘密,而是产生它的两个大素数 pq

创建私钥时会生成额外的值,如果泄露,会危及安全性。与 pq 一起,这些值在密钥生成后并不是严格必需的,因为一切都可以从 edn 中计算出来。然而,大多数库确实将它们作为私钥的一部分保存在内存和磁盘上。您应该阅读关于私钥生成的库文档,并遵循推荐的处理过程。

非对称加密的弱点之一是无法“撤销”私钥。如果 Bob 的私钥泄露,Alice 如何知道停止发送在相关公钥下加密的数据?在实践中,您的 RSA 密钥可能会与证书一起使用,证书可以包括一个证书和密钥的层次结构,允许一些密钥不如其他密钥敏感,还包括一个过期日期,以限制泄露的密钥。关于这一点,其他地方说得更多。

练习 4.15。分解 RSA 密钥

在本节中,我们建议使用 2048 位密钥。在这个练习中,在互联网上搜索一下,找出当前容易被分解的键的大小。例如,搜索“保理服务”,看看保理一个 512 位的密钥要花多少钱。

练习 4.16。ROCA 脆弱键

除非您的 RSA 密钥是由某些 RSA 硬件模块生成的,否则您为本章练习生成的密钥应该不会受到 ROCA 的攻击,但检查一下也无妨。在这个练习中,访问在线 ROCA 漏洞检查网站 https://keychest.net/roca#/ 并测试几个关键点。

算法参数

如果有什么东西是你应该从本章中吸取的,那就是这个:特别注意 RSA 的填充参数。在撰写本文时,您应该对加密操作使用 OAEP 填充方案,对签名使用 PSS 填充方案。不要不要使用 PKCS #1 v1.5,除非它对于遗留应用是绝对必要的。

量子密码术

在本书中,我们没有足够的篇幅来深入研究量子密码术,但是我们不能在不提及 RSA 的情况下结束对它的讨论。当量子计算到来时,我们目前的大多数非对称算法都将变得不可破解。RSA 已经很容易受到一些当代攻击,但当量子计算变得可行时,它将被彻底打破。因此,在未来十年左右,RSA 将完全无用。

非常短的附录

如果从这一章中可以得出什么,那就是:参数很重要,正确的实现是微妙的,并且随着时间的推移而发展。非对称加密如何工作以及如何使用的直觉很容易解释,但是有许多细节可以使一种实现安全,而另一种实现非常容易受到攻击。

为工作选择正确的工具,并为工具选择正确的参数。

哦,砸东西很有趣!

*

五、消息完整性、签名和证书

在这一章中,我们将讨论“密钥哈希”以及如何使用非对称加密技术通过数字签名不仅提供消息隐私,还提供消息完整性和 ?? 真实性。我们还将讨论证书与密钥有何不同,以及这种区别为何如此重要。让我们深入一个例子和一些代码!

过于简单的消息认证码(MAC)

在爱丽丝和鲍勃的报道中,我们的东南极间谍二人组最近在西部敌对领土的冒险中遇到了一些麻烦。显然,伊芙设法截获了他们之间的一些通信。这些用对称加密法加密的信息是不可读的,但是 Eve 想出了如何改变它们,插入一些错误的指令和信息。爱丽丝和鲍勃根据错误的情报采取行动,差点中了埋伏。幸运的是,由于全球变暖,一堆冰融化了,他们设法游回了安全的地方!

他们很快从千钧一发中吸取教训,在总部花了一点时间擦干身体,设计新的通信机制,以防止他们的加密数据被未经授权的修改。

最终,东南极洲真相间谍机构(EATSA)发现了一个新概念:“消息认证码”或“MAC”。

Alice 和 Bob 被告知,MAC 是与消息一起传输的任何“代码”或数据,可以对其进行评估以确定消息是否被更改。这是出于直觉目的的非正式定义。在 Alice 和 Bob 学习这个介绍性的错误起点时,请耐心等待。这个过于简单的 MAC 的基本思想是这样的:

  1. 发送方使用函数f(M1)为给定的消息M1 计算代码C1。

  2. 发送者将M?? 1 和C1 发送给接收者。

  3. 接收者以 MC 的形式接收数据,但不知道它们是否已被修改。

  4. 接收者重新计算 f ( M )并将输出与 C 进行比较,以验证消息未被更改。

假设夏娃截获了爱丽丝发给鲍勃的 M 1C 1 。如果 Eve 想要将消息 M 1 更改为 M 2 ,她必须同时重新计算C2=f(M2)并发送 M 2否则,由于 f ( M )和 C 不匹配,Bob 将会检测到某些东西已经被改变。

如果你在问,“那又怎样?Eve 可以重新计算 MAC,对吗?”那么你就看到了我们过于简单的设置的问题。我们必须假设 Eve 拥有除键之外的所有东西,但是这个例子也假设她没有 f 。我们会尽快解决这个问题。敬请关注!

现在,Alice 和 Bob 只是假设 Eve 不会计算,或者很容易计算函数 f 。如果这个假设是真的(事实并非如此),那么几乎任何创建指纹的机制都可以工作。东南极洲间谍机构决定将消息哈希作为消息的附件发送。因此,在这种情况下,MAC 是一个哈希。

让我们深入一些代码,看看这个简单的想法是如何形成的。当我们这样做的时候,我们可以把我们新的假 MAC 技术和第三章的对称加密结合起来。这在清单 5-1 中有所展示。

 1   # THIS IS NOT SECURE. DO NOT USE THIS!!!
 2   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 3   from cryptography.hazmat.backends import default_backend
 4   import os, hashlib
 5
 6   class Encryptor:
 7       def __init__ (self, key, nonce):
 8           aesContext = Cipher(algorithms.AES(key),
 9                                modes.CTR(nonce),
10                                backend=default_backend())
11           self.encryptor = aesContext.encryptor()
12           self.hasher = hashlib.sha256()
13
14       def update_encryptor(self, plaintext):
15           ciphertext = self.encryptor.update(plaintext)
16           self.hasher.update(ciphertext)
17           return ciphertext
18
19       def finalize_encryptor(self):
20           return self.encryptor.finalize() + self.hasher.digest()
21
22   key = os.urandom(32)
23   nonce = os.urandom(16)
24   manager = Encryptor(key, nonce)
25   ciphertext = manager.update_encryptor(b"Hi Bob, this is Alice !")
26   ciphertext += manager.finalize_encryptor()

Listing 5-1Fake MAC with Symmetric Encryption

回想一下,“计数器模式”不需要填充,在我们前面的例子中,“finalize”函数实际上没做什么。但是现在,当我们完成我们的管理器时,它不仅完成了加密,还返回了计算出的哈希,作为附加到加密数据的最后几个字节。因此,最终的加密消息将我们的简单 MAC 附加到它的末尾。

练习 5.1。信任但核实

完成简单加密加哈希系统的代码,并增加一个解密操作。解密操作应该在完成后重新计算密文的哈希,并将其与发送过来的哈希进行比较。如果哈希值不匹配,应该会引发一个异常。小心点!MAC 没有加密,不应该解密!如果您不仔细考虑这一点,您可能会解密不存在的数据!

练习 5.2。永远邪恶的夏娃

继续“拦截”一些由您在本节中编写的代码加密的消息。修改截获的消息,并验证您的解密机制是否正确地报告了错误。

麦克,HMAC 和 CBC-MAC

Alice 和 Bob 的支持人员告诉他们,任何验证消息的机制都是消息验证代码(MAC)。正如我们所暗示的,这不是一个完整的定义。真正的 MAC 也需要一个1

我们已经使用密钥进行加密,但是到目前为止,我们还没有在其他方面使用它们。正如您可能已经猜到的那样,MAC 密钥与加密根本没有关系。相反,它确保消息认证码只能由知道密钥的各方计算。

在我们的例子中,爱丽丝和鲍勃必须假设伊芙不会计算函数 f ( M )。当然,这是不合理的。艾丽丝和鲍勃利用 SHA-256 得到了一个指纹,所以显然伊芙也可以用它来计算她自己的认证码。假设她可以决定性地修改密文,正如我们在前一章中看到的,在某些情况下,她可以插入一条新消息一个新的假 MAC。

然而,真正的 MAC 依赖于一个密钥,不可能由 Eve 生成,除非她已经泄露了密钥!请记住,良好的安全性意味着除了密钥之外的一切都可以被知道,并且它仍然正常工作。 2

MAC 保护消息的完整性。没有密钥的攻击者无法不被察觉地更改数据。此外,如果密钥仍然保密,MAC 还提供真实性:接收者知道只有共享密钥的其他人才能发送 MAC,因为只有拥有密钥的人才能生成合法的 MAC。

虽然有许多 MAC 算法,我们将着眼于两个易于理解的方法:HMAC 和 CBC-MAC。这些算法在教授 MAC 如何工作以及为什么工作方面做得很好。它们在实践中也是有用的。

HMAC

HMAC 是一种“基于哈希的消息验证码”事实上,你已经知道 HMAC 最复杂的特征:哈希。HMAC 通常只是一个由键控的哈希。

“被键控”是什么意思?为了说明这一点,让我们首先回顾一下没有密钥的标准加密哈希。对于这样的哈希,如果输入不变,输出也不变。它们完全是确定性的,只基于一个输入:消息内容。如果你重温“谷歌知道!”在第二章中,你会回忆起我们实际上可以在 Google 中输入一些哈希值并找到匹配的输入。

打开 Python shell,再测试一两次:

>>> import hashlib
>>> hashlib.sha256(b"hello world").hexdigest()
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'
>>> hashlib.sha256(b"happy birthday").hexdigest()
'd7469a66c4bb97c09aa84e8536a85f1795761f5fe01ddc8139922b6236f4397d'

“hello world”和“happy birthday”的 SHA-256 输出在每台计算机上永远都是这些值。他们将永远不会改变。您可以通过自己运行代码来验证这一点。SHA-256 定义要求如此。你也可以尝试在线搜索哈希值。

重复一遍,使用非密钥算法,相同的输入总是产生相同的输出。

当一个算法被键控时,意味着输出依赖于输入和一个键。但是如何对哈希算法进行加密呢?

从概念上讲,这其实很容易。因为即使对哈希算法输入的微小改变也会完全改变输出,所以我们可以让密钥成为输入本身的一部分!

虽然下面的例子不是真正的 HMAC,并且不是被认为足够安全,但是它说明了这个想法:

>>> import hashlib
>>>
>>> password1 = b"CorrectHorseBatteryStaple" # See XKCD 936
>>> password2 = b"LiverKiteWorkerAgainst"
>>>
>>> # This is not really HMAC, it is for illustration ONLY:
>>> hashlib.sha256(password1 + b"hello world").hexdigest()
'ca7d4abd13bceb305eef2738e3592da77ed826aa1665ba684b80f36bd7522b32'
>>>
>>> hashlib.sha256(password2 + b"hello world").hexdigest()
'b22786bc894c8bb27d1e7e698a9bddfd6b95f35dcd063e37d764fa296216408a'

在这个例子中,我们使用人类可读的密码作为密钥。我们将输入的“hello world”哈希了两次,但每次都插入不同的密码作为前缀。基本上,我们用密钥来改变我们所哈希的内容。每个密码会产生完全不同的输出,这意味着有人要重新创建消息“hello world”的输出 MAC 的唯一方法是也知道密码(或者通过暴力破解)。与任何其他加密算法一样,密钥/密码必须足够大并且足够随机。

说到大小,值得注意的是,密码的大小并不是它如何有效地改变哈希输出的一个因素。你还记得雪崩原理吗?改变哈希函数的输入的单个位*会完全改变输出哈希值。您可以拥有一个万亿字节的文档,只改变其中的一个字符,并产生一个与未改变的文档的哈希无关的新哈希。类似地,您的密码可以是单个字符,它将有效地“扰乱”任何给定输入的输出,不管输入有多大。您需要担心的是,您的密码长度(和随机性)足够强大,可以防止暴力攻击。

练习 5.3。又是暴力

您应该已经在前面的章节中完成了一些强力攻击,但是重要的是重复这个练习,直到您对这个概念有了直觉。使用我们前面的假 HMAC,让计算机生成一个特定大小的随机密码,并使用暴力方法找出它是什么。更具体地说,假设你已经知道消息是什么(例如,“你好,世界”,“生日快乐”,或者你选择的消息)。编写一个程序来创建一个字符的随机密码,将密码添加到消息的前面,然后打印出 MAC(哈希)。获取输出,遍历所有可能的密码,直到找到正确的密码。从简单的单字母字符测试开始,然后尝试两个字符,以此类推。通过使用不同的字符集来混淆事物,例如全部小写、小写和大写,或者大写加数字,等等。

练习 5.4。暴力破解四字密码

重复之前的练习。但是不要用从一个字母来源中提取的字母,而要用从一个单词来源中提取的单词。查找或创建一个包含常用单词列表的文本文件。至少要 2000 字。使用这个字典,通过选择 n 个随机单词来创建密码。通过尝试字典中所有可能的组合,尝试暴力破解该密码。从 n = 1(一个字的密码)开始,从那里往上走。

即使前面的方法也不够好,所以让我们来谈谈真正的 HMAC。我们已经反复说过,仅仅预先设置密码是不够安全的。“HMAC”是标准文档“RFC 2104”中定义的算法的正式名称如果您以前从未看过 RFC,这些是来自互联网工程任务组(IETF)的文档,代表了互联网协议和算法的标准、最佳实践、实验和讨论。它们都是免费的,可以在网上找到。RFC 2104 可以在 https://tools.ietf.org/html/rfc2104 找到。

该文件的摘要指出:

  • 本文档描述了 HMAC,一种使用加密哈希函数进行消息认证的机制。HMAC 可以与任何迭代加密哈希函数(例如,MD5、SHA-1)结合秘密共享密钥一起使用。

这部分应该已经说得通了。我们已经做的实验使用了 SHA-256 和一个秘密共享密钥,但我们显然可以使用 SHA-1 或 MD5。不过,提醒一下,那些哈希算法被认为是“坏的”,除非必要,否则不应该用于遗留应用。

回到 RFC 的第 3 页,我们看到一旦选择了哈希函数 H ,输入文本的 HMAC 就被计算出来,因此

  • h(k xorg opad,h(k xorg ipad,文本))

让我们来看看这些术语。我们已经知道H;这是底层的哈希函数。术语“文本”指的是输入,但不必像任何“明文”消息那样由可读的文本字符组成:它可以是任意的二进制数据。哦,我们需要解决逗号。因为 H 是一个函数,您可能会认为这个定义显示了一个带两个参数的哈希函数。但是在 RFC 的这个定义中,逗号可以被认为是连接。和我们所有的例子一样,哈希函数只接受一个输入。

术语 K 指的是密钥,但不能只是任何东西。RFC 对密钥有许多要求,通常需要一些预处理。这些要求大多与 H 的块大小有关。回想一下第三章中的内容,我们在分组密码中使用了术语“块大小”来描述分组密码一次操作的数据大小。例如,AES 的块大小为 16 字节(128 位)。哈希算法可以哈希任何大小的输入,那么哈希算法的块大小是多少呢?

实际上,哈希函数通常一次对一个块进行操作,但是将一个块的哈希输出输入到下一个块的哈希计算中。例如,SHA-1 的块大小为 64 字节(512 位),而 SHA-256 的块大小为 128 字节(1024 位)。RFC 将 H 的块大小称为 B (字节)。

我们的密钥的第一个要求是,如果它比块大小 B个字节,它必须用零填充,直到它的长度为 B 个字节。

第二个要求是,如果密钥比 B,那么首先通过用 H 哈希密钥来减少它。不要对此感到惊讶。我们将在一次 HMAC 操作中多次使用 H

*综上所述,如果 K 太短就用零填充,如果 K 太长就用 H ( K )代替。

眼尖的读者会注意到,哈希的长度可能也可能比块大小短。SHA-1 的哈希是 20 字节长,它的块大小是 64 字节。SHA-256 的哈希是 32 字节长,但是它的块大小是 128 字节。在用哈希函数减少了太长的关键字之后,它通常会太短,然后将需要填充。

最后,我们应该有一个长度正好为 B 字节的密钥。

接下来,我们需要计算 K ⊕ ipad (XOR)。术语“ipad”代表“内部填充”,因为这是 HMAC 中的内部哈希操作。RFC 将 ipad 定义为“重复 B 次的字节 0x36”,将“opad”定义为“重复 B 次的字节 0x5c”。为 ipad 和 opad 选择的值是任意选取的。最重要的是它们是不同的。

填充的原因超出了本书的范围,但是它们给了 HMAC 一些额外的安全性,以防底层的哈希函数被破坏。因此,举例来说,这些填充使得 HMAC-MD5 相对较强,即使 MD5 已经被破解。这很有帮助,但不是对新应用使用 HMAC-MD5 的好理由。请不要。HMAC 的填充意味着 HMAC-SHA256 将是一个相当强大的 MAC,即使有人发现 SHA-256 哈希函数中的漏洞,这可以帮助保持现有的使用(可能不容易立即升级到更好的哈希函数)相对安全。

⊕ ipad 的计算非常简单,因为它们大小相同。随后的值被添加到输入“文本”的前面,组合的数据由 H 哈希。我们现在已经计算出了 H ( K ⊕ (ipad text))。同样,这是内部哈希计算。

现在,对于外部哈希,我们计算 K ⊕ opad。随后的值被添加到内部哈希的输出中,聚合的字节再次被哈希。外部函数的哈希是输入文本在 K 上的 HMAC。

幸运的是,加密库几乎总是将 HMAC 作为原语。

>>> from cryptography.hazmat.backends import default_backend
>>> from cryptography.hazmat.primitives import hashes, hmac
>>>
>>> key = b"CorrectHorseBatteryStaple"
>>> h = hmac.HMAC(key, hashes.SHA256(), backend=default_backend())
>>> h.update(b"hello world")
>>> h.finalize().hex()
'd14110a202b607dc9243f83f5e0b1f4a1e59fba572fc5ea5f41d263dd4e78608'

为什么要大费周章去学习 HMAC 内部是如何工作的,而不仅仅是学习如何使用一个附带的库呢?有几个原因。首先,至少对事物如何运作有一点点了解是有好处的。它有助于直觉和推理何时使用它以及为什么使用它。

第二,也许是最重要的,它提醒你 YANAC(你不是一个密码学家...还没!).你一定要记住这个原则!尽可能多地使用加密库,不要试图想出自己的“聪明”算法。再看一眼 HMAC。它建立在一些与简单地在输入前加一个键相同的概念上,但是有更高的复杂性。这种复杂性来自更深层次和更微妙的目标,包括在哈希函数被破坏的情况下的前向安全性。这种复杂性不是任意的;HMAC 行动是基于密码学家的一篇研究论文,该论文从数学上证明了某些安全特性。除非你是一名密码学家,发布你的作品(通常有正式的证明)供公众同行审查、测试和辩论,否则你真的不应该创建自己的算法,除非是为了教育或演示的目的。

练习 5.5。测试 Python 的 HMAC

虽然你不应该推出自己的加密,但这并不意味着你不应该验证实现!按照 RFC 2104 的说明创建您自己的 HMAC 实现,并用您的实现和 Python 的cryptography库的实现测试一些输入和键。确保它们产生相同的输出!

CBC-MAC 电脑

HMAC 是一个非常受欢迎的 MAC,例如在 TLS 中使用,但也有其他方法来创建 MAC。例如,我们可以将您在第三章中学到的关于密码块链接(CBC)模式的知识作为推导安全 MAC 的另一种方法。

让我们快速介绍一些新的术语。MAC 有时也称为“标签”当我们创建一个消息的 MAC 时,我们可以称之为该消息的“标签”;这就像一件礼物或一件衣服上的标签:它是附在主文章上的一点点信息。在数学符号中,标签通常表示为 t 。这样,一个 MAC over 消息 m 1 产生一个标签t1,该对( m 1 ,t 1 )被发送到接收方进行验证。

img/472260_1_En_5_Fig1_HTML.jpg

图 5-1

因为所有的消息都影响最后一个加密数据块的值,所以 C[n]是所有 P 的 MAC...有一些瑕疵。

回想一下,当使用 AES 加密时,我们一次只能加密 128 位。如果我们单独加密每个 128 位数据块,仍然有信息可能会“泄露”整个数据。例如,大的图像特征可能仍然是可识别的。解决这个问题的一个方法是“连锁”加密,这样来自一个块的输入就可以延续并影响下一个块的加密。换句话说,开始时一个比特的变化会产生级联效应,一直到最后一个块。

换句话说,密文的最后一个块由链中每隔一个块的值决定:输入中任何地方的任何变化都将反映在最后一个块中!这使得 CBC 加密模式的最后一个块成为整个数据的 MAC,如图 5-1 所示。

希望您已经从本书中学到了这一点,所有的加密技术都有限制和关键参数。和 HMAC 一样,我们将首先做一些简单的例子,看看 CBC-MAC 算法背后的基本概念以及简单的方法是如何被利用的。

让我们从获取一条消息并通过 AES-CBC 加密来运行它开始。出于我们稍后将解释的安全原因,我们将把初始化向量固定为零。为了使我们的消息是块大小的倍数,我们还将使用用于加密的相同的 PKCS7 填充。为了简化下一个练习,我们需要一些没有填充的完整块消息,所以我们包含了一个关闭填充的标志。

 1   # WARNING! This is a fake CBC–MAC that is broken and insecure!!!
 2   # DO NOT USE!!!
 3   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 4   from cryptography.hazmat.backends import default_backend
 5   from cryptography.hazmat.primitives import padding
 6   import os
 7
 8   def BROKEN_CBCMAC1(message, key, pad=True):
 9       aesCipher = Cipher(algorithms.AES (key),
10                          modes.CBC(bytes(16)), # 16 zero bytes
11                          backend=default_backend())
12       aesEncryptor = aesCipher.encryptor()
13
14       if pad:
15           padder = padding.PKCS7(128).padder()
16           padded_message = padder.update(message)+padder.finalize()
17       elif len(message) % 16 == 0:
18           padded_message = message
19       else:
20            raise Exception("Unpadded input not a multiple of 16!")
21       ciphertext = aesEncryptor.update(padded_message)
22       return ciphertext[-16:] # the last 16 bytes are the last block
23
24   key = os.urandom(32)
25   mac1 = BROKEN_CBCMAC1(b"hello world, hello world, hello world, hello world", key)
26   mac2 = BROKEN_CBCMAC1(b"Hello world, hello world, hello world, hello world", key)

Listing 5-2Fake MAC with CBC

清单 5-2 中的代码虽然不安全,但确实展示了 MAC 背后的基本概念。一段数据首先被填充,然后被加密。然而,不管它有多长,最后一个块(16 字节)都是由所有前面的输入决定的。把第一个字母从“H”改成“H”,MAC 就完全不一样了。

然而,它可以被利用。回想一下,对于给定的消息和密钥对,MAC 必须是唯一的。如果攻击者可以用相同的密钥为不同的消息生成相同的 MAC,那么 MAC 算法就被破解了。

事实证明,对于 CBC-MAC 的这个幼稚版本,你完全可以做到这一点。让我们先用代码来做,看看你能不能猜出是怎么回事。请注意,清单 5-3 旨在与清单 5-2 合并。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Dependencies: BROKENCBCMAC1
 4   def prependAttack(original, prependMessage, key):
 5       # assumes prependMessage is multiple of 16
 6       # assumes original is at least 16
 7       prependMac = BROKEN_CBCMAC1(prependMessage, key, pad = False)
 8       newFirstBlock = bytearray(original [:16])
 9       for i in range (16):
10           newFirstBlock[i] ^= prependMac[i]
11       newFirstBlock = bytes(newFirstBlock)
12       return prependMessage + newFirstBlock + original [16:]
13
14   key = os.urandom(32)
15   originalMessage = b"attack the enemy forces at dawn!"
16   prependMessage = b"do not attack. (End of message, padding follows)"
17   newMessage = prependAttack(originalMessage, prependMessage, key)
18   mac1 = BROKEN_CBCMAC1(originalMessage, key)
19   mac2 = BROKEN_CBCMAC1(newMessage, key)
20   print("Original Message and mac:", originalMessage, mac1.hex())
21   print("New message and mac     :", newMessage, mac2.hex())
22   if mac1 == mac2:
23       print("\tTwo messages with the same MAC! Attack succeeded!!")

Listing 5-3MAC Prepend Attack

上市 5-3 生产的两款 MAC 是一模一样的。我们的攻击将我们选择的另一个消息添加到原始消息中,并且破坏了第一个块。对前置消息的唯一限制是,它还必须在同一密钥下具有前置消息的 CBC-MAC 值。我们关闭了这个前置消息的填充,以使攻击变得稍微容易一些,但这只是为了我们的方便,并不是攻击成功的先决条件。

对攻击者来说,可悲的是,原始消息需要修改第一个块;否则,袭击可能会更严重。攻击者然后可以创建消息说“不要在黎明攻击敌军!”攻击者也不能擦除第一块以外的任何数据。在运行代码时,您可能注意到“部队在黎明!”在新邮件中仍然可读。即便如此,这仍然很糟糕:我们添加了一个完全不同的消息,而没有改变 MAC 的值!

对于这个简单的例子,我们假设一个人正在阅读输出,我们希望我们的消息说,其余的数据是填充将足以说服发送者不要进一步阅读。在实际攻击中,传输数据长度和其他类似的机制通常可以用来达到相同的效果。如果我们成功了,我们基本上可以用原来的 MAC 发送任意消息。

出了什么问题?在我们给你解释之前,看看你自己能不能搞清楚。您可能需要重新了解 CBC 模式是如何工作的。如果你需要额外的提示,记住abb=a

不管怎样,让我们一起努力吧。假设我们有一个消息 M 由任意数据块M1 到Mn 组成。在下面的公式中,让 E 表示 AES 加密操作,让 t 表示对数据计算的 CBC-MAC 标签:

  • t=e(me(m】-我...。e(me(, k】-我...。, kk**

*请注意,消息的第一块 m 1 由 AES 在密钥 k 下加密,加密前输出与 m 2 进行异或运算。

假设我们预先计划了一条消息 P ,其长度正好是一个块。那会怎样改变事情?CBC-MAC 显然会产生一些不同的东西,因为我们改变了第一个计算:

  • t【p】=e(【m】**-我...。e(m(-我...。, kk

结局是应该的。改变消息(即,预先考虑新的块)改变了标签。但是如果我们已经知道了前置块 E ( P,k )的 AES 加密的输出会怎么样呢?姑且称之为 C 。如果 E ( P,k ) = C ,那么我们可以将 P 前置到链上而不改变最后的标签如果我们也将原来的第一块 m 1 破坏为m1c

  • t=e(mn-我...。e(m【2】e(-我...。, kk

当 CBC 在这个被破坏的链上操作时,它试图将前置块的加密输出( C )异或成被破坏的第一个块的明文(m1⊕c)。但是损坏的第一个块已经混合了 C 的 XOR 运算,因此 C 值取消!这就简化为

  • t = e(m【n】【e】(】n-1-我...。e(m2e(1-我...。, kk**

*实际上,我们已经取消了最后一个标签上的前置块的输入!我们回到了消息的原始 MAC!

  • t=e(me(m】-我...。e(me(, k】-我...。, kk**

这个例子只是针对单个块。但事实证明,无论前置消息有多长,我们只关心在加密之前将与 m 1 进行异或运算的部分。在任意长度的 CBC 链中,唯一延续到下一个块的部分是链的最后一个加密块。换句话说,CBC-MAC 操作的 MAC 输出, t ,是前置消息中唯一会影响其后内容的部分!*

然后,假设您有两个消息 M 1M 2 以及两个相应的标签 t 1t 2 ,这两个标签都是使用我们的破解 CBC-MAC 算法在同一密钥下生成的。为了创建一个伪造的消息,首先将第一个块M2 与第一个块t1 进行异或运算,以产生M2’。现在创建M3=M1+M2’(加号表示串联)。 M 3 的 CBC-MAC 也会是t2 因为(用 C ()表示“MAC”):

  • t=e(【m】【2】,e-我...。e(m*,1】-我...。, kk*****

由于 M 1 的 MAC 是 t 1 ,它与另一个 t 1 相抵消,剩下的 MAC 就是 M 2 的 MAC。

图 5-2 描述了这种攻击的可视化,以及我们刚刚研究过的数学方法。

重要的是,你不需要钥匙来进行这次攻击。在我们的代码示例中,我们自己拥有密钥并生成了一条任意的消息。这仍然是一种攻击,因为即使是共享密钥的拥有者也不能用同一个 MAC 发送两条消息。

img/472260_1_En_5_Fig2_HTML.jpg

图 5-2

攻击者可以在不改变(简单的)CBC-MAC 的情况下,通过破坏第一个块来预先计划消息

但是利用这种攻击,没有密钥的攻击者可以从两个现有的消息(例如,由受害者生成的)和相应的标签中生成新的消息和伪造的标签。

这个问题有各种各样的解决方案,但是我们在这里提到的唯一一个是强制每个消息都加上消息的长度,如清单 5-4 所示。

 1   # Reasonably secure concept. Still, NEVER use it for production code.
 2   # Use a crypto library instead!
 3   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 4   from cryptography.hazmat.backends import default_backend
 5   from cryptography.hazmat.primitives import padding
 6   import os
 7
 8   def CBCMAC(message, key):
 9       aesCipher = Cipher(algorithms.AES(key),
10                           modes.CBC(bytes(16)), # 16 zero bytes
11                           backend=default_backend())
12       aesEncryptor = aesCipher.encryptor()
13       padder = padding.PKCS7(128).padder()
14
15       padded_message = padder.update(message)
16       padded_message_with_length = len(message).to_bytes(4, "big") + padded_message
17       ciphertext = aesEncryptor.update(padded_message_with_length)
18       return ciphertext[-16:]

Listing 5-4
Prepend Message Length

为了安全地使用 CBC-MAC,有一些额外的注意事项:

  1. 如果您也使用 AES-CBC 加密数据,则不能对加密和 MAC 使用相同的密钥。

  2. 静脉注射应该固定为零。

对这些问题的全面解释超出了本书的范围。然而,假设您遵循了它们,包含的 CBC-MAC 代码是相当安全的。我们仍然不推荐使用它,因为创建你自己的加密算法或者你自己的已知加密算法的实现总是很危险。相反,请始终使用可信加密库中的算法。

我们在示例代码中使用的库包括 CMAC。该算法是 RFC 4493 中定义的 CBC-MAC 的更新和改进。CMAC 或 HMAC 是 MAC 算法的好选择;在没有专门的 AES 加密硬件的大多数系统上,HMAC 可能会更快。

使用图书馆的 CMAC 很简单。以下内容直接摘自在线文档:

>>> from cryptography.hazmat.backends import default_backend
>>> from cryptography.hazmat.primitives import cmac
>>> from cryptography.hazmat.primitives.ciphers import algorithms
>>> c = cmac.CMAC(algorithms.AES(key), backend=default_backend())
>>> c.update(b"message to authenticate")

加密和标记

在许多情况下,需要对消息进行加密并防止修改。在本章的第一个代码示例中,Alice 和 Bob 使用了一个未加密的哈希来保护加密的消息。显然,这是行不通的,因为没有密钥,任何人都可以生成相应的哈希。既然我们无畏(或卑怯)的二人组知道如何使用 HMAC 和 CMAC,他们可以更新他们的代码。

练习 5.6。加密然后 MAC

更新本章开头的代码,将 SHA-256 操作替换为 HMAC 或 CMAC 操作,以实现正确的 MAC。使用两个键。

请注意在前面的练习中,您何时使用 MAC 以及在什么设备上使用它。您会注意到 MAC 应用的是密文,而不是明文。正如这个练习的名字所暗示的,这叫做加密然后 MAC 。过去有两种发送加密和认证消息的方法。

一种是先 MAC 后加密。在这个版本中,MAC 应用于明文、,然后明文和 MAC 一起加密。早期版本的 TLS(用于 HTTPS 连接)采用了这种方法。

另一种方法叫做加密和 MAC。为了采用这种方法,MAC 也是在明文上计算的,但是 MAC 本身没有加密。它与密文一起发送(未加密)。如果您曾经使用过安全 Shell (SSH 或 PuTTY),它会使用 Encrypt-And-MAC。

大多数密码学家强烈推荐使用 Encrypt-Then-MAC3而不是其他两种方法,当然也有一些人反对。事实上,针对 MAC-Then-Encrypt 的某些组合,已经发现了某些实际的漏洞。你已经演示过一次了!前一章中针对 CBC 的填充 oracle 攻击仅适用于 MAC-Then-Encrypt 场景。

还有一种更好的方法叫做 AEAD(带附加数据的认证加密),我们将在第七章中了解到,它将加密和消息完整性结合到一个操作中。无论出于何种原因,如果您需要将加密和 MAC 结合起来,请确保选择 Encrypt-Then-MAC(即,加密明文,然后根据密文计算 MAC)。

我们不会深入讨论为什么 Encrypt-Then-MAC 通常被认为更好,但有一点值得一提。正如我们在其他情况下讨论过的,我们通常不希望坏人篡改我们的密文。这可能不直观,因为我们倾向于考虑最终目标:保护明文。但是,当坏人可以在我们无法检测到的情况下更改密文时,坏事就会发生。当您先加密再加密 MAC 时,应该保护密文不被修改。

练习 5.7。了解你的弱点

建议使用 Encrypt-Then-MAC 方法将加密和 MAC 结合起来。然而,理解所有这三种方法是有好处的。不说别的,如果您曾经不得不维护您没有编写的代码,或者不得不与遗留系统兼容,您将来可能会遇到这种情况。修改您的(强烈推荐)先加密后 MAC 系统,创建一个先 MAC 后加密的变体。最后,创建一个 MAC-And-Encrypt 版本。

数字签名:认证和完整性

Alice 和 Bob 喜欢用 hmac 发送加密消息(使用 Encrypt-Then-MAC)。在他们目前在西南极洲的任务中,他们每个人都有四把钥匙。一对允许它们相互发送加密和 MAC 保护的消息(记住,一个密钥用于加密,一个密钥用于 MAC 生成),第二对允许它们向位于东南极洲的总部发送和接收加密和 MAC 保护的消息。

不幸的是,有一天爱丽丝被抓获,因为她试图渗透到西南极雪球测试大厦。瞬间,一切都陷入混乱,因为夏娃现在可以使用她所有的钥匙。

这是一个可怕的妥协。Eve 现在可以发送消息了,就好像它们是来自 Alice 或 HQ 一样!试图减轻这种保密性和身份验证的损失是一场噩梦。鲍勃的情况很糟糕。他需要两把新钥匙来与总部沟通,也许还需要两把新钥匙来与现场的新合作伙伴沟通。这只能通过返回总部来完成,这意味着将他从现场拉出来,可能会浪费他花在渗透目标和收集数据上的时间和资源。更糟糕的是,他甚至不能被可靠地告知正在发生的事情!如果他没有爱丽丝被捕的第一手资料,总部向他发送的任何通知事件或指示他回家的消息都可以被拦截和更改。

尽管事情对鲍勃来说很糟糕,但总部的情况更糟。他们使用相同的共享密钥对所有邮件进行加密和标记。现场的每个特工都有爱丽丝丢失的密钥。伊芙可以在他们任何一个人面前冒充总部。Eve 可以像任何代理一样向总部发送消息,因为他们没有自己的密钥来与总部通信。

共享密钥的丢失使 EATSA 倒退了至少 12 个月。

更糟糕的是,Eve 可以使用加密密钥读取 HQ 和他们的代理之间的通信,更糟糕的是,她可以使用 MAC 密钥伪装成任何一方发送消息。重复我们之前的一个评论,当人们第一次开始学习密码学时,他们通常认为“加密”是其主要目的或特征。正如我们虚构的例子所示,身份验证——知道谁发送了消息——至少同样重要,甚至可以说更重要。

即使一旦 EATSA 设法得到他们所有代理的家,并且不再使用旧密钥(旧密钥因此被“撤销”),他们也有提出密钥管理系统以避免将来出现相同问题的问题。他们考虑的一个选择是让每个代理拥有他们自己的单独密钥。如果 HQ 或代理想要发送消息,他们使用各自的密钥对其进行标记。

问题是 MAC 需要共享密钥。消息的接收方必须与发送方拥有相同的密钥。他们将如何获得它?每个代理都有其他代理的钥匙吗?如果是这样,代理的捕获就像只有一把钥匙一样糟糕。更糟糕的是,没有什么能阻止一个代理使用另一个代理的密钥(冒充他们),无论是意外还是因为他们变得无赖。

最终,其中一名科学家记起了第四章中的不对称加密,特别是它可以用于一种叫做的数字签名。与消息身份验证代码一样,数字签名旨在提供真实性(您可以知道谁发送了消息)和消息完整性(消息不能被不可察觉地更改)。此外,因为他们使用非对称加密,所以没有共享密钥。当 EA 开始尝试非对称加密的时候,他们变得非常非常关注消息的加密(保密性),而数字签名则被抛到了一边。

现在是补救的时候了。

到底什么是数字签名?首先,让我们回顾一下不对称加密是如何为我们在第四章中学习的 RSA 算法工作的。与各方之间只有一个共享密钥的对称加密不同,RSA 的非对称加密包含一对密钥:公钥和私钥。这些密钥以相反的方式工作:一个加密,另一个解密。此外,RSA 公钥可以从私钥导出,但不能反过来。

顾名思义,一方应该保持 RSA 私钥的私密性,永远不要向任何人透露。另一方面,RSA 公钥可以而且通常应该广泛传播。这个设置支持两个非常有趣的操作。

首先,因为 RSA 公钥由任何人持有(并且可能由每个人持有!),世界上任何一个人都很容易将加密的消息发送给相应 RSA 私钥的所有者。任何人都可以用公钥加密信息,但是只有拥有私钥的一方才能解密。

这很重要!发送加密消息的人知道只有拥有私钥的一方可以解密消息。这是一种不同的反向真实性。消息的接收者不知道是谁发送的,但是发送者可以确定(如果密钥是安全的)只有预定的一方可以阅读消息。我们在第四章中对 RSA 非对称加密的介绍主要集中在这个用例上。

但是,加密的方向可以反过来:RSA 私钥也可以用来加密消息。因此,拥有私钥的一方可以用它来加密只能用公钥解密的东西。那有什么好处呢?任何人(大家!)可能有公钥。这种加密当然不会让数据保密!

这是真的!但是,在 RSA 私钥下加密发送的消息只能由拥有该私钥的某人加密。即使每个人都能解密它,它能被特定的公钥解密的事实是发送者持有私钥证明。换句话说,如果你收到一条可以用我的公钥解密的消息,你就知道它来自我;没人能加密它。听起来很有用!*

让我们假设环境局想要向全世界发布一份关于西南极洲犯罪的宣言。首先,他们可以到处传播他们的 RSA 公钥,然后用相关的私钥加密文档。现在,当他们分发文档时,世界上的任何人都可以解密它,这个事实向他们证明了它来自 EA。

这个系统很棒,但是它有几个重要的缺陷。首先,世界如何知道 RSA 公钥真的属于 EA(而不是来自 WA 的赝品)?这是一个非常重要的问题,我们稍后会谈到它。现在,我们假设接收者拥有合法的、可信的 RSA 公钥。

另一个问题是效率。RSA 加密。解密长文档来验证发送者并不是一个非常有效的方法。更糟糕的是,一些非对称算法没有任何内置的消息完整性。哦,当我们谈论 RSA 的局限性时,它不能加密像文档一样长的东西。

幸运的是,效率和完整性这后两个问题很容易解决。回想一下,我们不是为了保密而加密,而是为了证明来源或真实性。不加密消息本身,而是加密消息的一个哈希怎么样?

这是对任意数据进行 RSA 数字签名的基本思想。它包括两个步骤。首先,哈希数据。第二,用私钥加密哈希。加密哈希是应用于数据的发送方签名。现在,签名可以与原始(可能未加密)数据一起传输。当接收方收到数据和签名时,接收方生成哈希,用公钥解密签名,并验证两个哈希(生成的和解密的)是否相同。

下面是密码学家可能如何表示这一点。首先,对于一个消息 M ,我们使用哈希函数生成一个哈希: h = H ( M )。

一旦我们有了哈希值 h ,我们就用 RSA 私钥对它进行加密。为了描述这个操作,我们将使用加密协议中常用的一些符号。具体来说,我们将使用{}来表示 RSA 加密的数据。大括号内的所有内容都是明文,但是大括号表明明文在某个加密信封内。大括号还会有一个表示键的下标。所以比如密文 C 是在某个密钥 K 下加密的明文 P ,这个被描绘成C= {P}K

从这一点开始,在本书中,两方之间的共享密钥将用一个表示双方的下标来描述。所以,举例来说,爱丽丝和鲍勃之间的一个键可以描述为KA,B 。这是对称密钥的一个例子。

诸如 RSA 公钥之类的公钥将由仅具有一个识别方的密钥来表示。例如,爱丽丝的公钥可以表示为 K A ,而鲍勃的公钥同样可以表示为 K B 。因为公钥是被分发的,所以它是被命名的。私钥改为表示为公钥的逆:K1AK1B)。

在本章中,我们通常还会使用字母 t 来表示 RSA 签名,因为签名有时也被称为标签,就像 MAC 一样。因此,我们表示一个 R:

tM= {H(M)}K—1

当拥有 RSA 公钥的另一方 K 接收到 M、{H’(M)}K—1时,用公钥解密签名,恢复H’(M)。接收方生成自己的 H ( M ),如果H'(M)=H(M),则签名被认为是真实的。

冒着重复的风险,请记住 RSA 公钥加密与私钥加密用于不同的用途。用 RSA 公钥加密使消息保密:只有私钥拥有者才能阅读它。用 RSA 私钥加密证明了的真实性:只有所有者才能创作它。

在 EA 间谍机构,这似乎是奇迹!代理为自己生成一个 RSA 密钥对,并让所有代理生成一个 RSA 密钥对。代理保存所有代理的所有公共密钥的副本,并且每个代理获得代理的公共密钥的副本。

当代理机构向 Alice 发送加密消息时,他们使用她的公钥对其进行加密,只有 Alice 能够对其进行解密。他们用他们的私钥签署消息,并且 Alice 可以使用代理公钥来验证消息是真实的并且没有被破坏。只要 Alice 和 Bob 有对方公钥的副本,他们同样可以互相发送加密和认证的消息。

这是一个很大的进步,看起来很棒。

确实是这样,但正如 EA 的加密体验经常发生的那样,有复杂之处、警告和微妙之处。然而,在我们开始之前,让我们帮助 Alice 和 Bob 学习如何相互发送一些签名通信。为了简单起见,我们不打算加密它们。

再一次,cryptography库用它的签名和验证功能拯救了我们:我们不需要,也不应该试图自己实现数字签名。相反,使用我们的库,我们将生成一些 RSA 签名。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.asymmetric import rsa
 3   from cryptography.hazmat.primitives import hashes
 4   from cryptography.hazmat.primitives.asymmetric import padding
 5
 6   private_key = rsa.generate_private_key(
 7       public_exponent=65537,
 8       key_size=2048,
 9       backend=default_backend()
10   )
11   public_key = private_key.public_key()
12
13   message = b"Alice, this is Bob. Meet me at Dawn"
14   signature = private_key. sign(
15       message,
16       padding.PSS(
17           mgf=padding.MGF1(hashes.SHA256()),
18           salt_length=padding.PSS.MAX_LENGTH
19       ),
20       hashes.SHA256()
21   )
22
23   public_key.verify(
24       signature,
25       message,
26       padding.PSS(
27           mgf=padding.MGF1(hashes.SHA256()),
28           salt_length=padding.PSS.MAX_LENGTH
29       ),
30       hashes.SHA256()
31   )
32   print("Verify passed! (On failure, throw exception)")

Listing 5-5Sign Unencrypted Data

清单 5-5 中的内容可能比预期的要多一点,尤其是在填充配置中。让我们从头到尾走一遍。

首先,我们生成一个密钥对。对于 RSA,公钥是从私钥派生出来的,因此生成私钥会生成密钥对。API 包括一个从私钥获取公钥的调用。在这个例子中,两个密钥都被使用。在真实的例子中,签名和验证代码将存在于完全不同的程序中,并且验证程序将只能访问公钥,而不能访问私钥。

在第四章中,我们还学习了如何从磁盘中序列化和反序列化这些 RSA 密钥。

在代码的下一部分,我们对消息进行签名。您会注意到,我们在这里使用填充,就像我们在 RSA 加密中使用的一样,但这是一个不同的方案。RSA 的推荐填充是加密的 OAEP 和签名的 PSS。考虑到 RSA 签名是通过加密哈希生成的,这可能会让您感到惊讶。如果无论如何都是加密,为什么我们需要不同的填充方案?

答案是,因为签名是在哈希上操作的,所以数据的某些特征必须是真实的。任意数据加密与哈希加密的本质决定了两种不同的填充方案。

与第四章中使用的 OAEP 填充一样,PSS 填充功能也需要使用“屏蔽生成功能”在撰写本文时,只有一个这样的函数,MGF1。

最后,签名算法需要哈希函数。在本例中,我们使用 SHA-256。

验证算法的参数应该是不言自明的。请注意,验证函数不会返回 true 或 false,而是在数据与签名不匹配时引发异常。

重要的

请仔细注意下一段。这一点非常重要,也有些反直觉。

如果要加密签名,应该先签名再加密,还是先加密再签名?在前一节讨论了先加密后 MAC 之后,您可能会想到先加密后签名。

但是签名是而不是 MAC,你一般应该而不是使用先加密后签名。有两个非常重要的原因。

首先,记住签名的目标不仅仅是消息的完整性,还包括发送者的 ?? 认证。假设爱丽丝正在给鲍勃发送一条加密的消息,她在签名之前对消息进行了加密。任何人都可以截取消息,去掉签名,然后用自己的密钥发送重新签名的消息。哎呀。

目前还不清楚这种攻击有多实际,因为数据是在每个人都有的接收者的公钥下加密的。无论如何,攻击者可以发送他们自己的加密消息给 Bob(用 Bob 的公钥加密)。攻击者甚至无法解密 Alice 的消息,以查看他/她是否想邀功。但关键是,明文和签名之间没有关联,确实需要有关联:Bob 有兴趣知道他能读到的消息来自 Alice 而不是其他人。如果加密数据被签名而不是明文,当 Bob 收到密文和签名时,他不能可靠地确定原始消息的作者。

简而言之,如果你签署了一个加密的消息,它很容易被别人截取和签署,这就损害了它的真实性。签名应该应用于明文。

第二,也是更重要的一点,签名不能防止坏人篡改密文。请记住,使用 Encrypt-Then-MAC 的首要原因是防止加密数据被篡改。例如,通过“先加密后签名”,Eve 可以截取 Alice 发给 Bob 的消息,去掉 Alice 的签名,修改密文,然后用她自己的密钥对修改后的数据进行签名。你可能会问,这有什么好处?毕竟,Bob 将会看到邮件现在是由 Eve 而不是 Alice 签名的。他为什么要相信它?

鲍勃会接受签名的原因有很多。例如,伊芙可能泄露了另一个特工的钥匙。使用 RSA 加密的全部原因是为了防止一个代理的密钥泄露危及另一个代理的通信。但是如果 Eve 得到了合法的签名密钥,她就可以剥去 Alice 的签名,修改密文,用 Bob 会接受的东西重新签名。

一旦发生这种情况,Eve 可以观察 Bob 的行为来了解 Alice 的信息。正如我们在前面的例子中所使用的,即使 Bob 丢弃了一条消息,Eve 也可以利用这条信息(例如,她知道她发送给他的消息是不可读的)。

这听起来是不是很牵强?嗯,正是苹果 iMessage 的这种漏洞被马特·格林发现了。你可以在他的博客[6]上读到。我们不会在这里详细讨论他的攻击,只是说这种攻击实际上非常实用。

所以,请不要加密然后签名。

为什么这与 MAC 电脑如此不同?为什么先加密后 MAC 行得通?最根本的区别还是在于按键。对于 MAC,有一个共享密钥,通常只在双方之间共享。没有人能够替换由 Alice 和 Bob 共享的密钥创建的 MAC,因为其他人不应该拥有该密钥。然而,用于创建数字签名的私钥是不共享的,并且不将任何一方绑定在一起。

你应该做什么?首先,似乎没有太多的加密系统适用于此。如果您使用对称加密,包含对称 MAC 通常没有问题。如果苹果做到了这一点,我们提到的 iMessage 攻击就不可能发生。不对称加密通常不用于批量加密。当需要加密大量数据时,通常的方法是使用非对称加密交换或创建对称密钥,然后切换到对称算法。我们将在下一章谈到这一点。

如果您必须在没有对称 MAC(例如,RSA 加密加上一些签名)的情况下进行签名和加密,则应对明文消息进行签名,并对明文和签名进行加密(先签名后加密)。尽管这意味着攻击者可以试图篡改密文,但像 OAEP 这样的好的 RSA 填充方案应该会使这变得非常困难。

虽然目前还没有已知的针对先签名后加密的攻击,但一些最偏执的人仍然先签名后加密,然后再签名。内部签名在明文上,证明作者身份,外部签名在密文上,确保消息的完整性。另一种选择是所谓的“签密”因为 Python cryptography库不支持签密,所以我们在这里不花时间讨论它,但是好奇的人可以看看这篇关于它的文章: www.cs.bham.ac.uk/ ~ mdr/teaching/modules04/security/students/SS3/IntroductiontoSigncryption.pdf

现在,我们将坚持使用稍微不那么偏执的先签名后加密策略。但是,请记住,RSA 加密只能加密非常有限的字节数。当 OAEP 填充与 SHA-256 一起使用时,可以加密的最大明文只有 190 字节!如果您开始加密签名,可能就没有多少空间来做其他事情了。如果你的信息太长,你将不得不把它分成 190 字节的块进行加密。这就是我们在下一章将要看到的使用非对称和对称操作的更多原因。

练习 5.8。RSA 回归!

为 Alice、Bob 和 EATSA 创建一个加密和认证系统。该系统需要能够生成密钥对,并以不同的操作员名称将它们保存到磁盘。要发送一条消息,需要加载操作者的私钥和接收者的公钥。然后,要发送的消息由运营商的私钥签名。然后,发送者姓名、消息和签名的连接被加密。

为了接收消息,系统加载操作者的私钥并解密数据,提取发送者的名字、消息和签名。加载发送者的公钥来验证消息上的签名。

练习 5.9。MD5 返回!

在第二章中,我们讨论了一些破解 MD5 的方法。特别是,我们强调 MD5 仍然没有被破解(在实践中)来寻找原像(即,向后工作)。但是在寻找碰撞方面,它破的。这在涉及签名时非常重要,因为签名通常是通过数据的哈希而不是数据本身来计算的。

在本练习中,修改您的签名程序,使用 MD5 代替 SHA-256。找到两条具有相同 MD5 和的数据。您可以在或通过快速搜索互联网找到一些示例。一旦有了数据,验证这两个文件的哈希是否相同。现在,为这两个文件创建一个签名,并验证它们是否相同。

最后要提一件事。在某些情况下,您可能无法一次对所有数据进行签名。sign函数不像哈希函数那样有一个update方法。但是,它有一个 API 来提交预先哈希的数据。这允许您对需要单独签名的数据进行哈希处理。以下是摘自cryptography模块文档的一个示例:

>>> from cryptography.hazmat.primitives.asymmetric import utils
>>> chosen_hash = hashes.SHA256()
>>> hasher = hashes.Hash(chosen_hash, default_backend())
>>> hasher.update(b"data & ")
>>> hasher.update(b"more data")
>>> digest = hasher.finalize()
>>> sig = private_key.sign(
...     digest,
...     padding.PSS(
...         mgf=padding.MGF1(hashes.SHA256()),
...         salt_length=padding.PSS.MAX_LENGTH
...     ),
...     utils.Prehashed(chosen_hash)
... )

椭圆曲线:RSA 的替代方案

是时候告诉你不对称加密的真相了。到目前为止,我们告诉您的一切都是特定于 RSA 的,RSA 的很多工作实际上都是独一无二的。

当我们谈到非对称或公钥加密时,我们指的是涉及公钥和私钥对的任何加密操作。在第四章中,我们几乎专门研究了 RSA 加密,在这一章中,我们探讨了 RSA 签名。方便的是,RSA 签名也基于 RSA 加密(即对要签名的数据的哈希进行加密)。但是大多数其他非对称算法甚至根本不支持加密作为一种操作模式,并且不使用加密来生成签名。例如,其他非对称算法生成不涉及任何加密的签名或标签,并且在没有任何种类的可逆操作(例如解密)的情况下验证签名。

这就是我们在本书中试图通过具体提及“RSA 公钥”、“RSA 加密”和“RSA 非对称运算”来限定我们关于非对称加密的对话的原因之一。您不应该假设其他非对称算法提供相同的操作或以相同的方式执行它们。

为什么如此关注 RSA 加密?我们在这里这样做是因为几十年来 RSA 一直是不对称运算最流行的算法之一。它仍然随处可见,你很难不在某个地方碰到它。DSA(数字签名算法)是另一种非对称算法,但它只能用于签名,不能用于加密。出于教育和实践的目的,RSA 是一个很好的起点。

也就是说,RSA 正在慢慢被淘汰。人们发现它有许多弱点,其中一些我们已经探索过了。基于“椭圆曲线” 4 的加密技术已被用于签署数据和交换密钥。在本章中,我们将研究 ECDSA 的签名功能。在第六章中,我们将会看到一种叫做椭圆曲线 Diffie-Hellman (ECDH)的东西,它被用来创建和协商会话密钥。ECDH 的密钥协议为 RSA 加密所支持的密钥传输功能提供了一种替代方案(可以说是一种更好的替代方案)。

要使用椭圆曲线对数据进行签名,可以使用 ECDSA 算法。正如您必须为 RSA 选择参数(例如 e ,公共指数),您也必须在基于 EC 的操作中选择参数。其中最明显的是潜在曲线。同样,实际的数学在本书中没有讨论,所以我们可以满足于说不同的椭圆曲线可以用于这些算法中。

对于 ECDSA,cryptography库提供了许多 NIST 批准的曲线。需要注意的是,一些密码学家对这些曲线很警惕,因为有可能美国政府推荐了它知道可以被破解的曲线。也就是说,这些是该库目前提供的唯一曲线。如果您在生产中使用这些,您应该留意关于安全漏洞和潜在替代品的其他信息。

对于这个测试,我们将使用 NIST 的 P-384 曲线,在库中称为 SECP384r1。来自cryptography文档

>>> from cryptography.hazmat.backends import default_backend
>>> from cryptography.hazmat.primitives import hashes
>>> from cryptography.hazmat.primitives.asymmetric import ec
>>> private_key = ec.generate_private_key(
...     ec.SECP384R1(), default_backend()
... )
>>> data = b"this is some data I'd like to sign"
>>> signature = private_key.sign(
...     data,
...     ec.ECDSA(hashes.SHA256())
... )
>>> public_key = private_key.public_key()
>>> public_key.verify(signature, data, ec.ECDSA(hashes.SHA256()))

与 RSA 签名一样,您必须选择一个哈希函数。我们再次选择了 SHA-256。你会注意到,尽管选择一个曲线函数看起来令人畏惧,但一旦完成,剩下的操作就非常简单了。

ECDSA 也有与 RSA 相同的预哈希 API,用于处理大量数据。

证书:证明公钥的所有权

在我们关于 Alice 和 Bob 以及公钥的例子中,我们假设每个相关方都拥有其他相关方的公钥。在我们的场景中,这个可能是可能的。总部可以把所有的间谍聚集在一起,让每个人交换公钥。 5

然而,随着时间的推移,这可能不可行。

如果诺埃尔,一个新的间谍,在其他人之后进入这个领域会怎么样?假设特工查理被抓了,诺埃尔被派去接替他的位置。爱丽丝和鲍勃已经有了查理的钥匙,但是他们还没有诺尔的钥匙。

当然,Noel 不能就这样出现并分发公钥。否则,Eve 可能会派假特工来分发公钥,声称自己是真正的 EA 特工。她可以像 HQ 一样轻松地创建证书。爱丽丝和鲍勃怎样才能认出诺埃尔是一个真正的 EATSA 特工,而不是为伊芙工作?

一种可能是让 HQ 向 Alice 和 Bob 发送一条消息,其中包含新代理的名称和公钥。Alice 和 Bob 已经信任 HQ,并且已经有了 HQ 的公钥。HQ 可以在他们和 Noel 之间充当可信的第三方。在 PKI 的早期,这正是为了建立信任而提出的。这个模型被称为“注册表”注册中心将是身份到公钥映射的中央存储库。注册中心自己的公钥将被传播到任何地方:报纸、杂志、教科书、实体邮件等等。只要每个人都有注册中心密钥的真实副本,他们就可以查找在注册中心注册的任何人的公钥。

当时的问题是规模,尽管现在这个问题不那么严重了。尽管当代计算设想世界上的谷歌、亚马逊和微软每时每刻都在处理来自世界各地的数十亿次连接,但在 20 世纪 90 年代并非如此。人们认为,网上登记处根本无法扩展。

就我们的间谍而言,他们必须假设他们可能与总部失去了联系。他们可能不得不深入隐藏,或者他们可能正在躲避 Eve,或者 EA 想要暂时否认他们的任何活动。由于任何或所有这些原因,他们可能无法从总部得到及时的消息。如果他们在躲避夏娃,如果他们能知道在安全屋遇到他们的间谍是否站在他们一边,那就太好了。

这就把我们带到了证书。公钥证书只是数据;它通常包括一个公钥、与密钥所有权相关的元数据,以及一个已知“发行者”对所有内容的签名。元数据包括诸如所有者身份、发行者身份、截止日期、序列号等信息。其概念是将元数据(尤其是身份的元数据)绑定到公钥。身份可以是姓名、电子邮件地址、URL 或任何其他约定的标识符。

总部现在可以分发证书,而不是简单地将公钥分发给他们的代理。 6 首先,代理生成自己的密钥对(任何人,甚至是 HQ,都不应该拥有代理的私钥)。接下来,HQ 获取代理的公钥,并开始创建一个证书,其中包括代理的标识信息,例如他们的代码名称。 7 为了完成证书,HQ 用 HQ 私钥对其进行签名,成为发行方。

重复一遍,证书中的公钥属于代理。代理保持他们自己的私钥是私有的。 8 如图 5-3 所示,证书上的签名是由颁发者的私钥(本例中为 HQ 的私钥)生成的。

img/472260_1_En_5_Fig3_HTML.jpg

图 5-3

证书的主要目的是将身份和公钥绑定在一起。发行者可以签署证书数据以防止修改并提供信任。

让我们回到我们的场景,爱丽丝在南极洲西部逃亡,夏娃的特工紧追不舍。她来到一个安全屋,看到了一个她从未见过的特工:查理。为了证明他就是他所说的那个人,查理出示了他的证明。Alice 检查身份数据是否与他的声明匹配(例如,证书中的身份是“charlie”)。接下来,Alice 检查证书的颁发者是 HQ,然后验证证书中包含的签名。记住,证书中的签名是由发布者 (HQ)签署的。使用 HQ 在她执行任务前发给她的公钥,Alice 的签名检查成功。因此,Alice 知道证书一定是由 HQ 颁发的,因为没有其他人能够生成有效的签名。该证书是真实的,并且 Alice 现在拥有(并且信任)Charlie 的公钥用于将来的通信。

当然,还有一个问题。查理的证!没有什么能阻止伊芙拿一份拷贝给爱丽丝本人。爱丽丝怎么知道门口那个手里拿着证件自称查理的人真的是查理?

查理现在必须通过为爱丽丝签署一些数据来证明他的身份。爱丽丝给了他某种测试信息,查理用他的私钥签名。Alice 使用来自其证书的公钥来验证该数据上的签名。签名检查通过,因此 Alice 知道 Charlie 一定是证书的所有者。只有主人有(或者应该有!)与签名数据所需的公钥相关联的私钥。当然,如果查理被抓获,他的私人密钥泄露,所有的赌注都将关闭!

总之,Charlie 用他的私钥签名以证明这是他的证书,但是 Alice 检查证书中的签名以确保证书本身是由她信任的人签发的。Alice 对这一过程的观点如图 5-4 所示。

img/472260_1_En_5_Fig4_HTML.jpg

图 5-4

谁在敲门?爱丽丝想知道在她让谁进来之前是谁!

让我们通过一些例子来看看这是如何工作的。对于第一个练习,我们不打算使用真实的证书,至少现在还没有。现在,我们将使用一个简单的字典作为我们的证书数据结构,并使用 Python json 模块将其转换为字节。

警告:不用于生产

天啊,我们经常说“不用于生产用途”,不是吗?我们不得不这么做。密码学是独特的,同时也是微妙而诱人的:概念描述起来相对简单,但微小的细节可以决定良好的安全性和不安全性。这些细节有时很难发现,证明它们是正确的也很难。

不要在产品中使用本书中的任何非库实现,也不要假设我们使用库是一个合适的解决方案。不要假设一个例子已经教会了你足够的知识来开发你自己的密码,也不要假设你已经掌握了库的正确用法。甚至不要认为我们的出错清单是完整的!

记住,YANAC(你不是一个密码学家...还没!).我们会再说一遍。这是我们的工作。

我们要研究的例子有三方:声明身份的一方(Charlie),也称为主体,验证声明的一方(Alice),以及发布证书的可信第三方(HQ)。其中两方,Charlie 和 HQ,将需要 RSA 密钥对。您可以生成 RSA 密钥对,并使用第四章中的rsa_simple.py脚本将它们保存到磁盘。在本练习的其余部分,我们将假设 HQ 的密钥保存在hq_public.keyhq_private.key中,Charlie 的密钥保存在charlie_public.keycharlie_private.key中。

此外,为了清楚起见,我们为每一方创建了三个单独的脚本。发布者 (HQ)使用第一个脚本从现有的公钥生成证书。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.asymmetric import rsa
 3   from cryptography.hazmat.primitives.asymmetric import padding
 4   from cryptography.hazmat.primitives import hashes
 5   from cryptography.hazmat.primitives import serialization
 6
 7   import sys, json
 8
 9   ISSUER_NAME = "fake_cert_authority1"
10
11   SUBJECT_KEY = "subject"
12   ISSUER_KEY = "issuer"
13   PUBLICKEY_KEY = "public_key"
14
15   def create_fake_certificate(pem_public_key, subject, issuer_private_key):
16       certificate_data = {}
17       certificate_data[SUBJECT_KEY] = subject
18       certificate_data[ISSUER_KEY] = ISSUER_NAME
19       certificate_data[PUBLICKEY_KEY] = pem_public_key.decode('utf-8')
20       raw_bytes = json.dumps(certificate_data).encode('utf-8')
21       signature = issuer_private_key.sign(
22           raw_bytes,
23           padding.PSS(
24               mgf=padding.MGF1(hashes.SHA256()),
25               salt_length=padding.PSS.MAX_LENGTH
26           ),
27           hashes.SHA256()
28       )
29       return raw_bytes + signature
30
31   if __name__=="__main__":
32       issuer_private_key_file = sys.argv[1]
33       certificate_subject = sys.argv[2]
34       certificate_subject_public_key_file = sys.argv[3]

35       certificate_output_file = sys.argv[4]
36
37       with open(issuer_private_key_file, "rb") as private_key_file_object:
38           issuer_private_key = serialization.load_pem_private_key(
39                            private_key_file_object.read(),
40                            backend=default_backend(),
41                            password=None)
42
43       with open(certificate_subject_public_key_file, "rb") as public_key_file_object:
44           certificate_subject_public_key_bytes = public_key_file_object.read()
45
46       certificate_bytes = create_fake_certificate(certificate_subject_public_key_bytes,
47                                                   certificate_subject,
48                                                   issuer_private_key)
49
50       with open(certificate_output_file, "wb") as certificate_file_object:
51           certificate_file_object.write(certificate_bytes)

Listing 5-6
Fake Certificate Issuer

让我们浏览一下清单 5-6 。只有一个功能:create_fake_certificate。我们使用“假”这个名称不是为了表明欺诈,而是表明这不是一个真正的证书。同样,请不要在生产中使用它。 9

该函数创建一个字典并加载三个字段:主题名称(身份)、发布者名称和公钥。请注意,该文件中使用了两个密钥对(的一部分)。有一个发行者私钥和主体公钥。证书中存储的是主体的私钥。这个公钥在许多方面代表了主体,因为它将被用来证明他或她的身份。这就是证书签名如此重要的原因。另外,任何人都可以创建一个证书来宣称他们喜欢的任何身份。

一旦加载了字典,我们使用json将字典序列化为一个字符串。JSON 是一种常见的标准格式,但是在 Python 3.x 中,它不能直接对字节进行编码,而是输出一个文本字符串。为了与 Python cryptography库兼容,我们将 PEM 编码的键作为二进制字节而不是文本来加载。要存储在这个 JSON 证书中的公钥必须首先转换成一个字符串,但是因为它是 PEM 编码的(也就是说,它已经是明文),所以我们可以安全地将其转换成 UTF-8。类似地,json.dumps()操作的整个输出通过安全的 UTF-8 转换被转换成字节。

然后使用发布者的私钥对字节进行签名。只有颁发者可以访问这个私钥,因为这是颁发者向世界证明它(颁发者)已经创建了证书的方式。我们的最终证书是来自 json 的原始字节和来自签名的字节。

在我们假设的例子中,charlie 想要声明身份“Charlie”Charlie 从生成密钥对开始。公钥(而不是私钥)被发送到 HQ 证书颁发部门,并请求制作证书。发行部门中的人应该验证查理有权声明身份“查理”例如,负责的官员可能会要求查看查理的机构 ID,审查上级官员的文书工作,检查指纹等等,以确保真正的查理将获得证书。

颁发者脚本接受四个参数:颁发者私钥文件、将放入证书的声明身份、与身份相关联的公钥以及证书的输出文件名。使用您在本练习中生成的密钥,运行如下所示的脚本:

python fake_certs_issuer.py \
  hq_private.key \
  charlie \
  charlie_public.key \
  charlie.cert

这将为 Charlie 生成一个(伪造的)证书,其中包含声明的身份和相关的公钥,所有这些都由 HQ 签名。

现在查理可以向爱丽丝证明他拥有“查理”这个身份。他首先给她声称的身份(“查理”)并提供证书。

这里的第二个脚本是让 Alice 验证 Charlie 声称的身份。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.asymmetric import rsa
 3   from cryptography.hazmat.primitives.asymmetric import padding
 4   from cryptography.hazmat.primitives import hashes
 5   from cryptography.hazmat.primitives import serialization
 6
 7   import sys, json, os
 8
 9   ISSUER_NAME = "fake_cert_authority1"
10
11   SUBJECT_KEY = "subject"
12   ISSUER_KEY = "issuer"
13   PUBLICKEY_KEY = "public_key"
14
15   def validate_certificate(certificate_bytes, issuer_public_key):
16       raw_cert_bytes, signature = certificate_bytes[:-256], certificate_bytes [-256:]
17
18       issuer_public_key.verify(
19           signature,
20           raw_cert_bytes,
21           padding.PSS(
22               mgf=padding.MGF1(hashes.SHA256()),
23               salt_length=padding.PSS.MAX_LENGTH
24           ),
25           hashes.SHA256())
26       cert_data = json.loads(raw_cert_bytes.decode('utf-8'))
27       cert_data[PUBLICKEY_KEY] = cert_data[PUBLICKEY_KEY].encode('utf-8')
28       return cert_data
29
30   def verify_identity(identity, certificate_data, challenge, response):

31       if certificate_data[ISSUER_KEY] != ISSUER_NAME:
32           raise Exception("Invalid (untrusted) Issuer!")
33
34       if certificate_data[SUBJECT_KEY] != identity:
35           raise Exception("Claimed identity does not match")
36
37       certificate_public_key = serialization.load_pem_public_key(
38           certificate_data[PUBLICKEY_KEY],
39           backend=default_backend())
40
41       certificate_public_key.verify(
42           response,
43           challenge,
44           padding.PSS(
45               mgf=padding.MGF1(hashes.SHA256()),
46               salt_length=padding.PSS.MAX_LENGTH
47           ),
48           hashes.SHA256())
49
50   if __name__ == "__main__":
51       claimed_identity = sys.argv[1]
52       cert_file = sys.argv[2]
53       issuer_public_key_file = sys.argv[3]
54
55       with open(issuer_public_key_file, "rb") as public_key_file_object:
56           issuer_public_key = serialization.load_pem_public_key(
57                            public_key_file_object.read(),
58                               backend=default_backend())
59
60       with open(cert_file, "rb") as cert_file_object:
61           certificate_bytes = cert_file_object.read()
62
63       cert_data = validate_certificate(certificate_bytes, issuer_public_key)
64
65       print("Certificate has a valid signature from {}".format(ISSUER_NAME))
66
67       challenge_file = input("Enter a name for a challenge file: ")
68       print("Generating challenge to file {}".format(challenge_file))
69
70       challenge_bytes = os.urandom(32)
71       with open(challenge_file, "wb+") as challenge_file_object:
72           challenge_file_object.write(challenge_bytes)
73
74       response_file = input("Enter the name of the response file: ")
75
76       with open (response_file, "rb") as response_object:
77           response_bytes = response_object.read()
78
79       verify_identity(
80           claimed_identity,
81           cert_data,
82           challenge_bytes,
83           response_bytes)
84       print("Identity validated")

Listing 5-7Verify Identity in a Fake Certificate

清单 5-7 需要三个参数:声明的一方身份、出示的证书和发行者的公钥。

对所声称的身份的验证必须分两部分进行。首先,它加载证书以查看它是否由 HQ 的公钥签名。这由verify_certificate功能执行。请记住,如果签名检查失败,签名验证函数会引发异常。您会注意到,为了获得签名,脚本只需要证书的最后 256 个字节。因为签名连接在末尾,并且因为我们总是使用 2048 位密钥的 RSA 签名,所以签名总是 256 字节。

如果签名通过验证,我们使用json模块将其他字节加载到字典中(再次将 JSON 操作的字节转换为字符串,然后将公钥数据的字节转换为字符串)。

爱丽丝运行脚本:

python fake_certs_verify_identity.py \
  charlie \
  charlie.cert \
  hq_public.key

此时,Alice 的脚本已经给了她一些信息,但是它还在等待更多的输入。在这个过程的这个阶段,Alice 现在知道什么?她知道她得到了一份由 HQ 签名的真实证书。接下来会发生什么?她还不知道出示证书的人是否真的是查理。为此,她需要测试他或她是否有私钥。

她生成一条随机消息,并将其保存到文件charlie.challenge中,她将要求自称是查理的人用他的私钥签名。脚本正在等待这个随机消息,所以 Alice 提供了她刚刚创建的文件的名称,charlie.challenge

尽管 Alice 没有完成,我们现在需要切换到 Charlie 的操作。让爱丽丝的剧本一直运行到我们回来。Charlie 将使用另一个脚本和他的私钥来回答 Alice 的挑战。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.asymmetric import rsa
 3   from cryptography.hazmat.primitives.asymmetric import padding
 4   from cryptography.hazmat.primitives import hashes
 5   from cryptography.hazmat.primitives import serialization
 6
 7   import sys
 8
 9   def prove_identity(private_key, challenge):
10       signature = private_key.sign(
11           challenge,
12           padding.PSS(
13               mgf = padding.MGF1(hashes.SHA256()),
14               salt_length = padding.PSS.MAX_LENGTH
15           ),
16           hashes.SHA256()
17       )
18       return signature
19
20   if __name__ == "__main__":
21       private_key_file = sys.argv[1]
22       challenge_file = sys.argv[2]
23       response_file = sys.argv[3]
24
25       with open(private_key_file, "rb") as private_key_file_object:
26           private_key = serialization.load_pem_private_key(
27                            private_key_file_object.read(),
28                            backend=default_backend(),
29                            password=None)
30
31       with open(challenge_file, "rb") as challenge_file_object:
32           challenge_bytes = challenge_file_object.read()
33
34       signed_challenge_bytes = prove_identity(
35           private_key,
36           challenge_bytes)
37
38       with open(response_file, "wb") as response_object:
39           response_object.write(signed_challenge_bytes)

Listing 5-8Prove Identity on a Fake Certificate

清单 5-8 中 Charlie 的脚本很简单。它接受三个参数:证书主体的私钥、质询文件名和将用于存储响应的响应文件名。只需获取挑战字节并用私钥对它们进行签名,就可以生成响应。如下所示运行该脚本(在与 Alice 不同的终端中):

python fake_certs_prove_identity.py \
  charlie_private.key \
  charlie.challenge \
  charlie.response

Charlie 因此回答了 Alice 的挑战,并将响应放入文件charlie.response。现在我们终于可以完成 Alice 的脚本了,它正在等待响应文件名。输入由 Charlie ( charlie.response)生成的文件名以继续。

Alice 的脚本加载响应并验证它。为此,Alice 的脚本现在移到了verify_identity函数。它首先检查证书中的名称是否与所声明的身份(例如“charlie”)相匹配,以及颁发者是否为 HQ。接下来,它从证书中加载公钥,并验证挑战字节上的签名是否有效。

这向 Alice 证明了不仅 Charlie 出示的证书是有效的,而且 Charlie 是主体(所有者)。声称是查理的人必须有相关的私钥,否则他将不能回答她的挑战。

练习 5.10。检测假查理

使用前面的脚本进行实验,检查出试图欺骗 Alice 时出现的各种错误。创建一个假的颁发者并用这个私钥签署证书。让拿错私钥的人出示查理的证书。确保理解代码中执行的所有不同的检查。

虽然我们的证书是“假的”,但它们旨在教授证书概念背后的基本原则。真正的证书通常使用称为 X.509 的格式。我们将在第八章中详细讨论 X.509。

证书和信任

你可能会问自己的一个问题是,我们为什么要给发行者命名?毕竟,如果 Alice、Bob 和所有其他代理总是信任 HQ,那么为什么要求在证书中列出颁发者的名字呢?

在我们假设的南极洲陷入内战的世界中,可能有许多证书的发行者。例如,除了间谍单位之外的其他机构可能要出具证明。EA 军方开始发证怎么办?EA 教育部开始发证怎么办?爱丽丝和鲍勃也应该相信这些吗?也许他们会想相信军官证而不是学历证?

在证书术语中,我们也称发行者为“认证机构”(CA),证书验证者必须决定他们将信任哪个认证机构。事实上,ca 也有它们自己的证书,包括它们的身份名称和它们的公钥。因此,证书的发布者字段应该与 CA 证书中的主体相同。

如果 CA 有证书,谁给签那个?有一个概念叫做“中间”CA。中级 CA 的证书由“更高级”的 CA 签署。在 EA 政府中,可能会有一个顶级 CA 来签署国防、教育、间谍等所有其他 CA。这将创建一个层次证书链,其中最高级别的证书称为“根”证书。

谁签署这个最终的根 CA?

答案是:本身。这个 CA 的证书被称为自签名证书。请注意,任何人都可以生成自签名证书,因此在决定信任哪个自签名根证书时必须非常小心。基本上,他们和他们签署的所有证书一起成为公认可信的

虽然这看起来有点复杂,但它确实让事情变得更容易管理。整个 EA 政府可能只有一个顶级 CA。所有雇员、代理甚至公民只需要拥有最顶层的根 CA 证书。所有其他身份都可以在一个链中进行验证。例如,Charlie 可能持有三个证书:他的个人证书、为其签名的间谍 CA 的中间证书以及根 EA 证书本身。Charlie 可以将这三个证书呈现给任何其他 EA 员工,并让他或她验证链的根。

当有多个根时,事情会变得稍微复杂一些(并引入潜在的安全风险)。例如,也许 EA 政府没有一个单一的顶级根。毕竟,你真的希望你的间谍命令由一个可以追溯到政府的 CA 签署吗?假设 EA 政府有两个根基:一个是“公开”运作的部门和组织,另一个是秘密运作的团体和个人。

查理和其他特工应该信任这两个根源吗?

练习 5.11。我们在生活中锻造的锁链

修改身份验证程序以支持信任链。首先,为 EA 政府创建一些自签名证书(至少两个,如前所述)。现有的 issuer 脚本已经可以做到这一点。只需使自签名证书的颁发者私钥成为组织自己的私钥。因此,该组织正在签署其自己的证书,并且用于签署证书的私钥与证书中的公钥匹配。

接下来,为中级 ca 创建证书,如“教育部”、“国防部”、“间谍机构”等等。这些证书应该由上一步中的自签名证书进行签名。

最后,由间谍 CA 为 Alice、Bob 和 Charlie 签署证书。也许可以为国防部和教育部门的员工制作一些证书。这些证书应该由适当的中间 CA 签名。

现在修改验证程序,以获取一系列证书,而不仅仅是一个证书。去掉颁发者公钥的命令行参数,代之以硬编码哪些根证书文件名是可信的。要指定证书链,让程序将声明的身份作为第一个输入(已经这样做了),然后是任意数量的证书。每个证书的颁发者字段应该指示证书链中的下一个证书。例如,为了验证查理,可能有三个证书:charlie.certespionage.certcovert_root.certcharlie.cert的发行者应该与espionage.cert拥有相同的主题名称,以此类推。如果证书链中的最后一个证书已经被信任,验证程序应该只接受一个身份。

证书对于现代密码学和计算机安全非常重要。在第八章中,我们将介绍 real X.509 证书,并讨论 real CAs 如何运行以及其他问题和解决方案,作为学习 TLS 的一部分。

撤销和私钥保护

证书及其包含的公钥非常强大。同时,它们也有一个非常危险的致命弱点。如果相关的私钥泄露了,如何禁用它们呢?

我们这里说的是一个叫做“撤销”的概念撤销证书就是撤销发行人的背书。HQ 可能已经向 Charlie 颁发了证书,但是如果 Charlie 被抓获并且丢失了他的私钥,HQ 需要找到一种方法来告诉所有其他代理不要再信任该证书。

不幸的是,这并不容易做到。如果你还记得,CAs 而不是在线注册的出现的原因之一是对离线验证的渴望。离线验证过程如何提供实时撤销数据?

简单的回答是,“不能。”只有两个选择。要么验证过程必须具有实时部分,要么撤销不能实时更新。目前,这两种选项都可以用于在线证书状态协议(OCSP)和证书撤销列表(CRL)形式的证书,前者可以实时检查证书的状态,后者是不定期发布的带有已撤销证书的列表。我们将在第八章更详细地回顾这两者。

由于撤销证书的难度,私有密钥必须得到最大限度的保护。当不需要实时签名时,私钥应该脱机保存在安全的环境中。如果必须实时使用证书,并且必须将其存储在服务器上,则应该以必要的最低权限存储证书,并且在严格保密的基础上可读。对于最终用户密钥,例如用于电子邮件和其他应用的密钥,存储在磁盘上的私钥应该通过具有强密码的对称加密来充分保护。理想情况下,避免将私钥存储在台式机和服务器上(尤其是在连续备份的现代时代),而是将私钥存储在硬件安全模块中。

保留相对较短到期日期的证书并在必要时轮换它们可能不是一个坏主意。

重放攻击

在讨论消息完整性之前,还有最后一个安全问题需要解决。它同样适用于 MAC 和签名。问题是重放攻击

当先前通信中的合法消息在以后不再有效时被攻击者使用,就会发生重放攻击。

让我们考虑下面的信息:“我们在黎明时进攻!”

我们可以保护此消息不被修改,并用 MAC 或签名来验证发送者。但是什么能阻止 Eve 截取这条信息并在不同的日子发送出去呢?也许她会选择在 EA 正在而不是策划攻击的那一天发送它?Eve 可能无法更改消息内容;也许她甚至不能阅读它们,但这并不能阻止她随时重新发送(重放)信息。

出于这个原因,几乎所有加密保护的消息通常都需要某种独特的组件来将它们与所有其他消息区分开来。这段数据通常被称为随机数。在许多情况下,随机数可以是一个随机数。如果你快速回头看一下第三章,你会发现传递给 AES 计数器模式的 IV 值被称为 nonce。随机数,尤其是随机数随机数,也用于防止消息相同,如果这样做会引入安全漏洞的话。

然而,为了防止重放攻击,简单地使用随机数是不行的。为了检测重放,接收器必须跟踪已经使用的随机数,并在第二次看到它们时拒绝它们。

这可能会有很大的问题。应该保留多大的随机数列表?一百?一千?过了一段时间后,你会从列表中删除一个随机数吗?如果你这么做了,并且攻击者知道了,攻击者现在可以在重放中使用它。例如,如果攻击者知道您只跟踪最近 5 分钟内收到的随机数,那么攻击者可以重放从 6 分钟前开始的内容,并获得一定的成功。

一些系统使用时间戳而不是随机随机数。使用时间戳,接收者可以拒绝太旧的数据。这种方法的问题是所有的计算机都必须有同步的时钟才能可靠地工作。另外,带有“旧”时间戳的数据必须在某个窗口内被接受。毕竟,信息不会瞬间到达。你允许多大的窗户?不管它有多大,坏人都会想出办法用它来对付你。

将两种方法结合在一起是可能的。你可以发送带有时间戳和随机数的数据。时间戳用于删除真正旧的数据,随机数用于防止在允许的时间窗口内重放。这意味着时钟只需要相对接近(甚至可能在 24 小时内),并且要存储的随机数列表是有界的。

现在您已经看到了需要考虑在消息中发送的两个元数据:防止重放的 nonce 和/或时间戳以及发送者/接收者名称。通常,您应该将所有相关的上下文放入消息中,这样就不能在上下文之外使用它。

练习 5.12。山姆,再放一遍!

使用 MAC 或签名从 Alice 向 Bob 发送消息,反之亦然。在消息中包含一个 nonce,以防止使用本节中描述的所有三种机制进行重放。发一些 Eve 的回放,试着绕过 Alice 和 Bob 的防守。

总结-然后-MAC

新的一章,新的信息来源!在本章中,我们介绍了消息认证码,它是通过一系列数据计算得到的键控代码。没有密钥,就不可能不被察觉地更改数据。此外,当两方共享一个 MAC 密钥时,他们可以确定(除非共享密钥已经泄露)如果其中一方接收到正确的消息,它来自另一方。

使用非对称操作,可以使用私钥在一段数据上创建签名(通常在数据的哈希上)。与 MAC 操作不同,MAC 操作只能确保共享密钥的个人的正确性和真实性,理论上,任何人(信任它的人)都可以使用广泛分发的公钥来验证数据上的签名。

我们还提供了基本证书操作的快速概述。

而现在我们的总结已经完成,这里是 HMAC-SHA256(以十六进制表示)对前面三段(即来自“另一章……”通过”...证书操作。”)使用我们两次引用的 XKCD 密码:

c4d60c7336911cd0a23132f11ae1ca8ba392a05ae357c81bc995876693886b9e

现在你有办法知道在我们提交给他们之后,我们的编辑是否对摘要做了任何修改或变更!

******

六、组合不对称和对称算法

在这一章中,我们将花时间来熟悉非对称加密通常是如何使用的,它是通信隐私的一个关键部分,但不负责全部。通常,非对称加密,也称为“公钥加密”,用于在双方之间建立可信的会话,会话内的通信受到更快的对称方法的保护。

*让我们来看一个简短的例子和一些代码吧!

与 RSA 交换 AES 密钥

有了更新的加密技术,爱丽丝和鲍勃在他们的秘密行动中变得更加厚颜无耻。爱丽丝已经设法渗透到西南极洲的雪穴记录中心,并试图窃取一份与基因实验有关的文件,以将企鹅完全变成白色,从而创造出一个完美伪装的南极士兵。西澳大利亚士兵正迅速向她的位置移动,她决定冒险通过短波无线电将文件传送给在大楼外监视她的鲍勃。伊芙当然在听,爱丽丝不想让她知道哪份文件被偷了。

文件将近十兆。整个文档的 RSA 加密将花费永远。幸运的是,她和鲍勃事先同意使用 RSA 加密来发送 AES 会话密钥,然后使用 AES-CTR 与 HMAC 一起传输文档。让我们创建他们将用来使这个字母汤工作的代码。

首先,让我们假设 Alice 和 Bob 已经有了彼此的证书和公钥。鲍勃不能冒险通过发射暴露他的位置;他将只限于监视信道,而爱丽丝只能希望消息被接收到。商定的传输协议是传输单个字节流,所有数据连接在一起。传输流包括

  • 一个 AES 加密密钥 IV 和一个用 Bob 的公钥加密的 MAC 密钥

  • Alice 在 AES 密钥、IV 和 MAC 密钥的哈希上的签名

  • 被盗文件字节,在加密密钥下加密

  • 在 MAC 密钥下的整个传输上的 HMAC

正如我们之前所做的,让我们创建一个类来管理这个传输过程。清单 6-1 中的代码片段展示了操作的关键部分。

 1   import os
 2   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 3   from cryptography.hazmat.primitives import hashes, hmac
 4   from cryptography.hazmat.backends import default_backend
 5   from cryptography.hazmat.primitives.asymmetric import padding, rsa
 6
 7   # WARNING: This code is NOT secure. DO NOT USE!
 8   class TransmissionManager:
 9       def __init__(self, send_private_key, recv_public_key):
10           self.send_private_key = send_private_key
11           self.recv_public_key = recv_public_key
12           self.ekey = os.urandom(32)
13           self.mkey = os.urandom(32)
14           self.iv = os.urandom(16)
15
16           self.encryptor = Cipher(
17                algorithms.AES(self.ekey),
18                modes.CTR(self.iv),
19                backend=default_backend()).encryptor()
20           self.mac = hmac.HMAC(
21                self.mkey,
22                hashes.SHA256(),
23                backend=default_backend())

24
25       def initialize(self):
26           data = self.ekey + self.iv + self.mkey
27           h = hashes.Hash(hashes.SHA256(), backend=default_backend())
28           h.update(data)
29           data_digest = h.finalize()
30           signature = self.send_private_key.sign(
31               data_digest,
32               padding.PSS(
33                   mgf=padding.MGF1(hashes.SHA256()),
34                   salt_length=padding.PSS.MAX_LENGTH),
35               hashes.SHA256())
36           ciphertext = self.recv_public_key.encrypt(
37               data,
38               padding.OAEP(
39                   mgf=padding.MGF1(algorithm=hashes.SHA256()),
40                   algorithm=hashes.SHA256(),
41                   label=None)) # rarely used.Just leave it 'None'
42           ciphertext = data+signature
43           self.mac.update(ciphertext)
44           return ciphertext
45
46       def update(self, plaintext):
47           ciphertext = self.encryptor.update(plaintext)
48           self.mac.update(ciphertext)
49           return ciphertext
50
51       def finalize(self):
52           return self.mac.finalize()

Listing 6-1
RSA Key Exchange

希望这里的所有部分都是熟悉的,如果您遵循代码路径,也应该很容易看到这些东西是如何组合在一起的。也许你已经注意到了,我们借鉴了第 3 、第 4 和第五章的概念!所有这些部分将会结合在一起,形成一个更先进的整体。

有几点值得注意。首先,我们选择使用 AES-CTR,所以不需要填充。在本书的前面,我们使用了术语“nonce”来描述算法的初始化值,因为这是cryptography库对它的称呼。然而,在其他文献中它仍然被称为 IV,所以我们在这里使用这个术语。无论哪种方式,IV(或 nonce)都是计数器的初始值。

注意,我们并没有像在第五章中讨论的那样使用先签名后加密。一如既往,这是一个示例程序,并不意味着用于真正的安全。您可能想回顾一下我们讨论的与先签名后加密相关的问题,看看 Eve 是如何去掉签名、更改密钥并重新签名的。

然而,这不是我们将要讨论的主要漏洞。毕竟,在我们的场景中,Bob 可能只接受来自 Alice 的数据。当一个以上的签名可以被接受时,交换签名的问题更加适用。

像你到目前为止看到的大多数 API 一样,我们使用了updatefinalize,但是我们添加了一个叫做initialize的新方法。为了传输,Alice 将首先调用initialize来获得带有会话密钥的签名和加密的头。接下来,她会根据需要多次给update打电话,让整个文档通过。当一切完成后,她会调用finalize来获得所有传输内容的 HMAC 预告片。

练习 6.1。鲍勃的接收器

通过创建一个ReceiverManager实现该变送器的反向操作。确切的 API 可能略有不同,但您可能至少需要一个updatefinalize方法。您需要使用 Bob 的私钥解包密钥和 IV,并使用 Alice 的公钥验证签名。然后,您将解密数据,直到用完为止,最后验证所有接收到的数据的 HMAC。

请记住,传输的最后字节是 HMAC 尾部,而不是要由 AES 解密的数据。但是当调用update时,您可能还不知道这些是否是最后的字节!仔细想清楚!

不对称和对称:像巧克力和花生酱

希望在本章开始的练习中,Alice 给 Bob 的传输让您对非对称加密和对称加密如何协同工作有所了解。我们在代码中概述的协议是可行的,但是缺少一些重要的微妙之处,这是我们第一次尝试时经常遇到的情况。正如你现在所期望的,前面的代码是不安全的,我们将很快演示它的至少一个问题。不过,它确实说明了将这两个系统放在一起的想法。

让我们看看我们能从现有的东西中学到什么。我们将从会话密钥开始。

我们首先在第四章中介绍了术语会话密钥,但是并没有过多地讨论它。会话密钥本质上是临时的;它被用于一个单独的通信会话,然后被永久丢弃,永远不会被再次使用。在上述代码中,请注意 AES 和 MAC 密钥是由通信管理器在会话开始时生成的。每次创建新的通信管理器时,都会创建一组新的密钥。密钥不会存储或记录在任何地方。一旦所有的数据都被加密,它们就被扔掉了。?? 1

在接收端,使用接收者的私钥解密会话密钥。一旦这些密钥被解密,它们就被用来解密其余的数据和处理 MAC。同样,在传输的数据被处理后,密钥可以也应该被销毁。

由于多种原因,对称密钥是很好的会话密钥。首先,对称密钥很容易创建;在我们的例子中,我们简单地生成了随机字节。我们也可以通过使用密钥导出函数从一个基本秘密中导出对称密钥。这是一种常见的方法,我们将在后面看到,对于典型的安全通信,您几乎总是需要派生出多个密钥。不管它们是如何创建的,对称密钥(和 iv)都是普通的旧字节,不像大多数非对称密钥需要一些额外的结构(例如,公共指数、选择的椭圆曲线等)。).

第二,对称密钥是很好的会话密钥,因为对称算法。我们已经提到过一两次了,但还是值得重复一下。AES 通常比 RSA 快几百倍,因此 AES 可以加密的数据越多越好。这是对称密钥有时被称为“批量数据传输”密钥的另一个原因。

最后,让我们也认识到对称密钥是好的会话密钥,因为它们而不是总是好的长期密钥!记住,对称密钥不能是私有密钥,因为它们必须总是在至少两方之间共享。共享密钥使用的时间越长,各方之间信任破裂的风险就越高,并且该密钥不应再被共享。在爱丽丝闯入雪穴档案馆的情况下,她冒着被抓住和泄露她随身携带的任何钥匙的风险。正如我们在讨论证书撤销时所讨论的那样,丢失她的非对称私钥是非常严重的,但是如果 Alice 和 Bob 在他们的所有通信中都使用相同的共享对称密钥,那么丢失该密钥的情况会更糟,因为他们之间使用该密钥加密的任何被截获的通信现在都可以被解密。

另一方面,非对称密钥对于长期识别非常有用。使用证书,非对称密钥可以建立一种身份证明;一旦这样做了,短期密钥就在认证方之间实际传输数据。也就是说,有时短暂的非对称密钥非常有价值。我们将从具有“前向保密”属性的密钥交换以及勒索软件攻击者锁定受害者文件的方式中看到这一点。

衡量 RSA 的相对性能

尽管我们已经强调了 RSA 比 AES 慢多少,但还是让我们找点乐子,做几个实验吧。我们将编写一个测试器,它将为加密和解密生成随机测试向量。然后我们可以自己对比一下 RSA 和 AES 的性能。

在这个演练中,我们将从较小的部分创建一个更复杂的文件。清单 6-2 显示了整个脚本的导入。你可以从这个开始作为一个框架,并建立/复制其他部分。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Encrypt ion Speed Test Component
 4   from cryptography.hazmat.backends import default_backend
 5   from cryptography.hazmat.primitives.asymmetric import rsa
 6   from cryptography.hazmat.primitives import serialization
 7   from cryptography.hazmat.primitives import hashes
 8   from cryptography.hazmat.primitives.asymmetric import padding
 9   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
10   import time, os

Listing 6-2Imports for Encryption Speed Test

让我们从创建一些算法来测试开始。我们将为每个算法定义一个类,该类的实例将构建加密和解密对象。构建器将是独立的,提供所有的键和必要的配置。每个都有一个带有可读标签的name属性和一个get_cipher_pair()方法来创建一个新的加密器和解密器。每次调用此方法时,都必须生成新的加密和解密对象。

AES 非常简单,因为cryptography库已经提供了大部分机制,如清单 6-3 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Encryption Speed Test Component
 4   class AESCTRAlgorithm:
 5       def __init__(self):
 6           self.name = "AES-CTR"
 7
 8       def get_cipher_pair(self):
 9           key = os.urandom(32)
10           nonce = os.urandom(16)
11           aes_context = Cipher(
12               algorithms.AES(key),
13               modes.CTR(nonce),
14               backend=default_backend())
15           return aes_context.encryptor(), aes_context.decryptor()

Listing 6-3AES Library Use

get_cipher_pair()操作每次被调用时都会创建新的密钥和随机数。我们可以将它放在构造函数中,因为我们并不在乎是否在这些速度测试中重用键,但是为键和 nonce 重新生成几个字节可能并不是速度的限制因素。

RSA 加密稍微复杂一点。它真的不打算加密任意数量的数据。与 AES 不同,AES 使用计数器和 CBC 模式将数据块绑定在一起,RSA 必须一次加密所有数据,并且它可以处理的数据大小受到各种因素的限制。模数为 2048 位的 RSA 密钥一次不能加密超过 256 个字节。事实上,一旦您添加了 OAEP(带有 SHA-256 哈希)填充,它就少得多:只有 190 字节! 2

如果我们真的关心加密的安全性,我们就不能对超过 190 字节的数据使用 RSA。然而,我们在这里真正测试的是一个在现实世界中不存在的假设的 RSA 加密器。我们想探索的是:如果 RSA 可以加密任意数量的数据,需要多长时间?对于这个测试,我们将一次加密一个 190 字节的数据块,并将结果连接在一起。注意,当我们用 OAEP 填充加密时,190 字节的明文变成了 256 字节的密文。当我们解密时,我们需要解密 256 字节的块。

虽然真正安全的 RSA 加密算法必须将不同的单独加密操作的字节绑定在一起,但这个版本是有史以来最快的版本,所以它给了我们速度的上限,这是一个有趣的比较。

记住这一点,我们可以构建我们的 RSA 加密和解密算法,如清单 6-4 所示。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Encryption Speed Test Component
 4   class RSAEncryptor:
 5       def __init__(self, public_key, max_encrypt_size):
 6           self._public_key = public_key
 7           self._max_encrypt_size = max_encrypt_size
 8
 9       def update(self, plaintext):
10           ciphertext = b""
11           for offset in range(0, len(plaintext), self._max_encrypt_size):
12               ciphertext += self._public_key.encrypt(
13                   plaintext[offset:offset+self._max_encrypt_size],
14                   padding.OAEP(
15                       mgf=padding.MGF1(algorithm=hashes.SHA256()),
16                       algorithm=hashes.SHA256(),
17                       label=None))
18           return ciphertext
19
20       def finalize(self):
21           return b""
22
23   class RSADecryptor:
24       def __init__(self, private_key, max_decrypt_size):
25           self._private_key = private_key
26           self._max_decrypt_size = max_decrypt_size
27
28       def update(self, ciphertext): 

29           plaintext = b""
30           for offset in range(0, len(ciphertext), self._max_decrypt_size):
31               plaintext += self._private_key.decrypt(
32                   ciphertext[offset:offset+self._max_decrypt_size],
33                   padding.OAEP(
34                       mgf=padding.MGF1(algorithm=hashes.SHA256()),
35                       algorithm=hashes.SHA256(),
36                       label=None))
37           return plaintext
38
39       def finalize(self):
40           return b""
41
42   class RSAAlgorithm:
43       def __init__(self):
44           self.name = "RSA Encryption"
45
46       def get_cipher_pair(self):
47           rsa_private_key = rsa.generate_private_key(
48             public_exponent=65537,
49             key_size=2048,
50             backend=default_backend())

51           max_plaintext_size = 190 # largest for 2048 key and OAEP
52           max_ciphertext_size = 256
53           rsa_public_key = rsa_private_key.public_key()
54           return (RSAEncryptor(rsa_public_key, max_plaintext_size),
55                   RSADecryptor(rsa_private_key, max_ciphertext_size))

Listing 6-4
RSA Implementation

注意,我们创建的加密器和解密器具有与 AES 加密器和解密器相同的 API。也就是说,我们提供了updatefinalize方法。finalize 方法不做任何事情,因为 RSA 加密(带填充)以完全相同的方式处理每个块。逐块加密获取每个 190 字节的输入片段,将其加密为 256 字节的密文,然后返回所有这些片段的串联。解密器反转这个过程,接收每个 256 字节的块进行解密。我们的RSAAlgorithm类使用这些类构造适当的加密器和解密器。

既然我们有几个算法要测试,我们需要创建一个机制来生成明文并跟踪加密和解密时间。为此,我们在清单 6-5 中创建了一个类,它随机生成明文,并接收每个生成的密文块的通知。当测试为随后的解密测试阶段调用密文时,它完全按照接收到密文的方式重放这些密文块。根据加密密文和解密明文的通知,它还可以跟踪整个操作需要多长时间。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Encryption Speed Test Component
 4   class random_data_generator:
 5       def __init__(self, max_size, chunk_size):
 6           self._max_size = max_size
 7           self._chunk_size = chunk_size
 8
 9           # plaintexts will be generated,
10           # ciphertexts recorded
11           self._ciphertexts = []
12
13           self._encryption_times = [0, 0]
14           self._decryption_times = [0,0]
15
16       def plaintexts(self):
17           self._encryption_times[0] = time.time()
18           for i in range(0, self._max_size, self._chunk_size):
19               yield os.urandom(self._chunk_size)
20
21       def ciphertexts(self):
22           self._decryption_times[0] = time.time()
23           for ciphertext in self._ciphertexts:
24               yield ciphertext
25
26       def record_ciphertext(self, c):
27           self._ciphertexts.append(c)
28           self._encryption_times [1] = time.time()
29
30       def record_recovertext(self, r):
31           # don't store, just record time
32           self._decryption_times[1] = time.time()
33
34       def encryption_time(self):
35           return self._encryption_times [1] - self._encryption_times [0]
36
37       def decryption_time(self):
38           return self._decryption_times [1] - self._decryption_times [0] 

Listing 6-5
Random Text Generation

注意,新的random_data_generator包含特定于每个单独测试运行的时间和数据。所以需要为每个测试创建一个新的对象。

现在,有了算法和数据生成器,我们可以像清单 6-6 一样,编写一个相当通用的测试函数。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Encryption Speed Test Component
 4   def test_encryption(algorithm, test_data):
 5       encryptor, decryptor = algorithm.get_cipher_pair()
 6
 7       # run encryption tests
 8       # might be slower than decryption because generates data
 9       for plaintext in test_data.plaintexts():
10           ciphertext = encryptor.update(plaintext)
11           test_data.record_ciphertext(ciphertext)
12       last_ciphertext = encryptor.finalize()
13       test_data.record_ciphertext(last_ciphertext)
14
15       # run decryption tests
16       # decrypt the data already encrypted
17       for ciphertext in test_data.ciphertexts():
18           recovertext = decryptor.update(ciphertext)
19           test_data.record_recovertext(recovertext)
20       last_recovertext = decryptor.finalize()
21       test_data.record_recovertext(last_recovertext)

Listing 6-6
Encryption Tester

使用这些构建块,我们可以在各种块大小上测试这些加密算法,看看速度是根据它们处理的数据量而提高还是降低。例如,清单 6-7 是对 100MB 数据进行的 AES-CTR 和 RSA 测试,数据块大小从 1 KiB 到 1 兆字节不等。

 1   # Encryption Speed Test Component
 2   test_algorithms = [RSAAlgorithm(), AESCTRAlgorithm()]
 3
 4   data_size = 100 * 1024 * 1024 # 100 MiB
 5   chunk_sizes = [1*1024, 4*1024, 16*1024, 1024*1024]
 6   stats = { algorithm.name : {} for algorithm in test_algorithms }
 7   for chunk_size in chunk_sizes:
 8       for algorithm in test_algorithms:
 9           test_data = random_data_generator(data_size, chunk_size)
10           test_encryption(algorithm, test_data)
11           stats[algorithm.name][chunk_size] = (
12               test_data.encryption_time(),
13               test_data.decryption_time())

Listing 6-7
Algorithm Tester

stats字典用于保存各种测试中各种算法的加密和解密时间。这些可以用来生成一些有趣的图形。例如,图 6-1 和图 6-2 是我们运行的测试的加密和解密图。

img/472260_1_En_6_Fig2_HTML.jpg

图 6-2

RSA 解密速度与 AES-CTR 的比较

img/472260_1_En_6_Fig1_HTML.jpg

图 6-1

RSA 加密速度与 AES-CTR 的比较

如您所见,RSA 运算要慢得多,这甚至不能算是真正的比较。顺便说一句,如果你运行我们做的测试,超过 100 MiB 的 RSA 加密可能很慢(在我们的计算机上大约 20 秒),但解密是如此糟糕,它只是超出了图表(我们的测试大约 400 秒!).RSA 解密比 RSA 加密慢,所以这并不奇怪。当您有运行这么长时间的测试时,请确保以原始的数字格式保存统计数据,然后根据这些数据生成图表。这样,您就可以快速轻松地重新生成图形,而无需再次运行整个测试。

练习 6.2。RSA 赛车!

使用您之前的测试器比较 RSA 与 1024 位模数、2048 位模数和 4096 位模数的性能。请注意,对于使用 OAEP(和 SHA-256 哈希)的 1024 位 RSA 密钥,您需要将块大小更改为 62 字节,对于使用 OAEP(和 SHA-256 哈希)的 4096 位 RSA 密钥,您需要将块大小更改为 446 字节。

练习 6.3。柜台对连锁店!

使用您的测试仪比较 AES-CTR 和 AES-CBC 的性能。

练习 6.4。MAC 与签名

修改您的算法,对finalize方法中的数据签署或应用 MAC。尝试禁用加密(让更新方法返回未修改的明文),这样您就可以只比较 MAC 和签名的速度。差别一样极端吗?你能想到为什么会这样吗?

练习 6.5。ECDSA 与 RSA 签名

除了测试 MAC 和 RSA 签名的速度,还要比较 RSA 签名和 ECDSA 签名的速度。很难进行公平的比较,因为 ECDSA 的密钥大小并不总是显而易见的,但是请查看cryptography库文档中支持的曲线列表,并尝试一下,看看哪些曲线总体上更快,以及它们与使用不同模数大小的 RSA 签名相比如何。

希望这些定时测试有助于强化为什么撇开安全原因不谈,对称密码比非对称密码更适合批量数据传输。

Diffie-Hellman 和密钥协商

在本章的最后几节,我们将研究另一种非对称加密技术,称为 Diffie-Hellman(或 DH)和一种更新的变种,称为椭圆曲线 Diffie-Hellman(或 ECDH)。

DH 和 RSA 有点不一样。RSA 可用于加密和解密消息,而 DH 仅用于交换密钥。事实上,它在技术上被称为 Diffie-Hellman 密钥交换。正如我们在本章中已经探讨过的,除了签名之外,RSA 加密主要仅用于传输密钥,也称为“密钥传输”这意味着如果 Alice 有 Bob 的 RSA 公钥,Alice 可以向 Bob 发送一个只有 Bob 可以解密的加密密钥。

图 6-3 显示了 TLS 1.2 握手中的密钥传输。我们将在第八章中更详细地讨论 TLS 1.2 握手,这个数字也会出现。但是请注意,该图中的客户端可以生成一个随机的会话密钥,用服务器的公钥对其进行加密,然后将其“传输”回来。此过程还证明服务器拥有证书,因为只有服务器可以解密会话密钥并使用它进行通信。服务器不需要签名。3

另一方面,DH 和 ECDH 实际上似乎凭空创造了一把钥匙。双方之间不传输加密或其他形式的秘密。相反,它们交换公共参数,允许它们在两端同时计算相同的密钥。这个过程被称为密钥协商

首先,Diffie-Hellman 为每个参与者创建了一对数学数字,一个私人,一个公共。DH 和 ECDH 密钥协商协议要求爱丽丝和鲍勃都有密钥对。简单地说,Alice 和 Bob 互相共享他们的公钥。外部公钥和本地私钥——当组合在一起时——在两端创建一个共享的秘密。

图 6-4 描述了 A. J. Han Vinck 的课程“公钥密码学简介”[14]中的非数学解释。

img/472260_1_En_6_Fig3_HTML.jpg

图 6-3

使用 TLS 进行密钥传输的示例

请注意,与 RSA 不同,DH 和 ECDH 不允许传输任意数据。Alice 可以向 Bob 发送她选择的任何消息,用 Bob 的 RSA 公钥加密。然而,使用 DH 或 ECDH,两者所能做的就是对一些随机数据达成一致。他们不能选择信息内容。随机数据可以并且通常被用作对称密钥或者用于导出对称密钥。

除了不能交换任意内容之外,密钥交换还受到限制,因为它需要双向信息交换。在本章开始的场景中,Bob 因为害怕被发现而无法传输。如果事实如此,DH 和 ECDH 密钥交换将是不可能的,RSA 加密将是唯一的选择。这在几乎所有真实场景中都不是问题。在真实的互联网应用中,我们通常假设双方可以自由地相互通信。

用 Python 编写 DH 密钥交换很简单。清单 6-8 中的例子经过一些简化,直接取自cryptography模块的在线文档。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives import hashes
 3   from cryptography.hazmat.primitives.asymmetric import dh
 4   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 5   from cryptography.hazmat.backends import default_backend
 6
 7   # Generate some parameters. These can be reused.

Listing 6-8Diffie-Hellman Key Exchange

 8   parameters = dh.generate_parameters(generator=2, key_size=1024,
 9                                         backend=default_backend())
10
11   # Generate a private key for use in the exchange.
12   private_key = parameters.generate_private_key()

13
14   # In a real handshake the peer_public_key will be received from the
15   # other party. For this example we'll generate another private key and
16   # get a public key from that. Note that in a DH handshake both peers
17   # must agree on a common set of parameters.
18   peer_public_key = parameters.generate_private_key().public_key()
19   shared_key = private_key.exchange(peer_public_key)
20
21   # Perform key derivation.
22   derived_key = HKDF(
23        algorithm=hashes.SHA256(),
24        length=32,
25        salt=None,
26        info=b'handshake data',
27        backend=default_backend()
28   ).derive(shared_key)

img/472260_1_En_6_Fig4_HTML.jpg

图 6-4

迪菲-赫尔曼背后的直觉

与 RSA 不同,这里的陷阱和陷阱要少得多。

交换只有两个参数:生成器和密钥大小。生成器只有两个合法值,2 和 5。奇怪的是,对于加密协议来说,出于安全原因,生成器的选择并不重要,但对于交换双方来说必须是相同的。

然而,密钥大小很重要,应该至少为 2048 位。512 到 1024 位之间的密钥长度容易受到已知攻击方法的攻击。

警告:参数生成缓慢

Diffie-Hellman 被吹捧为可以快速生成密钥。然而,生成可以生成密钥的参数可能会非常慢。我们警告过您不要使用小于 2048 的密钥大小,然后在我们自己的代码示例中使用了 1024。我们想给你一些代码,不用花太多时间来演示基本操作。

那么如果参数生成这么慢,为什么我们说 DH 快呢?相同的参数可以生成许多键,因此成本可以分摊。因此,请确保不要在每次生成密钥时都重新生成参数,否则 DH 的运行速度会慢得令人无法接受。或者,使用更快的 ECDH。

img/472260_1_En_6_Fig5_HTML.jpg

图 6-5

使用 TLS 的密钥协商示例

另一个推荐的设置是从共享密钥派生另一个密钥,而不是直接使用共享密钥。密钥派生函数类似于我们在第三章中看到的函数。

TLS 1.2 握手可以使用 RSA 加密进行密钥传输,也可以使用 DH/ECDH 进行密钥协商。同样,这将在第八章中详细讨论,但图 6-5 显示双方交换公共数据,导出一个密钥,并可以使用约定的密钥进行通信。然而,与密钥传输不同的是,没有身份验证。任何一方或双方都必须对公共数据进行签名,以证明拥有公共密钥。

椭圆曲线 Diffie-Hellman(或 ECDH)是 DH 的一种变体,在现代使用中变得越来越流行。它以同样的方式工作,但使用椭圆曲线进行一些内部数学计算。使用 ECDH 的代码与清单 6-9 所示的cryptography模块中的 DH 几乎相同。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives import hashes
 3   from cryptography.hazmat.primitives.asymmetric import ec
 4   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 5
 6   # Generate a private key for use in the exchange.
 7   private_key = ec.generate_private_key(
 8       ec.SECP384R1(), default_backend()
 9   )
10   # In a real handshake the peer_public_key will be received from the
11   # other party. For this example we'll generate another private key
12   # and get a public key from that.
13   peer_public_key = ec.generate_private_key(
14       ec.SECP384R1(), default_backend()
15   ).public_key()
16   shared_key = private_key.exchange(ec.ECDH(), peer_public_key)
17
18   # Perform key derivation.
19   derived_key = HKDF(
20       algorithm=hashes.SHA256(),
21       length=32,
22       salt=None,
23       info=b'handshake data ',
24       backend=default_backend()
25   ).derive(shared_key)

Listing 6-9Elliptic-Curve DH

在大多数情况下,使用 DH 或 ECDH 密钥协议创建密钥优于 RSA 密钥交换。原因有很多,但也许最大的一个是前向保密

Diffie-Hellman 和前向保密

使用 RSA 加密,我们可以生成一个对称密钥,在某人的公钥下加密,然后发送给他们。假设交换协议遵循某些规则,这允许双方安全地共享会话密钥。甚至有办法让双方都贡献一把钥匙。每一方都可以向另一方发送一些随机数据,两者的连接可以被提供给哈希函数来产生会话密钥。

不幸的是,RSA 密钥传输并没有提供一个真正奇妙的特性,叫做前向保密的 ??。前向保密意味着即使一个密钥最终被泄露,它也不会泄露任何关于 以前的 通信的信息。

让我们回到爱丽丝、鲍勃和伊芙身上。Alice 和 Bob 已经假定 Eve 正在记录传输的所有内容。这就是为什么他们首先加密传输。因此,在传输完成后,Eve 有一段她还无法解密的密文记录。但是,伊夫没有把它扔在一边,而是把它存档在仓库里。

但是回想一下,在我们的场景中,爱丽丝实际上相信她正处于被抓住的边缘。如果警卫抓住了她,她的钥匙也泄露了,会有什么损失呢?幸好没事。记得 Alice 用 Bob 的公钥加密了会话密钥。捕获 Alice 不会使解密数据变得更容易(你看到这比共享密钥的优势了吗?).

但是假设夏娃找到了鲍勃,也许是在很久以后。即使是多年以后,如果伊芙设法得到了鲍勃的私人密钥,她就可以回到她对爱丽丝先前传输的录音并解密它!Bob 的私钥仍将解密会话密钥,然后 Eve 将能够解密整个传输。

前向保密比这个强多了。如果一个协议具有前向保密性,Eve 就不能从一个终止的会话中恢复数据,不管她获得了什么样的长期密钥。当会话密钥直接通过 RSA 加密(以我们刚刚描述的方式)发送时,前向保密是不可能的,因为一旦 RSA 私钥被泄露,来自先前会话的任何记录数据现在都是易受攻击的。

对于迪菲-赫尔曼(DH)和椭圆曲线迪菲-赫尔曼(ECDH),通过使用临时密钥来实现前向保密。RSA 产生一个短暂的对称会话密钥,但是 DH 和 ECDH 实际上也产生短暂的非对称密钥!新的临时密钥对是(或者应该是!)生成,然后被丢弃。对称密钥也是短暂的,并在每次会话后被丢弃。因为 DH 和 ECDH 通常以这种方式使用,所以在首字母缩略词(DHE 或 ECDHE)的末尾通常会加上一个“E”。 4

现在,每次交换都使用一个新的密钥对,泄露一个非对称密钥只会暴露一个对称密钥,从而暴露一个通信会话。当短暂的 DH 和 ECDH 私钥被正确处理后,就没有密钥留给伊芙去破解了,她也没有办法解密这些会话。在某些方面,这类似于古老的间谍比喻:吞下钥匙,这样间谍的克星就无法打开电影中的麦加芬。

注意,理论上,Alice 和 Bob 也可以进行短暂的 RSA 密钥交换。他们可以的每一次密钥传输生成新的 RSA 密钥对,在传输会话密钥之前互相发送他们的新公钥,然后在传输之后销毁密钥对。

问题是,用计算机术语来说,生成 RSA 密钥很慢。对于本书中的示例,您可能没有想到生成 RSA 密钥需要很长时间,但是对于涉及快速通信的计算机(例如建立从您的浏览器到网站的安全连接),RSA 慢得令人麻木。DH 和 ECDH 要快得多。由于密钥生成速度快,DH 和 ECDH 是前向保密通信的常见选择。

这种短暂的操作模式是 DH 和 ECDH 在几乎所有情况下的首选模式,这就是为什么 DH 和 ECDH 通常意味着 DHE 和 ECDHE。

练习 6.6。去比赛吧!

编写一个 python 程序来生成一千个左右的 2048 位 RSA 私钥,编写一个程序来生成一千个左右的 DH 和 ECDH 密钥。性能对比如何?

Diffie-Hellman 方法只有一个限制:它们没有认证。因为密钥完全是短暂的,所以没有办法将它们与身份联系起来;你不知道你在和谁说话。请记住,除了秘密交流,我们还需要知道我们在和谁秘密交流。卫生部和 ECDH 本身不提供任何此类保证。

因此,许多 DH 和 ECDH 密钥交换也需要长期公钥,如 RSA 或 ECDSA,并且该密钥通常在签名证书中受到保护。然而,这些长期密钥从不用于加密或密钥传输,也不以任何方式用于实际的密钥数据交换。它们的唯一目的是建立另一方的身份,通常是通过对正在交换的一些短暂的 DH/ECDH 数据进行签名,并通过某种挑战或随机数来确保新鲜性。

记住,为了确保前向保密性,每次密钥交换都必须重新生成 Diffie-Hellman 参数。如果你浏览一下cryptography库文档,你会注意到它们包含了样本代码,正如所写的那样,没有提供前向保密。此代码示例保存了应该是一次性使用的密钥,供以后使用。确保您的钥匙在使用后被销毁(从不记录)。

既然我们已经学习了非常高级的概念,让我们用经过认证的 ECDH 密钥交换代码来帮助 Alice 和 Bob。首先我们将为密钥交换创建一些代码(清单 6-10 ),然后我们将修改它以进行认证。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives import hashes, serialization
 3   from cryptography.hazmat.primitives.asymmetric import ec
 4   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 5
 6   class ECDHExchange:
 7       def __init__(self, curve):
 8           self._curve = curve
 9
10           # Generate an ephemeral private key for use in the exchange.
11           self._private_key = ec.generate_private_key(
12                curve, default_backend())
13
14           self.enc_key = None
15           self.mac_key = None
16
17       def get_public_bytes(self):
18           public_key = self._private_key.public_key()
19           raw_bytes = public_key.public_bytes(
20               encoding=serialization.Encoding.PEM,
21               format=serialization.PublicFormat.SubjectPublicKeyInfo)
22           return raw_bytes
23
24       def generate_session_key(self, peer_bytes):
25           peer_public_key = serialization.load_pem_public_key(
26               peer_bytes,
27               backend=default_backend())
28           shared_key = self._private_key.exchange(
29               ec. ECDH(),
30               peer_public_key)
31
32           # derive 64 bytes of key material for 2 32–byte keys
33           key_material = HKDF(
34               algorithm=hashes.SHA256(),
35               length=64,
36               salt=None,
37               info=None,
38               backend=default_backend()).derive(shared_key)
39
40           # get the encryption key
41           self.enc_key = key_material[:32]
42
43           # derive an MAC key
44           self.mac_key = key_material[32:64] 

Listing 6-10
Unauthenticated ECDH

要使用ECDHExchange,双方实例化该类并调用get_public_bytes方法来获取需要发送给另一方的数据。当接收到这些字节时,它们被传递给generate_session_key,在那里它们被反序列化成一个公钥,并用于创建一个共享密钥。

那么,HKDF是怎么回事?这是一个密钥派生函数,对实时网络通信很有用,但不应用于数据存储。它将共享密钥作为输入,并从中导出一个密钥(或密钥材料)。注意,在我们的例子中,我们得到了一个加密密钥和一个 MAC 密钥。这是通过使用 HKDF 导出 64 字节的密钥材料,然后将其拆分为两个 32 字节的密钥来实现的。实际上,我们需要获得更多的数据,我们将在下一节讨论这一点。但目前,它展示了 ECDH 交易所的基本情况。

再重复最后一次,注意 ECDH 正在动态生成私钥。每次交换密钥后,必须销毁该密钥以及创建的任何会话密钥。

练习 6.7。初级 ECDH 交易所

使用ECDHExchange类在双方之间创建共享密钥。您需要运行该程序的两个实例。每个程序都应该将它们的公钥字节写入磁盘,以便另一个程序加载。当他们完成时,让他们打印出共享密钥的字节,这样您就可以验证他们是否使用了相同的密钥。

练习 6.8。网络 ECDH 交易所

在接下来的章节中,我们将开始使用网络在两个对等体之间交换数据。如果您已经知道如何进行客户端-服务器编程,请修改前面的 ECDH exchange 程序,通过网络发送公共数据,而不是将其保存到磁盘。

到目前为止,我们的 ECDH 代码只进行 ECDH 短暂密钥交换。双方都有一个密钥,但是因为我们还没有进行任何认证,所以双方都不能确定他们在和谁说话!请记住,ECDH 密钥的短暂性质意味着它们不能用于建立身份。

为了补救这一点,我们将修改我们的ECDHExchange程序,使被认证。除了短暂的非对称密钥,它还将使用长期的非对称密钥对数据进行签名。

让我们修改我们的ECDHExchange类,并将其重命名为AuthenticatedECDHExchange,这是我们在清单 6-11 中做的。首先,我们需要修改构造函数,将一个长期(持久)私钥作为参数。这将用于签名。

 1   # Partial Listing: Some Assembly Required
 2
 3   from cryptography.hazmat.backends import default_backend
 4   from cryptography.hazmat.primitives import hashes, serialization
 5   from cryptography.hazmat.primitives.asymmetric import ec
 6   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 7   import struct # needed for get_signed_public_pytes
 8
 9   class AuthenticatedECDHExchange:
10       def __init__(self, curve, auth_private_key):
11           self._curve = curve
12           self._private_key = ec.generate_private_key(
13                self._curve,
14                default_backend())
15           self.enc_key = None
16           self.mac_key = None
17
18           self._auth_private_key = auth_private_key

Listing 6-11Authenticated ECHD

请注意_private_key_auth_private_key的区别,?? 是生成的,是短暂的。后者作为参数传入。这个持久密钥将用于建立身份。我们可以在这里使用一个 RSA 密钥,它会工作得很好,但是为了与椭圆曲线主题保持一致,我们将假设这是一个 ECDSA 密钥。

我们将使用清单 6-12 来生成签名的公共字节,而不是仅仅生成公共字节发送给另一端。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Part of AuthenticatedECDHExchange class
 4   def get_signed_public_bytes(self):
 5       public_key = self._private_key.public_key()
 6
 7       # Here are the raw bytes.
 8       raw_bytes = public_key.public_bytes(
 9           encoding=serialization.Encoding.PEM,
10           format=serialization.PublicFormat.SubjectPublicKeyInfo)
11
12       # This is a signature to prove who we are.
13       signature = self._auth_private_key.sign(
14           raw_bytes,
15           ec.ECDSA(hashes.SHA256()))

16
17       # Signature size is not fixed.Include a length field first.
18       return struct.pack("I", len(signature)) + raw_bytes + signature

Listing 6-12Authenticated ECDH Signed Public Bytes

当另一方收到我们的数据时,他们需要先解包前四个字节以获得签名的长度,然后再做其他事情。可以使用另一方的长期公钥来验证签名(就像我们对 RSA 所做的那样)。如果签名成功,我们有一些信心,我们收到的 ECDH 参数来自预期的一方。

练习 6.9。ECDH 留给读者的

我们没有展示验证在AuthenticatedECDHExchange类中接收的公共参数的代码。幸运的是,我们把它作为一个练习留给了读者!将generate_session_key方法更新为generate_authenticated_session_key。这个方法应该实现前面描述的算法,用于获取签名长度,使用公钥验证签名,然后导出会话密钥。

本节中的原则很重要。您可以考虑反复学习这一部分,直到您能够熟练地发送 RSA 下加密的密钥和使用 DH 或 ECDH 动态生成临时密钥。确保你也理解为什么 DH/ECDH 方法有前向保密性,而 RSA 版本没有。

练习 6.10。因为你喜欢折磨

为了强调 RSA 在技术上可以用作临时交换机制,请修改前面的 ECDH 程序来生成一组临时 RSA 密钥。交换相关的公钥,并使用每个公钥向另一方发送 32 字节的随机数据。将这两个 32 字节的传输与 XOR 结合起来,创建一个“共享密钥”,并通过 HKDF 运行它,就像 ECDH 的例子一样。一旦你向自己证明这是可行的,回顾你在练习 6.2 中的结果,看看为什么这太慢而不实际。

此外,使用 RSA 加密创建共享密钥需要一个创建密钥的往返过程(证书的传输和加密密钥的接收),而 DH 和 ECDH 只需要从一方到另一方的一次传输。例如,当我们学习 TLS 1.3 时,您将会看到这会如何极大地影响性能。

挑战应答协议

我们已经在第五章简要介绍了挑战响应协议。特别是,Alice 使用挑战-响应来验证自称是 charlie 的人是身份为“Charlie”的证书的所有者。在其核心,挑战-响应协议是关于一方向另一方证明他们当前控制共享秘密或私有密钥。让我们看看这两个例子。

首先,假设爱丽丝和鲍勃共享某个密钥KA,BT5。如果 Alice 通过网络与 Bob 通信,一个简单的认证协议是向 Bob 发送一个 nonce N (可能未加密)并要求他加密。出于安全原因,在响应中包含通信方的身份是一个好主意。相应的,Bob 应该用{ A,B,N } KA,B 回复。如果只有爱丽丝和鲍勃共享密钥 K A,B ,那么只有鲍勃可能正确响应了爱丽丝的挑战。即使伊夫无意中听到了这个挑战,并且知道了 ??,她没有密钥也应该无法加密。

对于非对称示例,它或多或少是相同的,但是使用由私钥生成的签名。这一次,Bob 正在通过网络与 Alice 通信,并希望确保他正在与真实的 Alice 通话。所以他发送了一个 nonce N 并要求她用她的公钥签名。与 Bob 的挑战一样,Alice 也应该发送她和 Bob 的姓名。因此,她的传输应该看起来像{ H ( B,A,N)}K1A(反正是 RSA 签名)。Bob 用 Alice 的公钥验证签名是否正确。只有私钥的拥有者才能签署该质询。

挑战-响应算法相对简单,但它们可能在许多方面出错。首先,随机数必须足够大,足够随机,即使知道以前的传输,也是不可猜测的。例如,在早期的汽车遥控钥匙中,发射器使用 16 位随机数。小偷只需要记录一次传输,然后一遍又一遍地询问系统,直到它循环通过所有可能的随机数,并返回到他们记录的那个随机数。在这一点上,他们可以重放随机数,并获得访问汽车。

另一种可能出错的方式是通过“(胡)中间人”(MITM)攻击。假设伊芙想让爱丽丝相信她是鲍勃。伊芙一直等到鲍勃想和爱丽丝通话,然后拦截了他们所有的通信。然后,她假装成鲍勃开始与爱丽丝交流。爱丽丝以一个挑战来回应,以证明和她说话的人(伊芙)是鲍勃。Eve 立即转身向 Bob 发出挑战,Bob 想和 Alice 说话,已经在等着了。Bob 愉快地在挑战上签名,并发还给 Eve,Eve 将挑战直接转发给 Alice。(作为一个有趣的,但可能是虚构的例子,罗斯·安德森描述了这种攻击的“米格战机在中间”的场景。3].)

解决 MITM 问题的一个方法是传递只有真正的政党才能使用的信息。例如,即使 Eve 转发 Bob 对挑战的响应,如果 Alice 的响应是向 Bob 发送用他的公钥加密的会话密钥,这对她也没有帮助。伊芙无法解密。如果所有后续通信都使用该会话密钥进行,Eve 仍然会被锁定。或者,爱丽丝和鲍勃可以使用 ECDH 加签名来生成会话密钥。即使 Eve 可以截获他们之间的每一次传输,Alice 和 Bob 也可以创建一个只有他们可以使用的会话密钥。伊芙最多能做的就是封锁通讯。

这里的要点是说明在认证你与之交谈的一方时需要考虑的各种不同的因素。

一旦建立了一方的身份,会话的所有后续通信都必须绑定到该认证。例如,Alice 和 Bob 可能使用挑战-响应来验证彼此,但是除非他们建立会话 MAC 密钥并使用它来摘要他们的所有后续通信,否则他们不能确定是谁在发送消息。

有时,初始化数据必须以明文形式发送,然后才能建立加密通信。所有这些数据也必须在某个时候结合在一起。建立会话密钥后,一种选择是使用新建立的安全通道发送到目前为止发送的所有未经身份验证的数据的哈希。如果哈希与预期的不匹配,那么通信方可以认为攻击者(如 Eve)已经修改了一些初始化数据。

综上所述,在结合非对称和对称密码时,不要只考虑保密部分(加密)。记住,知道你在和谁说话和知道你们之间的交流对其他人来说是不可读的一样重要,如果不是更重要的话。你可能不希望全世界都读到你的爱情诗,但你肯定不希望你的爱情表达被错误的人收到!请记住,在确定对方的身份后,您必须确保在剩余的会话中,所有剩余的通信都有一个真实性链。如果最初的身份是用签名证明的,而剩余的数据是由 MAC 认证的,那么在从一个数据链切换到另一个数据链时,要确保数据链没有中断。

常见问题

在了解了非对称密钥和对称密钥如何协同工作之后,您可能会想创建自己的协议。一定要忍住这种冲动。这些练习的目标是教你原理并阐明你的理解,但仅此还不足以让你为开发密码协议做好准备。密码学的历史充满了后来被发现可被利用的协议,尽管它们是由比你我更有经验的密码学家编写的。

让我们以 Alice 向 Bob 发送加密文档为例。你注意到我们打破了前几章的一个建议了吗?我们的数据没有随机数!这意味着 Bob 不知道来自 Alice 的消息是否是“新鲜的”如果这些数据是 Eve 一年前录制的,现在正在重播,会怎么样?

这是另一个例子。在我们推导加密密钥的过程中,我们只在双方之间生成了一个加密密钥。这只对单向通信是安全的!如果您想要全双工通信(双向发送数据的能力),您将需要每个方向的加密密钥!

但是等等。为什么我们不能使用相同的密钥将数据从 Alice 发送到 Bob,就像我们使用相同的密钥将数据从 Bob 发送到 Alice 一样?

你还记得你在第三章中学到了什么吗?你不能重复使用同一个密钥和 IV 来加密两个不同的消息!在全双工通信中,这正是你要做的。假设 AEC-CTR 模式用于批量数据传输。如果 Alice 使用一个密钥对她发送给 Bob 的消息进行加密,Bob 使用相同的密钥对发送给 Alice 的消息进行加密,那么两个数据流可以一起进行 XOR 运算,从而得到明文消息的 XOR 运算结果!正如我们所见,这是灾难性的。事实上,如果 Eve 可以欺骗 Alice 或 Bob 代表她加密数据(例如,通过植入“蜜罐”数据,这些数据肯定会被拾取和传输),她就可以将该数据异或运算,而将其他数据作为明文。

使用 RSA 加密的简单密钥交换也可以利用同样的原理。假设 Alice 发送给 Bob 一个用 Bob 的公钥加密的初始秘密 K 。爱丽丝和鲍勃正确地从 K 处获得了用于全双工通信的会话密钥和 iv。作为一个例子,鲍勃有一个密钥 K B,一个 用于向爱丽丝发送加密的消息,而爱丽丝有一个密钥 K A,B 用于向鲍勃发送加密的消息。(鲍勃使用 K A,B 解密爱丽丝的消息,爱丽丝使用 K B,A 加密鲍勃的消息。)

但是假设 Eve 记录了所有这些传输。然后,在很久以后的一天,她重新播放了第一次向 Bob 发送的 K 。鲍勃不知道这是一次重播,他使用 K 派生出 K B,A 。现在他开始向 Eve 发送用这个密钥加密的数据。

虽然伊芙确实没有KB、A并且不能直接解密鲍勃的信息,但是她确实有鲍勃在早期传输中用同一密钥发送给爱丽丝的信息。同样,假设 Alice 和 Bob 使用 AES-CTR,这两个传输流可以一起进行 xor 运算,以提取敏感信息。有很多方法可以解决这个问题(例如,通过重新引入挑战-响应),但也有很多方法可能会出错。

即使对于专家来说,也很难把一个密码协议的所有部分都弄对。一般来说,不要设计自己的协议。尽可能使用现有的协议,并在可行的情况下使用现有的实现。最重要的是,我们想再次提醒你,YANAC(你不是一个密码学家...还没有!).

练习 6.11。利用全双工密钥重用

在前面的练习中,您对一些数据进行了异或运算,看是否还能找到模式,但实际上并没有对两个密码流进行异或运算。想象一下,如果 Alice 和 Bob 使用您的 ECDH 交换机,并获得相同的密钥进行全双工通信。使用相同的密钥将一些文档一起加密,供 Alice 发送给 Bob,Bob 发送给 Alice。将密码流异或在一起,并验证结果是明文的异或。看看你能否从异或数据中找出任何模式。

练习 6.12。衍生出所有的片段

修改 ECDH 交换程序以获得六条信息:写加密密钥、写 IV、写 MAC 密钥、读解密密钥、读 IV 和读 MAC 密钥。困难的部分是让双方得到相同的密钥。请记住,密钥将以相同的顺序导出。那么 Alice 如何确定第一个派生的密钥是她的写密钥而不是 Bob 的写密钥呢?一种方法是将每一方的公钥字节的前 n 个字节作为一个整数,谁的数字最小,谁就“第一个”

不对称和对称和声的不幸例子

我们大多数的密码学例子在某种程度上都是有益的,或者至少不是本质上的邪恶。不幸的是,坏人可以像好人一样使用加密技术。考虑到他们可以从邪恶中赚很多钱,他们会非常积极地创造性地、有效地使用技术。

坏人非常擅长使用密码学的一个领域是勒索软件。如果你在过去十年里一直住在西南极洲的一个洞穴里,并且没有听说过勒索软件,那么它基本上是一种软件,可以加密你的文件,并拒绝解锁,直到你向背后的勒索者付款。

早期勒索软件背后的加密技术过于简单和幼稚。勒索软件使用不同的 AES 密钥加密每个文件,但所有 AES 密钥都存储在系统上的文件中。勒索软件的解密器可以很容易地找到密钥并解密文件,但安全研究人员也可以。如果你不想有人打开一个文件,把钥匙到处乱放(可以说是在门垫下面)是个坏主意。

勒索软件作者顺理成章地转向非对称加密作为解决方案。非对称加密最明显的优点是,公钥可能在受害者的系统上,而私钥可能在别的地方。出于你在本章看到的所有原因,文件本身不能直接用 RSA 加密。RSA 甚至没有能力加密大于 190 到 256 字节的数据,即使有,也太慢了。用户可能会注意到他们的系统在加密完成之前很久就被锁定了。

相反,勒索软件可以单独加密所有 AES 密钥。毕竟,AES-128 的密钥只有 16 个字节,AES-256 的密钥只有 32 个字节。在存储到受害者的系统之前,每个密钥都可以很容易地进行 RSA 加密。RSA 用公钥加密,所以只要受害者得不到私钥,他们就无法解密 AES 密钥。

这种方法有两种简单的变体,都是有问题的。第一种方法是提前生成密钥对,并将公钥硬编码到恶意软件本身中。恶意软件用公钥加密所有 AES 密钥后,受害者必须支付赎金才能将私钥发送给他们进行解密。这种设计的明显缺陷是,相同的私钥将解锁所有被勒索软件攻击的系统,因为恶意攻击文件的每个副本都有相同的公钥。

第二种方法是勒索软件在受害者的系统上生成 RSA 密钥对,并将私钥传输到命令和控制服务器。现在有一个唯一的公钥加密 AES 密钥,当攻击者释放私钥进行解密时,它只解锁特定受害者的文件。这里的问题是,系统必须在线才能摆脱私钥,许多网络监控系统会检测到向命令和控制服务器经常运行的危险 IP 的传输。传输私钥可能会在勒索软件开始加密系统上的文件之前泄露它。在系统完全锁定之前,在本地做任何事情都是比较隐蔽的。

现代勒索软件用一种非常聪明的方法解决了所有这些问题。首先,攻击者生成一个长期的非对称密钥对。出于我们的目的,让我们假设它是一个 RSA 密钥对,我们将这些密钥称为“永久”非对称密钥。

接下来,攻击者创建一些恶意软件,并将永久公钥硬编码到恶意软件中。当恶意软件在受害者的机器上激活时,它做的第一件事就是生成一个新的非对称密钥对。同样,为了简单起见,我们假设它是一个 RSA 密钥对。我们称之为“本地”密钥对。它会立即通过恶意软件中嵌入的攻击者的永久公钥对新生成的本地私钥进行加密。未加密的本地私钥被删除

现在,恶意软件开始使用 AES-CTR 或 AES-CBC 加密磁盘上的文件。每个文件用不同的密钥加密,然后每个密钥用本地公钥加密。一旦文件完成加密,密钥的未加密版本就被销毁。

当整个过程完成后,受害者的文件被 AES 密钥加密,而 AES 密钥本身又被本地 RSA 公钥加密。这些 AES 密钥可以由本地 RSA 私钥解密,但该密钥是在攻击者的永久公钥下加密的,私钥不在计算机上。

现在攻击者联系受害者并索要赎金。如果受害者同意并支付赎金(通常通过比特币的方式),攻击者就会向恶意软件提供某种验证码。恶意软件将加密的本地私钥传输给攻击者。使用他或她的永久私钥,他或她解密本地私钥并将其发送回受害者。现在所有的 AES 密钥都可以被解密,并且文件随后被解密。

这种算法的巧妙之处在于攻击者不会公开他或她的永久私钥。它仍然是私人的。攻击者为受害者解密二级私钥,用于解锁系统的其余部分。

警告:危险的运动

即将到来的演习有些冒险。除非您的虚拟机可以恢复到快照或监狱(例如 chroot 监狱),其中的文件可能会永久丢失,否则您不应该进行此练习。

此外,本练习让您创建一个简化版本的勒索软件。我们不宽恕也不鼓励任何形式的勒索软件的实际使用。别犯傻,别作恶。

练习 6.13。扮演恶棍

帮助 Alice 和 Bob 创建一些勒索软件来感染 WA 服务器。首先创建一个函数,使用您选择的算法(例如 AES-CTR 或 AES-CBC)加密磁盘上的文件。加密数据应该以某种随机名称保存到一个新文件中。在继续之前,测试加密和解密文件。

接下来,创建假的恶意软件。该恶意软件应该配置有目标目录和永久公钥。如果您愿意,可以将公钥直接硬编码到代码中。一旦启动并运行,它需要生成一个新的 RSA 密钥对,用永久公钥加密本地私钥,然后删除本地私钥的任何未加密副本。如果私钥太大(例如,超过 190 字节),则分块加密。

生成本地密钥对后,开始加密目标目录中的文件。作为额外的预防措施,您可以在加密每个文件之前请求手动批准,以确保您不会意外加密错误的内容。对于每个文件,用一个新的随机名称对其进行加密,并用文件的原始名称、加密密钥和 IV 存储一个明文元数据文件。如果您认为可以安全地删除原始文件(我们将而不是对您的任何错误负责!使用 VM,只在目标目录下对不重要文件的副本进行操作,并手动确认每次删除!).

剩下的应该很简单。您的“恶意软件”实用程序需要将加密的私钥保存到磁盘。这应该由能够访问永久私钥的单独的命令和控制实用程序来解密。一旦解密,它将被恶意软件加载并用于解密/释放文件。

虽然希望您不是恶意软件/勒索软件的作者,但这一部分也应该有助于您思考如何加密“静态数据”我们在本书中讨论的大部分内容是保护“移动中的数据”,即通过网络或以其他方式在双方之间传输的数据。勒索软件的例子说明了如何保护大部分数据,这些数据通常由同一方加密和解密。

对磁盘上的文件进行加密的实用程序必须处理糟糕的密钥管理问题,就像处理移动中的数据一样。一般来说,每个文件必须有一个密钥,就像每个网络通信会话必须有一个密钥一样;这防止了密钥重用。必须存储密钥(和 iv ),或者必须能够重新生成它们。如果存储它们,它们必须用某种主密钥加密,并与有关所用算法等的附加元数据一起存储。这些信息可以添加到加密文件的开头,也可以存储在清单中的某个位置。

如果稍后重新生成密钥,这通常是通过从密码中导出密钥来完成的,正如我们已经在第二章中讨论的那样。因为每个文件需要不同的密钥,所以在派生过程中使用随机的每个文件 salt 来确保密钥的唯一性。盐必须与文件一起存储,盐的丢失将导致文件丢失,并且永远无法解密。

这是保护静态数据背后的基本加密概念,但是生产系统通常要复杂得多。例如,NIST 要求兼容的系统有一个确定的密钥生命周期。这包括操作前、操作中、操作后和删除阶段,以及每个密钥的操作“加密”期。该时间段进一步细分为“始发者使用期”(OUP),用于敏感数据可以被生成和加密的时间,以及“接收者使用期”(RUP),用于该数据可以被解密和读取的时间。期望密钥管理系统处理密钥翻转(将加密数据从一个密钥迁移到另一个密钥)、密钥撤销以及许多其他类似的功能。

我们不会再拿亚那克来烦你了...但是在这本书的这一点上,我们希望你自己的潜意识已经开始为我们做了!

那是一个包裹

本章的主旨是,您可以在初始的非对称会话建立协议中包装一个临时的对称通信会话。世界上的许多非对称基础设施都侧重于各方的长期身份识别,并且该基础设施对于以某种方式并基于某种信任模型建立身份是有用的。但是一旦建立了信任,创建一个临时对称密钥(实际上是几个)来处理加密和标记数据会更安全、更有效。

例如,我们回顾过,您可以使用 RSA 加密将密钥从一方传输到另一方。这种方法是长期以来使用的主要方法。尽管它仍然存在于许多系统中,但由于许多原因它正在被淘汰。现在更受欢迎的是使用短暂的密钥协商协议,如 DH 和 ECDH(实际上,准确地说是 DHE 和 ECDHE)来创建具有完美前向保密性的会话密钥。

无论采用哪种方式,无论是通过密钥传输还是密钥协商,各方都可以获得通信所需的密钥套件。或者,一方可以获得加密硬盘上的数据所需的密钥。在这两种情况下,非对称操作主要用于建立身份和获得初始密钥,而对称操作用于数据的实际加密。

如果你能理解这些原理,你就能熟悉你能找到的大多数加密系统。

*

七、更对称的加密:认证加密和 Kerberos

在这一章中,我们将讨论一些高级的对称加密技术,我们将更深入地研究认证加密。

让我们深入一个例子和一些使用 AES-GCM 的代码。

AES-GCM

在过去的一个月里,爱丽丝和鲍勃与伊芙有过几次千钧一发的时刻。在那段时间里,他们一直在交换装有加密文件的 USB 驱动器。到目前为止,这种方法对他们很有效,但他们似乎很难记住一些关键的事情:他们应该先加密然后 MAC,MAC 需要覆盖未加密的数据,以及他们需要有两个独立的密钥。在压力下,他们的记忆力不佳,这是可以理解的,在经历了一些恼怒和千钧一发之后,他们让总部知道他们想要一些不太容易出错的东西。

碰巧的是,他们可以使用一些新的东西。新的对称操作模式称为“认证加密”(AE)和“带附加数据的认证加密”(AEAD)。这些新的操作模式为提供了数据的保密性和真实性。AEAD 还可以提供“额外数据”的真实性,这些“额外数据”是没有加密的。这比听起来要重要得多,所以我们实际上要把 AE 放在一边,只关注 AEAD。

在本练习中,我们将使用一种称为“伽罗瓦/计数器模式”(GCM)的 AES 模式。这个模式的 API 与我们之前看到的略有不同,所以让我们给 Alice 和 Bob 上一堂速成课。在清单 7-1 中,我们使用 AES-GCM 加密一个文档认证加密过程中使用的 IV 和 salt。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
 3   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 4   import os, sys, struct
 5
 6   READ_SIZE = 4096
 7
 8   def encrypt_file(plainpath, cipherpath, password):
 9       # Derive key with a random 16-byte salt
10       salt = os.urandom(16)
11       kdf = Scrypt(salt=salt, length=32,
12                   n=2**14, r=8, p=1,
13                   backend=default_backend())
14       key = kdf.derive(password)
15
16       # Generate a random 96-bit IV.
17       iv = os.urandom(12)
18
19       # Construct an AES-GCM Cipher object with the given key and IV.
20       encryptor = Cipher(
21           algorithms.AES(key),
22           modes.GCM(iv),
23           backend=default_backend()).encryptor()
24
25       associated_data = iv + salt
26
27       # associated_data will be authenticated but not encrypted,
28       # it must also be passed in on decryption.
29       encryptor.authenticate_additional_data(associated_data)
30
31       with open(cipherpath, "wb+") as fcipher:
32           # Make space for the header (12 + 16 + 16), overwritten last
33           fcipher.write(b"\x00"*(12+16+16))
34
35           # Encrypt and write the main body
36           with open(plainpath, "rb") as fplain:
37               for plaintext in iter(lambda: fplain.read(READ_SIZE), b''):
38                   ciphertext = encryptor.update(plaintext)
39                   fcipher.write(ciphertext)
40               ciphertext = encryptor.finalize() # Always b''.
41                   fcipher.write(ciphertext) # For clarity
42
43               header = associated_data + encryptor.tag

44               fcipher.seek(0,0)
45               fcipher.write(header)

Listing 7-1
AES-GCM

这个函数的大部分应该看起来很熟悉。因为我们将这些数据存储在磁盘上,所以我们使用了Scrypt而不是HKDF,并使用它从密码中生成一个密钥。如前一章所述,因为用户可能在多个文件中使用相同的密码,所以每个文件都需要自己的 salt 来生成每个文件的密钥。请记住,我们不希望在不同的文件上,甚至在同一个文件上使用相同的密钥和 IV(例如,如果我们加密,然后修改文件并再次加密)。为了格外谨慎,我们甚至不会使用同一个密钥。

与我们之前所做的类似,我们也创建了一个Cipher对象。但是我们不使用 CTR 或 CBC 模式,而是使用 GCM 模式。这种模式需要一个 IV,我们稍后会讨论为什么它是 12 字节,而不是我们过去看到的 16 字节。加密器上唯一的新方法是authenticate_additional_data。正如你可能猜到的,这种方法接收的数据将而不是被加密,但仍然需要被认证。

在这种情况下,我们要验证的未加密数据是 salt 和 IV。这个数据必须是明文,因为没有它我们无法解密。通过认证,我们可以确定——一旦解密完成——没有人篡改过这些未加密的值。

这个 GCM 操作的另一个独特部分是encryptor.tag。该值是在finalize方法之后计算的,或多或少是加密和附加数据的 MAC。在我们的实现中,我们选择将相关的数据(salt 和 IV)和标签放在文件的开头处。因为这些数据(至少是标签数据)在加密过程结束之前是不可用的,所以我们预先分配了几个字节(最初是零),当我们在过程结束时最终获得标签时,我们将覆盖这些字节。在某些操作系统中,没有办法预先添加数据,所以预先分配的前缀字节确保我们在完成时有空间放置头。

清单 7-2 中的函数不会删除或覆盖原始文件,所以使用它是相当安全的。使用它在您的系统上创建文件的加密副本。使用像 hexdump 这样的实用程序检查字节,以确保数据实际上是加密的。

警告:小心异常大小的文件

不要加密大于 64 GiB 的文件,因为 GCM 有一些限制,我们稍后会讨论。

现在,让我们编写一个decrypt_file函数,如清单 7-2 所示。

 1   from cryptography.hazmat.backends import default_backend
 2   from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
 3   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 4   import os, sys, struct
 5
 6   READ_SIZE = 4096
 7   def decrypt_file(cipherpath, plainpath, password):
 8       with open(cipherpath, "rb") as fcipher:
 9           # read the IV (12 bytes) and the salt (16 bytes)
10           associated_data = fcipher.read(12+16)
11
12           iv = associated_data[0:12]
13           salt = associated_data[12:28]
14
15           # derive the same key from the password + salt
16           kdf = Scrypt(salt=salt, length=32,
17                   n=2**14, r=8, p=1,
18                   backend=default_backend())
19           key = kdf.derive(password)
20
21           # get the tag. GCM tags are always 16 bytes
22           tag = fcipher.read(16)
23
24           # Construct an AES-GCM Cipher object with the given key and IV
25           # For decryption, the tag is passed in as a parameter
26           decryptor = Cipher(
27               algorithms.AES(key),
28               modes.GCM(iv, tag),
29               backend=default_backend()).decryptor()
30           decryptor.authenticate_additional_data(associated_data)
31
32           with open(plainpath, "wb+") as fplain:
33               for ciphertext in iter(lambda: fcipher.read(READ_SIZE),b''):
34                   plaintext = decryptor.update(ciphertext)
35                   fplain.write(plaintext)

Listing 7-2AES-GCM Decryption

这个解密操作首先从读出未加密的 salt、IV 和 tag 开始。salt 与密码一起用于导出密钥。密钥、IV 和标签是 GCM 解密过程的参数。相关数据(salt 和 IV)也使用authenticate_additional_data函数传递到解密器中。

当解密器的finalize方法被调用并且任何数据被更改时,无论是密文还是附加数据,该方法都会抛出一个无效标签异常。

此函数不会尝试重新创建原始文件名。因此,您可以安全地将加密文件恢复为新的文件名,然后将新恢复的文件与原始文件进行比较。

练习 7.1。标签!就是你了。

人为“破坏”加密文件的不同部分,包括实际的密文和 salt、IV 或标记。演示解密文件会引发异常。

AES-GCM 细节和细微差别

在我们的入门练习中,Alice 和 Bob 了解了 AES 的 GCM 操作模式。AES-GCM 是一种 AEAD(认证加密和相关数据)模式。关键细节的总结包括

  • 该模式使用一个密钥对数据进行加密和认证。

  • 加密和认证一体化;不需要担心什么时候做什么(例如,先加密后 MAC 与先 MAC 后加密)。

  • AEAD 包括对加密的数据进行认证。

您可能已经注意到,这些功能解决了 Alice 和 Bob 的顾虑。它极大地减少了误用和错误配置,使 Alice 和 Bob(以及您)更容易做对。

其中值得特别强调的一个要素是额外数据的认证。在密码学的历史上,攻击者从一个上下文中取出数据,在另一个上下文中滥用它的情况屡见不鲜。例如,重放攻击就是这类问题的典型例子。在许多情况下,如果强制敏感数据的上下文,这些攻击就会失败。

在我们的文件加密示例中,我们验证了 IV 和 salt 值,但是我们可以很容易地加入文件名和时间戳。加密文件的一个问题是识别该文件的较旧但正确加密的版本的重放。如果用文件认证了时间戳,或者包括了版本号或其他现时,则加密文件更紧密地绑定到可识别的上下文。

当你加密数据时,仔细考虑哪些数据需要是真实的,而不仅仅是 ?? 的私人数据。您对加密环境的识别和保护越好,您的系统就越安全。

在保护数据不被修改方面,需要注意的是 AEAD 算法在知道数据是否被修改之前对数据进行解密。在您对上述文件解密的实验中,您可能已经注意到,即使加密的文件被损坏,解密器仍然会创建一个解密的文件。GCM 抛出的异常是在所有内容都被解密并(在我们的实现中)写入恢复的文件之后抛出的。

总之,记住在标签被验证之前,解密的数据是不可信的

AEAD 很棒,但是组合操作引入了一个有趣的问题。你要等多久才能拿到标签?假设 Alice 和 Bob 使用 AES-GCM 通过网络发送数据,而不是解密文件。假设是大量的数据。假设完全传输数据需要几个小时。如果我们像加密文件一样加密这些数据,那么在整个传输完成之前,标签不会被发送。

你真的想等到最后几个小时才收到标签吗?

更糟糕的是,如何计算安全通道的“终点”?如果一个加密通道连续几天打开,发送任意数量的数据,在什么时候你决定停止,计算并发送标签?

在 TLS 这样的网络协议中,我们将在第八章中更全面地探讨,每个单独的 TLS 记录(或多或少是一个 TLS 包)都是用它自己的单独标签单独进行 GCM 加密的。这样,恶意或意外的修改几乎可以实时检测到,而不是在传输结束时检测到。一般来说,对于流,建议使用更小的 GCM 加密方法。

对于这种小型 AES-GCM 加密操作,cryptography库有一个更简单的用户界面。它有一个额外的好处,即除非标签正确,否则解密操作不会返回解密的数据,从而防止您意外使用坏数据。下面是来自cryptography库文档的一些示例代码,演示了它的用法:

>>> import os
>>> from cryptography.hazmat.primitives.ciphers.aead import AESGCM
>>> data = b"a secret message"
>>> aad = b"authenticated but unencrypted data"
>>> key = AESGCM.generate_key(bit_length=128)
>>> aesgcm = AESGCM(key)
>>> nonce = os.urandom(12)
>>> ct = aesgcm.encrypt(nonce, data, aad)
>>> aesgcm.decrypt(nonce, ct, aad)
b'a secret message'

这个 API 很容易使用,概念也不太难,但是它有一个重要的安全考虑:nonce。回想一下,GCM 中的“C”代表“计数器”。GCM 多少有点像 CTR,其中集成了一个标记操作。这很重要,因为我们之前讨论过的计数器模式的许多问题仍然存在。特别是,虽然你不应该在 AES 加密的任何模式下重用一个密钥和 IV 对,但是对于计数器模式(和 GCM)来说尤其是是不好的。这样做可以轻松地公开两个明文的 XOR 运算。GCM 的 IV/nonce 必须永不重用

为了说明这个问题,让我们简单回顾一下计数器模式是如何工作的。请记住,与 CBC 模式不同,AES 计数器模式实际上并不使用 AES 块加密来加密明文。相反,单调递增的计数器用 AES 加密,这个与明文进行异或运算。值得重复的是,AES 分组密码首先应用于计数器,然后应用于计数器+1,再应用于计数器+2,以此类推,生成完整的流。重用随机数导致重用

这很重要。但是,如果您不更加小心,您可能会遇到同样灾难性的稍微类似的问题。例如,假设您决定从 nonce 0(0 的 16 个字节)开始,而不是为计数器模式选择一个随机 IV。您使用 nonce (0)在一个密钥下加密一组数据(可能是一个文件),然后将 nonce 加 1 来初始化一个新的 AES 计数器上下文,以便在同一密钥下加密一组新的数据(例如另一个文件)。因此,你的随机数只不过是一个不断递增的计数器。

这样做的问题是——即使您认为您没有重用 nonce(每次都不一样)——计数器模式通过为每个块增加一个 nonce 来工作。第一个操作加密 0,然后 1,然后 2,依此类推;第二个操作加密 1,然后 2,然后 3,依此类推。换句话说,用第二随机数加密的第二文件在第一个 128 位块之后重复相同的密钥流。在后续流之间有非常大量的重叠

对于像我们在示例中使用的相对少量的数据,使用完全随机的 16 字节 IV 对于标准计数器模式可能就足够了。在生产代码中,您必须进行安全性分析,以确定在创建重叠的密码流之前,您平均有多长时间。这种计算取决于您计划在同一密钥下加密多少数据。如果您想要显式地控制您的 IVs,以确保不可能重叠一个键/计数器对,那么您可以遵循一些规则。

例如,GCM 要求一个 12 字节的 IV 来明确地解决这个问题(它确实允许更长的 IV,但是这引入了新的问题,超出了本书的范围)。然后用 4 个零字节填充选定的 12 字节随机数,以产生 16 字节计数器。即使选择的随机数只比前一个随机数多一个,只要不溢出 4 字节块计数器,计数器也不会重叠。128 位块上的 4 字节计数器意味着在溢出计数器之前,最多可以加密 2 36 字节(或 64 GiB)的数据,这就是为什么 64 GiB 的数据被指定为 GCM 加密的上限。

使用 12 字节的 IV 和每个 key/IV 对不超过 64 GiB 的明文意味着永远不会有任何重叠。出于超出本书范围的原因,对 GCM IVs 的唯一其他要求是它们不能为零。

让我们回到使用 AES-GCM 加密流中一堆较小消息的问题。我们如何避免重用一个 key/IV 对?我们可以尝试提出一种确定性的方法,在传输的每一端旋转密钥,但这太复杂且容易出错。我们可以做的是为每个单独的加密使用不同的 IV/nonce 值。在最坏的情况下,随机数可以随每个数据包一起发送。与密钥不同,随机数不必是秘密的,只需是可信的就可以了。

此外,我们可以使用某些 nonce 构造算法来帮助防止重用。限制密钥的随机性是不行的,因为密钥必须是秘密的,并且任何选择的比特都决定性地降低了发现该秘密的强力难度。只要 IV 不会被同一个密钥重用,减少 IV 中某些位的随机性是可以接受的。

例如,IV 的一些字节可能是设备特定的。这确保了两个不同的设备永远不会生成相同的随机数。可选地,或者附加地,IV 的一些字节可以通过推断,减少必须存储或传输的 IV 数据量。也许文件加密的 IV 的一部分取决于文件在磁盘上的存储位置。

现在,我们将继续生成随机的虚拟信息,并根据需要发送它们,但是理解一些生成和使用虚拟信息的不同方式是有好处的。

练习 7.2。矮胖的 GCM

修改本章前面的文档加密代码,以不超过 4096 字节的块进行加密。每次加密将使用相同的密钥,但不同的随机数。这一变化意味着,您将需要为每个加密块存储一个 IV 和一个标记,而不是在文件顶部存储一个 IV 和一个标记。

其他 AEAD 算法

除了 AES-GCM 模式,cryptography库还支持另外两种流行的 AEAD 算法。第一个是 AES-CCM。第二种被称为查查。

AES-CCM 与 AES-GCM 非常相似。像 GCM 一样,它使用计数器模式进行加密;然而,标签是由类似于 CBC-MAC 但也优于 CBC-MAC 的方法生成的。

AES-CCM 和 AES-GCM 之间的一个关键区别是 IV/nonce 可以是可变长度的:在 7 和 13 字节之间。IV/nonce 越小,密钥/IV 对可以加密的数据量就越大。像 GCM 一样,这个 nonce 只是完整的 16 字节计数器值的一部分。因此,nonce 使用的 16 个字节越少,计数器在溢出前可以使用的字节就越多。

由于超出本书范围的原因,nonce 被限制为 15- L 字节长,其中 L 是长度字段的大小:如果您的数据需要 2 个字节来存储长度,nonce 可以达到 13 个字节。另一方面,如果数据的大小需要 8 个字节来存储长度,则 nonce 被限制为 7 个字节。这两个值代表 CCM 模式支持的最小值和最大值。

假设您希望将 CCM 用于大量数据,只需选择一个 7 字节的 nonce,然后继续。该算法的安全性不会基于 nonce 的大小而改变,只要您不重用带有密钥的 nonce。

除了这个令人痛苦的 nonce 问题,CCM 与 GCM 没有其他 API 差异。然而,就性能而言,GCM 更容易并行化。这可能不会对您的 python 编程产生太大影响,但如果您想将图形卡用作加密加速器,这就有所不同了。

使用cryptography库时,不支持将 CCM 作为 AES 密码上下文的操作模式。只有独立的AESCCM对象可用。

>>> import os
>>> from cryptography.hazmat.primitives.ciphers.aead import AESCCM
>>> data = b"a secret message"
>>> aad = b"authenticated but unencrypted data"
>>> key = AESCCM.generate_key(bit_length=128)
>>> aesccm = AESCCM(key)
>>> nonce = os.urandom(7)
>>> ct = aesccm.encrypt(nonce, data, aad)
>>> aesccm.decrypt(nonce, ct, aad)
b'a secret message'

我们将向您介绍的最后一种 AEAD 模式称为 ChaCha20-Poly1305。这种密码在本书讨论的 AEAD 方法中是独一无二的,因为它是唯一不基于 AES 的 AEAD 算法。它由 Daniel J. Bernstein 设计,结合了他设计的流密码 ChaCha20 和 Bernstein 设计的 MAC 算法 Poly1305。Bernstein 是一名相当出色的密码学家,目前正在从事一些与椭圆曲线、哈希、加密和抵抗量子攻击的不对称算法相关的项目。他还是一名程序员,编写了许多与安全相关的程序。

安全社区的一些人担心,AES 的流行意味着如果在 AES 中发现严重的漏洞,互联网的加密车轮可能会停止转动。将 ChaCha 确立为一种有效的替代方案意味着,如果发现这种漏洞,将会有一种经过充分测试的、成熟的替代方案可用。ChaCha20-Poly1305 作为认证的加密是更好的。

ChaCha20 还有其他一些优点。对于纯软件驱动的实现,ChaCha 通常比它的同类产品更快。此外,根据设计,它是一个流密码。AES 是一种可以用作流密码的分组密码,而 ChaCha 只是一种流密码。在互联网的早期,RC4 是一种流密码,用于许多安全环境,包括 TLS 和 Wi-Fi。不幸的是,人们发现它有很大的弱点和缺陷,这几乎使它无法使用。ChaCha 被一些人视为它的精神继承者。

像 AES-GCM 一样,ChaCha20-Poly1305 需要一个 12 字节的 nonce。它在cryptography库中的 API 非常相似:

>>> import os
>>> from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
>>> data = b"a secret message"
>>> aad = b"authenticated but unencrypted data"
>>> key = ChaCha20Poly1305.generate_key()
>>> chacha = ChaCha20Poly1305(key)
>>> nonce = os.urandom(12)
>>> ct = chacha.encrypt(nonce, data, aad)
>>> chacha.decrypt(nonce, ct, aad)
b'a secret message'

这些 AEAD 算法中的任何一种都可以在或多或少相同的安全保证下使用。这三种方法都被认为比通过单独加密和 MAC 来创建认证加密要好得多。只要 AEAD 算法可用,您就应该利用它们。

您可能已经注意到了这三种不同模式的generate_key方法。这是一个方便的功能,不是必需的。例如,您仍然可以像往常一样使用密钥派生函数来创建密钥。但是正如您所看到的,使用 ChaCha,您甚至不必指定位大小。它只是给你一个大小合适的键,可以消除一类常见的错误。

练习 7.3。快速恰恰

为 AES-GCM、AES-CCM 和 ChaCha20-Poly1305 创建一些速度比较测试。运行一组测试,将大量数据准确地输入每个encrypt函数一次。测试解密算法的速度。注意,这也测试了标记检查。

运行第二组测试,将大数据分成较小的数据块(每个数据块可能有 4 个 KiB),每个数据块单独加密。

在网络中工作

东南极洲的间谍们终于走出了石器时代,开始将电脑接入互联网。是时候让 Alice 和 Bob 学习编写一些支持网络的代码来来回回发送他们的代码了。

因为他们使用 Python 3,Alice 和 Bob 将使用asyncio模块进行一些异步网络编程。如果你以前用过套接字编程,这将会有一点不同。

作为解释,套接字通常是网络通信的一种阻塞同步方式。套接字可以被配置为非阻塞的,在这种模式下,你可以将它们与类似于select函数的东西一起使用,以防止程序在等待数据时被卡住。或者,可以将套接字放在一个线程中,以保持数据流入主程序循环。

asyncio模块采用异步方法,试图在网络通信的概念模型之后对数据结构建模。特别是,网络数据由一个具有处理connection_madedata_receivedconnection_lost事件的方法的Protocol对象处理。Protocol对象被插入到一个异步事件循环中,当事件被触发时Protocol的事件处理程序被调用。

一个Protocol类通常看起来类似于清单 7-3 。

 1   import asyncio
 2
 3   class ConcreteProtocol(asyncio.Protocol):
 4       def connection_made(self, transport):
 5           self.transport = transport
 6
 7       def data_received(self, data):
 8           pass
 9           # process data
10           # send data using transport.write as needed
11
12       def connection_lost(self, exc):
13           pass
14           # do cleanup

Listing 7-3Network Protocol Intro

一个Protocol对象的约定是,在构造之后,当底层网络准备好时,将有一个对connection_made的调用。这个事件之后将会有零个或多个对data_received的调用,然后在底层网络连接断开时会有一个connection_lost调用。

协议可以通过调用self.transport.write向对等体发送数据,并可以通过调用self.transport.close强制关闭连接。

应该注意的是,每个连接只创建一个协议对象:当一个客户端建立一个出站连接时,只有一个连接和一个协议。但是,当一个服务器正在监听一个端口上的连接时,一次可能有很多连接。服务器为每个进入的客户机产生连接,而asyncio为每个新的连接产生一个协议对象。

这是对asyncio的网络 API 的一个非常快速的概述。更详细的解释超出了本书的范围,但是如果你需要更多的信息,asyncio文档非常全面。此外,随着您对示例的理解,这其中的大部分内容可能会变得更加清晰。说到这里,让我们利用我们所学的知识,创建一个“安全的”echo 服务器。

echo 协议是网络通信的“Hello World”。基本上,服务器监听客户端连接的端口。当客户机连接时,它向服务器发送一串数据(通常是人类可读的)。服务器通过镜像完全相同的消息(即“echo”)并关闭连接来做出响应。你可以在网上找到很多这样的例子,包括asyncio文档中的一个例子。

我们将添加一个扭曲。我们将构建一个变体,在传输时加密,在接收时解密。

让我们从创建服务器开始,如清单 7-4 所示。

 1   from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
 2   from cryptography.hazmat.primitives import hashes
 3   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 4   from cryptography.hazmat.backends import default_backend
 5   import asyncio, os
 6
 7   PW = b"password"
 8
 9   class EchoServerProtocol(asyncio.Protocol):
10       def __init__(self, password):
11           # 64 bytes gives us 2 32-byte keys.
12           key_material = HKDF(
13               algorithm=hashes.SHA256(),
14               length=64, salt=None, info=None,
15               backend=default_backend()
16           ).derive(password)
17           self._server_read_key = key_material[0:32]
18           self._server_write_key = key_material[32:64]
19
20       def connection_made(self, transport):
21           peername = transport.get_extra_info('peername')
22           print('Connection from {}'.format(peername))
23           self.transport = transport
24
25       def data_received(self, data):
26           # Split out the nonce and the ciphertext.
27           nonce, ciphertext = data[:12], data[12:]
28           plaintext = ChaCha20Poly1305(self._server_read_key).decrypt(
29               nonce, ciphertext, b"")
30           message = plaintext.decode()
31           print('Decrypted message from client: {!r}'.format(message))
32
33           print('Echo back message: {!r}'.format(message)) 

34           reply_nonce = os.urandom(12)
35           ciphertext = ChaCha20Poly1305(self._server_write_key).encrypt(
36               reply_nonce, plaintext, b"")
37           self.transport.write(reply_nonce + ciphertext)
38
39           print('Close the client socket')
40           self.transport.close()
41
42   loop = asyncio.get_event_loop()
43   # Each client connection will create a new protocol instance
44   coro = loop.create_server(lambda: EchoServerProtocol(PW), '127.0.0.1', 8888)
45   server = loop.run_until_complete(coro)
46
47   # Serve requests until Ctrl+C is pressed
48   print('Serving on {}'.format(server.sockets[0].getsockname()))
49   try:
50       loop.run_forever()
51   except KeyboardInterrupt:
52       pass
53
54   # Close the server
55   server.close()
56   loop.run_until_complete(server.wait_closed())
57   loop.close()

Listing 7-4
Secure Echo Server

该文件中只有一个协议类:EchoServerProtocol。为了便于说明,connection_made方法报告了连接客户端的详细信息。这通常是客户端的 IP 地址和出站 TCP 端口。这只是为了增加趣味性,对于服务器的运行并不重要。

真正的肉在data_received法里。该方法接收数据,解密数据,重新加密数据,然后将其发送回客户端。

实际上,我们有点超前了:对于这种加密,密钥来自哪里?密码是EchoServerProtocol构造函数的一个参数,但是如果你在代码的后面看一下create_server行,你会看到我们正在传递一个硬编码的值。鉴于“密码”仍然是一个常见的密码,我们选择该字符串作为“秘密” 1

使用密码,EchoServerProtocol得到两个密钥:一个“读”密钥和一个“写”密钥。因为我们将使用随机随机数,所以我们可以对客户机和服务器使用相同的密钥,但是拥有两个不同的密钥很容易做到,并且是一个好的实践。我们使用HKDF生成 64 字节的密钥材料,并将其分成两个密钥:服务器的读取密钥和服务器的写入密钥。

回到data_received方法,记住当我们从客户端收到一些东西时,这个方法被调用。因此,data变量是客户端发送给我们的。我们假设(没有任何错误检查)客户端发送了一个 12 字节的 nonce,后跟任意数量的密文。使用这个随机数和服务器的读取密钥,我们可以解密密文。注意,第三个参数只是一个空字节字符串,因为我们现在不验证任何额外的数据。

一旦数据被解密,恢复的明文在服务器的写密钥和新生成的随机数下被重新加密。我们可以重用 nonce,因为我们有一个不同的密钥,但是使用单独的 nonce 是一个很好的实践,可以让传输双方使用相同的消息格式。然后,新的随机数和重新加密的消息被发送回客户端。

剩下的就是设置服务器了。除了create_server方法之外,您可以忽略它的大部分。该方法在本地端口 8888 上设置一个侦听器,并将其与一个匿名工厂函数相关联。每次有新的连接进来时,lambda 都会被调用。换句话说,对于每个传入的客户端连接,都会产生一个新的EchoServerProtocol对象。

完成服务器代码后,我们创建清单 7-5 中的客户机代码,它发送初始消息并解密响应。

 1   from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
 2   from cryptography.hazmat.primitives import hashes
 3   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 4   from cryptography.hazmat.backends import default_backend
 5   import asyncio, os, sys
 6
 7   PW = b"password"
 8
 9   class EchoClientProtocol(asyncio.Protocol):
10       def __init__(self, message, password):
11           self.message = message
12
13           # 64 bytes gives us 2 32-byte keys
14           key_material = HKDF(
15               algorithm=hashes.SHA256(),
16               length=64, salt=None, info=None,
17               backend=default_backend()
18           ).derive(password)
19           self._client_write_key = key_material[0:32]
20           self._client_read_key = key_material[32:64]
21
22       def connection_made(self, transport):
23           plaintext = self.message.encode()
24           nonce = os.urandom(12)
25           ciphertext = ChaCha20Poly1305(self._client_write_key).encrypt(
26               nonce, plaintext, b"")
27           transport.write(nonce + ciphertext)
28           print('Encrypted data sent: {!r}'.format(self.message)) 

29
30       def data_received(self, data):
31           nonce, ciphertext = data[:12], data[12:]
32           plaintext = ChaCha20Poly1305(self._client_read_key).decrypt(
33               nonce, ciphertext, b"")
34           print('Decrypted response from server: {!r}'.format(plaintext.decode()))
35
36       def connection_lost(self, exc):
37           print('The server closed the connection')
38           asyncio.get_event_loop().stop()
39
40   loop = asyncio.get_event_loop()
41   message = sys.argv[1]
42   coro = loop.create_connection(lambda: EchoClientProtocol(message, PW),
43                                 '127.0.0.1', 8888)
44   loop.run_until_complete(coro)
45   loop.run_forever()
46   loop.close()

Listing 7-5
Secure Echo Client

这段代码与服务器有一些相似之处,这应该很明显。首先,我们有相同的硬编码(非常糟糕)密码。显然,我们需要相同的密码,否则双方将无法相互通信。我们在构造函数中也有相同的密钥派生例程。

尽管如此,还是有一些重要的区别。如果你看看密钥材料是怎么划分的,这次前 32 个字节是客户端的密钥,后 32 个字节是客户端的密钥。在服务器代码中,这当然是相反的。

这不是意外。我们正在处理对称密钥;客户端写什么,服务器读取什么,反之亦然。换句话说,客户端的写密钥就是服务器的读密钥。当您派生密钥时,您必须确保密钥材料拆分的顺序在两端都得到正确的管理。有几个早期的练习在没有太多解释的情况下处理了这个问题。如果那些练习在当时没有多大意义,现在可能是重温它们的好时机。

解决这个问题的另一个方法是在两端总是调用相同的派生密钥。因此,举例来说,您可以选择对客户端和服务器都使用“客户端写入”密钥和“服务器写入”密钥,而不是派生“读取”密钥和“写入”密钥。这样,前 32 个字节可以始终是客户端的写密钥,后 32 个字节是服务器的写密钥。

一旦创建了这两个键,其他的名字只是别名。也就是说,“客户端读取”密钥只是“服务器写入”密钥的别名,而“服务器读取”密钥只是“客户端写入”密钥的别名。

练习 7.4。名称又能代表什么呢

在许多情况下,“读”和“写”是使用的正确名称,因为尽管称一台计算机为客户机,一台计算机为服务器,但它们的行为是对等的

但是,如果您正在处理这样一个上下文,其中只有客户端发出请求,只有服务器响应请求,那么您可以适当地重命名您的密钥。我们创建的 echo 客户机/服务器就是这种模式的一个例子。**

从清单 7-4 和 7-5 中的代码开始,将所有对“读”和“写”数据或键的引用改为“请求”和“响应”。给他们起个合适的名字!客户端编写请求并读取响应,而服务器读取请求并编写响应。客户机和服务器代码之间的关系会发生什么变化?

与服务器代码的另一个区别是,我们在客户端的connection_made方法中传输数据。这是因为服务器在响应之前等待客户端发送一些东西,而客户端只是尽可能快地传输。

数据传输本身应该看起来很熟悉。生成一个随机数,并使用transport.write写入随机数和密文。

服务器的响应在data_received中处理。这个应该也很眼熟吧。分离随机数,并使用读取的密钥和接收的随机数解密密文。

create_connection方法中,您会注意到我们仍然使用匿名 lambda 函数来构建客户端协议类的实例。这可能会让你吃惊。在服务器中,使用工厂函数是有意义的,因为可能有多个连接需要多个协议实例。然而,在出站连接中,只有一个协议实例和一个连接。实际上,工厂是不必要的。使用它是为了让create_servercreate_connection的 API 尽可能的相似。

这段代码是研究使用加密技术的网络协议的良好开端。然而,对于真正的网络通信,通常需要额外的机器。生产代码中可能出现的一个问题是消息被分割成多个data_received调用,或者多个消息被压缩成一个data_received调用。data_received方法将传入的数据视为,这意味着无法保证在一次调用中会收到多少数据。asyncio库不知道你发送的数据是否应该被分割。要解决这个问题,您需要能够识别一条消息的结束位置和另一条消息的开始位置。这通常需要一些缓冲,以防不是所有的数据都被一次接收到,并且需要一个协议来指示在哪里分离各个消息。

Kerberos 简介

尽管 PKI 目前广泛用于建立和验证身份,但也有仅使用对称加密在双方之间建立身份和信任的算法。与 PKI 一样,这些算法需要可信的第三方。

用于双方认证通信的最著名的协议之一是 Kerberos 。Kerberos 是一种单点登录(SSO)服务,它在 20 世纪 90 年代早期发展成当前的形式(版本 5)。尽管自那以后有过更新,但该协议基本保持不变。它允许某人先登录 Kerberos 系统,然后无需再次登录即可访问其他网络资源。真正酷的是,虽然已经添加了扩展以使用特定组件的 PKI,但核心算法都使用对称加密。

Alice 和 Bob 听说 Kerberos 现在被部署在某些 WA 网络中的系统上。为了探索渗透这些系统的各种机会并寻找其中的弱点,Alice 和 Bob 在总部花了一些时间学习 Kerberos 是如何工作的。

我们将帮助 Alice 和 Bob 创建一些类似 Kerberos 的代码。与本书中的大多数例子一样,这不是真正的 Kerberos,完整的系统超出了本书的范围。我们仍然可以探索基本组件,并感受 Kerberos 如何使用相对简单的网络协议来施展它的魔力。我们将尝试确定我们遗漏的更高级和更复杂的部分,但是如果您真的想深入了解生产 Kerberos,您将需要研究其他来源。

我们还将引入一些新的符号来描述在加密协议中发送的消息。在我们已经用密钥({plaintext} K )表示密文的基础上,我们现在添加一些符号来表示一方(当事人)向另一方发送消息。假设 Alice 想给 Bob 发送一条消息,其中包括她的名字(明文)和一些用共享密钥加密的密文。我们对这种预期交换的符号如下所示:

$$ A\to B:A,\left{\mathrm{plaintext}\right}{K}_{A,B}. $$

你看到的箭头并不代表收到了的消息。由于数据丢失或被 Eve 截获,Bob 可能永远得不到它。箭头代表意图,所以 AB 的意思是 A(爱丽丝)打算给 B(鲍勃)发一个信息。然而,出于实际目的,有时把它想象成发送和接收会更简单,所以我们也要做这个简化的假设。

A 代表爱丽丝的名字,或者身份串。标识字符串可以是很多东西。这可能是爱丽丝的法定姓名,用户名,URI,或者只是一个不透明的令牌。因为消息中的 A 不在任何大括号内,所以它是明文。在 K A,B 下的密文与我们之前用来表示由 AB 共享的密钥的符号相同。然而,当 A 正在向 B 发送在“属于】B 的秘密下加密的数据时(例如,在从与 B 关联的密码导出的密钥下),我们将该密钥标记为KB。尽管 A 知道这个秘密,并且从技术上来说,它是一个共享密钥,但是这个想法是,这个消息被加密,只供 B. 使用

Kerberos 有多个主体,消息交换可能有点复杂。我们将使用这种符号来帮助表达谁在向谁发送数据。

做好准备后,Alice 和 Bob 坐下来上 Kerberos 如何工作的课。第一课是关于 Kerberos 如何使用身份和密码的中央存储库。与不需要保存所有签名证书的在线注册表(当然也不存储任何私钥)的证书颁发机构不同,Kerberos 身份验证服务器(AS)跟踪每个可用的身份并将其映射到密码。这些数据必须随时可用。

Kerberos AS 显然是系统中非常敏感的部分。如果 AS 遭到破坏,攻击者就会获得每个用户的每个密码。因此,应该小心保护这个系统。此外,如果 AS 宕机,Kerberos 的其余部分就会崩溃。因此,AS 必须能够抵抗拒绝服务(DoS)攻击。

让我们暂停一下,为我们的玩具快速搭建一个骨骼框架。在整个例子中,从清单 7-6 开始,我们将我们的系统称为SimpleKerberos,以表明这不是完整的协议。我们首先为 AS 创建一个协议类,并硬编码一个基于字典的密码数据库。我们还不知道 AS 什么,所以我们将所有的联网方法都留空。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Skeleton for Kerberos AS Code, User Database, initial class decl
 4   import asyncio, json, os, time
 5   from cryptography.hazmat.backends import default_backend
 6   from cryptography.hazmat.primitives import hashes
 7   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
 8   from cryptography.hazmat.primitives import padding
 9   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
10
11   # we used the most common passwords
12   # from 2018 according to wikipedia
13   # https://en.wikipedia.org/wiki/List_of_the_most_common_passwords
14   USER_DATABASE = {
15       "johndoe": "123456",
16       "janedoe": "password",
17       "h_world": "123456789",
18   }
19
20   class SimpleKerberosAS(asyncio.Protocol):
21       def connection_made(self, transport):
22           self.transport = transport
23
24       def data_received(self, data):
25           pass

Listing 7-6Kerberos Authentication Server

到目前为止,清单 7-6 中没有什么复杂的东西:只是一个用户名到密码的字典和一个空的协议类。为了填充这些方法,我们需要知道 AS 是如何工作的。

至此,一些真正酷的密码术出现了!用户应该如何登录?我们绝对不希望通过网络将密码明文发送出去。显然,用户必须向 AS 注册,以便将他们的密码存储在那里,所以我们应该利用这个机会来创建一个共享的加密密钥吗?

原来这些都不是必须的!用户只需发送他们的名字就可以登录。使用我们的协议符号,下面是 Alice 登录 AS 的方式:

$$ A\to \mathrm{AS}:A. $$

真的吗?这是怎么回事?是什么让伊芙不发送爱丽丝的名字?

神奇之处在于的回应。AS 将发回只有真正的爱丽丝才能解密的加密数据。这假设 Alice 知道她的密码,其他人都不知道。

首先,AS 将从 Alice 的密码中得到她的密钥 K A 。然后,AS 将发回一个新生成的会话密钥,该密钥在 Alice 的 K A 密钥下加密!

$$ \mathrm{AS}\to A:\left{{K}_{\mathrm{session}}\right}{K}_A $$

如果爱丽丝知道密码,她将能够导出 K A 并解密会话密钥,稍后我们将解释其目的。现在,我们只能说它是 SSO 操作的一部分。

Kerberos 通过使用时间戳和随机数来抵御重放攻击。虽然 Kerberos 是可配置的,但它通常不会接受超过 5 分钟的消息。时间戳也用作 nonce,这意味着同一个时间戳不能使用两次。时间戳包括微秒字段;很难想象一个客户端在同一微秒内发送两个请求。真正的 Kerberos 检查它是否在同一时间(精确到微秒)发送了多个包。如果发生这种情况,应该人为地将时间戳中微秒字段的值增加 1。

为了简单起见,我们将使用时间戳,而不像对待随机数那样对待它们(例如,检查重复)。我们将更新我们的协议,将 t 1 作为爱丽丝的时间戳:

$$ A\to \mathrm{AS}:A,{t}_1. $$

让我们更新我们的 AS 来接收 Alice 的消息并发回一个加密的会话密钥。对于我们在前面的示例和练习中发送的消息,我们只是将数据与足够长的固定长度片段连接在一起,我们可以将所有的单个元素分开。

这一次,我们发送的消息长度不太容易预测。当 Alice 发送她的用户名和时间戳时,AS 如何能够将消息分成两部分?我们可以使用一个分隔符,比如逗号,并禁止它成为用户名的一部分,但是我们将发送多个加密值。我们怎么知道一个在哪里结束,另一个在哪里开始?分隔符不能直接用于原始加密数据,因为该数据使用所有可能的字节值。

在实际的网络通信中,这个问题有许多解决方法。例如,HTTP 使用分隔符(例如,key: value<newline>)发送元数据,如果任何数据是任意的(并且可能包含分隔符),则使用某种预定义的算法(例如 Base-64 编码)将其转义或转换为 ASCII。其他网络数据包是通过序列化所有值并将长度字段作为二进制数据包的一部分来创建的。

为了简化这个练习,我们将使用 Python 的json库来序列化和反序列化字典。我们在前面的章节中已经使用过一次,将数据存储到磁盘。现在我们将使用 json 对通过网络传输的数据进行编码。然而,json并不总是能很好地处理字节字符串。清单 7-7 定义了两个快速的方法,用于将我们的字典快速转储到 JSON 中,并再次从其中重新加载。确保我们将在这个例子中创建的所有三个 Kerberos 脚本中都有这个代码(或者从一个公共文件中导入它们)。

 1   # These helper functions deal with json's lack of bytes support
 2   def dump_packet(p):
 3       for k, v in p.items():
 4           if isinstance(v, bytes):
 5               p[k] = list(v)
 6       return json.dumps(p).encode('utf-8')
 7
 8   def load_packet(json_data):
 9       p = json.loads(json_data)
10       for k, v in p.items():
11           if isinstance(v, list):
12               p[k] = bytes(v)
13       return p

Listing 7-7Utility Functions for JSON Handling

真正的 Kerberos 将从 Alice 发送到的包称为“AS_REQ包”我们也将使用这种符号。Alice 发送到我们的简单 Kerberos AS 的包将是一个包含以下字段的字典:

  • 类型:AS_REQ

  • 委托人:爱丽丝的用户名

  • 时间戳:当前时间戳

当 AS 收到数据时,它需要检查时间戳是否是新的,以及用户是否在数据库中。让我们更新清单 7-8 中的data_received方法来处理这个问题。

 1   # Partial Listing: Some Assembly Required
 2
 3   class SimpleKerberosAS(asyncio.Protocol):
 4   ...
 5       def data_received(self, data):
 6           packet = load_packet(data)
 7           response = {}
 8           if packet["type"] == "AS_REQ":
 9               clienttime = packet["timestamp"]
10               if abs(time.time()-clienttime) > 300:
11                   response["type"] = "ERROR"
12                   response["message"] = "Timestamp is too old"
13               elif packet["principal"] not in USER_DATABASE:
14                   response["type"] = "ERROR"
15                   response["message"] = "Unknown principal"

Listing 7-8Kerberos AS Receiver

“包”一旦还原,就只是一本字典了。我们首先检查类型,确保它是我们期望的数据包类型。接下来,我们检查时间戳。如果差值大于 300 秒(5 分钟),我们将发回一个错误。同样,如果用户名不在密码数据库中,我们也会返回一个错误。

这种错误包类型是完全虚构的。Kerberos 使用不同的包结构来报告错误,但这将满足我们的需要。

现在我们到了有趣的部分。假设时间戳是最近的,并且用户名在我们的数据库中,我们需要从用户的密码中获得用户的密钥,创建一个会话密钥,并发送回这个在用户密钥下加密的会话密钥。

应该用什么算法和参数?

这是一个真正的 Kerberos 比我们将要做的要复杂得多的领域。像许多加密协议一样,真正的 Kerberos 实际上定义了一套可用于其各种操作的算法。当 Kerberos v5 首次部署时,DES 对称加密算法被广泛使用。当然,现在这种技术已经大部分被淘汰,并增加了 AES。

我们现在知道,不要认为“AES”是一个完整的答案。我们使用的是 AES 的哪种模式?我们从哪里得到静脉注射?

有趣的是,Kerberos 使用一种称为“CTS”(密文窃取)的操作模式。我们不打算在这种操作模式(通常构建在 CBC 模式之上)上花费太多时间,但是我们将简要提及,对于许多 Kerberos 密码套件,它们不使用 IV 来区分消息。相反,他们使用一个“混杂因素”混杂信号是一种随机的、块大小的明文消息,附加在真实数据的前面。当使用 CBC 模式时,随机的第一个块在许多方面起到与 IV 相同的作用。

我们不会弄乱这些复杂的东西。我们将重点讨论加密过程以及对称加密在协议中的使用方式。因此,对于我们简单的 Kerberos,我们将使用 AES-CBC,其中有一个固定的充满零的 IV。我们也将暂时忽略 MAC 操作。很明显,这是不安全的,不应该在生产环境中使用。

让我们编写助手函数,用于从密码中导出密钥、加密和解密。这些可以在清单 7-9 中找到。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Encryption Functions for Kerberos AS
 4   def derive_key(password):
 5       return HKDF(
 6               algorithm=hashes.SHA256(),
 7               length=32,
 8               salt=None,
 9               info=None,
10              backend=default_backend()
11       ).derive(password.encode())
12
13   def encrypt(data, key):
14       encryptor = Cipher(
15           algorithms.AES(key),
16           modes.CBC(b"\x00"*16),
17           backend=default_backend()
18       ).encryptor()
19       padder = padding.PKCS7(128).padder()
20       padded_message = padder.update(data) + padder.finalize()
21       return encryptor.update(padded_message) + encryptor.finalize()
22
23   def decrypt(encrypted_data, key):
24       decryptor = Cipher(
25           algorithms.AES(key),
26           modes.CBC(b"\x00"*16),
27           backend=default_backend()
28       ).decryptor()
29       unpadder = padding.PKCS7(128).unpadder()
30       padded_message = decryptor.update(encrypted_data) + decryptor.finalize()
31       return unpadder.update(padded_message) + unpadder.finalize()

Listing 7-9Kerberos with Encryption

注意,为了满足 CBC 要求,我们使用了填充。顺便提一下,Kerberos 使用 CTS 模式的一个原因是因为它不需要填充。之所以称之为“窃取”,是因为它从倒数第二个块中窃取了一些密码数据,以填充最后一个块缺失的字节。

前面三个函数将在多个脚本中使用,所以您可能希望将它们保存在一个单独的文件中并导入它们。

现在我们准备发送来自 AS 的响应,如清单 7-10 所示。Kerberos 将这个包称为AS_REP,我们也将这样做。我们的响应将是发送前序列化的字典。由于我们将很快解释的原因,我们不加密整个包;我们只加密我们称之为user_data的部分。

 1   # Partial Listing: Some Assembly Required
 2
 3   class SimpleKerberosAS(asyncio.Protocol):
 4   ...
 5       def data_received(self, data):
 6           packet = load_packet(data)
 7           response = {}
 8           if packet["type"] == "AS_REQ":
 9               if ... # check errors
10               else:
11                   response["type"] = "AS_REP"
12
13                   session_key = os.urandom(32)
14                   user_data = {
15                       "session_key":session_key,
16                       }
17                   user_key = derive_key(USER_DATABASE[packet["principal"]])
18                   user_data_encrypted = encrypt(dump_packet(user_data), user_key)
19                   response["user_data"] = user_data_encrypted
20               self.transport.write(dump_packet(response))
21           self.transport.close()

Listing 7-10Kerberos AS Responder

这似乎很合理。现在我们需要编写客户端,但在此之前,是时候解释 Kerberos 协议的下一部分是如何工作的了。

一旦 Alice 通过 as 登录,她接下来需要与另一个名为票据授予服务(TGS)的实体进行对话。Alice 将告诉 TGS 她想要连接到哪个服务或应用。TGS 将验证她是否登录,然后向她提供用于该服务的凭证。

为了使 Alice 能够让 TGS 相信她已经登录,AS 还向她发送了一张票据授予票据(TGT)。TGT 是在 TGS 密钥下加密的信息,向 TGS 证明 AS 已经验证了 Alice 的身份。这就修改了我们的协议:

$$ \mathrm{AS}\to A:\left{{K}_{\mathrm{sessoin}}\right}{K}_A,\mathrm{TGT}. $$

爱丽丝看不到 TGT。她不能以任何方式解密或阅读它;她只能把它传给 TGS。TGT 包含发送给 Alice 的完全相同的会话密钥、Alice 的名字(身份)和时间戳。真正的 Kerberos 包括额外的数据,比如 IP 地址和票证生命周期,但是前三个元素对于加密来说是最关键的。Kerberos 协议的第一阶段如图 7-1 所示。

img/472260_1_En_7_Fig1_HTML.jpg

图 7-1

Alice 用一条表明其身份的明文消息启动 Kerberos 登录过程。AS 在其数据库中查找她的密钥,并为 TGS 加密会话密钥。它还将在 TGS 密钥下加密的 TGT 发送给爱丽丝。

如上所述,会话密钥被发送给爱丽丝(用她的密钥)和 TGT 内的 TGS(用 TGS 密钥加密)。该密钥是爱丽丝和 TGS 之间的会话密钥,将允许他们进行通信。我们应该把 K session 改名为 K A, 。如果我们在协议符号中展开 TGT,我们现在得到的是

$$ \mathrm{AS}\to A:\left{{K}_{A,\mathrm{TGS}}\right}{K}_A,\left{{K}_{A,\mathrm{TGS}},A,{t}_2\right}{K}_{\mathrm{TGS}}. $$

我们需要更新我们的代码,以包括 TGT。我们还需要更新我们的用户数据库,为 TGS 有一个条目。在真实的 Kerberos 中,TGS 的密钥不一定来自存储在密码数据库中的密码,但是如果共享密钥都来自我们可以在命令行输入的密码,那么运行 AS、TGS 和其他服务将会更容易。这显示在清单 7-11 中。

 1   # Partial Listing: Some Assembly Required
 2
 3   # we used the most common passwords
 4   # from 2018 according to wikipedia
 5   # https://en.wikipedia.org/wiki/List_of_the_most_common_passwords
 6   USER_DATABASE = {
 7       "johndoe": "123456",
 8       "janedoe": "password",
 9       "h_world": "123456789",
10       "tgs": "sunshine"
11   }
12
13   class SimpleKerberosAS(asyncio.Protocol):
14   ...
15       def data_received(self, data):
16           packet = load_packet(data)
17           response = {}
18           if packet["type"] == "AS_REQ":
19               if ... # check errors
20               else:
21                   response["type"] = "AS_REP"
22
23                   session_key = os.urandom(32)
24                   user_data = {
25                       "session_key":session_key,
26                       }
27                   tgt = {
28                       "session_key":session_key,
29                       "client_principal":packet["principal"],
30                       "timestamp":time.time()
31                       }
32                   user_key = derive_key(USER_DATABASE[packet["principal"]])
33                   user_data_encrypted = encrypt(dump_packet(user_data), user_key)
34                   response["user_data"] = user_data_encrypted
35
36                   tgs_key = derive_key(USER_DATABASE["tgs"])
37                   tgt_encrypted = encrypt(dump_packet(tgt), tgs_key)
38                   response["tgt"] = tgt_encrypted
39               self.transport.write(dump_packet(response))
40           self.transport.close()

Listing 7-11Kerberos Ticket-Granting Ticket

让我们现在开始处理客户端,并为通信的这一方创建一个协议类。首先,我们的类(清单 7-12 )需要能够将用户名传输给 AS,并且它需要密码来导出自己的密钥。我们将把这些作为参数传递给类构造函数。

我们还将传入一个回调函数on_login,用于在收到会话密钥和 TGT 时接收它们。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Skeleton for Kerberos Client Code. Imports, initial class decl
 4   # Dependencies: derive_key(), encrypt(), decrypt(),
 5   #               load_packet(), dump_packet()
 6   import asyncio, json, sys, time
 7   from cryptography.hazmat.backends import default_backend
 8   from cryptography.hazmat.primitives import hashes
 9   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
10   from cryptography.hazmat.primitives import padding
11   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
12
13   class SimpleKerberosLogin(asyncio.Protocol):
14       def __init__(self, username, password, on_login):
15           self.username = username
16           self.password = password
17           self.on_login = on_login
18
19           self.session_key = None
20           self.tgt = None

Listing 7-12Kerberos Login

一旦建立连接,SimpleKerberosLogin类就应该传输用户的身份,所以让我们把这个功能放到清单 7-13 中的connection_made方法中。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Dependencies: derive_key(), encrypt(), decrypt()
 4   class SimpleKerberosLogin(asyncio.Protocol):
 5   ...
 6       def connection_made(self, transport):
 7           self.transport = transport
 8           request = {
 9               "type":      "AS_REQ",
10               "principal": self.username,
11               "timestamp": time.time()
12           }
13           self.transport.write(dump_packet(request))

Listing 7-13Kerberos Login Connection

里面应该没有惊喜。我们创建自己的AS_REQ包并发送出去。当服务器回写给我们的时候,要么是一个错误,要么是一个AS_REP包。如果是后者,我们将需要解密user_data来获得我们的会话密钥。TGT 对我们来说是不透明的,并且没有以任何其他方式进行处理。

 1   # Partial Listing: Some Assembly Required
 2
 3   # Dependencies: derive_key(), encrypt(), decrypt()
 4   class SimpleKerberosLogin(asyncio.Protocol):
 5   ...
 6      def data_received(self, data):
 7          packet = load_packet(data)
 8          if packet["type"] == "AS_REP":
 9              user_data_encrypted = packet["user_data"]
10              user_key = derive_key(self.password)
11              user_data_bytes = decrypt(user_data_encrypted, user_key)
12              user_data = load_packet(user_data_bytes)
13              self.session_key = user_data["session_key"]
14              self.tgt = packet["tgt"]
15          elif packet["type"] == "ERROR":
16              print("ERROR: {}".format(packet["message"]))
17
18          self.transport.close()
19
20      def connection_lost(self, exc):
21          self.on_login(self.session_key, self.tgt)

Listing 7-14Kerberos Login Receiver

在清单 7-14 中,连接将以某种方式关闭。当它出现时,我们用会话密钥和 TGT 触发回调。如果有错误,这些值将是None

到目前为止,我们编写的代码应该为我们提供一个客户端,它可以连接到 AS,发送身份,并接收回加密的会话密钥和 TGT。现在,是时候创建 TGS 了!

在许多 Kerberos 系统中,AS 和 TGS 位于同一台主机上。它们服务于相似的目的并具有相似的安全要求。在许多情况下,他们可能需要共享数据库信息。然而,在我们的练习中,为了将 TGS 可视化为一个单独的实体,我们让它作为一个单独的脚本运行。

当 Alice 登录并希望与服务通话时,Alice 向 TGS 发送一条消息,其中包含 TGT、服务名称和“认证者”认证符包含爱丽丝的身份和一个时间戳,该时间戳在由 AS 生成的会话密钥KA,TGS 下加密。相同的会话密钥在 TGT 内。当 TGS 解密 TGT 并获得 K A 、TGS 时,TGS 将能够解密认证器并验证爱丽丝也拥有密钥 K A 、TGS 。如果 Alice 没有那个密钥,她就不能创建验证器。她有那把钥匙,而且同一把钥匙在 TGT,这意味着 as 授权她进行这次通信。

通过协议符号,下面是 Alice 发送给 TGS 的消息:

img/472260_1_En_7_Fig2_HTML.jpg

图 7-2

Alice 使用 TGT 来证明她的身份,并向 TGS 请求会话密钥以与 echo 服务通信。与 TGT 类似,Alice 将收到 echo 服务的加密消息,她无法打开该消息,但可以转发。

$$ A\to \mathrm{TGS}:S,\left{A,{t}_3\right}\ {K}_{A,\mathrm{TGS}},\left{{K}_{A,\mathrm{TGS}},A,{t}_2\right}\ {K}_{\mathrm{TGS}}. $$

如果 TGS 验证了数据并批准了请求,它会发回一张票和一个新的会话密钥,供 Alice 与服务 S 进行通信。像 TGT 一样,这张票对爱丽丝来说是不透明的。它是用的密钥加密的,包含了与爱丽丝相关的授权数据。具体来说,它包含 Alice 的身份、服务的身份和时间戳。同样,真正的 Kerberos 票据包含这里没有包含的附加数据。这次传输的协议符号是

$$ \mathrm{TGS}\to A:\left{S,{K}_{A,S}\right}\ {K}_{A,\mathrm{TGS}},\left{{K}_{A,S},A,{t}_3\right}\ {K}_S. $$

图 7-2 描绘了这一过程。

Alice 将使用她与 TGS 的会话密钥来解密新的会话密钥,供她与服务 S 一起使用。但是在我们做那部分之前,让我们把 TGS 写下来。

票证授予服务的许多操作与身份验证服务的操作相同,我们不会再写出所有代码。但是,值得注意的是,TGS 需要一个数据库,其中包含它授权的各种服务的密钥。我们再次使用了带密码的数据库,使事情变得更容易。清单 7-15 中的示例代码只有一个服务:echo

 1   # Partial Listing: Some Assembly Required
 2
 3   # Skeleton for Kerberos TGS. Imports, initial class decl, Service DB
 4   # Dependencies: derive_key(), encrypt(), decrypt(),
 5   #               load_packet(), dump_packet()
 6   import asyncio, json, os, time, sys
 7   from cryptography.hazmat.backends import default_backend
 8   from cryptography.hazmat.primitives import hashes
 9   from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
10   from cryptography.hazmat.primitives import padding
11   from cryptography.hazmat.primitives.kdf.hkdf import HKDF
12
13   # we used the most common passwords
14   # from 2018 according to wikipedia
15   # https://en.wikipedia.org/wiki/List_of_the_most_common_passwords
16   SERVICE_DATABASE = {
17       "echo":"qwerty",
18   }
19
20   class SimpleKerberosTGS(asyncio.Protocol):
21       def __init__(self, password):
22           self.password = password

Listing 7-15Kerberos Ticket-Granting Service

请注意,我们还向构造函数传递了一个密码。我们的SimpleKerberosTGS需要能够导出它的密钥;否则,它将无法解密由 AS 发送给它的 TGT。

TGS 代码的内容在列表 7-16 中的data_received内。我们将直接在该方法中跳转到 TGS 服务器接收一个TGS_REQ包的地方(遵循 Kerberos 命名)。

 1   # Partial Listing: Some Assembly Required
 2
 3   class SimpleKerberosTGS(asyncio.Protocol):
 4   ...
 5       def data_received(self, data):
 6           packet = load_packet(data)
 7           response = {}
 8           if packet["type"] == "TGS_REQ":
 9               tgsKey = derive_key(self.password)
10               tgt_bytes = decrypt(packet["tgt"], tgsKey)
11               tgt = load_packet(tgt_bytes)
12
13               authenticator_bytes = decrypt(packet["authenticator"], tgt["session_key"])
14               authenticator = load_packet(authenticator_bytes)
15
16               clienttime = authenticator["timestamp"]
17               if abs(time.time()-clienttime) > 300:
18                   response["type"] = "ERROR"
19                   response["message"] = "Timestamp is too old"
20               elif authenticator["principal"] != tgt["client_principal"]:
21                   response["type"] = "ERROR"
22                   response["message"] = "Principal mismatch"
23               elif packet["service"] not in SERVICE_DATABASE:
24                   response["type"] = "ERROR"
25                   response["message"] = "Unknown service"
26               else:
27                   response["type"] = "TGS_REP"
28
29                   service_session_key = os.urandom(32)
30                   user_data = {
31                       "service":             packet["service"],
32                       "service_session_key": service_session_key,
33                       }
34                   ticket = {
35                       "service_session_key": service_session_key,
36                       "client_principal":    authenticator["principal"],
37                       "timestamp":           time.time()
38                       }
39                   user_data_encrypted = encrypt(dump_packet(user_data), tgt["session_key"])
40                   response["user_data"] = user_data_encrypted
41
42                   service_key = derive_key(SERVICE_DATABASE[packet["service"]])
43                   ticket_encrypted = encrypt(dump_packet(ticket), service_key)
44                   response["ticket"] = ticket_encrypted
45               self.transport.write(dump_packet(response))
46           self.transport.close()

Listing 7-16Kerberos TGS Receiver

正如我们所建议的那样,大部分代码看起来与 AS 代码非常相似。但是有几个关键的区别。

首先,TGS 必须解密验证器以获得时间戳。这次它不是明文发送的,但是它确保加密的数据(验证者)至少是新的(在最近 5 分钟内)。在真正的 Kerberos 中,时间戳将被存储,重复的时间戳将被识别和丢弃。

还要注意,TGS 检查验证者中的主体是否与 TGT 中的主体相同。它必须进行这种检查,以确保由 AS 授权的身份与请求票证的身份相同。

最后,带有会话密钥等的用户数据没有使用从他们的密码导出的密钥加密(反正 TGS 没有)。而是在会话密钥KA,TGS 下加密。TGS 用这个密钥加密,因为只有爱丽丝能够解密它。

我们需要更新客户端代码来处理 TGS 通信。这包括处理从 AS 接收的登录信息,并触发到 TGS 的新通信。让我们首先创建清单 7-17 中的SimpleKerberosGetTicket类来与我们刚刚创建的 TGS 服务器通信。

 1   # Partial Listing: Some Assembly Required
 2
 3   # SimpleKerberosGetTicket is also part of the Client
 4   # This class connects to the TGS to get a ticket
 5   class SimpleKerberosGetTicket(asyncio.Protocol):
 6       def __init__(self, username, service, session_key, tgt, on_ticket):
 7           self.username = username
 8           self.service = service
 9           self.session_key = session_key
10           self.tgt = tgt
11           self.on_ticket = on_ticket
12
13           self.server_session_key = None
14           self.ticket = None
15
16       def connection_made(self, transport):
17           print("TGS connection made")
18           self.transport = transport
19           authenticator = {
20               "principal": self.username,
21               "timestamp": time.time()
22           }
23           authenticator_encrypted = encrypt(dump_packet(authenticator ), self.session_key) 

24           request = {
25               "type":          "TGS_REQ",
26               "service":       self.service,
27               "authenticator": authenticator_encrypted,
28               "tgt":           self.tgt
29           }
30           self.transport.write(dump_packet(request))
31
32       def data_received(self, data):

Listing 7-17Get Kerberos Ticket

33            packet = load_packet(data)
34            if packet["type"] == "TGS_REP":
35                user_data_encrypted = packet["user_data"]
36                user_data_bytes = decrypt(user_data_encrypted, self.session_key)
37                user_data = load_packet(user_data_bytes)
38                self.server_session_key = user_data["service_session_key"]
39                self.ticket = packet["ticket"]
40           elif packet["type"] == "ERROR":
41                print("ERROR: {}".format(packet["message"]))
42
43           self.transport.close()
44
45       def connection_lost(self, exc):
46           self.on_ticket(self.server_session_key, self.tgt)

img/472260_1_En_7_Fig3_HTML.jpg

图 7-3

Alice 和 echo 服务最终都有一个共享的对称密钥,它们可以使用这个密钥进行安全通信

这个协议在连接时发送TGS_REQ包以及加密的认证符、服务名和 TGT。请记住,TGT 是由 as 传输的,会话密钥也是如此。这些数据被传递给这个协议的构造函数。一旦我们收到了TGS_REP,我们就可以提取服务的会话密钥和发送给服务的票据。我们使用另一个回调on_ticket来处理这个信息。

图 7-3 显示了协议的其余部分。

为了将所有这些粘合在一起,我们使用清单 7-18 中的ResponseHandler类来接收回调on_loginon_ticketon_login也将触发对 TGS 的呼叫。

 1   # Partial Listing: Some Assembly Required
 2
 3   # ResponseHandler is also part of the client. It connects to the service.
 4   class ResponseHandler:
 5       def __init__(self, username):
 6           self.username = username
 7
 8       def on_login(self, session_key, tgt):
 9           if session_key is None:
10               print("Login failed")
11               asyncio.get_event_loop().stop()
12               return
13
14           service = input("Logged into Simpler Kerberos. Enter Service Name: ")
15           getTicketFactory = lambda: SimpleKerberosGetTicket(
16               self.username, service, session_key, tgt, self.on_ticket)
17
18           coro = asyncio.get_event_loop().create_connection(
19               getTicketFactory, '127.0.0.1', 8889)
20           asyncio.get_event_loop().create_task(coro)
21
22       def on_ticket(self, service_session_key, ticket):
23           if service_session_key is None:
24               print("Login failed")
25               asyncio.get_event_loop().stop()
26               return
27
28           print("Got a server session key:",service_session_key.hex())
29           asyncio.get_event_loop().stop()

Listing 7-18Kerberos Client

这段代码中唯一值得指出的部分是使用了input来获取要连接的服务的名称。这通常不是使用asyncio程序的最佳方式,因为它是一个阻塞调用,会阻止其他任何东西工作。但是,对于我们简单的客户来说,这是合理的。无论如何,它应该在网络通信之间。

请注意,在我们的示例中,TGS 拥有的唯一服务是“echo”,因此这应该是您输入的服务名,除非您想测试错误处理代码。我们还将 TGS 的 IP 地址和端口硬编码为本地端口 8889。您应该对此进行相应的调整。

当该说的都说了,该做的都做了,如果一切都做对了,on_ticket回调应该有一个服务会话密钥和一个票据。

在真正的 Kerberos 中,这是事情变得有点棘手的地方。每个使用 Kerberos 进行身份验证的服务都必须“Kerberos 化”这意味着必须修改服务以接受 Kerberos 票证,而不是用户名和密码(或者它通常使用的任何其他身份验证方法)。无论如何配置,Alice 将发送票以及她的身份和服务会话密钥下的另一个时间戳。可选地,服务可以使用相同服务会话密钥下的时间戳进行响应。我们可以把这个协议交换写成

$$ {\displaystyle \begin{array}{l}A\to S:\left{A,{t}_4\right}\ {K}_{A,S},\left{A,{K}_{A,S},{t}_3\right}\ {K}_S\ {}S\to A:\left{{t}_4\right}\ {K}_{A,S}.\end{array}} $$

当这完成时,Alice 和服务 S 知道他们正在与正确的方通信(基于对 AS/TGT 的信任),并且他们有一个会话密钥来使他们能够通信。

您会注意到会话密钥显示为双向工作。这主要用于主体(Alice 和服务 S )之间的实际认证。一旦建立了会话密钥,如果有必要,他们可以进一步协商会话密钥。Kerberos 文档中有关于“子项”的说明,可以根据需要发送或派生这些子项。

对于实际的 Kerberos 身份验证交换,如果使用混淆器,即使在相同的密钥下,消息也将是唯一的。

再重复一遍,Kerberos 本身远比我们在这里展示的要复杂。有各种扩展,例如,启用对 AS 的 PKI 认证、AEAD 算法支持、广泛的选项和核心规范中的附加细节。

尽管如此,这种演练应该有助于 Alice 和 Bob(以及您!)更好地了解 Kerberos 是如何具体工作的,以及对称密钥通常如何用于在各方之间建立身份。

练习 7.5。Kerberize 化 Echo 协议

我们没有展示任何 Kerberized 化的 echo 协议的代码。我们让你自己去解决。但是,我们已经设置了一些您需要的部件。在真实的 Kerberos 中,Kerberos 化的服务必须向 TGS 注册。我们已经这样做了。我们的 TGS 代码在服务数据库中有“回声”,密码是“阳光”。

您需要修改 echo 客户端和 echo 服务器,以使用来自 TGS 的会话密钥,而不是从密码中获得会话密钥。您可以将来自 TGS 的会话密钥视为密钥材料,并且仍然使用 HKDF 来派生写密钥和读密钥(Kerberos 称之为两个子会话密钥)。

许多 Kerberized 化的实现接受票证和请求,您在这里也可以这样做。换句话说,发送 Kerberos 消息以及要回显的(加密的)数据。因为您正在发送一条人类可读的消息,所以如果最简单的话,您可以使用空终止符来指示 echo 消息的结束和 Kerberos 消息的开始。或者,您可以做一些更复杂的事情,比如首先传输 Kerberos 消息,以它的长度作为前缀,以人类可读的 echo 消息作为尾部。

还需要对服务器进行修改,以接受用 TGS 导出其密钥的密码。服务器已经有一个作为参数给出的密码。您可以简单地修改它来派生它的 Kerberos 密钥,而不是读写密钥。此外,确保使用适当的求导函数。在票证被接收和解密后,需要在data_received方法中导出读写密钥。您可以省去可选的 Kerberos 对 echo 客户机的响应。

最后,您必须想办法将 Kerberos 票据数据发送到 echo 客户端。您可以将 echo 客户机协议直接构建到您的 Kerberos 客户机中,或者找到其他方法来传输它。

练习 7.6。混杂因素

检查您的加密数据包中是否有重复的部分。如果进入加密例程的数据(具有固定的 IV 和密钥)在开始时是相同的,就会发生这种情况。因为字典不一定对它们的数据进行排序,所以用户名可能在时间戳之后,在这种情况下,数据包可能每次都不同。如果您的数据包根本没有重复任何字节,也许可以修改时间戳,或者强制加密函数将相同的数据加密两次。

一旦有了重复的字节,通过在序列化的字节前面加上 16 字节的随机明文,在代码中引入混淆。请确保在解密时删除它。这能去掉重复的字节吗?对于 AES-CTR 模式,混杂因素会起作用吗?

练习 7.7。防止服务器重播

从我们的 AS 和 TGS 到客户端的传输不包括时间戳。没有时间戳和 nonce,它们可以被完全重放。将时间戳添加到由两个服务器传输的用户数据结构中,并修改客户端代码来检查它们。

附加数据

就概念而言,这一节稍微简单一点,就工程而言,这一节稍微沉重一点。

首先,我们引入了一些新的 AES 加密操作模式和新的 ChaCha 加密算法。AEAD 算法(使用附加数据的认证加密)在很大程度上被视为优于单独进行加密和 MAC(例如,使用 AES-CTR 和 HMAC)。只要这些操作模式可用,您就应该使用它们。

我们还介绍了 Kerberos SSO 服务,它很有趣,因为它是基于对称密钥算法构建的。在一个 PKI 无处不在的世界中,很高兴看到一个 25 岁的(在撰写本文时)基于对称的系统继续被广泛使用。

希望亲自动手编写一些客户机/服务器代码会很有趣。我们希望如此。因为最后一章就要到了,网络通信是 TLS 的全部!

八、TLS 通信

在本章中,我们将讨论安全互联网通信的基石之一:TLS。这个主题,就像密码学中的许多东西一样,是一个大主题,充满了复杂的参数、微妙的陷阱和惊人的逻辑。让我们了解更多!

拦截流量

伊芙为自己感到非常自豪。她设法进入东南极洲的计算机房,安装了“嗅探”软件。基本上,她已经设法拦截 HTTP (web)流量,并将其渗透出来,供其机构(“西南极洲中央骑士办公室”,或 WACKO)的情报人员进行分析。

HTTP 协议本身支持代理。HTTP 客户端可以通过中间 HTTP 服务器(代理)连接到服务器。当客户机第一次连接到代理时,它发送一个名为CONNECT的特殊 HTTP 命令,告诉代理真正的目的地在哪里。一旦代理连接到真正的服务器,它就充当一个简单的通道,将数据从一方转发到另一方。

伊芙设法在她敌人的电脑上安装了一个 HTTP 代理。它与清单 8-1 中的代码非常相似。

 1   import asyncio
 2
 3   class ProxySocket(asyncio.Protocol):
 4       CONNECTED_RESPONSE = (
 5           b"HTTP/1.0 200 Connection established\n"
 6           b"Proxy-agent: East Antarctica Spying Agency\n\n")
 7
 8       def __init__ (self, proxy):
 9           self.proxy = proxy
10
11       def connection_made(self, transport):
12           self.transport = transport
13           self.proxy.proxy_socket = self
14           self.proxy.transport.write(self.CONNECTED_RESPONSE)
15
16       def data_received(self, data):
17           print("PROXY RECV:", data)
18           self.proxy.transport.write(data)
19
20       def connection_lost(self, exc):
21           self.proxy.transport.close()
22
23
24   class HTTPProxy(asyncio.Protocol):
25       def connection_made(self, transport):
26           peername = transport.get_extra_info('peername')
27           print('Connection from {}'.format(peername))
28           self.transport = transport
29           self.proxy_socket = None
30
31       def data_received(self, data):
32           if self.proxy_socket:
33               print("PROXY SEND:", data)
34               self.proxy_socket.transport.write(data)
35               return
36
37           # No socket, we need to see CONNECT.
38           if not data.startswith(b"CONNECT"):
39               print("Unknown method")
40               self.transport.close()
41               return
42
43           print("Got CONNECT command:", data)

44           serverport = data.split(b" ")[1]
45           server, port = serverport.split(b":")
46           coro = loop.create_connection(lambda: ProxySocket(self), server, port)
47           asyncio.get_event_loop().create_task(coro)
48
49       def connection_lost(self, exc):
50           if not self.proxy_socket: return
51           self.proxy_socket.transport.close()
52           self.proxy_socket = None
53
54   loop = asyncio.get_event_loop()
55   coro = loop.create_server(HTTPProxy, '127.0.0.1', 8888)
56   server = loop.run_until_complete(coro)
57
58   # Serve requests until Ctrl+C is pressed
59   print('Proxying on {}'.format (server.sockets[0].getsockname()))
60   try:
61       loop.run_forever()
62   except KeyboardInterrupt:
63       pass
64
65   # Close the server
66   server.close()
67   loop.run_until_complete(server.wait_closed())
68   loop.close()

Listing 8-1
HTTP Proxy

这个 HTTP 代理打印出它从任一端点接收到的所有内容。Eve 的真实代理不会这样做。相反,它通过网络将截获的数据发送到命令和控制服务器。或者,她可以让它将数据保存到磁盘,以便以后提取。

让我们看看我们的网络流量连接到一个不受保护的 HTTP 服务器是什么样子。首先,复制 HTTP 代理的代码(只有大约 70 行)并启动它。 1 应该是在本地主机:8888 上服务。这在 Python shell 中显示如下。

>>> import http.client
>>> conn = http.client.HTTPConnection("127.0.0.1", 8888)
>>> conn.set_tunnel("www.example.com")
>>> conn.request("GET", "/")
>>> r1 = conn.getresponse()
>>> r1.read()

#SHELL# output_ommitted

Python 的http.client模块有一些与 HTTP 服务器交互的内置方法。它还具有 HTTP 代理功能。在示例代码中,HTTPConnection对象配置了代理的 IP 地址和端口。set_tunnel方法重新配置对象,假设它正在连接到代理,但是将通过CONNECT方法请求“www.example.com”。

在得到响应后,read方法得到输出。结果您应该会看到类似于 HTML 文档的东西。这表示当西澳大利亚用户导航到 www.example.com 时,他们的浏览器接收到的数据。

注意:查找 HTTP 站点

为了使本练习能够进行,您需要浏览到一个仍然支持 HTTP 的网站。越来越多的网站完全禁用 HTTP,你只能通过 HTTPS 连接到它们。在撰写本文时,www.example.com 仍然支持这两者。

与此同时,伊芙在看着。在运行 HTTP 代理的终端中,您应该看到如下内容:

Got CONNECT command: b'CONNECT www.example.com:80 HTTP/1.0\r\n\r\n'
PROXY SEND: b'GET/HTTP/1.1\r\nHost: www.example.com\r\nAccept-Encoding: identity\r\n\r\n'
PROXY RECV: b'HTTP/1.1 200 OK\r\nCache-Control: max-age=604800\r\nContent-Type: text/html...

您会注意到,他们看到了客户端(例如浏览器)和 web 服务器之间的整个通信流。伊芙偶然发现了一个奇妙的情报来源。

警告:多个代理方法

我们的代理使用的是CONNECT方法。配置 web 代理有多种方法,我们的基本源代码只支持这一种方法。因此,它不能与试图使用其他方法的浏览器或工具一起工作。

一天,Eve 正在愉快地收集她的敌人的流量,突然一切都停止了工作。明确地说,代理仍然在代理数据。事实上,CONNECT方法仍然存在,但是几乎所有流经代理的数据都是不可读的!

仔细查看日志,伊芙注意到一个有趣的变化。

Got CONNECT command: b'CONNECT www.example.com:443 HTTP/1.0\r\n\r\n'

你看出区别了吗?几乎一切都是一样的,除了一件事:港口。Eve 过去常常看到浏览器通过 80 端口连接到 www.example.com。现在它在 443 端口上。这是怎么回事?

原来 EA 的对手已经转而使用 HTTPS(“HTTP Secure”)。默认情况下,HTTP 使用端口 80,而 HTTPS 使用端口 443。要明确的是,不是端口使事情变得安全,而是新的协议。端口差异仅仅是 Eve 的第一个线索,表明某些东西被有意地改变了。

为了验证这一点,请再次尝试相同的练习,但有一点小小的不同,如下所示。

>>> import http.client
>>> conn = http.client.HTTPSConnection("127.0.0.1", 8888)
>>> conn.set_tunnel("www.example.com")
>>> conn.request("GET", "/")
>>> r1 = conn.getresponse()
>>> r1.read()

#SHELL# output_ommitted

这段代码实际上只有一个字符不同。你看到了吗?我们把HTTPConnection改成了 HTTPSConnection。

看看你的 HTTP 代理嗅探器。会有很多的输出。它的一部分可能看起来像这样:

Got CONNECT command: b'CONNECT www.example.com:443 HTTP/1.0\r\n\r\n'
PROXY SEND: b"\x16\x03\x01\x02\x00\x01\x00\x01\xfc\x03\x03\x81<\x06f...
...
PROXY RECV: b'\x16\x03\x03\x00E\x02\x00\x00A\x03\x03\xb1\xf0T\xd0\xc...

夏娃,不安,她不能再读取网络流量,她拦截,头回华盛顿做一些研究 HTTPS。她了解到 HTTPS 将 HTTP 流量封装在另一种称为 TLS 的协议中。该协议允许客户端验证服务器的身份,并允许双方在彼此之间建立密钥。即使窃听者(如 Eve)正在监听整个通信流,该密钥也是保密的。理论上,TLS 将完全阻止 Eve 窥探 Alice、Bob 和 EA!

这个发现让夏娃很沮丧。但是,作为一个坚定的人,她决定开始寻找弱点。如果说她在这本书中学到了什么的话,那就是密码学经常被错误地使用,因此会被利用。

练习 8.1。网络流量里有什么?

假装成 Eve,检查一些你自己的加密流量。也就是说,配置你的浏览器使用你的代理,浏览一些 HTTP 网站,窥探你自己的数据。提示:安全通信的某些部分仍然是明文吗?

如果您不知道如何配置您的浏览器代理,请在您选择的搜索引擎上做一些搜索!请注意,您可能无法将您的浏览器配置为对未加密(HTTP)流量正确使用您的代理。我们亲自测试了 Chrome,发现它对 HTTPS 使用了CONNECT方法,但对 HTTP 不使用。

数字身份:X.509 证书

为了开始寻找弱点,Eve 首先转向 TLS 协议的认证部分。

她了解到 TLS 使用公钥基础设施(PKI)来建立身份和安全通信。希望拥有用于 TLS 的身份的各方(通常)需要 X.509 证书。

在第五章中,我们介绍了证书的概念。当时,为了简单起见,我们使用了假证书,这些证书只不过是我们用 Python json库序列化的字典。现在是时候研究真正的 X.509 证书了,这是当今互联网上最常用的一种证书。

X.509 字段

有点类似于我们基于字典的证书,X.509 是一个键/值对的集合。虽然 X.509 的字段允许分层子字段,但是这些对也可以用字典来表示。

具体来说,X.509 的版本 3 具有以下分层键:

  1. 证书

    1. 版本号

    2. 序列号

    3. 签名算法 ID

    4. 发行人名称

    5. 有效期

      1. 之前不会

      2. 不是之后

    6. 主题名称

    7. 主题公钥信息

      1. 公钥算法

      2. 主题公钥

    8. 发行者唯一标识符(可选)

    9. 主题唯一标识符(可选)

    10. 扩展(可选)

  2. 证书签名算法

  3. 证书签名

X.509 的版本 1 和 2 是子集。版本 3 最重要的附加功能是扩展。这些扩展用于使启用证书的 PKI 更加安全,例如,通过限制证书的用途。尽管如此,版本 1 证书仍然存在并且是可用的,当我们开始生成一些示例时,我们将会看到这一点。

证书的主要目的是将主体的身份与颁发者签名下的公钥联系起来。标识主题、公钥和颁发者的字段是最关键的,但是其他字段提供了理解和解释数据所必需的上下文信息。

例如,有效期用于确定证书何时应被视为有效。虽然“不得早于”字段很重要并且必须检查,但实际上“不得晚于”时间段通常最受关注。具有较高泄密风险的证书可以用较短的有效期来发行,以减轻如果发生泄密所造成的损害。

X.509 证书的另一个重要内容是在标识所使用的证书创建算法和嵌入其中的公钥类型的字段中。与本书中的大多数玩具例子不同,真正的加密系统使用了广泛的算法,证书必须足够灵活以支持它们。

浏览前面的 X.509 字段,有一个“证书:签名算法 ID”字段,它标识证书是如何签名的。 2 因为它规定了嵌入证书中的实际签名的所有细节,所以它既包括签名算法(例如 RSA)又包括消息摘要(例如 SHA-256)。

另一方面,“证书:主体公钥信息:公钥算法”字段指定证书所有者正在使用什么类型的公钥。

我们将提到的最后一个上下文字段是序列号。这是唯一标识证书的唯一编号(每个颁发者)。该编号对于本章后面讨论的撤销目的很有用。

现在让我们回到我们拥有证书的真正原因:识别主体、主体的公钥以及“证明”这一点的可信第三方。

显然,字段“发布者名称”和“主题名称”描述了发布者和主题所声明的身份。在前几章的假证书中,这些只是简单的字符串。在真实的证书中,这些不仅仅是原始的文本字段,还具有结构和子组件。称为“识别名”,这两个身份字段通常具有以下子字段 3 :

  1. CN: CommonName

  2. OU:组织单位

  3. o:组织

  4. l:地点

  5. s:state corporations name

  6. c:国家名称

因此,例如,“主题名称”或“发行者名称”可能如下所示:

CN= Charlie, OU= Espionage, O=EA, L= Room 110, S=HQ, C=EA

并非所有这些子字段都必须填写,但 CN(通用名称)通常是关键的子字段。后来,当我们试图验证一个证书时,主体的通用名称被用作主要标识符。此外,大多数现代证书包括一个名为“Subject Alternative Name”的字段(这是版本 3,用于存储替代主题名称。虽然在我们的许多示例中,我们一直使用代理(代码)名称(例如,“Charlie”)作为主题名称,但与受 TLS 保护的 web 服务器相关联的证书必须将主机名(例如google.com)标识为主题的身份。

您可能还注意到,证书包括“颁发者唯一标识符”和“主题唯一标识符”字段,但是这些字段通常可以省略,这里不讨论。

确定了主题和颁发者后,剩下的字段是公钥和根据证书内容计算的签名。签名是通过称为“DER”(“区分编码规则”)的证书二进制编码计算的。签名既证明了证书是由真正的颁发者签署的,也证明了它没有被修改。

证书签名请求

为了在现实生活中创建证书,一方创建一个证书签名请求(CSR ),并将其传输到证书颁发机构(CA)。CSR 具有几乎所有与 X.509 证书相同的字段,但是缺少发行者(因为发行是我们试图通过请求获得的)。一旦 CA 拥有了 CSR,它就使用自己的证书和相关的私钥来生成最终的证书,并根据需要填写字段。最重要的字段之一是“发行者”字段。一个证书的颁发者应该与签名者证书的“主题”字段相同。一旦填充了所有字段,CA 就用自己的私钥签署证书。

注意:私钥仍然是私有的

请求证书的一方没有向 CA 发送其私钥。它只发送了一个带有其公共密钥的 CSR!任何人,即使是 CA,都不应该拥有私钥!

我们前面提到过,在签名之前,证书以一种称为 DER 的格式进行编码。正如我们所说,DER 格式是一种二进制格式。大多数证书(以及 CSR 和私钥)的磁盘表示实际上是一种称为 PEM(“隐私增强邮件”)的文本(ASCII)格式。因为所有的二进制数据都被编码为 ASCII,所以通过基于文本的传输系统(例如,电子邮件)发送这些证书是很容易的。

有了这些关于证书的知识,Eve 决定创建一个证书。因为 Eve 没有证书颁发机构(CA)来签署她的证书,所以她将尝试两种替代方法:自签名和由她自己创建的“假”CA 签署。 4

生成 X.509 证书的一种常见方法是从命令行使用openssl。由于您在本书的练习中使用了cryptography模块(它使用 OpenSSL 库),所以您应该安装了 OpenSSL。伊芙知道,所以她要用它。

首先,Eve 需要创建一个私钥和一个相关的 CSR(“证书签名请求”)。她首先用 2048 位模数的 RSA 公钥和阿沙-256 消息摘要创建一个 CSR。下面的许多命令可以组合在一起形成一个更简单的命令行,但是我们将它们分开,以强调 Eve 采取的不同步骤:

  1. 生成一个 RSA 密钥。

  2. 从密钥创建一个 CSR。

  3. 发送给证书颁发机构进行签名(或自己签名)。

生成密钥

首先,她生成一个 RSA 密钥。我们以前在 Python 中做过这种工作,但是为了获得一些使用 OpenSSL 的实践,让我们看看命令行方法:

openssl genpkey -algorithm RSA -out domain_key.pem -pkeyopt rsa_keygen_bits:2048

在互联网上散布的各种生成 RSA 密钥的说明中,有许多使用不同的 OpenSSL 命令genrsa的指南和演练。请注意,更通用的genpkey已经取代了genrsa。Eve 的示例命令要求使用 RSA 算法生成一个 2048 位的私钥。输出将保存在domain_key.pem(PEM 格式)。

Eve 在文本编辑器中检查了密钥文件,看到如下内容:

    -BEGIN PRIVATE KEY    -
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpQ0VUe4P0r8+l
6rX4qQGyNHD613X16sqeIW2x+PtkeE9pjAm6sNhFKAspHKa7nWgFoW/O9iiT8oiy
1ah7KbtJsAXceUEbj9Yt6fHPytGe+qIidI1/Rg7ah4k7cn6pbPrqaxGc8n8368pM
NzJZMnLZL0ePVn/y2mTsGX5wR+Cm+imEFBWxL7jgnhYAyLRdOYsdGaZi5DJQaHl7
HqXaL7+6G6RAjhW+Hn34ImBufOvY9eV3dCRvOFCSWr4e5uHv5ofUyRWB2Emwm8u6
SM3zzI30OFb6zHWoBsccU8xJadhWgPXLq27rcSl3A5NK6y1p7KKHimqcp6WDUgMK
3NzCIXK9AgMBAAECggEAB2zfDry4ZjSMPHAWeYkYfPPV/PsUvqwFJXi78jHE/XxV
p4CwMJNveWEvVCdgnRxjotOZLxAXaZ4bJxU+ZeDHyYzCRRDArW/a6nq30/DGz12Z
XT+VsX6mSinl+Eimi9IvE7eMt0DgGdjrL/q/56/R3/s1/XDC/ilcggsAQ/azQT/n
3cOxWoo0HYQQdbMkoi7YDRKOC7F2sfV3X02WMDq4PuWG6mFtLg4j8tpAaJRCOlEz
bNnJnbBS6Dj3RnU53nj5TKBObCIZWkgpYcGK9e2iIg5+kMgkmwY5uxv3hTB5QHZY
tKDOPM9wgvDIR6NrccOGQOJ0cvJmMHDNS8apT2rewQKBgQDhjsS3M3qWT6lzhFx3
+w6NJv7i/uOA2eNd+Kor0q5XYOTicT8XCShSO2gFT6Fg4HRrSvwcjaTpjacUIyjZ
IhfrIIcSEe8Bk1VoBbrcS2NEZ3hMpPrPQ/hZtzUchhA1ftMJOfnysYGtqjA4drpq
HS8rPGmcP8NN1zYnv29ptfkmzQKBgQDAG3W8gA/mqjpboOB/OeC1fMX7u6pJVWGj
f+Bahjj5FAwfOYHJ80N10m/NpUD7BnKKds0dYyOwV287+hhLnQZ2c3glxM/zONUn
9uYIgAWNm0wjsCKOVY6r9nc6kWW07I0kIm628K50BPxiXC/GqsXVpKSPjSrDhKnQ
vG1xFN4bsQKBgA1kP5Os78NK2YGtQxwwgK2quglaHsHArfofUGMnsAgqDYzQMnG4
rncrZcKi9q7cxKy2F//N/ROMwHW2nK8/kfH4zWwqOml6iOCTLoPzyeH+zqqmROnX
XEBfWzzlTMMQU5FBqvBYz50y9If1rJ2uO+WyQYbwVjUh6Oo1OHUrQ66lAoGAXKti
aiHkicLID/dVFEpZKXMdFkf65xE23mYLVd+1kAGpr05QW5jri+SNZkg3RmBf1Idm
fqyaRLCIygfkvGTs/yrIZH/CSHO772FcqfEHvL2TRwvqP3rqLe3gqfIFe/c4RpwN
iFYl8XWOQexyZ4VtlZesgkr4vAQ83qJmsMv+MKECgYEAjRVzqXEAV8DB5nzN+1cf
20vCrZxd1Ktgb/DUqRfZwpAWU5K9YFCHbLWTS96KiMFh45kuAUg/hSKJIktuY1eI
Pl+r3g9FwlnntIHaUiRstDGXuyZku//+gWZMAZU4t5DwvhIXXAG3AqSe0EsB/bi4
kdlstdXcN/HgthWvTQkVycY=
    -END PRIVATE KEY    -

从密钥创建 CSR

现在 Eve 有了她的密钥,她为这个密钥创建了一个 CSR。CSR 生成过程将从 Eve 创建的私钥中提取公钥,并将其放入请求中。Eve 使用带有以下参数的openssl req命令进行该操作:

openssl req -new -key domain_key.pem -out domain_request.csr

这指示 OpenSSL 从私钥构建一个 CSR,并将结果放入domain_request.csr。运行这个命令会产生一些交互式问题,用于填写主题名称的元素。只有“通用名称”是 TLS 工作所绝对需要的,但是许多证书颁发机构将要求在他们愿意签署它之前填写这些字段。

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
    -
Country Name (2 letter code) [AU]: WA
State or Province Name (full name) [Some-State]:West Antarctic Shelf
Locality Name (eg, city) []:West Antarctic City
Organization Name (eg, company) [Internet Widgits Pty Ltd]:WACKO
Organizational Unit Name (eg, section) []:Espionage
Common Name (e.g. server FQDN or YOUR name) []:wacko.westantarctica.southpole.gov
Email Address []:eve@wacko.westantarctica.southpole.gov

一旦 Eve 输入了所有这些字段,OpenSSL 就会生成 CSR 文件并将其保存到磁盘上(也是 PEM 格式)。Eve 使用相同的实用程序(openssl req)从磁盘加载 CSR,并以人类可读的格式查看字段。

执行命令

openssl req -in domain_request.csr -text

产生以下输出:

Certificate Request:

    Data:
         Version: 1 (0 x0)
         Subject: C = WA, ST = West Antarctic Shelf, L = West Antarctic City,
              \
         O = WACKO, OU = Espionage, CN = wacko.westantarctica.southpole.gov,
             \
         emailAddress = eve@wacko.westantarctica.southpole.gov
         Subject Public Key Info:
             Public Key Algorithm: rsaEncryption
                 Public-Key: (2048 bit)
...
    Signature Algorithm: sha256WithRSAEncryption
         6d:ef:8c:91:cd:a0:5d:9f:56:42:44:7f:1a:06:94:3f:8e:e1:
...

您会注意到 Eve 的 CSR 版本是版本 1,而不是版本 3。除非正在使用版本 3 扩展,否则 OpenSSL 总是分配版本 1。但是记住,这只是请求,而不是实际的证书。当 ca 生成实际证书时,出于安全原因,它们可能会插入 V3 扩展,从而生成使用 X.509 版本 3 的证书。

此外,有些证书字段不存在,如“序列号”CA 签署 CSR 时也会添加这些内容。

从 Eve 的肩膀上看过去,你可能会惊讶地发现 CSR 已经有了一个签名(数据在Signature Algorithm后面的一行)。那是从哪里来的?签名不是在颁发者签署证书时创建的吗?

CSR 通常由它们自己的密钥签名,作为一种指示私钥实际上由请求者持有的方式。任何人都可以将任何人的公钥扔进 CSR。通过自签名,这向 CA 证明请求者控制着私钥,有时称为“拥有证明”CA 生成证书的真正签名是一个单独的过程,也是下一步。

签署 CSR 以生成证书

回顾一下,让我们记住,证书总是必须由 CA/发行者签名。例如,如果 Eve 创建了一个网站,并想要一个 TLS 证书,她将生成 CSR 并将其发送给 CA 进行签名,正如我们所讨论的那样。这个签名是他们认可 Eve 的证书是有效的,并且她被允许要求所要求的身份。CA 负责一定级别的验证。例如,如果 Eve 在东南极政府内部请求一个身份,ca 应该确定,作为他们验证过程的一部分,她不能声明那个身份。然后他们会拒绝她的请求。另一方面,她可以在她出生的西南极洲要求一个身份,并且可能需要向政府提供物理文档,并且亲自与 ca 的代表会面来证明这一点。

除了将 CSR 发送给 CA,Eve 还有另一种选择。她可以使用同一个私钥自己签署证书。这被称为生成一个自签名证书。所有根证书(例如由 CA 持有的根证书)都是自签名的。毕竟,链条总要在某个地方停下来。

我们太超前了。什么是证书链?

我们在第五章简要提到了这个概念。如果您还记得,当我们使用简化的(不是非常真实的)证书时,我们讨论过拥有一个可以任意长的颁发者链的颁发者。也就是说,一方的证书(比如 Eve 的证书)可以由一个发行者签名,然后由一个“更高”的发行者签名,再由一个更高的发行者签名,直到某个根证书成为整个链的最高级别的发行者。根证书由自己签名!事实上,根证书的主题和颁发者部分是相同的。

这就是为什么验证证书需要非常小心的原因之一。你必须确保你的证书链以一个可信的根结束。系统的整个安全性取决于这一要求。任何人,包括西南极洲的 Eve,你,或者美国的黑手党黑帮老大,都可以为任何身份(西南极洲政府,Google,Amazon,你的银行等)创建自签名证书。).您的浏览器不信任 Eve 的自签名证书的唯一原因是,它不是由它(浏览器)已经信任的发行者签名的。

浏览器如何知道哪些根证书值得信任?大多数浏览器都内置了某些受信任的根证书。在我们假设的南极例子中,东南极和西南极可以生产只安装了政府授权的 CAs 的浏览器。这实际上会阻止这两个国家相互通信(至少通过 HTTPS 或 TLS)。

但让我们回到伊芙身上。她无法获得由 EA 根签名的证书。相反,自签名证书可能是有用的,并且生成一个自签名证书是有益的。也是 Eve 目前最好的选择,就让她往前走吧。Eve 使用openssl x509命令签署她的 CSR:

openssl x509 -req \
  -days 30 \
  -in domain_request.csr \
  -signkey domain_key.pem \
  -out domain_cert.crt

此命令创建一个有效期为 30 天的证书。它由domain_key.pem签名,这是与 CSR 关联的同一个密钥。自签名证书保存在文件domain_cert.crt中。

使用类似于我们用于openssl req的语法,Eve 将字段转储为人类可读的格式以供查看。命令

openssl x509 -in domain_cert.crt -text

产生类似如下的输出:

Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number:
            a5:f5:15:a8:55:58:12:5e
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = WA, ST = West Antarctic Shelf, L = West Antarctic City,
            \
        O = WACKO, OU = Espionage, CN = wacko.westantarctica.southpole.gov,
            \
        emailAddress = eve@wacko.westantarctica.southpole.gov
        Validity
            Not Before: Jan 6 01:13:18 2019 GMT
            Not After : Feb 5 01:13:18 2019 GMT
        Subject: C = WA, ST = West Antarctic Shelf, L = West Antarctic City,
             \
        O = WACKO, OU = Espionage, CN = wacko.westantarctica.southpole.gov,
            \
        emailAddress = eve@wacko.westantarctica.southpole.gov
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                Modulus:
                    00:a9:43:45:54:7b:83:f4:af:cf:a5:ea:b5:f8:a9:
...
    Signature Algorithm: sha256WithRSAEncryption
         20:da:25:88:db:4e:ee:21:19:78:58:ed:b8:7b:3f:28:dd:83:
...

现在所有的字段都已填写完毕。例如,Eve 没有指定序列号,所以会自动生成一个序列号。还填写了 issuer 字段,与自签名证书一样,它与主题具有相同的身份。

Eve 决定创建第二个证书并用这个证书签名。她着手创建新的证书,并决定给它分配身份127.0.0.1 (localhost)。Eve 决定尝试创建除 RSA 密钥之外的密钥,她着手创建一个 EC(椭圆曲线)密钥对。

openssl genpkey \
  -algorithm EC \
  -out localhost_key.pem \
  -pkeyopt ec_paramgen_curve:P-256

这种 EC 密钥基于 P-256 曲线,这是一种非常流行和广泛使用的曲线,也是一种合理的选择。 5

Eve 使用与前面相同的命令行从 EC 密钥生成一个新的 CSR:

openssl req -new -key localhost_key.pem -out localhost_request.csr

现在 Eve 有一个请求来创建一个证书,而不是一个签名证书。反正还没有。为了创建证书,Eve 需要用domain_key.pem签名,因为她将该密钥和证书视为 CA 密钥/证书。

她还准备增加一些 X.509 V3 选项。这些选项用于限制证书的使用方式。例如,Eve 想要使用她的第一个证书和私钥(domain_cert.crtdomain_key.pem)来签署她的第二个证书。她希望她的第一个证书能够用作 CA。但是,她不希望她的第二个证书(用于本地主机)能够签署其他证书。使用 V3 扩展,Eve 可以将这些限制直接编码到证书本身中。

为了理解为什么这很重要,想象一下如果 Eve 被一个真正的 CA 授予了wacko.westantarctica.southpole.gov的证书。如果这个证书没有的使用限制,没有什么可以阻止 Eve 用它来签署一个新的证书,授予她eatsa.eastantarctica.southpole.gov的身份。这将为 Eve 提供一个返回到 CA 的授权链,以获得她不应该拥有的身份。因此,为了使证书链有意义,Eve 的证书必须拒绝她创建其他证书的权利。

在 Eve 的实验中,她最关心的两个领域是

  • 密钥用法

  • 基本限制

Eve 将使用这些字段来表示这个新证书不应被用作 CA。事实上,它会在“基本约束”字段中明确说明。“密钥使用”字段将包括正常的密钥使用,如“数字签名”,但它将省略诸如用作签署“证书撤销列表”(CRL)之类的内容。

为了将这些 V3 特性添加到她的证书中,Eve 创建了一个名为v3.ext.的扩展文件,它包含以下两行:

keyUsage=digitalSignature
basicConstraints=CA:FALSE

现在 Eve 准备签署 CSR。

openssl x509 -req \
  -days 365 \
  -in localhost_request.csr \
  -CAkey domain_key.pem \
  -CA domain_cert.crt \
  -out localhost_cert.crt \
  -set_serial 123456789 \
  -extfile v3.ext

用 CA 密钥和证书签名时,删除signkey参数,添加CA选项和CAkey参数。CA选项指定 CA/issuer 的证书,CAkey指定用于签名的相关私钥。Eve 插入她第一次实验的私钥和自签名证书。

虽然在创建自签名证书时不需要,但 Eve 现在必须在使用 CA 密钥和证书签名时明确指定序列号。一个真正的 CA 不得重复使用序列号,并且必须保留一份序列号记录,以防证书需要被撤销。

Eve 使用她的命令行检查了这个新证书,并发现了一些不同之处:

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 123456789 (0x75bcd15)
    Signature Algorithm: sha256WithRSAEncryption
        Issuer: C = WA, ST = West Antarctic Shelf, L = West Antarctic City,
            \
        O = WACKO, OU = Espionage, CN = wacko.westantarctica.southpole.gov,
            \
        emailAddress = eve@wacko.westantarctica.southpole.gov
        Validity
            Not Before: Jan 6 05:41:35 2019 GMT
            Not After : Jan 6 05:41:35 2020 GMT
        Subject: C = WA, ST = WhoCares, L = MyCity, O = Localhost,
            OU = Office, CN = 127.0.0.1
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:46:64:ca:95:0c:fc:dd:85:fb:cc:54:5a:9b:e9:
...
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Key Usage:
                Digital Signature
            X509v3 Basic Constraints:
                CA:FALSE
    Signature Algorithm: sha256WithRSAEncryption
         07:78:b5:1d:4a:2f:e4:33:a6:f6:a8:fb:e2:51:16:eb:c5:3b:
...

正如你所料,这次发行人和主题是不同的。事实上,该证书的 issuer 字段与签名证书的 subject 字段相匹配。这是正确的证书链验证所必需的。

还有,公钥算法现在是椭圆曲线而不是 RSA,但是Signature Algorithm还是sha256WithRSAEncryption。那是因为这个证书是由之前创建的domain_cert.crt Eve 签名的,那还是 RSA。

如您所见,X.509 V3 扩展存在,证书的版本现在也列为“3”。

夏娃故意制造了主体身份127.0.0.1。她决定测试一下她新制作的证书,看看 web 浏览器如何处理它们。使用openssl s_server,Eve 快速设置了一个对她生成的证书的测试。

openssl s_server -accept 8888 -www \
    -cert localhost_cert.pem -key localhost_key.pem \
    -cert_chain domain_cert.crt -build_chain

这个命令启动服务器监听端口 8888(对于您自己的测试,确保您的 HTTP 代理关闭,否则选择一个不同的端口)。它使用本地主机证书作为其身份证书,但使用域证书文件作为证书列表,用于构建链。build_chain选项指示服务器尝试构建一个完整的证书链以传输给客户端。换句话说,它将整个链发送给客户端,而不仅仅是身份证书。

一旦 Eve 启动了服务器,她将浏览器指向https://127.0.0.1:8888。她看到了类似图 8-1 的东西。

img/472260_1_En_8_Fig1_HTML.jpg

图 8-1

Chrome 关于不可信证书的警告

这是一个来自 Chrome 浏览器的图片,报告它不喜欢 Eve 创建的证书。注意 Eve 收到的是ERR_CERT_AUTHORITY_INVALID错误。使用 Chrome 的开发工具,Eve 获得了更多关于浏览器如何看待这个证书及其链的信息,如图 8-2 所示。

img/472260_1_En_8_Fig2_HTML.jpg

图 8-2

Chrome 对不可信情况的警告

图 8-2(b) 是一个关于证书链细节的图像,具体来说。请注意,它收到了该链(证书及其颁发证书)。它将 Eve 创建的域证书(在该图中由通用名称wacko.westantarctica.southpole.gov标识)识别为“根”证书,因为它是自签名的。但是,它说这个根证书不是可信证书。如果根证书不可信,那么整个链的安全性就无法建立。

有多种方法可以将根证书添加到浏览器的可信证书库中。Eve 非常仔细地研究了这个概念,因为她可能能够使用这种方法来击败 TLS。然而,我们不会在这本书里包括细节,因为这实际上是一个非常糟糕和危险的想法。这可能是迄今为止我们讨论过的最危险的事情。 6 如果你在浏览器中安装一个新的根证书,你的浏览器将信任该根证书签署的任何证书。如果您考虑不周的可信根不知何故逃到了野外,攻击者基本上可以让您的浏览器相信任何网站都是可信的。

说到这里,浏览器如何信任任何证书颁发机构呢?令人不安的武断的答案是,少数“权威”已经把自己确立为可靠的根权威。这些组织和公司将它们的根公钥默认安装在流行的计算机系统和浏览器中。所有其他的信任必须来自这些武断的权威。这让你感到安全吗?

然而,总的来说,为了让 TLS 正常工作,它必须有正确配置的(和正确限制的)信任锚。有时,工程团队可能需要使用自签名证书进行测试和其他临时用途。但是,一般来说,浏览器不会信任它们,您编写的任何支持 TLS 的代码也不应该信任它们。

练习 8.2。证书实践

使用不同的算法和参数(如密钥大小)生成一些不同的 TLS 证书。

练习 8.3。梦幻证书

为一些你喜欢的组织创建一些“幻想”证书。自签一两个写着amazon.comgoogle.com的证书。你不能使用这些,因为没有人的浏览器会接受它们。 7 但这是一种有趣的游戏。

也许你可以打印一份 Openssl 的文本表示,并把它框起来。毕竟你有几个朋友有亚马逊 TLS 证书?

用 Python 创建密钥、CSR 和证书

完成 OpenSSL 证书测试后,Eve 探索使用 Python cryptography库以编程方式创建这些相同的对象。使用这个库,Eve 可以生成自签名证书、证书请求、签名证书和密钥。Alice、Bob、Eve 和您已经在前面的章节中生成了密钥,所以让我们直接跳到证书请求。

cryptography库有一个用于构建 CSR 的“builder”类和一个单独的表示 CSR 的类。当使用builder构建 CSR 时,唯一需要的信息是主题名称数据和私有密钥。所有其他字段都可以派生或自动填充。可以选择添加扩展。以下代码摘自cryptography模块的文档:

>>> from cryptography import x509
>>> from cryptography.hazmat.backends import default_backend
>>> from cryptography.hazmat.primitives import hashes
>>> from cryptography.hazmat.primitives.asymmetric import rsa
>>> from cryptography.x509.oid import NameOID
>>> private_key = rsa.generate_private_key(
...     public_exponent=65537,
...     key_size=2048,
...     backend=default_backend())
>>> builder = x509.CertificateSigningRequestBuilder()
>>> builder = builder.subject_name(x509.Name([
...     x509.NameAttribute(NameOID.COMMON_NAME, 'cryptography.io')]))
>>> builder = builder.add_extension (
...     x509.BasicConstraints(ca=False, path_length=None),
...     critical=True)
>>> request = builder.sign(
...     private_key,
...     hashes.SHA256(),
...     default_backend())

CertificateSigningRequestBuilder遵循面向对象的“构建器模式”,其中每个构建方法返回构建器对象的一个新副本。当 Eve 决定用部分重叠的参数构造多个 CSR 时,这很方便。可以用重叠参数配置一个构建器,然后在参数不同时创建单独的构建器。

作为对 X.509 扩展的补充说明,您会注意到在我们的示例集ca=False中创建的 CSR。与我们之前的 OpenSSL 示例一样,我们明确地将该证书标记为不能签署其他证书(例如,充当 CA)。在这个例子中,它还设置了path_length=None,但是这是一个多余的数据,因为path_length仅在ca=True时适用。critical标志表示这是必须由处理软件处理的强制扩展。

准备就绪后,Eve 使用sign方法使用私钥构建实际的 CSR 请求对象。回想一下,CSR 是自签名的,以确保请求者拥有与嵌入的公钥相对应的私钥。sign方法从私钥中提取公钥,将其插入 CSR,然后用私钥签名。用这种方法构建的对象是CertificateSigningRequest的一个实例。

为了将 CSR 保存到磁盘,Eve 在返回数据的 PEM 序列化的CertificateSigningRequest对象中使用了public_bytes方法。

>>> from cryptography.hazmat.primitives.serialization import Encoding
>>> csr.public_bytes(Encoding.PEM)
b'    -BEGIN CERTIFICATE REQUEST    -\
    nMIICcDCCAVgCAQAwGjEYMBYGA1UEAwwPY3J5cHRvZ3JhcGh5LmlvMIIBIjANBgkq\
    nhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAntx7bGVFlIa0/dlImzUHbN4xCQ8d8/if\
    ng8GQaASN9oyfXUmOB8r+P8p4K6U8xoPXa+lc+KgexZrqibY5x1FEAvzQPanhm0w8\
    nhS7Uo1Pqt3okP6zsdfzXcjgceud8JJhVTqZWpN1Q5e+RldYwuzIsJyxNUFMUZrpL\
    nqZNQ0S/KG5re7YIHJLy3iCx6a/KAW5BbqW9cq989sdTp0Fo462+qCqoHaQ0//hQM\nTmWI/
    IJIZ9mIcP4ggJr0sy8JLAw/RLzcrpMRut8e1/A9mozo+YZJDPt9d+WzXj5p\
    nZvTkpFUfOB8HpogCdtbhPmc5jfgbN/rwOzSO8bQTdHAwTS/5fQjtAQIDAQABoBEw\
    nDwYJKoZIhvcNAQkOMQIwADANBgkqhkiG9w0BAQsFAAOCAQEAR1E3c/aF1X41x4tI\
    n2kUeCeV38C01ZFrCJADXKKl4k6wvHU81ZoDCV6F1ytCeJAlD1ShGS6DmlfH78xay\
    nrefzaIjCp0tRs5R4rccoRNK3LhyBnxEqLY1LZx1fq2F0XiMHlG8jEcK/jjhWm70B\
    naKwBbvWwlHGgha5ZlOgvALOPSFUC9+6LvTStanSABtlBM4eA2izLG2hMek9S5xIw\
    nK53WJG42Mz3PHDMUfYWdGtsJalAnGMkQtqbvR4yKi9o5y4RcvihQtitGFeYQmZc+\
    nhmuVB0BGCe9LUB0iL9J3kUgL4avO2AviCFev48i9OYGD54G73vKrd5KODtY78own\
    nVrbzMw==\n    -END CERTIFICATE REQUEST    -\n'

CSR 对象不能直接构造。它们可以由 builder 类构建,也可以从磁盘加载。该类是一个只读样式的类,允许访问数据字段,但不允许更改它们。必要时,Eve 使用 builder 类来构造新的 CSR。她还使用load_pem_x509_csr方法从磁盘加载 CSR。以下示例代码摘自cryptography文档。

>>> from cryptography import x509
>>> from cryptography.hazmat.backends import default_backend
>>> pem_req_data = b'''      BEGIN CERTIFICATE REQUEST      \
    nMIICcDCCAVgCAQAwGjEYMBYGA1UEAwwPY3J5cHRvZ3JhcGh5LmlvMIIBIjANBgkq\
    nhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAntx7bGVFlIa0/dlImzUHbN4xCQ8d8/if\
    ng8GQaASN9oyfXUmOB8r+P8p4K6U8xoPXa+lc+KgexZrqibY5x1FEAvzQPanhm0w8\
    nhS7Uo1Pqt3okP6zsdfzXcjgceud8JJhVTqZWpN1Q5e+RldYwuzIsJyxNUFMUZrpL\
    nqZNQ0S/KG5re7YIHJLy3iCx6a/KAW5BbqW9cq989sdTp0Fo462+qCqoHaQ0//hQMnnTmWI/
    IJIZ9mIcP4ggJr0sy8JLAw/RLzcrpMRut8e1/A9mozo+YZJDPt9d+WzXj5p\
    nZvTkpFUfOB8HpogCdtbhPmc5jfgbN/rwOzSO8bQTdHAwTS/5fQjtAQIDAQABoBEw\
    nDwYJKoZIhvcNAQkOMQIwADANBgkqhkiG9w0BAQsFAAOCAQEAR1E3c/aF1X41x4tI\
    n2kUeCeV38C01ZFrCJADXKKl4k6wvHU81ZoDCV6F1ytCeJAlD1ShGS6DmlfH78xay\
    nrefzaIjCp0tRs5R4rccoRNK3LhyBnxEqLY1LZx1fq2F0XiMHlG8jEcK/jjhWm70B\
    naKwBbvWwlHGgha5ZlOgvALOPSFUC9+6LvTStanSABtlBM4eA2izLG2hMek9S5xIw\
    nK53WJG42Mz3PHDMUfYWdGtsJalAnGMkQtqbvR4yKi9o5y4RcvihQtitGFeYQmZc+\
    nhmuVB0BGCe9LUB0iL9J3kUgL4avO2AviCFev48i9OYGD54G73vKrd5KODtY78own\
    nVrbzMw==\n      END CERTIFICATE REQUEST      \n'''
>>> csr = x509.load_pem_x509_csr(pem_req_data, default_backend())

为了制作证书,Eve 发现cryptography库遵循与制作 CSR 相似的模式。有一个生成器类和一个只读证书类,它们也可以序列化到磁盘和从磁盘序列化。

有趣的是,没有从 CSR 创建证书的方法。cryptography文档明确指出 certificate builder 类的目的是生成自签名证书。没有理由从企业社会责任开始。

即使 Eve 想要建立一个 CA(为她自己的西南极同事),对她来说最好不要自动签署 CSR。正如我们前面讨论的,ca 需要非常仔细地验证 CSR 信息,有时需要手动验证;签署前必须确定正确性和有效性。

尽管如此,Eve 发现如果她需要从 CSR 创建证书,她可以加载 CSR,然后使用它的数据字段来填充证书生成器。

cryptography文档中,清单 8-2 包含了一个构建自签名证书的例子。这段代码运行后,certificate变量就有了我们需要的东西。

注意:点链接

我们利用了构建器上的每个操作都返回自身这一事实。这允许你看到“点链接”的方法。由于对“sign”的最后调用返回一个证书,而不是一个构建器,我们可以将这个长操作分配给证书本身。

 1   from cryptography import x509
 2   from cryptography.hazmat.backends import default_backend
 3   from cryptography.hazmat.primitives import hashes
 4   from cryptography.hazmat.primitives.asymmetric import rsa
 5   from cryptography.x509.oid import NameOID
 6
 7   import datetime
 8
 9   one_day = datetime.timedelta(1, 0, 0)
10
11   private_key = rsa.generate_private_key(
12       public_exponent=65537,
13       key_size=2048,
14       backend=default_backend())
15
16   public_key = private_key.public_key()
17
18   certificate = x509.CertificateBuilder(
19   ).subject_name(x509.Name([
20        x509.NameAttribute(NameOID.COMMON_NAME, 'cryptography.io')])
21   ).issuer_name(x509.Name([
22        x509.NameAttribute(NameOID.COMMON_NAME, 'cryptography.io')])
23   ).not_valid_before(datetime.datetime.today() - one_day
24   ).not_valid_after(datetime.datetime.today() + (one_day * 30)
25   ).serial_number(x509.random_serial_number()
26   ).public_key(public_key
27   ).add_extension(
28        x509.SubjectAlternativeName([x509.DNSName('cryptography.io')]),
29        critical=False,
30   ).add_extension(
31        x509.BasicConstraints(ca=False, path_length=None),
32        critical=True,
33   ).sign(
34        private_key=private_key, algorithm=hashes.SHA256(),
35        backend=default_backend())

Listing 8-2
TLS Builder

为了修改这个示例以从 CSR 创建证书,Eve 可以直接从 CSR 对象中提取主题名称、公钥和可选的扩展,并将它们复制到证书生成器中。要使用 CA 证书/密钥对对证书进行签名,Eve 需要加载 CA 证书和密钥,将签名证书中的“Issuer”字段复制到证书生成器中,并使用证书的私钥进行签名。

可以使用load_pem_x509_certificate加载证书,然后使用public_bytes方法序列化以便存储或传输。

练习 8.4。Openssl 到 Python 的转换

用 Python 生成 CSR,用 Openssl 签名。

用 Openssl 生成一个 CSR,在 Python 中打开它,并从中创建一个自签名证书。

练习 8.5。证书在中间截取

在下一节中,我们将讨论 TLS,它是 HTTPS 的基础安全协议。TLS 依赖于您在本节中学到的证书。回到您的 HTTP 代理,拦截更多的 HTTPS 流量,看看您是否能判断出证书何时被发送。

这是一项艰巨的任务,对于那些对实验和修补感兴趣的人来说更是如此。提示一下,证书不是以 PEM 格式发送,而是以 DER 格式发送。这是二进制格式。但不是加密。您可以尝试探索某些二进制字节组合。您还可以使用 openssl 将您创建的证书转换成 DER 格式,并在一个十六进制编辑器中检查它们,看看是否有要寻找的公共字节。

练习 8.6。中间修改证书

如果您设法发现证书何时通过网络,请修改您的 HTTP 代理程序来拦截和修改它们。至少,你可以发送一个预装的证书。你的浏览器对此有何感想?

TLS 1.2 和 1.3 概述

凭借对 X.509 证书的一点点了解,Eve 开始研究 TLS 协议。当您继续学习时,您应该认识到 TLS 协议利用了我们在前面所有章节中学习过的加密组件。这对你和 Eve 来说是一个机会,可以看到现代安全协议中的所有部分是如何组合在一起的。

TLS 协议的目标是提供传输安全性(TLS 代表“传输层安全性”)。互联网所基于的 TCP/IP 协议组没有任何安全保证。它不提供保密性,这就是为什么 Eve 能够使用 HTTP 代理来读取双方之间发送的数据。

至少同样糟糕的是,TCP/IP 也不提供真实性。Eve 可以使用她的 HTTP 代理,稍加修改就伪装成真正的目的地(example.com),而 Alice 和 Bob 对此一无所知。TCP/IP 协议组也不提供消息完整性。代理可以改变数据,并且这种改变不会被检测到。

TLS 旨在将这些安全功能添加到 TCP/IP 之上。该协议起源于 20 世纪 90 年代中期 Netscape 的“安全套接字层”(SSL)协议。版本 2 是第一个公开发布的版本,紧接着是版本 3。随后,它接受了一些修改,并被重新命名为 TLS 1.0。 8 版本 1.2 已经存在了很多年,仍然被认为是最新的。最近,1.3 版本也发布了,但目前还没有被描述为 1.2 的替代版本(两个版本都被认为是当前版本)。

TLS 是如何工作的?从握手开始。那次握手极其重要。请记住,TLS 有两个主要目标:第一,建立身份 9 ,第二,相互导出用于安全传输的会话密钥。这两个目标通常通过成功的 TLS 握手来实现。

握手也是各种 TLS 版本之间最大的不同之处。在本节中,我们将回顾 TLS 1.2 握手,然后简要讨论 TLS 1.3 的握手有何不同。在解释了 TLS 1.2 握手之后,TLS 1.3 的变化会更有意义。

请注意,这部分有点学术性。Eve 在编程方面没有太多可以尝试的地方。这一背景将有助于她理解 TLS 应该如何工作,以及过去哪里出了问题。Eve 可以利用这些信息来判断哪些服务器比其他服务器更容易被破解。

与此同时,你,读者,将从观察 Eve 试图突破 TLS 应该提供的加密屏障中受益。在整本书中,我们一直在向你灌输,只要有经过良好测试的库,你就不应该创建自己的算法,也不应该创建自己的实现。

TLS 实际上是一个你可以并且应该使用的协议,Python 中有很多对它的库支持,这很有帮助。但是,如果 Eve 想要攻击您的系统,您想知道她会寻找什么样的东西。让我们开始吧。

开场白“你好”

TLS 1.2 从客户端向服务器发送客户端“hello”消息开始。客户端问候消息包括关于其 TLS 配置的信息,以及一个随机数。其中一个配置是客户端的密码套件列表。对于新来者来说,TLS 最令人困惑的特征之一可能是 TLS 协议实际上是一起工作的协议的组合。它支持许多不同的算法和协议组合。

出于需要,hello 消息必须让客户机和服务器准备使用相同的算法和组件协议进行通信。客户端发送一个密码套件列表,以指示它愿意使用的所有不同方式,服务器将在其响应中选择一种方式(假设它们支持的密码套件之间有任何重叠)。

TLS 的密码套件通常包括密钥交换、签名、批量加密和哈希算法的一种选择。正如我们所说,TLS 汇集了您在本书中学习的所有不同元素,因此这些术语应该看起来很熟悉!

TLS 1.2 使用的一个密码套件是TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384。这个密码套件可以这样理解:

  • 密码套件适用的协议。很简单。

  • ECDHE:如第六章所述,客户端和服务器将使用 ECDHE 创建对称密钥。

  • 从对 ECDHE 的了解中回忆起,它是未经认证的。为了确保服务器是它所声称的那个人,它将在一些握手数据上使用 ECDSA 签名。

  • AES_256_CBC:握手结束后,客户端和服务器将以 CBC 模式发送受 AES-256 保护的数据。

  • SHA_384:该参数与 TLS 操作的两个不同的部分有关。握手期间,SHA-384 算法将用于密钥推导函数。此外,握手后发送的批量加密消息(在 CBC 模式下由 AES-256 加密)将受到 HMAC-SHA-384 的保护,不会被修改。

当我们学习 TLS 协议的其余部分时,这些元素将变得更有意义。同时,它也很好地介绍了作为 TLS 操作一部分的组件数量。

注:ECDH 诉 ecdh

在本书中,我们没有过多区分 DH/ECDH 和 DHE/ECDHE。提醒一下,“E”代表“短暂”当 DH/ECDH 用于临时模式时,公钥/私钥对被使用一次并被丢弃。

我们没有努力说“DHE”而不是“DH”的原因是,在许多上下文中,DH 隐含地是短暂的。

TLS 的情况不是。有些运营模式根本不是昙花一现。因此,我们将在这一章中使用完整的 DHE/ECDHE 术语。

请注意,TLS 的强度很大程度上取决于它的密码套件。有点可怕的是,两台服务器可以“使用”TLS 1.2,其中一台服务器受到强保护,而另一台服务器由于选择了密码套件而容易受到攻击。不要忽略 TLS 握手中的 hello 部分!

真的很重要!

在客户端的 hello 中还有一些其他字段。图 8-3 是 Wireshark(一种网络嗅探器,可以捕获任何类型的网络流量,而不仅仅是像你的代理一样的 HTTP)截获的实际 hello 消息。

img/472260_1_En_8_Fig3_HTML.jpg

图 8-3

TLS 1.2 hello 消息的 Wireshark 解码

请注意,有一整节是关于密码套件的!在图 8-4 中,我们看到了扩展的列表。这是一个相当长的密码套件列表!请记住,这是一个从客户端到服务器的问候消息,这个列表是客户端愿意使用的所有密码套件。

img/472260_1_En_8_Fig4_HTML.jpg

图 8-4

TLS 1.2 hello 消息的密码套件组件的 Wireshark 解码

当服务器收到客户机的问候时,它将查看是否愿意使用客户机建议的密码套件中的一个。如果是,它会发回一个包含多个元素的响应:

  • Hello:服务器的 hello 消息包含它自己的随机 nonce。

  • 证书:服务器的 TLS 证书或证书链,我们在本章前面已经详细介绍过。

  • 密钥交换:如果密码套件使用 DHE 或 ECDHE,服务器也将连同 hello 一起传输它的 Diffie-Hellman 交换部分。对于 RSA 密钥传输,服务器不发送此元素。

  • 完成:一种消息结束标记。

TLS 规范实际上为握手中发送的每种消息都指定了特定的名称。因此,虽然我们非正式地提到了客户端问候消息,但 TLS 1.2 实际上指定消息的名称是ClientHello。图 8-5 显示了ClientHelloServerHello的交换以及官方消息名称。

img/472260_1_En_8_Fig5_HTML.jpg

图 8-5

TLS 1.2 客户端和服务器 hello exchange

练习 8.7。谁

如果您一直在练习使用 HTTP 代理(特别是在前面的练习中),您应该已经对 TLS 中的来回交换有所了解。所以,现在你知道了 TLS 握手是如何从最初的 hellos 开始的,试着对它进行一点逆向工程。记住,这部分沟通是明文!

你能弄清楚你看到的是 TLS 1.2 还是 1.3 握手吗?这是一个很好的开始!

客户端身份验证

当服务器的证书与ServerHello一起发送时,当今最流行的 TLS 配置只对服务器进行认证。除非服务器明确请求,否则客户端不会发送证书来验证自身。

对于许多互联网应用来说,这已经足够了。服务器运行在互联网上,向全世界广播它们的信息。他们想向世界证明他们就是他们所说的那个人。欢迎任何人前来参观,无需证明身份。此外,客户的确切身份还不太清楚。服务器的身份通常与域名(例如,google.com)或 IP 地址相关联。但是当你在浏览互联网的时候,你的电脑的应该是什么身份呢?

对于服务器需要识别客户端的情况,比如银行交易或任何其他类型的帐户访问,用户的身份(而不是机器的身份)才是真正重要的。在这些情况下,服务器关心的是用户名和密码(或其他类型的个人身份)。从概念上讲,通过首先对服务器进行身份验证并创建一个共享密钥,用户就可以使用密码之类的东西安全地向服务器表明自己的身份,而不必担心将自己的机密信息泄露给错误的一方。

然而,有时安全策略规定客户端设备也必须被认证。当 TLS 如此配置时,它被称为“相互 TLS”(MTLS)。在这种模式下,服务器让客户端知道它需要一个证书和证书所有权的证明。

练习 8.8。客户认证研究

Mutual TLS 用的不是很频繁,但是用的到。即使使用了证书,客户端的身份验证通常也略有不同。在互联网上搜索一下如何用客户端证书配置浏览器,如何获得这样的证书,以及为主题选择哪种标识符。

驾驶会话密钥

回想一下第六章,一种非常常见的加密配置是使用非对称操作来交换或生成对称会话密钥。在同一章中,我们讨论了两种不同的方式:密钥传输和密钥协商。

在 TLS 1.2 握手中,目标是让客户端和服务器获得对称密钥的相同副本。事实上,这并不完全正确。目标是获得所谓的“预掌握秘密”(PMS)。PMS 和其他一些非机密数据将用于生成“主机密”主密钥将用于生成批量数据通信所需的所有会话密钥。

TLS 1.2 通过其各种密码套件,提供了密钥传输和密钥协商方法来提供 PMS。

TLS_RSA开头的 TLS 密码套件是指使用 RSA 加密进行密钥传输的 TLS 套件。比如密码套件TLS_RSA_WITH_3DES_EDE_CBC_SHA。您可能会注意到,在我们之前的例子中,对于 ECDHE,我们需要 ECDSA 签名。为什么 RSA 密钥传输不需要 RSA 或 ECDSA 签名?

正如我们在上一节中所说的,如果 ECDHE 或 DHE 用于密钥交换,服务器会将这些参数与服务器 hello 一起发送。但是如果使用 RSA 密钥传输,它什么也不发送。相反,在 RSA 密钥传输模式中,客户端接收随服务器 hello 发送的服务器证书,提取公钥,并用公钥加密 PMS。它将加密的 PMS 传输到服务器,并且只有服务器能够随后解密它。现在客户机和服务器都有相同的 PMS。

不需要签名的原因是因为 RSA 加密只能由拥有相应私钥的一方打开。如果服务器能够使用从 PMS 导出的会话密钥进行通信,则它必须拥有私钥,并且必须是证书的所有者。该过程如图 8-6 所示。

img/472260_1_En_8_Fig6_HTML.jpg

图 8-6

使用 RSA 密钥传输的 TLS 密钥交换

DHE 和 ECDHE 表现不同。它们被称为密钥协商协议,因为不传输 PMS。取而代之的是,双方交换 DH/ECDH 临时公钥,该公钥可用于同时导出双方的 pm。提醒一下,交换的 DH/ECDH 公钥与证书中的 RSA 或 ECDSA 公钥不同。DH/ECDH 公钥在现场生成,并且只使用一次。这就是它们短暂的原因。

这也是他们不可信的原因。如果公钥只是现场编造的,那么客户机如何知道公钥真的来自服务器?服务器如何知道它收到的公钥真的来自客户端?

长期 RSA 或 ECDSA 私钥被服务器用来签署其 DHE 或 ECDHE 公钥和参数(如曲线)。当客户端收到它们时,它可以使用证书中服务器的公钥来验证 DHE 或 ECDHE 数据来自正确的来源。正如上一节所讨论的,通常客户端不签署任何东西。

用于 TLS 握手的 DHE/ECDHE 版本的密钥交换如图 8-7 所示。

img/472260_1_En_8_Fig7_HTML.jpg

图 8-7

使用 DHE/ECDHE 的 TLS 密钥协商

这两种方法的安全性非常不同。正如我们已经在第六章中讨论的,DH/ECDH 方法提供了完美的前向保密性,而 RSA 加密方法则没有。此外,RSA 加密方法具有完全由客户端生成的预主密钥。服务器必须相信客户端没有重复使用相同的预主密钥(或者从不良的随机性来源生成它们)。

即使从预主密钥导出的会话密钥依赖于防止普通重放攻击的附加数据(包括ClientHello随机数和ServerHello随机数),重用预主密钥也是次优的,并可能降低系统的安全性。另一方面,当使用 DH/ECDH 时,服务器和客户端都参与密钥材料的生成,确保服务器在这个值上不完全依赖于客户端。

RSA 加密方案有问题还有一个原因:它使用 PKCS 1.5 填充。在第四章中,你发现这个方案容易受到填充预言攻击。TLS 1.2 提供了旨在消除预言的“对策”(记住,要使攻击奏效,攻击者需要知道填充何时被接受),但不幸的是,它们并不总是成功的。正如本章后面更详细描述的,这种攻击仍然是一种威胁。

由于这些原因和其他原因,大多数安全专家鼓励 TLS 服务器停止使用 RSA 加密进行密钥传输。至少,这种形式的密钥交换应该是最后的选择。

练习 8.9。关键练习

尝试重新创建 TLS 的密钥传输和密钥协议操作。先说关键运输。从获取您生成的一个 RSA 证书开始。如果你是一个浏览器,这就是你通过网络收到的内容。创建一个 Python 程序来导入证书,提取 RSA 公钥,并使用它来加密您写回磁盘的一些随机字节(例如,类似于密钥)。

第六章中已经有关于密钥协商的练习,甚至是通过网络。如果你没有做这些练习,那么现在可以再试一次。

切换到新的密码

一旦客户端发送完密钥交换信息(使用 RSA 加密或 DHE/ECDHE),它就不再需要明文发送数据。所有随后的信息都应加密和认证发送。

为了表示这一点,客户端向服务器发送一个名为ChangeCipherSpec的消息。这基本上就是说,从这一点开始,从客户端发送的所有其他内容都将使用协商的密码进行发送。一旦服务器接收到客户端密钥交换数据,它也可以导出会话密钥。与客户端一样,没有理由进行明文通信,服务器发送自己的ChangeCipherSpec消息。

然后每一方发送一个Finished消息来完成握手。Finished消息包含迄今为止发送的所有握手消息的哈希,因为它是在ChangeCipherSpec消息之后发送的,所以在新的密码套件下被加密和认证。

图 8-8 显示了整个握手过程,不包括一些不常见的信息。

img/472260_1_En_8_Fig8_HTML.jpg

图 8-8

TLS 1.2 握手

握手消息哈希的目的是防止攻击者在更改密码规范之前更改任何明文发送的消息。例如,如果攻击者拦截并修改了客户端 hello 消息,他们就可以消除更难的密码,而让弱密码发挥作用,从而降低破解系统的难度。但是,双方都保留发送的消息的记录,并在新的密码套件下传输所有这些消息的哈希。如果哈希值不匹配,那么一方发送的不是另一方收到的。在这种情况下,通信信道被认为受到损害,并被立即关闭。

派生密钥和批量数据传输

至此,TLS 1.2 握手结束。客户端已经使用公钥证书验证了服务器的身份,并且双方共享一个预主密钥。

不管预主密钥是如何生成的,客户端和服务器都使用它来导出密钥。这些密钥使用对称加密和消息认证来创建安全的认证通道。使用此通道设置应用数据。但是首先,我们来谈谈这些派生的密钥。

在本书中,我们已经使用了多种方法从数据中导出了键。许多都是围绕哈希以这样或那样的形式构建的。在 TLS 1.2 中,使用规范中所谓的“伪随机函数”(PRF),将预主密钥扩展为“主密钥”。默认情况下,PRF 是使用 HMAC-SHA256 构建的,它使用一种基于 HMAC 的扩展机制来重复调用;一个调用的输出被提供给另一个调用,以便将数据扩展到任意大小。如果密码套件指定,也可以使用不同的底层机制来构建 PRF。

提醒一下,密钥扩展的思想就是简单地将一个秘密扩展成更多的字节。在 TLS 的情况下,我们将预主密钥扩展为 48 个字节,不管它有多大。这是最高机密。主秘密本身被扩展成密码组所需的所有会话密钥和 iv 所需的字节数。不同的套件需要不同的参数和不同的大小,因此称为key_block的主套件的最终输出是可变长度的。

最多有六个参数:

  • 客户端写入 MAC 密钥

  • 服务器写入 MAC 密钥

  • 客户端写入密钥

  • 服务器写入密钥

  • 客户端写入 IV

  • 服务器写入 IV

考虑将 PMS 扩展到主秘密和将主秘密扩展到key_block可能会有点混乱。为了说明所有这些活动部件,请看图 8-9 。

img/472260_1_En_8_Fig9_HTML.jpg

图 8-9

TLS 密钥派生。预主秘密被扩展到主秘密,主秘密被扩展到key_block。最终的输出根据需要被分成单独的键和 iv。

您会注意到没有列出 read 键。这是因为这些是对称密钥。换句话说,服务器的写密钥就是客户端的读密钥。

练习 8.10。实施 PRF

查看 RFC 5246(在线提供),并查找 PRF。在第 13 和 14 页有关于它如何工作的描述。为 HMAC-SHA256 实施 PRF,并尝试一些关键扩展。生成大约 100 个字节,然后将其中一些划分给不同的键。

也不是所有这些参数都用于每个密码套件。AES-GCM 和 AES-CCM 等 AEAD 算法不需要 MAC 密钥。即便如此,每个密码套件都提供了保密性和认证性。 10 这要么涉及加密和应用 MAC,要么使用 AEAD 加密。

说到这里,TLS 1.0 中的 AES-CBC 模式容易受到填充 oracle 攻击,因为它们首先应用 MAC,然后加密。这容易受到与你在第三章中所做的练习相同的攻击。虽然理论上 TLS 1.2 不应该容易受到这种攻击,但是一些实现没有正确遵循规范,并且被发现容易受到攻击。因此,CBC 运营模式近年来已经失宠。

了解 MAC 的应用领域也是很好的。在第五章中,我们就这个问题进行了简短的讨论。你可能还记得,我们曾经讨论过在使用 MAC 之前,人们希望加密多少数据。在通信环境中,您会等到通信会话即将结束时才发送所有传输数据的 MAC 吗?这可能是个坏主意。毕竟,如果沟通会持续一个月呢!这将是一件可怕的事情,到了月底,发现所有收到的数据都是假的。TLS 选择在每个数据包上放置一个 MAC(在ChangeCipherSpec之后)。

TLS 在一个叫做TLSCipherText的数据结构中传输所有的批量数据。你可以把TLSCipherText想象成一个 TLS 加密的数据包,每个数据包可以容纳大约 16K 的明文。TLS 标准像 C 风格的结构一样表达这种数据结构:

 1   struct {
 2         ContentType type;
 3         ProtocolVersion version;
 4         uint16 length;
 5         select (SecurityParameters.cipher_type) {
 6             case stream: GenericStreamCipher;
 7             case block: GenericBlockCipher;
 8             case aead: GenericAEADCipher;
 9         } fragment;
10   } TLSCiphertext;

如果你不熟悉 C 风格的结构,这只是一个原始的数据结构。这有点像 Python 中的一个类,但是没有任何方法。该结构有typeversionlength字段,这些字段相当简单。ContentTypeProtocolVersion的确切类型在文档的其他地方有定义,但是即使不用查找它们,意图也很清楚。

select的陈述可能更令人困惑。这部分结构表达的是有一个fragment字段,但是它的类型是三个选项之一:GenericStreamCipherGenericBlockStreamGenericAEADCipher。这三个选项中的每一个都代表一种不同的密码。

为了清楚起见,这里显示的结构是概念性的。这个结构展示了数据是如何以二进制形式以一种易于理解的方式以及层次结构(数据结构中的数据结构)进行布局和连接的。当发送数据时,TLS 按以下顺序构造一个包含这些片段的二进制数据流。

流和块密码类型都包括 MAC 作为密码类型的一部分。子类型定义如下:

 1   stream-ciphered struct {
 2         opaque content[TLSCompressed.length];
 3         opaque MAC[SecurityParameters.mac_length];
 4   } GenericStreamCipher ;
 5
 6   struct {
 7         opaque IV[SecurityParameters.record_iv_length];
 8         block-ciphered struct {
 9             opaque content[TLSCompressed.length];
10             opaque MAC[SecurityParameters.mac_length];
11             uint8 padding[GenericBlockCipher.padding_length];
12             uint8 padding_length;
13         };
14   } GenericBlockCipher;

这两种类型的内容字段都是明文(可能是压缩的)。各自结构前面的stream-cipheredblock-ciphered关键字表示二进制数据被加密。这两种密码类型的 MAC 都在加密结构内。文档说明这些 MAC 是根据内容计算的,包括内容类型、版本、长度和明文本身。显然,这是一个先 MAC 后加密的方案。

AEAD 算法的工作方式略有不同。协议中定义的概念结构如下所示:

1   struct {
2        opaque nonce_explicit[SecurityParameters.record_iv_length];
3        aead-ciphered struct {
4            opaque content[TLSCompressed.length];
5        };
6   } GenericAEADCipher ;

这里没有 MAC,因为 MAC 默认包含在输出中。回想一下第七章,AEAD 中的“AD”是指经过认证但未加密的“附加数据”。在 TLS AEAD 密码的情况下,AD 在流密码和块密码中包括相同的数据(应用了 MAC ),即内容类型、版本和长度。通过将该广告直接插入解密过程,算法将不会解密明文,除非上下文数据是正确的。这有助于减少错误并确保正确性。

重要的是,因为每个记录都有一个 MAC,所以每个TLSCiphertext块的 AEAD 加密都是最终确定的。在第七章中,我们讨论了在确定密文已经被修改之前不想等待千兆字节数据的想法。相应地,AEAD 算法在这些TLSCiphertext结构中的每一个上用单独的密钥和 IV (nonce)运行(在完成加密并产生标签之后,相同的密钥和 IV 不得被再次使用)。

在为 TLS 定义的GenericAEADCipher结构中,它包括一个携带一定量 IV/nonce 数据的nonce_explicit字段。对于 AEAD 算法,通常有 IV 的隐式部分和 IV 的显式部分。计算隐含部分。对于 TLS 1.2,密钥派生操作中派生的服务器(或客户端)IV 是隐式部分。双方在内部计算,而不通过网络发送。片段中包含的显式部分构成 IV/nonce 的其余部分,允许 nonce 对于每个数据包是唯一的。

练习 8.11。TLS 1.2 件

尝试将本章中其他练习中类似于 TLS 1.2 的内容串联起来。通过网络交换证书(如果更简单,您可以将其保留为 PEM 格式)。一旦你得到了服务器的证书,让客户端或者发回一个加密的 PMS,或者使用 ECDHE 来生成双方的 PMS。

你可以省去 TLS 所有复杂的东西。您不需要协商密码套件,创建底层记录层,或者在最后对所有消息进行哈希处理。交换一个证书,获得一个 PMS,并派生一些密钥。对于“包”结构,您可以使用与 Kerberos 练习相同的 JSON 字典。

TLS 1.3

TLS 1.3 协议代表了 TLS 历史上握手过程的最大变化。

首先,TLS 1.3 去掉了 TLS 1.2 中几乎所有的密码。只有五种可用的密码,而且都是 AEAD 密码:

  • TLS_AES_256_GCM_SHA384

  • TLS_CHACHA20_POLY1305_SHA256

  • TLS_AES_128_GCM_SHA256

  • TLS_AES_128_CCM_8_SHA256

  • TLS_AES_128_CCM_SHA256

基本上,TLS 1.3 支持 AES-GCM、AES-CCM 和 ChaCha20-Poly1305。你已经在这本书里看到了这三种算法。通过减少可用的密码套件并要求 AEAD,TLS 1.3 使服务器很难意外或不知不觉地使用弱加密或身份验证来保护他们的网站。

RSA 加密也不再作为密钥传输机制。

TLS 1.3 更大的变化是握手现在是一次往返。这个显著地减少了设置的等待时间。新的握手如图 8-10 所示。

img/472260_1_En_8_Fig10_HTML.jpg

图 8-10

TLS 1.3 握手的简化描述。整个握手被设计成在单个往返行程中起作用。

从技术上讲,还有一条来自客户端的“已完成”消息,但如图所示,它可以与客户端的第一条应用消息放在一起。服务器可能已经传输了其握手消息附带的应用数据。

这种加速对于像 HTTP 这样的无状态协议尤其重要。大多数 HTTP 消息都是一次性的传输。为每条消息建立一个新的 TLS 1.2 隧道真的会降低网站的速度和响应能力。将等待时间减半对网络通信来说意义重大。

更重要的是,弱密码和模式已被删除。例如,通过消除 RSA 密钥传输,TLS 1.3 使得前向保密成为强制!将算法限制到 AEAD 也是一个重要的改进。

这两种协议还有其他的不同之处和细节没有在这里介绍,但是这对于介绍来说已经足够了。

警告:极度缺乏安全性(eTLS)

TLS 1.3 的一个“变体”被称为 eTLS。我们用引号将变体括起来,因为它不是由 IETF 开发的标准,IETF 是 TLS 背后的标准机构。它采用 TLS 1.3,并删除了一些最重要的安全功能,包括前向保密。

据称动机是防止数据丢失(DLP)、性能和其他可用性原因。但我们自己不支持有意削弱协议和算法的加密标准。我们强烈建议您在任何情况下都不要使用 ETL,并为拒绝支持它的浏览器喝彩。请注意,在未来的版本中,eTLS 将被重新命名为企业传输安全(ETS)[9]。

练习 8.12。现在什么坏了?

做一些研究,看看你是否能找到自这本书出版以来在 TLS(任何版本)中发现的新漏洞。及时了解您周围发生的漏洞以及未来的缓解途径非常重要。当坏人在他们之前发现你很脆弱,这是一件很可怕的事情。

证书验证和信任

Eve 已经读完了关于 TLS 的内容。她已经收集了一些攻击 TLS 的可能性:

  • TLS 的某些版本和实现中针对 RSA 加密的填充 oracle 攻击。

  • TLS 的某些版本和实现中针对 AES-CBC 加密的填充 oracle 攻击。

  • 试图强迫客户端和服务器使用弱密码套件。

所有这些都有防御措施,但这些都是 Eve 可以研究的领域。也许她运气好,会找到一个配置很差的服务器。我们将很快探讨这些攻击以及其他一些攻击。但是首先,Eve 决定看看另一个潜在的巨大漏洞:证书检查

在上一节中,我们只简要地提到了证书验证。当客户端收到服务器的证书时,客户端必须确保该证书有效且可信。客户端证书可能依赖于一系列的 ca,验证过程被称为遵循证书路径。路径必须以受信任的根目录结束。

该流程的概要如下

  • 客户端证书的主题名称必须与来自 URI 的预期主机名匹配(例如,如果我们导航到 https://google.com ,那么google.com需要是 TLS 证书的主题)。

    • 主机名可以与主题的通用名称相匹配,或者

    • 主机名可以与主题的一个备用名(V3 扩展名)相匹配。

  • 路径中的所有证书都不能过期。

  • 路径中的任何证书都不能被吊销。

  • 在到达根之前,证书的颁发者必须是证书链中下一个证书的主体。

  • 实施证书限制(如KeyUsageBasicConstraints)。

  • 实施与最大路径长度、名称约束等相关的策略。

夏娃意识到这是一个复杂的过程。有许多检查要做,任何一个错误都可能让她进入。许多 TLS 漏洞与协议关系不大,更多的是与程序员或用户错误有关。

TLS 的整体安全性取决于颁发给授权方的证书。如果 Eve 可以获得一个未经授权的证书,窃取一个私钥,或者让 Alice 或 Bob(或您)相信她有一个经过授权的证书,那么其余的安全性就会崩溃。Eve 可能尝试的最强大的证书攻击是说服 Alice 或 Bob(或您)安装一个邪恶的根证书!如果发生这种情况,TLS 将接受 Eve 选择发送的任何证书!

证书吊销

我们在第五章提到过,证书在撤销方面有一个很大的弱点。不幸的是,撤销证书是一件非常痛苦的事情,Eve 正在仔细研究如何利用这一点。

有两种撤销证书的经典方法。第一个是证书撤销列表(CRL)。顾名思义,这只是已被吊销的证书的静态记录。为了保持 CRL 的大小易于管理,证书由其序列号来标识。CRL 通常是特定于 CA 的,并且由 CA 签名,因此 CA 跟踪颁发的序列号是很重要的。它必须确保序列号不会被使用超过一次,并且它必须确保序列号与预期的所有者信息相匹配。CRL 倾向于按固定的时间表发布(例如,每天一次)。

证书验证系统(如 TLS 中使用的系统)必须保存所有已撤销证书的列表,以便在验证过程中可以使任何这种检测到的证书无效。

检查撤销的另一个经典方法是使用在线证书状态协议(OCSP)。与 CRL 一样,该协议用于通过序列号查找来检查证书的有效性。然而,与 CRL 不同的是,该协议与在线服务器一起实时使用,并且可以在证书验证过程中执行。同样,颁发证书的 CA 通常是他们颁发的证书的 OCSP 响应者。

显然,OCSP 将比静态 CRL 拥有更多的最新信息。然而,OCSP 在 TLS 握手设置中引入了额外的延迟。更糟糕的是,如果 OCSP 响应者没有响应,像浏览器这样的客户端应该做什么?应该是而不是连接吗?它是否应该告诉用户“很抱歉,由于 OCSP 服务器停机,我今天无法让您进行网上银行操作?”

大多数浏览器拒绝采取这种强硬路线。如果浏览器不能得到 OCSP 响应,它就向前移动,并假设证书没有被撤销。这让 Eve 超级兴奋。如果她可以获得一个被吊销的证书(或者一旦她的盗窃行为被发现就立即被吊销的证书),她就可以用它来对抗 Alice 和 Bob 的浏览器。如果浏览器试图访问 OCSP 服务器,她将执行拒绝服务攻击,确保永远收不到 OCSP 的响应。这是绕过安全措施的简单方法。

由于这些和许多其他原因,CRL 和 OCSPs 被认为是过时的。许多浏览器,如谷歌 Chrome,甚至没有打开这些功能的选项。 11

事实是,撤销仍然是一个难题,Eve 会尽她所能利用这个事实。

好消息是,现在正在探索新形式的证书撤销,包括强制 OCSP 装订。这个概念是服务器包括一个 OCSP 响应和他们的证书。OCSP 响应只在相对较短的时间内有效,因此服务器必须定期刷新。这种方法的全部细节已经超出了本书的范围,但是这可能是 Alice 和 Bob 的一个很好的研究主题。

不可信的根、锁定和证书透明性

对我们来说不幸的是(也让 Eve 高兴的是),和所有已知的建立信任的方法一样,TLS 需要一个可信的第三方。就像罗马诗人尤维纳利斯说的那样,“这是你的职责吗?”(“谁看守看守?”或者“谁看着守望者?”)

CAs 的问题在于,如果 CA 私钥被泄露,窃贼可以为自己生成任何域的证书。这是而不是一个理论问题。例如,2011 年对现已解散的 DigiNotar CA 进行了一次成功的攻击[8]。攻击者渗透进他们的服务器,设法生成伪造的证书,包括一个google.com的“通配符”证书,加上 Yahoo、WordPress、Mozilla 和 TOR 的附加证书。DigiNotar CA 必须从浏览器和移动设备的可信 CA 列表中删除。不出所料,DigiNotar 在攻击被揭露后几乎立刻就倒闭了。

举一个最近的、在某些方面更令人不安的例子,TLS 证书经销商 Trustico 要求 DigiCert 撤销 20,000 多份证书。这本身没有问题。由于对发行者失去信任,证书被吊销。令人震惊的是,Trustico 承认拥有这些证书的私钥,并通过电子邮件【4】发送给了 DigiCert!这意味着经销商正在为其客户生成密钥对,并持有私钥。虽然据说保存在“冷库”中,但理论上转售商、转售商的雇员或转售商的不满的前雇员可以获取客户的私钥并冒用他们的数字身份。

CA 保存客户私钥的这个特殊问题在技术上无法解决。如果一方放弃了他们的私钥,就没有机制来保证它们的安全。所有的密码学都建立在保密的基础上。

欺诈和滥用证书的问题更加严重和普遍。如果可以的话,Eve 非常想破坏 CA 或 CA 的证书(特别是 Alice 或 Bob 信任的证书)。偷一个证书只给她一个欺诈身份。窃取 CA 证书给了她无限数量的欺诈身份。

幸运的是,爱丽丝和鲍勃可以用一些方法来保护自己。让我们来看看其中的两个。

第一个是“证书锁定”这个术语有很多不同的用法,所以在研究时一定要小心。基本概念是,像 Alice 或 Bob 这样的客户在收到证书之前,以某种方式期望证书应该是什么样的。收到证书后,会将其与预期版本(即“固定”版本)进行比较,如果不匹配,则会调用一个策略。假设不匹配很可能意味着 Eve 正在使用欺诈性证书。

虽然 pinning 更通用,但是一些资料来源将更具体的 HTTP 公钥 Pinning (HPKP)视为同义词。也许这是因为曾经有一段时间,包括谷歌在内的一些方面都在推动这项技术,将其作为识别和拒绝泄密证书的通用解决方案。自那以后,人们普遍认为这种方法是不够的,新的举措是走向“证书透明”(CT)。

即使如此,锁定(作为一个一般概念)仍然有它的用途,尤其是在移动应用中。例如,手机上的一个应用可以将它的作者证书嵌入到应用中。该证书的固定版本总是与 TLS 握手中收到的证书进行比较。如果不匹配,就说明有问题。如果公司需要更改他们的证书或更换密钥,他们可以在应用升级中推送新的固定版本。撇开移动应用不谈,谷歌和火狐在他们的浏览器中做了这种静态锁定。

这是有效的。由于静态锁定,Google 实际上发现了 DigiNotar 颁发的 Google 证书的问题。

练习 8.13。监控证书轮换

假设您在 HTTP 代理程序中成功地截获了 TLS 证书,请多次访问一个站点,看看是否每次都收到相同的证书。您希望服务器的证书多久更改一次?

另一方面,HPKP 是一种通用的动态锁定技术,它依赖于第一次使用时的信任(豆腐)原则。基本上,客户端第一次访问某个网站时,该网站可以请求客户端将证书固定一段时间。如果证书在这段时间内发生变化,它应该将修改后的证书视为冒名顶替者。这个想法很有趣也很合理,但是它引入了许多问题,并且仍然可能被攻击者以不愉快的方式利用。因此,这种想法已经消失了。

相反,前面提到的证书透明性(CT)是解决证书问题的第二种方法,其势头越来越大。其基本思想在某些方面类似于区块链和分布式分类账。无论何时颁发证书,它都会被提交到公共日志。公共日志由第三方托管,甚至可能是颁发证书的 CA,但它是可验证的,因此第三方不必被信任。

日志的目的是透明的:因此 ca 本质上是为了它们产生的证书而被审计的。目标是以一种可加密验证的方式公开所有已颁发的证书以供检查。 12 浏览器最终将被配置为不接受在这样的日志中找不到的任何证书。

使用 CT 测井我们能得到什么?这看似简单,但却出人意料地有用。假设 Eve 试图为 e a 服务器创建一个假证书。如果 EA 浏览器不接受证书,除非它被发布,Eve 将不得不把它提交给一个公共日志。如果发生这种情况,EA 可以立即检测到生成了一个伪造的证书。虽然这确实需要 EA 监控日志,但是很容易部署一个自动系统来检查是否颁发了任何不应该颁发的新证书。EA 知道(或者应该知道)哪些证书是合法颁发的,并且可以标记那些不合法的。

即使 Eve 如此聪明,以某种方式干扰了东南极洲的审计系统,并设法逃脱了一些托词,一旦检测到攻击,公共日志将能够对问题进行彻底调查,并对损害进行准确评估。可怕的是,在 DigiNotar 黑客攻击中,调查人员甚至无法完全识别所有生成的证书!直到今天,没有人知道攻击者到底创建了多少个证书。这也是 DigiNotar 不得不彻底关闭的原因之一。不可能确定所有需要吊销的证书。

CT 还是比较新的,所以它可能会随着时间的推移而继续发展。例如,它没有提供一种验证撤销的机制,而且已经有一个建议要在其中增加“撤销透明性”。这绝对是值得关注并尽快开始使用的技术。

针对 TLS 的已知攻击

Eve 总是试图以这样或那样的方式破解证书。如果她通过了那道门,其他的都坏了。当然,如果爱丽丝和鲍勃正在使用 DHE 或 ECDHE 进行前向保密,那么未来的一切都被打破了,但至少过去没有。

除了证书之外,还有一些针对 TLS 的当代攻击需要注意。以下是对针对 TLS 的众所周知的攻击以及如何防止它们的简要概述。

狮子狗

POODLE 代表“在降级的传统加密上填充 Oracle”正如我们所讨论的,TLS 1.0 可以在使用 CBC 模式时被利用。当时,分组密码是 DES,但是只要操作模式是 CBC,攻击就可以在 DES 或 AES 上工作。

TLS 1.1 和 1.2 应该通过改变 CBC 加密的填充方式来解决这个问题。但是 POODLE 攻击表明,即使对于运行 1.1 和 1.2 的服务器,它们也可以被重新协商到 TLS 1.0 以便被攻击。

更糟糕的是,后来发现一些 TLS 1.1 和 1.2 实现使用了与 TLS 1.0 相同的填充(与规范相反)。这种错误不会对正常通信造成任何问题,因为两种填充方案对于合法流量是兼容的。只有当数据受到攻击时,才清楚填充是错误的。对于有错误实现的实现,如果没有降级,它们是易受攻击的。

防御措施包括

  1. 禁用 TLS 1.0(和 1.1 真的一样)。

  2. 使用审核工具验证 TLS 1.2 不容易受到攻击。

反常和僵局

Logjam 攻击和 POODLE 一样,依赖于强制降级到 TLS 的早期版本。实际上,我们的目标是降低密码套件的等级。

在 20 世纪 90 年代,美国政府有一项政策,现在允许强加密技术出口到外国。政府的政策将这类算法视为武器。 13 安全软件仍然带着这一政策的伤疤,并且出现了被称为导出算法的特定 TLS 密码套件。事实上,这些算法非常弱。

在 Logjam 中,攻击者拦截客户端的消息,删除所有建议的密码套件,并用 Diffie-Hellman (DH)的导出变体替换它们。服务器相应地挑选弱参数,并将它们发送回客户端。客户端不知道有什么问题,只是接受服务器糟糕的配置。

由此产生的密钥很容易被破解。

请注意,TLS 协议的Finished消息应该可以检测到这种攻击。发送包含握手期间交换的所有消息的哈希的消息的全部意义在于揭示这种操纵。

问题是Finished消息是在新的(弱)密钥下加密发送的。如果伊芙试图攻击,她可以在破解密钥的同时截获真正的信息。一旦密钥被破解,她可以创建一个假的Finished消息,并使用破解的会话密钥对其进行加密。除非破解密钥的时间比内部超时长,否则 Eve 可以成功。

FREAK 是一种与 Logjam 非常相似的攻击,但它使用“导出”RSA 参数。

对僵局和反常的防御包括

  1. 在服务器上禁用弱密码套件,尤其是“导出”密码。

  2. 使用无条件拒绝接受弱参数(例如,弱的 DH/ECDH 或 RSA 参数)的客户端。

甜蜜 32

Sweet32 攻击与我们之前看到的攻击略有不同。它是专门为块大小为 64 位的块密码设计的。对于大多数 TLS 1.2 安装,只有一个使用中的密码具有这样的块大小:3DES。

虽然对 3DES 的完整解释超出了本书的范围,但它在下面使用了 DES。它很慢,但至少不像 DES 那么弱。DES 密钥可以在相当合理的时间内被泄露;3DES 还不能。

然而,3DES 使用的是 64 位块大小。算法的块大小会影响在轮换之前在单个密钥下应该加密多少数据。数学已经超出了本书的范围,但是一旦超过 2 个 n/ 2 的块被加密,加密技术就会崩溃。对于 64 位块大小,限制是大约 32GB 的数据,这在现代计算机上很容易生成。更糟糕的是,2n/2是一个界!在实践中,漏洞的出现要快得多。

遗憾的是,许多 TLS 实现并没有用一个键强制最大数据限制。Sweet32 攻击利用这一点发送足够的数据来强制冲突和恢复数据。

防御措施包括

  • 禁用基于 3DES 的密码套件(以及任何其他 64 位密码,如果有的话)。

机器人

回想一下,在第四章中,我们花了很多时间来敲打 RSA。我们证明了在没有填充的情况下使用它是微不足道的失败。我们还展示了某些形式的填充也可以被利用。特别是,PKCS 1.5 容易受到填充 oracle 攻击。这正是 TLS 中用于 RSA 加密的填充,直到并包括版本 1.2。

布莱肯巴赫在 1999 年发现了对 PKCS 1.5 的攻击。显然,那是在 TLS 1.2 版之前很久的事了。为什么没有改?

出于兼容性原因,TLS 背后的设计者决定保持相同的填充方案并插入对策。正如我们在本章前面提到的,填充神谕攻击需要神谕!如果 TLS 协议可以防止泄露填充的成功或失败,它应该可以消除攻击。

不幸的是,事情没那么简单。机器人代表“布莱肯巴赫预言威胁的回归”机器人背后的研究人员发现,TLS 对策并不总是成功的。他们还找到了从 TLS 中提取 oracle 信息的新方法,并且能够证明他们的攻击是可行的。例如,他们可以在没有适当的私钥的情况下为脸书签署消息。

机器人防御包括

  • 禁用所有使用 RSA 加密进行密钥交换的密码套件(任何以TLS_RSA开头的密码)。

犯罪、时间和违约

TLS 1.2 版提供了加密前的数据压缩。这在 TLS 1.3 中已被禁用。压缩的问题是它会把信息泄露给像 Eve 这样的人。该信息可用于恢复密文中的信息。

CRIME 的意思是“压缩比让信息泄露变得容易”,在 2012 年首次展示。压缩的问题在于,只有当数据被重复时,它才能正常工作。因此,即使您只有一些压缩明文的密文,如果您可以插入或部分插入消息,密文大小的下降强烈表明存在一些重复数据,从而导致更好的压缩率。此信息可用于恢复少量字节。任何数据丢失,无论多小,都是不可接受的。但是如果被攻击的数据已经很小了(例如,带有认证信息的 web cookie),少量的字节丢失就可能是灾难性的。

犯罪之后是时间,时间稍微有效一点。它还启发了 BREACH,这是一种不同的攻击,但也使用压缩来泄露信息。

防御措施包括

  • 禁用压缩。

赫斯特里德

Heartbleed 在我们的列表中特别提及,因为它不是 TLS 本身的漏洞。更确切地说,这是 OpenSSL 实现中的一个错误(是的,你一直在使用的库)。具体来说,这是 TLS 扩展中的一个错误,该扩展支持检测死连接的心跳。虽然是扩展,但却是常用的。

OpenSSL 实现的问题是,他们没有对从另一端收到的心跳请求进行边界检查。典型的心跳请求包括一些要回显的数据和数据长度。如果长度比要回显的数据长,不正确的实现只是从内存中读取内容。尽管无法保证这些内容中会包含什么,但其中可能包含私钥和其他秘密。

此漏洞的目的是表明并非所有攻击都针对协议本身,但有时会针对协议的实现。关注这两种问题非常重要。

防御措施包括

  • 保持 TLS 库和应用最新。

将 OpenSSL 与 Python 一起用于 TLS

在这一章中我们已经谈了很多,但是没有太多的编程。这个背景对 Eve 很有帮助,希望对你也有帮助。让我们把手上的脏东西弄干净一点来收尾。

Python 的许多内置网络操作都支持 TLS(通常在引用 SSL 的参数名称下,因为该名称甚至在 TLS 20 年后仍然存在)。Eve 担心 TLS 会阻止她嗅探流量。然而,从她在这一章中所学到的,她已经看到有很多方法可以把事情做错。Eve 决定浏览一些示例,看看她可以利用什么。

她首先像 Alice 和 Bob 一样连接到 TLS 服务器。执行本章开头的代码,但为了简单起见,这一次中间没有 HTTP 代理窥探。

对 Eve 来说是个坏消息(对你来说也是个好消息), Python 正试图确保程序员不会搬起石头砸自己的脚。默认情况下,这段代码会尝试做一些与 SSL 相关的事情。默认参数加载系统的可信证书,验证主机名,并验证证书。这些事情听起来可能很明显,但是一些 API 要求程序员自己实现所有这些检查,这增加了遗漏某些内容或错误实现的风险。

Eve 决定看看 TLS 检查的执行情况。她使用自己创建的证书再次启动openssl s_server。她尝试连接 Python,遇到了以下错误(略有删节):

>>> import http.client
>>> conn = http.client.HTTPSConnection("127.0.0.1", 8888)
>>> conn.request("GET", "/")

#SHELL# output_match: '''certificate verify failed'''

Traceback (most recent call last):
  File "<stdin >", line 1, in <module>
  File "/usr/lib/python3.6/http/client.py", line 1239, in request
    self._send_request(method, url, body, headers, encode_chunked)
...
  File "/usr/lib/python3.6/ssl.py", line 689, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:841)

不出所料,它拒绝了夏娃的证书。毕竟它没有理由信任它。服务器(s_server)发送的证书不是以有效的证书颁发机构为根。默认情况下,Python 代码做了正确的事情。伊芙低声咒骂着。

尽管如此,在搜索完 Python 文档后,Eve 发现 Python 让你搬起石头砸自己的脚,如果你真的真的想这么做的话。

HTTPSConnection类可以接受一个名为context的参数。它需要一个名为SSLContext的类的实例。 14 Eve 实验通过插入她自己的版本,在下面所示的代码块中再次运行测试。

>>> import http.client
>>> import ssl
>>> evil_context = ssl.SSLContext()
>>> conn = http.client.HTTPSConnection("127.0.0.1", 8888, context=evil_context)
>>> conn.request("GET", "/")
>>> r1 = conn.getresponse()
>>> r1.read()

#SHELL# output_ommitted

伊芙很高兴!她成功收到了s_server的回复。为什么?

SSLContext对象包含 TLS 配置参数并控制(至少部分)TLS 握手的处理,包括证书检查。一个空的SSLContext证件上没有的检查。

事实上,Python 文档建议不要以这种方式创建SSLContext。相反,程序员通常应该使用SSLContextcreate_default_context()。这个方法创建了一个SSLContext,它执行 Eve 之前遇到的导致证书被拒绝的默认检查。

但是使用这种手动方法,Eve 可以更好地控制证书验证的工作方式。卷起袖子,Eve 将她的evil_context配置为信任她的域证书,该证书是她的本地主机证书的颁发者。她使用load_verify_locations方法将她的域证书指定为可信 CA 文件。

>>> import http.client
>>> import ssl
>>> evil_context = ssl.SSLContext()
>>> evil_context.verify_mode = ssl.CERT_REQUIRED
>>> evil_context.load_verify_locations("domain_cert.crt")
>>> conn = http.client.HTTPSConnection("127.0.0.1", 8888, context=evil_context)
>>> conn.request("GET", "/")
>>> r1 = conn.getresponse()
>>> r1.read()

#SHELL# output_ommitted

为了验证信任系统正在工作,Eve 重新运行了这个测试,只留下了verify_mode = ssl.CERT_REQUIREDload_verify_locations。这会导致她之前看到的证书检查失败。只有通过告诉她的上下文,她信任的根源在哪里,她才能使她的证书得到验证。

还有一个检查目前被禁用:主机名检查。回想一下,在验证证书时,证书应该具有与主机 URI 相同的主题名称(可分辨名称的通用名称或主题的替代名称)。Eve 故意创建了这个具有通用名称127.0.0.1的本地主机证书,以便她可以运行主机名匹配测试。当她浏览到https://127.0.0.1时,她希望证书的主题名称匹配。

为了查看主机名检查是否有效,Eve 首先停止openssl s_server并使用新参数重新启动它。这一次,她使用她的证书作为服务器的证书(而不是作为发行者)。因为她使用的是自签名证书,所以不需要与链相关的命令行参数。她的命令看起来像这样:

openssl s_server -accept 8888 -www -cert domain_cert.crt -key domain_key.pem

她重新运行测试代码,它仍然工作。即使 URI 是https://127.0.0.1,主题通用名是wacko.westantarctica.southpole.gov,数据也是允许的。如果不启用主机检查,这种不匹配不会导致错误。

Eve 现在在打开主机检查后重复她的测试。

>>> import http.client
>>> import ssl
>>> evil_context = ssl.SSLContext()
>>> evil_context.verify_mode = ssl.CERT_REQUIRED
>>> evil_context.load_verify_locations("domain_cert.crt")
>>> evil_context.check_hostname = True
>>> conn = http.client.HTTPSConnection("127.0.0.1", 8888, context = evil_context)
>>> conn.request("GET", "/")

#SHELL# output_match: '''doesn't match'''

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.6/http/client.py", line 1239, in request
    self._send_request(method, url, body, headers, encode_chunked)
...
  File "/usr/lib/python3.6/ssl.py", line 331, in match_hostname
    % (hostname, dnsnames[0]))
ssl.CertificateError: hostname '127.0.0.1' doesn't match'wacko.westantarctica.southpole.gov'

正如您在我们截断的异常跟踪中看到的,TLS 抱怨主机名(127.0.0.1)与主题名(wacko.westantarctica.southpole.gov)不匹配。

一般来说,不希望 Eve 获得假证书的程序员不应该乱用这些参数。默认上下文及其默认检查是一个良好的开端。

练习 8.14。社会工程

这是一个思维练习;不涉及编程。Eve 如何让其他人使用不太安全的软件?她能做些什么来说服他们使用配置不佳的 SSL 上下文呢?

不过,这些额外的功能确实有重要的用途。如果 Alice 和 Bob 想要进行静态证书锁定,该怎么办?也许 Bob 正在运行一个命令和控制服务器,而 Eve 正在现场使用一个 Python 程序,需要与它进行安全通信。Alice 如何将证书固定到 Bob 的服务器上?没有一个 API 能让SSLContext做到这一点。它只能指定受信任的 CA 证书。它没有指定可信的服务器证书的方法。

幸运的是,还有其他 Python APIs 可以在连接后获取对等体的证书。例如:

>>> import http.client
>>> import hashlib
>>> conn = http.client.HTTPSConnection("google.com", 443)
>>> conn.request("GET", "/")
>>> conn.sock.getpeercert(binary_form=True)

#SHELL# output_match: ''''''

b'0\x82\x02\xdb0\x82\x01\xc3\xa0\...
>>> peer_cert = conn.sock.getpeercert(binary_form=True)
>>> hashlib.sha256(peer_cert).hexdigest ()
#SHELL# output_match: ''''''

'bf52e8d42812c7a09586aa19219b0c15a92de6664aad380ed4c66dea7c6a5b3a'

可以将哈希与固定值进行比较,以确保它是预期的证书。证书锁定,尤其是静态证书锁定,在某些情况下可能是个好主意。

不幸的是,对于 Alice 和 Bob 来说,还没有使用 CT 日志的 API。Python cryptography库开始增加支持,但现在似乎仅限于 X.509 证书中的扩展。没有用于提交序列号以获得 CT 响应的 API,也没有用于向日志提交证书以进行插入的机制。

再次强调,请注意这一点(Eve 肯定会的)。Python 库可能很快会有新的补充。

如果 Eve 有她的方法,她会很乐意看到 Alice 和 Bob 编写他们自己的证书检查算法。她希望他们能这样做,而不是使用 Python 的内置检查器。

例如,Alice 和 Bob 可以获得整个证书链,并尝试手动验证每个证书。cryptography模块使用发行者的公钥进行证书“验证”,如下所示。

 1   from cryptography.hazmat.primitives.serialization import load_pem_public_key
 2   from cryptography.hazmat.primitives.asymmetric import padding
 3   from cryptography.hazmat.backends import default_backend
 4   from cryptography import x509
 5
 6   import sys
 7
 8   issuer_public_key_file, cert_to_check = sys.argv[1:3]
 9   with open(issuer_public_key_file,"rb") as key_reader:
10       issuer_public_key = key_reader.read()
11
12   issuer_public_key = load_pem_public_key(
13       issuer_public_key,
14       backend=default_backend())
15
16   with open (cert_to_check,"rb") as cert_reader:
17       pem_data_to_check = cert_reader.read()
18   cert_to_check = x509.load_pem_x509_certificate(
19       pem_data_to_check,
20       default_backend())
21   issuer_public_key.verify(
22       cert_to_check.signature,
23       cert_to_check.tbs_certificate_bytes,
24       padding.PKCS1v15(),
25       cert_to_check.signature_hash_algorithm)
26   print("Signature ok! (Exception on failure!)")

请注意,tbs_certificate_bytes是 DER 编码的(不是 PEM 编码的)字节,它们被哈希以签署证书。因此,在示例代码中,发行者的公钥用于检查证书中这些字节的签名。重复一遍,PEM 数据上的签名是而不是

Eve 希望 Alice 和 Bob 这样做的原因是因为这只是真实证书验证的一小部分! 15 在前面的代码中,没有检查有效数据,没有检查撤销列表,甚至没有检查客户端证书的颁发者是否与颁发证书的主题行匹配。有很多方法会出错,如果 Alice 和 Bob 使用他们自己的方法,Eve 更有可能找到漏洞。

如果你比 Alice 和 Bob 聪明,那么就把证书验证留给库操作吧。如果你真的觉得你想做一些专门的验证,除了这些广泛部署和广泛测试的库函数之外,不要代替它们。

最后,除了正确的证书检查,Eve 还决定研究另一组参数:支持的 TLS 版本和支持的密码套件。

关于版本,尽管 TLS 1.0 和 1.1 已被弃用,但大多数 TLS 实现仍继续支持它们,以实现向后兼容性和遗留操作。这几乎总是错误的做法。默认情况下,服务器和客户端应该禁用 TLS 1.0 和 1.1,只有当这导致某种真正的、具体的、无法解决的问题时,才重新启用它们。Eve 希望发现她可以对仍然支持这些遗留版本的服务器使用像 POODLE、Logjam 和 FREAK 这样的攻击。

令 Eve 高兴的是,她发现这些易受攻击的版本仍然存在。SSLv3 和 SSLv2 被禁用,但这还不够。TLS 1.0 绝对必须禁用,TLS 1.1 也应该禁用。

然而,Python 确实允许关闭它们,也许我们应该向 Alice 和 Bob 展示如何这样做。以下代码为特定的SSLContext对象关闭 TLS 1.0 和 1.1。 16

>>> import ssl
>>> good_context = ssl.create_default_context()
>>> good_context.options |= ssl.OP_NO_TLSv1
>>> good_context.options |= ssl.OP_NO_TLSv1_1

在检查 Python 以查看哪些版本的 TLS 被启用之后,Eve 现在将注意力转向默认的密码套件。她运行下面的代码来查看安装在她的测试系统上的所有密码。

>>> default_ctx = ssl.create_default_context()
>>> for cipher in default_ctx.get_ciphers():
...   print(cipher["name"])
...
ECDHE-ECDSA-AES256-GCM-SHA384
ECDHE-RSA-AES256-GCM-SHA384
ECDHE-ECDSA-AES128-GCM-SHA256
ECDHE-RSA-AES128-GCM-SHA256
ECDHE-ECDSA-CHACHA20-POLY1305
ECDHE-RSA-CHACHA20-POLY1305
DHE-DSS-AES256-GCM-SHA384
DHE-RSA-AES256-GCM-SHA384
DHE-DSS-AES128-GCM-SHA256
DHE-RSA-AES128-GCM-SHA256
DHE-RSA-CHACHA20-POLY1305
ECDHE-ECDSA-AES256-CCM8
ECDHE-ECDSA-AES256-CCM
ECDHE-ECDSA-AES256-SHA384
ECDHE-RSA-AES256-SHA384
ECDHE-ECDSA-AES256-SHA
ECDHE-RSA-AES256-SHA
DHE-RSA-AES256-CCM8
DHE-RSA-AES256-CCM
DHE-RSA-AES256-SHA256
DHE-DSS-AES256-SHA256
DHE-RSA-AES256-SHA
DHE-DSS-AES256-SHA
ECDHE-ECDSA-AES128-CCM8
ECDHE-ECDSA-AES128-CCM
ECDHE-ECDSA-AES128-SHA256
ECDHE-RSA-AES128-SHA256
ECDHE-ECDSA-AES128-SHA
ECDHE-RSA-AES128-SHA
DHE-RSA-AES128-CCM8
DHE-RSA-AES128-CCM
DHE-RSA-AES128-SHA256
DHE-DSS-AES128-SHA256
DHE-RSA-AES128-SHA
DHE-DSS-AES128-SHA
ECDHE-ECDSA-CAMELLIA256-SHA384
ECDHE-RSA-CAMELLIA256-SHA384
ECDHE-ECDSA-CAMELLIA128-SHA256
ECDHE-RSA-CAMELLIA128-SHA256
DHE-RSA-CAMELLIA256-SHA256
DHE-DSS-CAMELLIA256-SHA256
DHE-RSA-CAMELLIA128-SHA256
DHE-DSS-CAMELLIA128-SHA256
DHE-RSA-CAMELLIA256-SHA
DHE-DSS-CAMELLIA256-SHA
DHE-RSA-CAMELLIA128-SHA
DHE-DSS-CAMELLIA128-SHA
AES256-GCM-SHA384
AES128-GCM-SHA256

AES256-CCM8
AES256-CCM
AES128-CCM8
AES128-CCM
AES256-SHA256
AES128-SHA256

AES256-SHA
AES128-SHA
CAMELLIA256-SHA256
CAMELLIA128-SHA256
CAMELLIA256–SHA
CAMELLIA128-SHA

Eve 测试电脑上的默认列表对她很不利(对我们有利!).没有用于密钥交换的 RSA 加密,没有 AES-CBC 模式密码,也没有 3DES。看起来爱丽丝和鲍勃不需要做任何改变。根据 Python 文档,大多数弱密码已经被禁用。尽管如此,检查一下也无妨。

如果 Alice 和 Bob 有任何使用 RSA 加密进行密钥交换的密码(例如TLS_RSA_WITH_AES_128_CBC_SHA,他们应该通过管理get_ciphers返回的列表将它们从密码套件中删除,然后使用set_ciphers方法更新SSLContext

伊芙叹了口气,然后离开了房间。她正在回东南极洲的路上,尝试一些窃取信息的新方法。她可能试图伪造证书,或者试图找到一个易受攻击的 TLS 实现。这可能是一个挑战;这可能需要一些时间,但夏娃是有耐心的,狡猾的,坚持不懈的。她总是在倾听。

练习 8.15。学会四处打探

利用新获得的(或改进的)密码学知识,你可以做的最好的事情之一就是学习探索。本章的大部分示例代码都是像在 Python shell 中故意执行一样编写的。轻松使用 shell 来访问服务器或测试连接。有很多工具可以测试公共可访问的 TLS 服务器,但是内部的呢?如果您发现您的公司对内部 TLS 连接使用了较差的安全性,请让它知道。意识到你周围发生的事情是很重要的。

记住这一点,用 Python 编写一个诊断程序,连接到给定的服务器并寻找弱算法或配置数据。例如,您已经看到了SSLSocket类具有获取远程证书的getpeercert()方法。编写一个程序,在连接到服务器时,获取证书并报告证书上的签名是使用阿沙-1 哈希(非常不可靠,不太可能)还是仍然支持 RSA 加密(更有可能)。

您还可以使用SSLSocket对象通过cipher()来检查当前密码。服务器从所有建议的密码套件中选择了哪一个?那是一个好的选择吗?

在这个密码检查的基础上,把你的 Python SSLContext改成只有支持弱密码。也就是说,创建一个禁用强密码并重新启用弱密码的上下文。您可以使用SSLContext.set_ciphers()函数设置上下文的密码。可在 www.openssl.org/docs/manmaster/man1/ciphers.html 找到各版本 TLS 的可用密码套件列表。该测试的目标是查看服务器是否仍然支持旧的、不推荐使用的密码。

如果您的分析工具发现任何弱点,请向适当的 IT 或管理人员报告,并提供补救建议。

开始的结束

好了,读者,这本书到此结束。希望这是你的开始。关于密码学有很多东西需要学习,我要重复一千遍,这只是一个介绍。你学到了很多,但你还不是一个(秘密)绝地!

伊芙,永远倾听的偷听者的代表,是不可低估的。在这本书的大部分时间里,伊芙,爱丽丝和鲍勃,有时被认为有点落后于时代。事实是 Eve 总是站在技术的最前沿。TLS 服务器仍有很多方式被成功攻击。请留意关于 TLS 的新闻和更新。不幸的是,新的漏洞和弱点比我们希望的更经常地被发现,并且有许多人喜欢看到和利用它们。

好消息是,随着强大的密码套件的使用和 TLS 遗留版本的禁用,您已经有了很多很好的安全性。本章介绍 Python 编程中的 TLS 安全性。如果你能理解这一章中的概念,这将是一个很好的基础,但要继续学习!夏娃对付我们最有效的武器是无知。

除了 Python 之外,如果您运行的是支持 TLS 的网站,请花时间让 TLS 审计程序偶尔审查一下您的网站。例如,Qualys SSL Labs 目前运行一个免费项目来报告网站的 TLS 卫生状况。你可以在这里免费试用: www.ssllabs.com/ssltest/index.html

此外,也可以登录cryptodoneright.org网站。这个项目旨在让加密用户尽可能地了解信息和建议。

简而言之,让我们尽可能地让伊芙的生活变得艰难。总会有风险,但不要让她轻易取胜。让任何胜利都变得痛苦而短暂。毕竟,她总是让我们保持警惕,所以我们应该回报她!

练习 8.16。三声欢呼!

这是书上的最后一个练习!为自己走到这一步鼓掌。

当你合上封面时,请随时给作者反馈,无论是好是坏。尤其是如果你让我们知道我们是否错过了什么!

posted @ 2024-08-09 17:41  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报