Python 密码学实践指南(一)
一、密码学:不仅仅是保密
欢迎来到实用密码学的世界!这本书的目的是教你足够多的关于密码学的知识,使你能够推理它做什么,什么时候某些类型可以被有效地应用,以及如何选择好的策略和算法。每一章都有例子和练习,通常在开始会有后续练习来帮助你找到方向。这些例子往往伴随着一些虚构的舞台设置,以增加一些背景。在你有了一些接触和经验之后,那些例子后面的术语应该更有意义,也更容易记住。我们希望你喜欢它。
设置 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。自动解码
在我们的例子中,Eve 尝试解码各种信息,直到她看到看起来像英语的东西。尝试自动化这一点。
-
得到一个包含几千个英文单词的数据结构。 1
-
创建一个程序,接受一个编码字符串,然后尝试用所有 25 个移位值解码它。
-
使用字典来尝试自动确定最有可能是哪一个班次。
因为您必须处理没有空格的消息,所以您可以简单地记录解码输出中出现了多少字典单词。偶尔,一两个单词可能会偶然出现,但正确的解码应该有更多的命中。
练习 1.3。一种强替代密码
如果不是移动字母表,而是随机打乱字母,会怎么样?创建一个使用这种替代编码和解码消息的程序。
一些报纸刊登类似这样的谜题,叫做密码。
练习 1.4。清点字典
对于上一个练习中的密文样式的替换,有多少个替换字典是可能的?
练习 1.5。识别字典
修改你的密码程序,这样你就可以识别和挑选带有数字的乱糟糟的字符替换图。也就是说,每个映射都有一个唯一的编号来标识它:每次选择替换 n 都应该创建相同的替换映射。这个练习比其他的稍微难一点。尽力而为!
练习 1.6。蛮力
试着让你的密码解码程序暴力破解一条信息。测试每个可能的映射需要多长时间?你能写一个程序用任何一种“聪明的猜测”来加速这个吗?
密码学的简明介绍
示例结束后,我们就可以开始真正的密码学了。欢迎光临!希望你对替代密码感兴趣。如前所述,这种特殊形式的加密被称为“凯撒密码”,因为它被凯撒大帝用于保护重要文件。
像凯撒一样,我们大多数人都有我们想要保密的信息。用密码学的术语来说,我们希望保密。加密是数据机密性的基石。
你对凯撒密码有什么看法?即使没有电脑,你认为你要花多长时间才能打破这样的东西?也许在凯撒的时代,如果凯撒的敌人没有受过良好的教育,这是相当有效的。这是密码学和计算机安全的重要一课。密码术的有效性通常依赖于上下文。无论你的对手受过多少教育,他们有多少台计算机,他们是否知道你使用的算法,或者他们有多大的动机,好的加密技术都是有效的。
简而言之,当你不太依赖环境时,你会过得更好,至少环境是在你控制之外的。
然而,良好的安全性将永远取决于你的选择(??)。本书的目标是帮助密码初学者了解一些特定密码算法的工作原理以及它们的设计环境。这本书是针对程序员的,因此使用了大量的源代码来教授和阐释概念。当我们使用 Python 编程语言时,Python 程序员会特别喜欢这些练习。然而,这些概念并不依赖于语言。
因此,我们假设对编程有些熟悉。Python 很容易学习和阅读,任何人都应该很容易至少理解示例,为了方便起见,我们尽量远离非常特殊的 Python 习惯用法。
然而,我们不也不假设读者事先熟悉密码学。如果你对密码学略知一二,请耐心阅读书中可能针对绝对初学者的一些解释。如果你是初学者,这本书适合你。我们希望你喜欢弄湿你的脚。
密码学的用途
您可能已经意识到,在当今互联的现代世界中,加密技术无处不在。世界上的人们正在以令人难以置信的数量和速度交换信息。2018 年福布斯的一篇文章报道了以下统计数据[10]:
-
每天都会产生 2.5 万亿字节的数据,而且这个数字还在加速增长。
-
谷歌每天处理 35 亿次搜索。
-
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 环境,自己尝试了一些代码,并准备学习更多内容。
我们走吧!
Footnotes 1您可以在网上找到这些单词的列表,并且您的程序可以用它们自动填充您的数据结构。
**
二、哈希
哈希是加密安全的基石。它涉及到一个单向函数或指纹的概念。哈希函数只有在满足以下几个条件时才能正常工作:
-
它们为每个输入产生可重复的唯一值。
-
输出值没有提供关于产生它的输入的线索。
有些哈希函数比其他函数更能满足这些要求,我们将讨论一些好的函数(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。谷歌知道!
使用以下哈希进行快速谷歌搜索(在谷歌搜索栏中输入哈希):
-
5f 4 DCC 3b 5aa 765d 61d 8327 deb 882 cf 99
-
d 41 D8 CD 98 f 00 b 204 e 9800998 ECF 8427 e
-
6384 和 2b2184bcbf58eccf10ca7a6563c
把教育搞得一团糟
在计算机安全领域,术语“哈希”或“哈希函数”总是指加密哈希函数,除非另有说明。还有一些非常有用的非加密哈希函数。事实上,你在小学会学到一个非常简单的方法:计算一个数是奇数还是偶数。让我们看看这个简单、熟悉的函数是如何阐释适用于所有哈希函数的原理的。
哈希函数从根本上试图将大量(甚至无限)的事物映射到一个(相对)小的事物集合上。例如,当使用 MD5 时,无论我们的文档有多大,我们最终都会得到一个 16 字节的数字。在离散代数术语中,这意味着哈希函数的域比它的范围大得多。给定数量非常非常大的文档,它们中的许多可能会产生相同的哈希。
哈希函数因此有损。从源文档到摘要或哈希,我们丢失了信息。这实际上对它们的功能至关重要,因为在不丢失信息的情况下,有一种方法可以从哈希返回到文档。我们真的不希望这样,我们很快就会知道为什么。
因此,计算一个数字是奇数还是偶数非常符合这个描述。不管这个(整数)数有多大或者有多有趣,我们都可以把它压缩到一个比特的空间里:1 代表奇数,0 代表偶数。那是杂碎!给定任何大小的任何数字,我们可以有效地产生它的“奇怪”值,但是考虑到它的奇怪,我们将很难找出是哪个数字产生了它。我们可以创建非常非常多的个可能的输入,但是我们无法知道哪个特定的被用来做出那个答案。
“偶数或奇数”位有时被称为“奇偶”位,通常被用作基本的检错码。
奇偶哈希示例说明了将输入“压缩”成固定大小值的原理。这个值是一致的,这意味着如果你两次输入相同的数字,你不会得到不同的值。它将大量输入压缩到一个固定大小的空间中(只有一位!),而且是有损:只看输出是无法告诉我哪个数字被用作输入的。
所有的哈希函数,包括非密码哈希函数,都具有一致性、压缩和有损性的基本性质,在计算机科学中有各种重要的应用。然而,仅仅这些品质还不足以让哈希函数成为加密的或安全的:为此,哈希函数还需要更多的属性。9]:
-
原像电阻
-
第二原像电阻
-
耐碰撞性
我们将依次讨论这些重要的品质。
原像电阻
通俗地说,原像是一个哈希函数的输入集合,它产生一个特定的输出。如果我们把它应用到前面的奇偶校验例子中,奇数奇偶校验位的原像是(无穷大!)所有奇整数的集合。类似地,偶数奇偶校验位的原像是所有偶数整数的集合。
这对加密哈希意味着什么?之前,我们计算出 MD5 哈希值6384e2b2184bcbf58eccf10ca7a6563c可以由输入b'alice'生成。因此,的原像
- MD5( x ) =
6384e2b2184bcbf58eccf10ca7a6563c
包含元素x == b'alice'。
这很重要,所以让我们用更精确的术语来陈述(使用我们的域和范围中的整数—记住,文档是有序的位,因此只是一个大整数):
前像:哈希函数 H 的前像和哈希值 k 是 x 的组值,其中 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 ,表示。但是那个是什么意思呢?嗯,eh=十进制的 14 * d ,以及d*h= 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,
这并不意味着你可以找到另一个值输入到中,它会给你同样的值。你将不得不再次诉诸暴力。
将它与它的名字联系起来,如果您已经有了前映像的一个成员,找到前映像的第二个成员并不容易:前映像中没有可利用的模式。
耐碰撞性
碰撞阻力比我们刚刚提到的任何一个原像特征都要微妙一些。碰撞阻力意味着很难找到任何两个产生相同输出的*:不是一个特定的输出,只是相同的输出。*
描述这一点的经典方式是用生日。 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.urandom和base64.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参数,先说一下backend。cryptography模块主要是一个低级引擎的包装器。例如,该模块可以利用 OpenSSL 作为这样的引擎。这使得系统更快(因为计算不是在 Python 中完成的)和更安全(因为它依赖于一个健壮的、经过良好测试的库)。纵观这本书,我们将永远依靠default_backend()。
其他参数是针对scrypt的。length参数是该过程完成后密钥的长度。在这些示例中,密码被处理成 32 字节的输出。参数r、n和p是影响计算时间和所需内存的调优参数。为了更好地保护您的密码,您希望该过程需要更长的时间和更多的内存,以防止攻击者一次破坏大块的数据库(每次破坏都需要很长时间)。
幸运的是,推荐的参数是可用的。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 个小时,难度比今天的要难多了。
图 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,除非你告诉人们它是坏的,以及如何使用它们来实现更安全的密码存储甚至加密货币。哈希是密码学中一个强大而重要的部分,随着我们的发展,我们会一次又一次地看到它。
既然我们已经了解了如何将一个文档分解成一个安全的有代表性的值,那么是时候回顾一下加密了。
Footnotes 1如果考虑域有所帮助的话,那么哈希函数的每个原像的一个好的品质就是它的所有元素都以不可预测的间距非常分散。这样你就不太可能通过猜测意外地选择一个(它们真的分散开了),如果命中了,你也不太可能找到任何其他的(不可预测的间距)。最后一部分我们稍后会深入探讨。
2
一个老笑话。不客气我们很抱歉。
3
在十六进制中,这个数字与二进制更紧密相关,看起来更合理一些:1000000000000000000000000。
4
“生日问题”是概率论中的一个经典问题,其起源并不确定。
5
有时称为“MD5 sum”,其中“sum”是“checksum”的缩写,这是一个有着自己有趣(和悠久)历史的名称,来自数字传输中的错误检测。
6
要求是唯一性,而不是随机性,但是随机性为我们提供了一种简单的方法,对于我们的例子来说,这种方法非常有效。
7
bcrypt 算法非常好,只有一个“难度”参数,比有许多参数的方法更容易正确使用。
**
三、对称加密:双方,一个密钥
对称加密是所有现代安全通信的基础。我们用它来“扰乱”信息,这样,只有当人们能够使用加密信息的同一密钥时,他们才能解密信息。这就是“对称”在这种情况下的含义:在通信信道的两端使用一个密钥来加密和解密消息。
我们抢吧!
不出所料,东南极的反派 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 所示。
图 3-1
对update方法的两次调用。前 8 个字节不返回任何内容,因为还没有完整的数据块要加密。
练习 3.2。最新技术
从第一章升级 Caesar cipher 应用以使用 AES。与其指定一个移位值,不如弄清楚如何让键进出程序。您还必须处理 16 字节消息大小的问题。祝你好运!
到底什么是加密?
对于那些听说过密码学的人来说,加密可能是他们听说得最多的。网站和在线服务通常会提到加密,以向您保证您的信息是“安全的”它们通常会包含类似“所有通过互联网传输的数据都受到 128 位加密保护,防止被盗”的语句
你不是已经感觉好多了吗?
像这样的声明实际上只是营销。它们听起来不错,但通常没有多大意义。这是因为“加密”包括像凯撒密码这样容易破解的东西,它本身也不足以保证通信安全。在密码学中,有几个属性有助于不同方面的安全,它们需要协同工作。1].这些特性通常被认为是最重要的:
-
机密
-
完整
-
证明
我们在本章探索的加密都是关于机密性的。保密性意味着只有拥有正确密钥的人才能够读取数据。我们使用加密来保护信息,这样外人就无法阅读。
同样重要的是诚信。完整性意味着数据不能在你没有注意到的情况下被更改。理解这一点很重要,因为某些东西不能被读取并不意味着它不能被有效地改变*。为了说明这一点,我们将在这一章中进行这种恶作剧。*
最后,认证与了解你与之通信的一方的身份有关。认证通常包括一些机制来建立身份和存在、 3 以及将通信绑定到已建立的身份的能力。
很明显,这三个属性在许多交流形式中都是必不可少的。如果伊芙可以在他们不知道的情况下改变消息的内容,保密对爱丽丝和鲍勃没什么好处:伊芙不需要阅读消息来引起真正的问题。同样,如果 Alice 和 Bob 不确定在信道的另一端是否有合适的人,他们的秘密通信也不会成功。
当你阅读这一章时,请记住这些想法!我们对保密性的关注有助于展示,它确实是安全性的一个重要组成部分,但这还不够。花一些时间在保密本身上,将有助于我们证明,没有它的朋友,保密是多么的不充分。
AES:一种对称分组密码
如前所述,对称加密背后的思想是加密和解密使用相同的密钥。在现实世界中,几乎所有物理锁的钥匙都可以被认为是“对称的”:锁门的同一把钥匙也可以开锁。还有其他非常重要的加密方法,对每个操作使用不同的密钥,但是我们将在后面的章节中讨论这些方法。
对称密钥加密算法通常分为两个子类型:分组密码和流密码。块密码的名字来源于它对数据块的工作:在它能做任何事情之前,你必须给它一定量的数据,并且较大的数据必须被分解成块大小的块(而且,每个块必须是满的)。另一方面,流密码可以一次加密一个字节的数据。
AES 基本上是一种对称密钥、分组密码算法。无论如何,这都不是唯一的一个,但却是我们在这里关注的唯一一个。它用于许多常见的互联网协议和操作系统服务,包括 TLS(由 HTTPS 使用)、IPSec 和文件级或全磁盘加密。鉴于其无处不在,它可以说是知道如何正确使用的最重要的密码。更重要的是,AES 的正确使用原则很容易转移到其他密码的正确使用。
最后,尽管 AES 本质上是一种分组密码,但它(像许多其他分组密码一样)可以像流密码一样使用,因此我们不会因为将本机流密码排除在讨论之外而失去任何教学机会。在过去,RC4 是一种常用的流密码,但是已经发现它容易受到各种攻击,并且正在被 AES 的流模式所取代。
还有,就像 Bob 说的,“这是高级!“这对任何人来说都足够了,对吧?
练习 3.3。历史课
在网上做一些关于 DES 和 3DES 的研究。DES 的块大小是多少?它的密钥大小是多少?3DES 如何强化 DES?
练习 3.4。其他密码
做一些关于 RC4 和两条鱼的研究。它们用在哪里?RC4 有什么样的问题?Twofish 比 AES 有哪些优势?
因为 AES 是一个很好的起点,所以让我们深入了解一些背景知识。我们知道这是一种对称密钥分组密码。根据我们看到的 Alice 和 Bob 使用它的尝试,您能猜出块的大小吗?
如果你在想“16 字节!”(128 位),你得到一颗金星。告诉你所有的朋友! 4
AES 有几种工作模式,允许我们实现不同的加密属性:
-
电子代码簿(ECB) ( 警告!危险! )
-
密码分组链接(CBC)
-
计数器模式
这些并不是 AES 的唯一工作模式。7].事实上,虽然 CBC 和 CTR 仍在使用,但现在推荐使用一种称为 GCM 的新模式在许多情况下取代它们,我们将在本书后面详细研究 GCM。然而,这三种模式非常有指导意义,它们一起涵盖了最重要的概念。它们将提供一个坚实的基础,在此基础上建立对分组密码,特别是 AES 的更好理解。
ECB 不适合我
请注意,依赖欧洲央行模式来保障安全是不负责任的危险行为,?? 永远不应该使用这种模式。请将它视为仅用于测试和教育目的。请不要在您的应用或项目中使用它!说真的。你已经被警告了。别让我们过去。
顺便说一句,你看到这里的发展模式了吗?有时候解释一件事情的最佳方法根本不适合在实践中使用它。这似乎特别适用于密码学,这也是我们敦促人们总是使用成熟的库而不是构建自己的库的一个原因。基本的原则很简单,但是如果没有成熟库所具有的复杂特性和对如何使用它们的深刻理解,仅仅这些原则将会给你非常差的安全性,而不仅仅是“稍微不完美”的安全性。很少有中间立场;保险箱一旦被破解,它的壁有多厚都没用。密码概念通常很简单,但是安全和正确的实现通常很复杂。
随着所有这些警告的消失(事实上,还会有更多),欧洲央行是什么?在某种程度上,ECB 是“原始”AES:它独立处理每个 16 字节的数据块,使用提供的密钥以完全相同的方式加密每个数据块。正如我们将在计数器模式和密码块链接模式中看到的,有很多有趣的方法可以将这种方法用作更高级(更安全)密码的构建块,但这真的不是加密本身的好方法。
“电子密码本”这个名字可以追溯到早期的密码本,你可以拿着你的(小)钥匙,找到书上正确的那一页,使用那一页上的表格来查找与你的输入(明文)的每一部分相对应的输出(密文)。AES ECB 模式可以这样想,但要用一本大得令人难以置信的书。关键相似度(哈!)就是一旦有了密钥,每个可能的块的加密值都是已知的,解密也是如此;就像我们正在查找它们一样,如图 3-2 所示。
图 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 像素。
图 3-3
带有文本“绝密”的图像加密后应该就不可读了,对吧?
获取您新创建的文件,并通过您的加密程序运行它,将输出保存到类似于encrypted_image.bmp的地方。完成后,在图像浏览器中打开加密文件。你看到了什么?
我们的加密图像如图 3-4 所示。
图 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 。数学上看起来是这样的:(a⊕b⊕b=a。
为什么会这样?如果你把“输入 1”看成一个控制位,当它是 0 时,出来的就是简单的“输入 2”另一方面,当“输入 1”为 1 时,输出的是“输入 2”的倒数如果您获取输出并再次对“输入 1”应用 XOR,它会保持之前未更改的内容不变(再次对 0 进行 XOR),同时将反转的内容翻转回原来的样子(再次对 1 进行 XOR)。
我们经常不是对单个比特进行异或运算,而是对比特序列同时进行异或运算。这就是我们在本书中使用 XOR 的方式:作为位块之间的运算,就像这样:
你可以在这里看到如何应用⊕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 [ n 块 n 和“加密前明文”的P′[n块 n (使用 XOR 运算来完成非常科学地命名为“加密”的过程),我们首先从先前加密的块C创建P′[n创建P′[n的公式如下:**
从那里,我们可以将 AES 加密应用于P'[n],这是一个 AES 块的长度,以获得 C [ n 。当解密时,我们得不到明文,我们得到的是“加密前的明文”P′[n。要获得实际的明文,我们需要颠倒前面的过程,我们可以通过对它与前面的加密块进行 XOR 运算来做到这一点(回想一下 XOR 是它自己的逆运算)。通过执行一些基本的代数运算,您可以明白为什么这样做:
因此,为了在解密时得到原始明文,我们只需要将解密的块与先前加密的块进行异或运算。没有前身的第一个块在解密后简单地与初始化向量进行异或运算。这就是 CBC 模式的本质:每个块都依赖于之前的块。这个过程在图 3-5 中被形象化了,或许更直观一点。
图 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 密码上下文的填充上下文。在下一个代码清单中,创建了padder和unpadder对象来添加和移除填充。注意,这些对象也使用了update和finalize,因为调用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()行动会做到这一点。
图 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_message和decrypt_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 加密”):
差不多就是这样!唯一的另一个小变化是,我们不想每次都从相同的计数器值开始。因此,我们的 IV(我们称之为“nonce”)被用作起始计数器值。更新我们的定义:
XOR 是一种非常通用的数学运算。你可以把它想象成“受控比特翻转”:为了计算 A ⊕ B ,你一前一后地向下移动它们的比特;当你在 B 中遇到 1 时,你反转 A 中相应的位,当你在 B 中遇到 0 时,你把那个位单独留在 A 中。这样想的话,很容易理解为什么做两次就能简单地将 A 恢复到之前的状态。
更正式地说,如前所述,异或是它自己的逆运算:(a⊕b*)⊕b=a。因为我们通过对密钥流中的适当值应用 XOR 来创建加密块流,所以我们简单地做完全相同的事情来解密:对加密块及其对应的密钥应用 XOR:
当然,如果你只是与 0 进行异或运算,什么都不会发生(因为 A ⊕ 0 = A ,这就是逆属性的来源),所以流中的密钥需要由看起来随机的位组成,但这正是 AES 产生的密钥流的类型。
图 3-7 提供了 AES-CTR 操作的直观表示。
图 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 会更糟糕。因为计数器模式是一种流密码,所以明文只是与密钥流进行异或运算。如果刚好知道明文,可以恢复密钥:k⊕p⊕p=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 是密钥流):
对两个明文消息进行异或运算会得到什么?可读吗?看情况。因为明文消息通常是有结构的,私有数据通常是可提取的或可猜测的。以我们的例子中的这些虚构的购买消息为例。如果你把两条这样的消息异或在一起,你能学到什么?
首先,任何完全重叠的部分都简化为 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 所示。
图 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。
图 3-9
如果填充块的前 15 个字节被忽略,我们可以在倒数第二个块中进行替换,看看 oracle 是否告诉我们填充是正确的。如果是这样,我们可以算出前一个块的最后一个字节。
那真的那么有用吗?首先,我们必须足够幸运地拥有一整块衬垫。其次,我们只有 1/256 的机会解码单个字节。这似乎没什么帮助。
是吗?
再说一次,密码学可能非常反直觉。计算机的行为不像我们期望的那样,这就是我们遇到麻烦的地方。
虽然 SSLV3 忙于保护 web 流量,但事实证明恶意广告可以通过多种方式为 SSL 加密的网站带来流量。但是因为广告产生了流量,它的作者可以控制加密信息的长度。因此,如果攻击者试图解密一个加密的 cookie,触发不同长度的 GET 请求可以控制整个消息的长度。
在这种情况下,获取完整的填充块并不十分困难,因为恶意的请求者可以在 GET 请求中放入任意数据。
对于一台计算机来说,通过网络发出 256 个请求并不算什么。注意,在 SSLV3 上下文中,客户机和服务器将对每个连接使用不同的密钥(正如我们所看到的,这是有充分理由的!).这意味着在每个连接上,密文将是不同的!因此,如果攻击者发送 256 个请求,倒数第二个块每次都会不同,这提供了一个新的幸运机会,可以获得正确的“随机”数字,提供所需的 15 个。
图 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 模式。您可能还想知道他们是否正确地使用了他们的加密,因为您已经经历了对称加密被破解的一些方式(通常是意想不到的)。
是的,你已经向密码世界迈出了第一步。你准备好再拍几张了吗?那我们来说说非对称加密吧!
Footnotes 1...或者英雄,取决于你的观点,学徒。
2
“高级加密标准”这个名称实际上更多的是一个标题。许多算法竞相成为“高级加密标准”,包括许多今天仍然可用的算法。算法的原名是 Rijndael ,是两个发明者姓氏的合成。
3
身份和存在大致意思是“我知道这是谁,我知道他们同意我现在就知道。”如果你曾经不得不翻出你的信用卡向一个网站提供“CVV 代码”,而这个网站说已经把你的卡存档,那么你就遇到了在场的概念:CVV 代码意味着你的卡在那里,因此你同意使用它。这就假设你是唯一一个能持有自己的牌的人,一个巨大又容易被证伪的假设。因此,CVV 是一个极其微弱的存在迹象,但最终建立存在正是它试图完成的。
4
...通过加密频道。
5
我们利用 Python 方便的“负模数”行为,其中-len(msg) % 16与16 - (len(msg) % 16)相同。
6
这个代码清单没有显示所有必需的导入,但是与前面的清单相比,它不需要什么新的东西。出于篇幅的考虑,我们将定期省略在前面的示例中已经展示过的细节。
7
在现实生活中,如果文件头是加密的,您可以根据文件大小用合理的内容覆盖它。
8
我们保证下次会用更多的缩写。
9
是的,这是书中的一个主题。我们发现,通常当东西被打破时,它们才最容易被理解。
10
不要将它用于生产代码!始终使用经过良好测试的库。
11
有限制,但这些超出了本书的范围。
12
你可以记住它,因为它也代表“选择正确的”你甚至可以购买“CTR”戒指,作为一种友好的、持续的,并且当稍微扭曲一下以达到我们的目的时,密码提醒。
13
从技术上讲,他们可以使用同一个密钥,前提是 iv 不同。但是,在实践中,iv 可能会有意或无意地重叠,因此通常建议使用不同的键,不要依赖于不同的 iv。
***