Python 密码学实践指南(三)
六、组合不对称和对称算法
在这一章中,我们将花时间来熟悉非对称加密通常是如何使用的,它是通信隐私的一个关键部分,但不负责全部*。通常,非对称加密,也称为“公钥加密”,用于在双方之间建立可信的会话,会话内的通信受到更快的对称方法的保护。*
*让我们来看一个简短的例子和一些代码吧!
与 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 一样,我们使用了update和finalize,但是我们添加了一个叫做initialize的新方法。为了传输,Alice 将首先调用initialize来获得带有会话密钥的签名和加密的头。接下来,她会根据需要多次给update打电话,让整个文档通过。当一切完成后,她会调用finalize来获得所有传输内容的 HMAC 预告片。
练习 6.1。鲍勃的接收器
通过创建一个ReceiverManager实现该变送器的反向操作。确切的 API 可能略有不同,但您可能至少需要一个update和finalize方法。您需要使用 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。也就是说,我们提供了update和finalize方法。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 是我们运行的测试的加密和解密图。
图 6-2
RSA 解密速度与 AES-CTR 的比较
图 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]中的非数学解释。
图 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)
图 6-4
迪菲-赫尔曼背后的直觉
与 RSA 不同,这里的陷阱和陷阱要少得多。
交换只有两个参数:生成器和密钥大小。生成器只有两个合法值,2 和 5。奇怪的是,对于加密协议来说,出于安全原因,生成器的选择并不重要,但对于交换双方来说必须是相同的。
然而,密钥大小很重要,应该至少为 2048 位。512 到 1024 位之间的密钥长度容易受到已知攻击方法的攻击。
警告:参数生成缓慢
Diffie-Hellman 被吹捧为可以快速生成密钥。然而,生成可以生成密钥的参数可能会非常慢。我们警告过您不要使用小于 2048 的密钥大小,然后在我们自己的代码示例中使用了 1024。我们想给你一些代码,不用花太多时间来演示基本操作。
那么如果参数生成这么慢,为什么我们说 DH 快呢?相同的参数可以生成许多键,因此成本可以分摊。因此,请确保不要在每次生成密钥时都重新生成参数,否则 DH 的运行速度会慢得令人无法接受。或者,使用更快的 ECDH。
图 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)}K*–*1A(反正是 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)来创建具有完美前向保密性的会话密钥。
无论采用哪种方式,无论是通过密钥传输还是密钥协商,各方都可以获得通信所需的密钥套件。或者,一方可以获得加密硬盘上的数据所需的密钥。在这两种情况下,非对称操作主要用于建立身份和获得初始密钥,而对称操作用于数据的实际加密。
如果你能理解这些原理,你就能熟悉你能找到的大多数加密系统。
Footnotes 1嗯,它们应该被扔掉。在实际应用中,这可能意味着用零安全地覆盖内存,并确保所有副本都被考虑在内。当他们真的要抓你的时候,这就不是妄想了。
2
这里不要混淆字节和位!即使一个 AES-256 密钥也有 256 位,或者仅仅 32 个字节。所以 RSA 甚至可以安全地保存一个“大”AES 密钥。
3
TLS 通常不对客户端进行身份验证。但是如果请求客户端身份验证,它将必须通过签署来自服务器的挑战随机数来独立地证明它是证书的所有者。
4
我们将在本书末尾讨论的 TLS 协议非常严格。当 TLS 说“DH”时,它并不表示 DHE,反之亦然。在其他情况下,这种区别并不总是那么明显。
*
七、更对称的加密:认证加密和 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_made、data_received和connection_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_server和create_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 发送一条消息,其中包括她的名字(明文)和一些用共享密钥加密的密文。我们对这种预期交换的符号如下所示:
你看到的箭头并不代表收到了的消息。由于数据丢失或被 Eve 截获,Bob 可能永远得不到它。箭头代表意图,所以 A → B 的意思是 A(爱丽丝)打算给 B(鲍勃)发一个信息。然而,出于实际目的,有时把它想象成发送和接收会更简单,所以我们也要做这个简化的假设。
A 代表爱丽丝的名字,或者身份串。标识字符串可以是很多东西。这可能是爱丽丝的法定姓名,用户名,URI,或者只是一个不透明的令牌。因为消息中的 A 不在任何大括号内,所以它是明文。在 K A,B 下的密文与我们之前用来表示由 A 和 B 共享的密钥的符号相同。然而,当 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 的方式:*
真的吗?这是怎么回事?是什么让伊芙不发送爱丽丝的名字?
神奇之处在于的回应。AS 将发回只有真正的爱丽丝才能解密的加密数据。这假设 Alice 知道她的密码,其他人都不知道。
首先,AS 将从 Alice 的密码中得到她的密钥 K A 。然后,AS 将发回一个新生成的会话密钥,该密钥在 Alice 的 K A 密钥下加密!
如果爱丽丝知道密码,她将能够导出 K A 并解密会话密钥,稍后我们将解释其目的。现在,我们只能说它是 SSO 操作的一部分。
Kerberos 通过使用时间戳和随机数来抵御重放攻击。虽然 Kerberos 是可配置的,但它通常不会接受超过 5 分钟的消息。时间戳也用作 nonce,这意味着同一个时间戳不能使用两次。时间戳包括微秒字段;很难想象一个客户端在同一微秒内发送两个请求。真正的 Kerberos 检查它是否在同一时间(精确到微秒)发送了多个包。如果发生这种情况,应该人为地将时间戳中微秒字段的值增加 1。
为了简单起见,我们将使用时间戳,而不像对待随机数那样对待它们(例如,检查重复)。我们将更新我们的协议,将 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 的身份。这就修改了我们的协议:*
爱丽丝看不到 TGT。她不能以任何方式解密或阅读它;她只能把它传给 TGS。TGT 包含发送给 Alice 的完全相同的会话密钥、Alice 的名字(身份)和时间戳。真正的 Kerberos 包括额外的数据,比如 IP 地址和票证生命周期,但是前三个元素对于加密来说是最关键的。Kerberos 协议的第一阶段如图 7-1 所示。
图 7-1
Alice 用一条表明其身份的明文消息启动 Kerberos 登录过程。AS 在其数据库中查找她的密钥,并为 TGS 加密会话密钥。它还将在 TGS 密钥下加密的 TGT 发送给爱丽丝。
如上所述,会话密钥被发送给爱丽丝(用她的密钥)和 TGT 内的 TGS(用 TGS 密钥加密)。该密钥是爱丽丝和 TGS 之间的会话密钥,将允许他们进行通信。我们应该把 K session 改名为 K A, 。如果我们在协议符号中展开 TGT,我们现在得到的是
我们需要更新我们的代码,以包括 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 的消息:
图 7-2
Alice 使用 TGT 来证明她的身份,并向 TGS 请求会话密钥以与 echo 服务通信。与 TGT 类似,Alice 将收到 echo 服务的加密消息,她无法打开该消息,但可以转发。
如果 TGS 验证了数据并批准了请求,它会发回一张票和一个新的会话密钥,供 Alice 与服务 S 进行通信。像 TGT 一样,这张票对爱丽丝来说是不透明的。它是用的的密钥加密的,包含了与爱丽丝相关的授权数据。具体来说,它包含 Alice 的身份、服务的身份和时间戳。同样,真正的 Kerberos 票据包含这里没有包含的附加数据。这次传输的协议符号是
图 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)
图 7-3
Alice 和 echo 服务最终都有一个共享的对称密钥,它们可以使用这个密钥进行安全通信
这个协议在连接时发送TGS_REQ包以及加密的认证符、服务名和 TGT。请记住,TGT 是由 as 传输的,会话密钥也是如此。这些数据被传递给这个协议的构造函数。一旦我们收到了TGS_REP,我们就可以提取服务的会话密钥和发送给服务的票据。我们使用另一个回调on_ticket来处理这个信息。
图 7-3 显示了协议的其余部分。
为了将所有这些粘合在一起,我们使用清单 7-18 中的ResponseHandler类来接收回调on_login和on_ticket。on_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 将发送票以及她的身份和服务会话密钥下的另一个时间戳。可选地,服务可以使用相同服务会话密钥下的时间戳进行响应。我们可以把这个协议交换写成
当这完成时,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 的全部!
Footnotes 1如果任何阅读这本书的人还在使用“密码”作为任何真正重要的密码,请停止阅读并去更改它。真的。我们会等的。
八、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 具有以下分层键:
-
证书
-
版本号
-
序列号
-
签名算法 ID
-
发行人名称
-
有效期
-
之前不会
-
不是之后
-
-
主题名称
-
主题公钥信息
-
公钥算法
-
主题公钥
-
-
发行者唯一标识符(可选)
-
主题唯一标识符(可选)
-
扩展(可选)
-
-
证书签名算法
-
证书签名
X.509 的版本 1 和 2 是子集。版本 3 最重要的附加功能是扩展。这些扩展用于使启用证书的 PKI 更加安全,例如,通过限制证书的用途。尽管如此,版本 1 证书仍然存在并且是可用的,当我们开始生成一些示例时,我们将会看到这一点。
证书的主要目的是将主体的身份与颁发者签名下的公钥联系起来。标识主题、公钥和颁发者的字段是最关键的,但是其他字段提供了理解和解释数据所必需的上下文信息。
例如,有效期用于确定证书何时应被视为有效。虽然“不得早于”字段很重要并且必须检查,但实际上“不得晚于”时间段通常最受关注。具有较高泄密风险的证书可以用较短的有效期来发行,以减轻如果发生泄密所造成的损害。
X.509 证书的另一个重要内容是在标识所使用的证书创建算法和嵌入其中的公钥类型的字段中。与本书中的大多数玩具例子不同,真正的加密系统使用了广泛的算法,证书必须足够灵活以支持它们。
浏览前面的 X.509 字段,有一个“证书:签名算法 ID”字段,它标识证书是如何签名的。 2 因为它规定了嵌入证书中的实际签名的所有细节,所以它既包括签名算法(例如 RSA)又包括消息摘要(例如 SHA-256)。
另一方面,“证书:主体公钥信息:公钥算法”字段指定证书所有者正在使用什么类型的公钥。
我们将提到的最后一个上下文字段是序列号。这是唯一标识证书的唯一编号(每个颁发者)。该编号对于本章后面讨论的撤销目的很有用。
现在让我们回到我们拥有证书的真正原因:识别主体、主体的公钥以及“证明”这一点的可信第三方。
显然,字段“发布者名称”和“主题名称”描述了发布者和主题所声明的身份。在前几章的假证书中,这些只是简单的字符串。在真实的证书中,这些不仅仅是原始的文本字段,还具有结构和子组件。称为“识别名”,这两个身份字段通常具有以下子字段 3 :
-
CN: CommonName
-
OU:组织单位
-
o:组织
-
l:地点
-
s:state corporations name
-
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 采取的不同步骤:
-
生成一个 RSA 密钥。
-
从密钥创建一个 CSR。
-
发送给证书颁发机构进行签名(或自己签名)。
生成密钥
首先,她生成一个 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.crt和domain_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 的东西。
图 8-1
Chrome 关于不可信证书的警告
这是一个来自 Chrome 浏览器的图片,报告它不喜欢 Eve 创建的证书。注意 Eve 收到的是ERR_CERT_AUTHORITY_INVALID错误。使用 Chrome 的开发工具,Eve 获得了更多关于浏览器如何看待这个证书及其链的信息,如图 8-2 所示。
图 8-2
Chrome 对不可信情况的警告
图 8-2(b) 是一个关于证书链细节的图像,具体来说。请注意,它收到了该链(证书及其颁发证书)。它将 Eve 创建的域证书(在该图中由通用名称wacko.westantarctica.southpole.gov标识)识别为“根”证书,因为它是自签名的。但是,它说这个根证书不是可信证书。如果根证书不可信,那么整个链的安全性就无法建立。
有多种方法可以将根证书添加到浏览器的可信证书库中。Eve 非常仔细地研究了这个概念,因为她可能能够使用这种方法来击败 TLS。然而,我们不会在这本书里包括细节,因为这实际上是一个非常糟糕和危险的想法。这可能是迄今为止我们讨论过的最危险的事情。 6 如果你在浏览器中安装一个新的根证书,你的浏览器将信任该根证书签署的任何证书。如果您考虑不周的可信根不知何故逃到了野外,攻击者基本上可以让您的浏览器相信任何网站都是可信的。
说到这里,浏览器如何信任任何证书颁发机构呢?令人不安的武断的答案是,少数“权威”已经把自己确立为可靠的根权威。这些组织和公司将它们的根公钥默认安装在流行的计算机系统和浏览器中。所有其他的信任必须来自这些武断的权威。这让你感到安全吗?
然而,总的来说,为了让 TLS 正常工作,它必须有正确配置的(和正确限制的)信任锚。有时,工程团队可能需要使用自签名证书进行测试和其他临时用途。但是,一般来说,浏览器不会信任它们,您编写的任何支持 TLS 的代码也不应该信任它们。
练习 8.2。证书实践
使用不同的算法和参数(如密钥大小)生成一些不同的 TLS 证书。
练习 8.3。梦幻证书
为一些你喜欢的组织创建一些“幻想”证书。自签一两个写着amazon.com或google.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 消息。
图 8-3
TLS 1.2 hello 消息的 Wireshark 解码
请注意,有一整节是关于密码套件的!在图 8-4 中,我们看到了扩展的列表。这是一个相当长的密码套件列表!请记住,这是一个从客户端到服务器的问候消息,这个列表是客户端愿意使用的所有密码套件。
图 8-4
TLS 1.2 hello 消息的密码套件组件的 Wireshark 解码
当服务器收到客户机的问候时,它将查看是否愿意使用客户机建议的密码套件中的一个。如果是,它会发回一个包含多个元素的响应:
-
Hello:服务器的 hello 消息包含它自己的随机 nonce。
-
证书:服务器的 TLS 证书或证书链,我们在本章前面已经详细介绍过。
-
密钥交换:如果密码套件使用 DHE 或 ECDHE,服务器也将连同 hello 一起传输它的 Diffie-Hellman 交换部分。对于 RSA 密钥传输,服务器不发送此元素。
-
完成:一种消息结束标记。
TLS 规范实际上为握手中发送的每种消息都指定了特定的名称。因此,虽然我们非正式地提到了客户端问候消息,但 TLS 1.2 实际上指定消息的名称是ClientHello。图 8-5 显示了ClientHello和ServerHello的交换以及官方消息名称。
图 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 所示。
图 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 所示。
图 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 显示了整个握手过程,不包括一些不常见的信息。
图 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 。
图 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 中的一个类,但是没有任何方法。该结构有type、version和length字段,这些字段相当简单。ContentType和ProtocolVersion的确切类型在文档的其他地方有定义,但是即使不用查找它们,意图也很清楚。
select的陈述可能更令人困惑。这部分结构表达的是有一个fragment字段,但是它的类型是三个选项之一:GenericStreamCipher、GenericBlockStream和GenericAEADCipher。这三个选项中的每一个都代表一种不同的密码。
为了清楚起见,这里显示的结构是概念性的。这个结构展示了数据是如何以二进制形式以一种易于理解的方式以及层次结构(数据结构中的数据结构)进行布局和连接的。当发送数据时,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-ciphered和block-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 所示。
图 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 扩展名)相匹配。
-
-
路径中的所有证书都不能过期。
-
路径中的任何证书都不能被吊销。
-
在到达根之前,证书的颁发者必须是证书链中下一个证书的主体。
-
实施证书限制(如
KeyUsage和BasicConstraints)。 -
实施与最大路径长度、名称约束等相关的策略。
夏娃意识到这是一个复杂的过程。有许多检查要做,任何一个错误都可能让她进入。许多 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 相同的填充(与规范相反)。这种错误不会对正常通信造成任何问题,因为两种填充方案对于合法流量是兼容的。只有当数据受到攻击时,才清楚填充是错误的。对于有错误实现的实现,如果没有降级,它们是易受攻击的。
防御措施包括
-
禁用 TLS 1.0(和 1.1 真的一样)。
-
使用审核工具验证 TLS 1.2 不容易受到攻击。
反常和僵局
Logjam 攻击和 POODLE 一样,依赖于强制降级到 TLS 的早期版本。实际上,我们的目标是降低密码套件的等级。
在 20 世纪 90 年代,美国政府有一项政策,现在允许强加密技术出口到外国。政府的政策将这类算法视为武器。 13 安全软件仍然带着这一政策的伤疤,并且出现了被称为导出算法的特定 TLS 密码套件。事实上,这些算法非常弱。
在 Logjam 中,攻击者拦截客户端的消息,删除所有建议的密码套件,并用 Diffie-Hellman (DH)的导出变体替换它们。服务器相应地挑选弱参数,并将它们发送回客户端。客户端不知道有什么问题,只是接受服务器糟糕的配置。
由此产生的密钥很容易被破解。
请注意,TLS 协议的Finished消息应该可以检测到这种攻击。发送包含握手期间交换的所有消息的哈希的消息的全部意义在于揭示这种操纵。
问题是Finished消息是在新的(弱)密钥下加密发送的。如果伊芙试图攻击,她可以在破解密钥的同时截获真正的信息。一旦密钥被破解,她可以创建一个假的Finished消息,并使用破解的会话密钥对其进行加密。除非破解密钥的时间比内部超时长,否则 Eve 可以成功。
FREAK 是一种与 Logjam 非常相似的攻击,但它使用“导出”RSA 参数。
对僵局和反常的防御包括
-
在服务器上禁用弱密码套件,尤其是“导出”密码。
-
使用无条件拒绝接受弱参数(例如,弱的 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。相反,程序员通常应该使用SSLContext。create_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_REQUIRED和load_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。三声欢呼!
这是书上的最后一个练习!为自己走到这一步鼓掌。
当你合上封面时,请随时给作者反馈,无论是好是坏。尤其是如果你让我们知道我们是否错过了什么!
Footnotes 1如果您已经习惯使用 Wireshark、Fiddler 或 tcpdump 等工具,那么您可以使用这些工具中的任何一种。我们为那些以前没有做过任何流量嗅探的人提供这个代理脚本。这个脚本是轻量级的,易于使用,并且一目了然。
2
后面出现的“证书签名算法”是一个副本,原因与我们当前的讨论无关。
3
使用其他种类的标识符,但是这些字段是“经典的”身份定义。
4
CA 证书将是自签名的。
5
SafeCurves 组织列出了包括 P-256 在内的许多曲线的某些问题。这条曲线没有已知的漏洞,但是关于它的参数以及它是否被设计成有“后门”存在疑问。其他曲线,如曲线 25519 可能是更好的选择,但数字签名的加密库尚不支持它。
6
许多公司、大学和其他组织要求他们的员工这样做。这是不明智的充其量。它破坏了这些人在公司网络内所做的一切的安全性,使他们像公司本身一样容易受到攻击,因为他们的流量应该是端到端加密的,但不是。这意味着,对有动机的犯罪分子来说,一家公司代表着一个有吸引力的目标,同时也将他们的员工个人置于丢失数据的风险之中,这些数据是以不加密的方式通过公司网络的一部分传输的。这是坏。
7
说真的,不要作恶。这个练习并不意味着以任何方式鼓励欺诈。
8
旧习难改。很多时候,术语“SSL”仍然被使用,甚至在谈论 TLS 时也是如此。例如,证书仍然经常被称为 SSL 证书,即使它们只用于 TLS。
9
在通常的实践中,只有服务器的身份被验证,尽管有越来越多的“相互 TLS”(MTLS)用例,其中客户端验证服务器,服务器也验证客户端。
10
有一些罕见的算法仅用于身份验证。
11
Google Chrome 和 Firefox 实际上创建了自己的“坏”证书列表,并作为软件更新的一部分发送给浏览器。他们实际上是在创建一种专有的 CRL。对于某些类型的证书来说,这实际上已经相当不错了。
12
这背后的一个原始项目的名字叫做“阳光”,是在 DigiNotar hack 之后开始的。
13
也许这就是东西南极洲如此落后于时代的原因?
14
下面的例子都使用了HTTPSConnection类,但是SSLContext对象在 Python 的各种网络操作中使用,所以这个信息比我们正在使用的例子更通用。
15
因此,我们将“验证”放在引号中的原因。
16
版本 3.7 引入了一个新的 API 来指定最低版本和最高版本。然而,这本书不仅是为 Python 3.6 编写的,而且新的 API 也需要特定版本的底层 OpenSSL。我们已经决定暂时坚持使用 3.6 API。