如何一步步构建加密聊天应用

 

一、前言

前一阵子我正在编写一个加密聊天客户端以及服务器,想通过开发过程了解关于加密方法的更多知识,也了解如何在网络协议中实现加密协议(如基于TLSSSH或者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获取完整代码。

server.py

# 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.org74.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)

现在,服务器可以使用pg以及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 keypackage方法可以将整数变量转化为字节并添加填充数据,直到长度满足要求为止。

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),接收客户端发过来的加密消息,解压这些消息,然后将消息(再次加密后)广播给服务器上其他所有客户端。

 

四、总结

整个过程大概就是这样,原理及代码已经介绍清楚。后面我们还可以看一下服务器如何处理消息、客户端的工作流程等,感谢大家阅读本文。

(完)