一、前言
前一阵子我正在编写一个加密聊天客户端以及服务器,想通过开发过程了解关于加密方法的更多知识,也了解如何在网络协议中实现加密协议(如基于TLS的SSH或者HTTPs协议)。现在这个工作已基本完成,我想与大家一起分享下我学到的一些经验及知识。
本文可以分为两部分,第一部分介绍了一些相关的基本概念,第二部分会深入分析代码,将这些概念与具体程序结合起来(@oaktree也建议我采用这种组织结构)。
话不多说,开始步入正题。
二、基本概念
我们的目标是创建一个共享密钥,以便加密及解密客户端和服务器交互的消息。
为了创建聊天室,客户端必须能够以某种安全的方式将消息发送给服务器,反之亦然。对称加密(symmetric encryption)机制采用了相同的密钥来加密及解密消息,我们可以考虑采用这种方式来实现。然而如果采用这种方式,那么通信双方必须就通过某种方式来获取相同的密钥。
请注意:加密密钥实际上是非常大的数字。
那么我们怎么样才能让两台计算机使用相同的大数呢?我们不能直接发送这个数字,这样做会在第一步就破坏掉整个加密体系,因为这样窃听者就可以窃取密钥,解开加密信息。相反,我们会使用Diffie-Hellman密钥交换算法来完成这个任务。
第一步:DH密钥交换算法
这种方法是创建共享密钥的绝佳方法。
Diffie-Hellman(简称为DH)算法可以让两个实体在若干次交互后获得相同的数字,并且该过程没有直接公开这个数字。许多DH实现方法会在有限循环中让整数对p
取模(p
是一个素数),然而我们也可以选择使用其他方式(比如椭圆曲线算法,大家在浏览加密网页时也用到了这种技术)。
我们可以抛开复杂的数学概念,直观地来理解这个密钥交换过程背后的原理,并且我们也不需要掌握太高超的数学知识就能理解这个过程。维基百科上有一张示意图可以描述这个过程:
在上图中,“common paint”包含一个非常大的素数p
(至少为2048位)以及小素数g
,这两个数都可以公开,不会对我们的安全性造成影响。“secret colors”是每个成员自己生成的随机数,不能对外公布。这种方法之所以能实现密钥共享,原因在于如下等式成立:
(g^a % p)^b % p = (g^b % p)^a % p
其中,a
以及b
为“secret colors”,%
为取模操作(即除法运算后取余数)。大家可能会问,为什么这两个表达式会相等?这是个数学问题,以我的水平可能很难解释清楚。我想这就相当于在问为什么2 + 2 = 4
,不同的是看起来没有那么直白,但事实的确如此。如果有人能简单明了地解释这个等式,欢迎发表高见,让我能更好地理解。我还没有专门学习过相关理论,希望有人能帮忙解释一下。
如果这里你还没有完全理解,没关系,后面我们会在代码中再详细解释一下。现在重要的是我们可以通过这种方法获得相同的数字:一旦完成这个任务,我们就可以继续执行下一个任务。
第二步:AES加密
AES的全称为Advanced Encryption Standard(高级加密标准),由于加密速度较快、安全性较高,AES现在已经成为广泛使用的一种对称加密算法。除非使用该算法的系统本身正在泄露数据,否则想攻破这种算法,只能采用暴力破解法。我们会以Cipher Block Chaining)(加密块链,CBC)模式来使用AES-256算法,这种模式需要使用256位的密钥(因此我们需要通过SHA-256算法将共享大数的长度标准化为256位),加密序列中的每个加密块都需要依赖前一个加密块才能进行加解密。
在这种模式下,我们必须使用一个初始化向量(initialization vector,IV)来作为加密链中的初始块,以便处理后面的每个数据块。
在CBC模式下,每个明文块会先使用前一个密文块进行异或(XOR)处理,然后再加密。这样一来,每个加密块都需要依赖之前所有的加密块,直到追溯到第一个加密块为止。
这也是IV的作用所在,因为如果我们还没有开始加密,我们就没办法找到起点之前的一个块来异或处理。
(感谢@pry0cc提供如上解释)
与加密密钥不同的是,这个值可以对外公开。这种方法基本上可以让针对密钥的逆向分析无功而返,因为即便使用相同的密钥对“hello”进行两次加密,如果IV值不同,那么我们会得到完全不同的加密结果。但我们必须小心谨慎,重复使用相同的IV会降低通信的安全性。大家可以参考WEP中的安全缺陷了解更多细节。
此外,我们还需要加入一些额外的字符(即“填充”数据),使待加密数据的大小为AES块大小的整数倍。如果在消息尾部添加空格符,对端处理起来也比较方便,因此我们可以采用这种填充策略。这样一来,第一个块为IV,后面的块为我们提供的消息(包含填充数据)。
基本概念就这么多。现在我们可以在服务器和客户端之间来回发送加密数据。客户端可以将加密的聊天消息提交给服务器,服务器会解密消息,使用其他客户端的密钥重新加密消息然后再广播消息。如果网络中有人在窃听,那么他所收到的每个消息看起来完全不同,没办法破译其中内容。
三、代码实现
现在我们来看一下客户端以及服务端的具体代码。
整个工程所包含的文件如下:
文件 | 描述 | 类别 |
---|---|---|
client.py | 客户端 | 网络 |
server.py | 服务器 | 网络、控制 |
dhke.py | DH密钥交换 | 加密 |
cipher.py | AES加密及解密 | 加密 |
cli.py | 命令行接口 | 接口 |
客户端以及服务端包含许多相同代码,其中大部分为消息的加密以及解密代码。然而,由于服务端负责整个流程中的大部分工作,因此我们先从服务端开始。
这里我只会介绍一些重要内容,略过一些无关紧要的代码(使用…来替代),大家可以访问Github获取完整代码。
# Inside Server Class
def __init__(self, host='127.0.0.1', port=DEFAULT_PORT):
...
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.dh_params = M2DH.gen_params(DH_SIZE, 2)
...
首先我们需要创建一个新的socket以便服务器进行通信。
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
这里socket.AF_INET
表明我们使用的是IPv4地址(如0x00sec.org
、74.125.136.94
),socket.SOCK_STREAM
表明使用的是TCP协议。
接下来,我们使用m2crypto
库的DH
模块生成DH密钥交换算法所需的参数。
self.dh_params = M2DH.gen_params(DH_SIZE, 2)
请记住,这里我们需要创建两个对外公开的值:素数p
以及素数q
。当我们指定所需素数的大小(以bit为单位,至少为2048位)以及所需的生成器后,这个函数就会返回包含p
以及g
的一个类。该函数使用OpennSSL来完成这个任务,OpenSSL是“专为TLS(传输层安全)协议以及SSL(安全套接层)协议设计的功能强大的商业级工具包”,许多程序都用到了这个库,比如OpenVPN。
现在服务器可以监听新客户端的连接请求,然后尝试进行DH密钥交换,监听客户端发来的加密数据。
while True:
connection, address = self.socket.accept()
client = Client(self, connection, address)
...
self.clients.append(client)
...
threading.Thread(target=self.listen, args=(client, )).start()
我们使用同一个套接字来接受(accept)下一个连入请求(该函数为阻塞型函数),将新的连接初始化为新的客户端。Client类的初始化函数如下所示:
# Inside Client Class (this is the client object the server uses)
def __init__(self, server, connection, address):
...
self.key = self.dh(server.dh_params)
Client类接受3个参数:server(该客户端所属的服务器)、connection(socket连接)以及address(地址信息,格式为(ip_address, port)
),然后调用dh()
函数来生成共享密钥。
# Inside Client Class
def dh(self, dh_params):
"""
Perform Diffie-Hellman Key Exchange with a client.
:param dh_params: p and g generated by DH
:return shared_key: shared encryption key for AES
"""
# p: shared prime
p = DH.b2i(dh_params.p)
# g: primitive root modulo
g = DH.b2i(dh_params.g)
# a: randomized private key
a = DH.gen_private_key()
# Generate public key from p, g, and a
public_key = DH.gen_public_key(g, a, p)
# Create a DH message to send to client as bytes
dh_message = bytes(DH(p, g, public_key))
self.connection.sendall(dh_message)
# Receive public key from client as bytes
...
client_key = DH.b2i(response)
# Calculate shared key with newly received client key
shared_key = DH.get_shared_key(client_key, a, p)
return shared_key
首先,该方法会使用DH.b2i
函数将字节转换为整数(b2i
函数位于dhke.py
中),生成参数p
以及g
,然后再生成一个随机的私钥a
(同样是一个整数)。服务器的公钥采用这几个参数生成,公式为g^a % p
,所使用的函数为DH.gen_public_key()
,该函数的定义如下:
def gen_public_key(g, private, p):
# g^private % p
return pow(g, private, p)
现在,服务器可以使用p
、g
以及public_key
来构造一则公开消息,发送给客户端。
注意:客户端需要这3个参数才能生成自己的公钥。
接下来这行代码可以生成一个新的DH
对象,并将其转换为字节:
dh_message = bytes(DH(p, g, public_key))
我们可以查看DH
类的__bytes__
方法,了解如何将这3个变量编码为字节:
def __bytes__(self):
"""
Convert DH message to bytes.
:return: packaged DH message as bytes
+-------+-----------+------------+
| Prime | Generator | Public Key |
| 1024 | 16 | 1024 |
+-------+-----------+------------+
"""
prm = self.package(self.p, LEN_PRIME)
gen = self.package(self.g, LEN_GEN)
pbk = self.package(self.pk, LEN_PK)
return prm + gen + pbk
由于我们需要规定标准的消息格式,以便客户端对消息进行解封装处理,因此我们约定前1024个字节为p
,后面的16个字节为g
,最后的1024个字节为public key
。package
方法可以将整数变量转化为字节并添加填充数据,直到长度满足要求为止。
def package(i, length):
"""
Package an integer as a bytes object of length "length".
:param i: integer to be package
:param length: desired length of the bytes object
:return: bytes representation of the integer
"""
# Convert i to hex and remove '0x' from the left
i_hex = hex(i)[2:]
# Make the length of i_hex a multiple of 2
if len(i_hex) % 2 != 0:
i_hex = '0' + i_hex
# Convert hex string into bytes
i_bytes = binascii.unhexlify(i_hex)
# Check to make sure bytes to not exceed the max length
len_i = len(i_bytes)
if len_i > length:
raise InvalidDH("Length Exceeds Maximum of {}".format(length))
# Generate padding for the remaining space on the left
i_padding = bytes(length - len_i)
return i_padding + i_bytes
消息封装完毕后,我们将其发给客户端,等待客户端返回公钥数据:
self.connection.sendall(dh_message)
try:
response = self.connection.recv(LEN_PK)
except ConnectionError:
print("Key Exchange with {} failed".format(self.address[0]))
return None
client_key = DH.b2i(response)
然后我们可以将响应数据从字节转化为整数,再与我们自己的私钥和公开素数p
一起提交给DH.get_shared_key()
方法:
shared_key = DH.get_shared_key(client_key, a, p)
get_shared_key()
函数可以计算(client_key ^ a) % p
公式,将结果转化为十六进制字符串,然后提交给sha256
函数,以标准化结果的长度。
def get_shared_key(public, private, p):
"""
Calculate a shared key from a foreign public key, a local private
key, and a shared prime.
:param public: public key as an integer
:param private: private key as an integer
:param p: prime number
:return: shared key as a 256-bit bytes object
"""
s = pow(public, private, p)
s_hex = hex(s)[2:]
# Make the length of s_hex a multiple of 2
if len(s_hex) % 2 != 0:
s_hex = '0' + s_hex
# Convert hex to bytes
s_bytes = binascii.unhexlify(s_hex)
# Hash and return the hex result
return sha256(s_bytes).digest()
现在我们终于生成了一个共享密钥!这个过程非常有趣,当然实际环境会比理论实验更加复杂。接下来我们还需要做些什么呢?
生成共享密钥后,我们才刚完成服务器上客户端的初始化工作。
client = Client(self, connection, address)
...
# Add client to list of clients on server
self.clients.append(client)
...
# Listen for incoming messages from client
threading.Thread(target=self.listen, args=(client, )).start()
服务端会启用新的线程(self.listen
),接收客户端发过来的加密消息,解压这些消息,然后将消息(再次加密后)广播给服务器上其他所有客户端。
四、总结
整个过程大概就是这样,原理及代码已经介绍清楚。后面我们还可以看一下服务器如何处理消息、客户端的工作流程等,感谢大家阅读本文。