深入浅出 HTTPS:从原理到实战
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.4 对称加密算法

所谓数据加密,就是将一段数据处理成无规则的数据,除非有关键的密钥,否则谁也无法得知无规则数据的真实含义。

在密码学中,用于数据加密的算法主要有两种,分别是对称加密算法(Symmetric-key Algorithms)和非对称加密算法(Asymmetrical Cryptography)。

首先介绍对称加密算法的知识。不管是对称加密算法还是非对称加密算法主要用来保证数据的机密性。什么是对称加密算法呢?一般是通过一个算法和一个密钥(secret key)对明文(plaintext)进行处理,得到的不规则字符就是密文(ciphertext)。

图2-1很形象地描述了对称加密算法的操作。

图2-1 对称加密算法

对称加密算法可以用下列公式简单表述:

密文=E(明文,算法,密钥)

明文=D(密文,算法,密钥)

E和D分别表示加密和解密,通过公式可以了解几个关键点:

◎密钥是关键,密钥是一串数字,加密和解密使用同样的一个密钥,如果没有密钥,基于密文是无法获取明文的。

◎加密和解密操作(算法)是一个互逆过程,算法的背后就是复杂的数学知识。

读者可能好奇对称加密算法的可逆过程,本书不进行阐述,只是从应用的角度解释如何正确地应用密码学算法。

对称加密算法有两种类型,分别是块密码算法(block ciphers)和流密码算法(stream ciphers),表2-7和表2-8简单列举了常用的对称加密算法。

表2-7 块密码算法

表2-8 流密码算法

读者可以暂时不用理解分组长度的概念,密钥长度是对称加密算法中非常关键的一个概念,密钥长度决定了算法的安全性。

既然有这么多块密码算法,使用哪种算法呢?建议使用AES算法,该算法是对称加密算法的标准算法,后续也主要以AES算法讲解。

美国国家标准与技术研究院(National Institute of Standards and Technology, NIST)对众多的对称加密算法进行了考核,从安全性和效率进行了多方面评测,最终选取Rijndael算法作为对称加密算法的标准。以Rijndael算法为原型,创建了AES(Advanced Encryption Standard)算法,AES就是最终的对称加密算法标准。Rijndael算法和AES算法略微不同,但在理解的时候可以认为是相同的算法。

2.4.1 流密码算法

在介绍流密码算法之前,先简单介绍下一次性密码本(one-time pad)的概念,一次性密码本诞生了流密码算法。

一次性密码本非常简单,大概原理如下:

◎明文与同样长度的序列进行XOR运算得到密文。

◎密文与加密使用的序列再进行XOR运算就会得到原始明文。

一次性密码本的核心操作就是XOR运算(异或操作),公式如下:

        0 XOR 0 = 0
        0 XOR 1 = 1
        1 XOR 0 = 1
        1 XOR 1 = 0

也就是两个比特(可能是1或者0)进行XOR运算,如果比特相同,结果就是0,或者结果就是1。

接下来看一个实际的例子:

good字符串是明文,其对应的明文序列是

        01100111011011110110111101100100

skey字符串是密钥,其对应的密钥序列是

        01110011011010110110010101111001

对明文序列和密钥序列进行XOR运算得到密文序列值:

            01100111011011110110111101100100
        XOR 01110011011010110110010101111001
            00010100000001000000101000011101

将密文序列和密钥序列进行XOR运算得到明文序列:

            00010100000001000000101000011101
        XOR 01110011011010110110010101111001
            01100111011011110110111101100100

通过两次XOR操作,最终会得到原始序列。

一次性密钥本的关键在于:

◎密钥每次必须不一样,否则同一个明文和密钥就会获得相同的内容。

◎一次性密钥本是无法破解的,原因就在于破解者无法确认破解的明文就是原始明文。

理解一次性密钥本之后,就可以大概明白流密码算法的工作原理了,以RC4流密码算法为例,关键就在于算法内部生成了一个伪随机的密钥流(keystream),密钥流的特点如下:

◎密钥流的长度和密钥长度是一样的。

◎密钥流是一个伪随机数,是不可预测的。

◎生成伪随机数都需要一个种子(seed),种子就是RC4算法的密钥,基于同样一个密钥(或者称为种子),加密者和解密者能够获取相同的密钥流。

有了密钥流,随后的加密解密就非常简单了,就是XOR运算。

流密码算法之所以称为流密码算法,就在于每次XOR运算的时候,是连续对数据流进行运算的一种算法,每次处理的数据流大小一般是一字节。流密码算法可以并行处理,运算速度非常快,但目前RC4已经被证明是不安全的了,建议使用接下来讲解的块密码算法。

2.4.2 块密码算法

块密码算法在运算(加密或者解密)的时候,不是一次性完成的,每次对固定长度的数据块(block)进行处理,也就是说完成一次加密或者解密可能要经过多次运算,最终得到的密文长度和明文长度是一样的。

数据块的长度就称为分组长度(block size),由于大部分明文的长度远远大于分组长度,所有要经过多次迭代运算才能得到最终的密文或明文,块密码算法有多种迭代模式(Block cipher modes of operation),迭代模式也可以称为分组模式。

通过以上的描述可以了解:

◎块密码算法不是一次运算完成的,块密码算法有多种迭代模式,每次迭代固定长度的数据块,这是需要重点理解的。

◎分组长度和密钥长度并没有必然的联系,对称加密算法的安全性取决于密钥长度。

◎如果明文(或者密文)的长度除以分组长度不是整数倍,需要对明文进行填充(后续章节会讲解),保证最终处理的数据长度是分组长度的整数块。

块密码算法有多种迭代模式,接下来讲解几个比较有代表性的迭代模式。

1)ECB模式

ECB模式(Electronic Codebook)是最简单的一种迭代模式,这种迭代模式是存在安全问题的,一般不建议使用。

先通过图2-2了解加密过程。

图2-2 ECB模式加密

◎将明文拆分成多个数据块,每个数据块的长度等于分组长度,如果最后一个数据块长度小于分组长度,需要进行填充保证最后一个数据块长度等于分组长度。

◎依次对每个数据块进行迭代得到每个数据块的密文分组,将所有密文分组组合在一起就得到最终的密文,密文长度等同于明文长度。

接下来看看解密过程,如图2-3所示。

图2-3 ECB模式解密

◎将密文拆分成多个数据块,每个数据块的长度等于分组长度。

◎依次对每个数据块进行迭代得到每个数据块的明文分组,最后一个明文分组要去除填充值,最终将明文分组组合在一起就得到最终的明文。

ECB模式最大的特点就是每个迭代过程都是独立的,是可以并行处理的,能够加快运算速度。由于固定的明文和密钥每次运算的结果都是相同的,这会造成很多的安全问题。

举个例子:

        +-----------------------+-----+--------------+
        |h  e  l  l  o |c  h  i  n  a | 原始值
        +--------------+--------------+--------------+
        |68 65 6c 6c 6f|63 68 69 6e 61| 原始值十六进制
        +--------------+--------------+--------------+
        |77 82 71 71 82|33 77 23 54 62| 加密值
        +--------------+--------------+--------------+

“hellochaia”这个字符串对于同一个密钥来说,经过两次迭代运算得到的密文值永远是不变的,攻击者截取到密文很容易发现加密采用的是ECB模式,从而可以观察到很多规律,比如密文中多次出现71,最终可能能成功破解出明文。

即使攻击者不能破解,也可以篡改密文,比如将所有的71替换为77,然后再将篡改的数据发送给接收者,接收者最终根据密钥反解得到字符串“hehhochina”,可这个字符串并不是原始明文,虽然能够正确解密但是明文已经被篡改了。

2)CBC模式

CBC模式(Cipher Block Chaining)是比较常见的一种迭代模式,解决了ECB模式的安全问题。

先通过图2-4了解加密过程。

图2-4 CBC模式加密

◎将密文拆分成多个数据块,每个数据块的长度等于分组长度,如果最后一个数据块长度小于分组长度,需要进行填充保证最后一个数据块长度等于分组长度。

◎首先处理第一个数据块,生成一个随机的初始化向量IV(Initialization Vector),初始化向量和第一个数据块进行XOR运算,运算的结果经过加密得到第一个密文分组。

◎接着处理后续的数据块,第n个数据块会和前n-1密文分组进行XOR运算,运算的结果再进行加密得到第n个密文分组。对于第一个数据块来说,它的前一个密文分组就是初始化向量。

◎将各个密文分组组合在一起就是完整的密文。

接下来看看解密过程,如图2-5所示。

图2-5 CBC模式解密

◎将密文拆分成多个数据块,每个数据块的长度等于分组长度。

◎对于解密者来说,初始化向量IV是随同密文发送给解密者的,而且该值是不加密的。

◎初始化向量和第一个数据块进行XOR运算,运算的结果经过解密得到第一个明文分组。

◎接着处理后续的数据块,第n个数据块会和前n-1密文分组进行XOR运算,运算的结果再进行解密得到第n个明文分组,最后一个明文分组要去除填充值。

◎将各个明文分组组合在一起就是最终的明文。

CBC加密模式非常常见,但是使用起来很烦琐,如果应用不当,很容易出现问题,需要注意以下几点:

◎CBC模式引入了初始化向量的概念,初始化向量是一个随机数,长度等于分组长度。

◎初始化向量必须每次都不一样,有了随机的初始化向量,同样的明文和密钥最终得到的密文是不一样的,解决了ECB模式存在的安全问题。

◎一般情况下初始化向量和密文是同时传输给解密者的,而且初始化向量是不加密的。

◎每个数据块(明文或者密文)和上一个数据块之间都是有关联的,上一个数据块稍有变化,最终得到的结果完全不一样。

◎迭代运算数据块不能并行处理,只有处理完第n个数据块,才能继续处理第n+1个数据块。

3)CTR模式

CTR模式(counter)在迭代的时候,相当于是一个流密码的运行模式。每次迭代运算的时候要生成一个密钥流(keystream),生成密钥流的方法可以是任意的,但是各个密钥流之间是有关系的,最简单的方式就是密钥流不断递增,所以才叫作计数器模式。

先通过图2-6了解加密过程。

图2-6 CTR模式加密

◎将密文拆分成多个数据块,和CBC迭代不一样的是不需要进行填充处理。

◎在处理迭代之前,先生成每个密钥流,有n个数据块,就有n个密钥流。根据第n个密钥流可以得到第n+1个密钥流,最简单的方式就是密钥流每次递增加一。

◎第一个密钥流的获取方式也很简单,就是生成一个随机值(Nonce),Nonce和IV可以等同理解,一个不容易推导出来的随机值。

◎接下来进行迭代加密处理,密钥流和密钥进行处理,得到的值再和数据块进行XOR运算(每次迭代相当于流密码运行模式)得到密文分组。

◎迭代运行每个数据块,最终得到密文。

接下来看看解密过程,如图2-7所示。

图2-7 CTR模式解密

◎将密文拆分成多个数据块,和CBC迭代不一样的是不需要进行填充处理。

◎对于解密者来说,Nonce是加密者随同密文发送给解密者的,而且该值是不加密的。

◎生成每个数据块对应的密钥流,每个密钥流之间是有关系的。

◎迭代加密密钥流和密钥,得到的值和每个密文分组进行XOR运算,得到明文分组。

◎对每个密文分组迭代运算,最终得到明文。

CBC模式和CTR模式是最常用的两种迭代模式,表2-9列举了所有的迭代模式。

表2-9 所有的迭代模式

2.4.3 填充标准

很多开发者知道特定密码学算法能够解决特定问题,但在实际应用算法的时候却经常犯错,比如在AES算法中不知道如何生成正确地初始化向量、不知道如何处理填充(padding)。

为了正确和安全地使用密码算法,定义了很多标准,指导开发者使用密码学算法,在后面的密码学算法中也会讲解更多的标准。本节会涉及PKCS#7和PKCS#5标准,更确切地说是这两个标准中的填充机制标准。

再回顾下填充机制,对于对称加密算法来说,明文长度必须是分组长度的倍数,如果不是倍数,必须有一种填充的机制,填充某些数据保证明文长度是分组长度的倍数。

填充机制并没有太多的限制,比如可以使用zero字符的填充模式,假设分组长度是64比特,明文最后一个分组长度是24比特,可以补充40比特的zero字符,描述如下:

        最后一个密钥块   = f  o  r  _  _  _  _  _
        (十六进制)        66 6f 72 00 00 00 00 00
        密钥           = 01 23 45 67 89 AB CD EF
        最后数据块密文   = 9E 14 FB 96 C5 FE EB 75

解密后,最后一个明文分组就是66 6f 72 00 00 00 00 00,去除明文末尾的zero字符,就得到原始明文。

zero字符填充模式最大的问题就是如果明文末尾本身就存在zero字符,解密后得到的明文就不是原始明文了。

接下来介绍PKCS#7填充标准,PKCS#7填充标准其实很简单,读者可以观察如下填充规律:

        01
        02 02
        03 03 03
        04 04 04 04
        05 05 05 05 05
        06 06 06 06 06 06

从伪代码中可以看出,根据填充的字节数量进行对应的填充,如果填充的字节长度n是3,填充的值就是030303;如果n是5,那么填充的值就是0505050505,填充值最后一个字节代表的就是实际填充的长度。

读者可以参考RFC 5652文档,了解计算填充的公式:

              01-- if lth mod k = k-1
            02 02-- if lth mod k = k-2
               .
               .
               .
      k k ... k k -- if lth mod k = 0

其中k可以理解为分组长度,lth表示明文或者密文的长度,如果分组长度是256比特,则最多填充255个字节。

完成解密后,读取解密值的最后一个字节的值n,去除最后n个字节得到原始明文。

PKCS#5和PKCS#7处理填充机制的方式其实是一样的,只是PKCS#5处理的分组长度只能是8字节,而PKCS#7处理的分组长度可以是1到255任意字节,从这个角度看,可以认为PKCS#5是PKCS#7标准的子集。AES算法中分组长度没有8字节,所以AES算法使用PKCS#7标准。

标准的好处:

◎约定俗成,通信双方约定AES算法使用标准(比如AES-128-CBC-PKCS#7),填充标准是PKCS#7,密钥长度是128比特,分组模式是CBC, AES算法默认分组长度是128比特,双方基于同样的标准处理加密和解密。

◎标准代表严谨,能够成为标准,必然是经过充分验证的,可以安全使用。

从安全的角度看,初始化向量应该是随机的,不容易预测的,推荐使用随机数生成器生成初始化向量,初始化向量长度等同于分组长度。

2.4.4 对称加密算法实践

对于读者来说,知晓了对称加密算法能够保证数据机密性,有不同的分组模式,加密和解密密钥是相同的。但更想知道如何实践,下面用OpenSSL命令行工具和PHP语言进行演示。

1)OpenSSL命令行应用AES算法

对于OpenSSL命令行来说,对称加密算法主要使用enc子命令,后面的参数可以指定具体的加密算法。不过也可以直接使用对称加密算法对应的子命令来操作,比如下面的命令是等价的:

        # 采用 3des算法
        $ openssl enc des3

        #采用 3des算法
        $ openssl des3

也可以通过如下命令显示系统支持的加密算法:

        $ openssl list -cipher-algorithms

该命令的输出很多,比如:

        aes256 => AES-256-CBC
        DES-EDE3-CBC
        ChaCha20-Poly1305
        AES-128-CBC-HMAC-SHA1

简单介绍下AES-256-CBC的概念,其他算法本章后续会有描述,AES-256-CBC算法标准表示采用AES算法,密钥长度是256比特,分组模式是CBC。

现在执行一个加密操作:

        $  openssl  enc  -aes-256-cbc  -salt  -in  file.txt  -out  file.enc  -pass
    pass:mypassword -p

最终输出:

        salt=8A023D3B41110145
        key=61C75CBC2AB30EBA1ECF0DFCFECF77C4
        iv =1AF6EFF5B184C9BF4554E1A5E60A1054

该命令使用aes-128-cbc算法对file.txt进行加密,最终输出的file.enc文件中包含密文和一些关键的信息(salt、iv),接下来简单介绍相关参数。

◎-in表示从文件中读出明文内容。

◎-out表示将加密内容保存到某个文件中。

◎-aes-256-cbc表示加密算法和标准。

◎-p参数是打印本次加密过程中salt、密钥、初始化向量的值。

读者可能有个疑问,AES算法使用的密钥在哪儿呢?在本例中密钥通过口令(就是mypassword)和Salt生成,口令的概念本章后续会讲解,目前只要明白几点:

◎AES算法使用的密钥通过口令和Salt生成,同样的口令和Salt会生成同样的密钥。

◎Salt的主要作用是为了保证同样的口令可以生成不同的密钥,是明文传输的。

在file.enc文件中包含salt和初始化向量值,这两个值不用加密也没法加密,因为解密的时候要用。

然后了解解密过程:

        $ openssl enc -d -aes-256-cbc -in file.enc

读者可能会说为什么没有-pass参数,在命令行中假如不指定-pass参数,OpenSSL命令会交互式提醒用户输入口令,输出的值如果等同于明文,表示验证正确。

如果对于对称加密算法比较了解,可以通过OpenSSL命令行显式地输入初始化向量、密钥,比如:

        # 加密
        $ openssl enc -aes-128-cbc -in e.txt -out m.txt -iv E9EDACA1BD7090C6-K
    89D4B1678D604FAA3DBFFD030A314B29

        # 解密
        $  openssl  enc  -aes-128-cbc  -in   m.txt  -d    -iv  E9EDACA1BD7090C6  -K
    89D4B1678D604FAA3DBFFD030A314B29

-iv表示初始化向量,-K表示密钥,密钥长度和初始化向量长度如果输入错误,OpenSSL命令行会报错。

读者可能很奇怪,填充标准怎么没有在OpenSSL命令行中涉及呢?实际上OpenSSL命令行默认使用的填充标准就是PKCS#7标准,这也进一步说明工具使用可能很简单,但会使用工具不代表理解事物的本质,读者可以输入下列命令了解详细的使用方法:

        $ openssl enc --help

2)PHP语言应用AES算法

接下来使用PHP语言了解如何正确使用AES算法。

        class AES128Encryptor
        {
            // 128 表示的是分组长度,可以用MCRYPT_RIJNDAEL_128 表示AES算法
            private $_cipher = MCRYPT_RIJNDAEL_128;
    // 分组模式
    private $_mode = MCRYPT_MODE_CBC;

    // 密钥
    private $_key;

    // 初始化向量长度
    private $_ivSize;

    // 构造函数
    public function __construct($key)
    {
        $this->_key = $key;

        // 可以从算法计算出初始化向量长度
        $this->_ivSize = mcrypt_get_iv_size($this->_cipher, $this->_mode);

        // 获取特定算法和分组模式对应的密钥长度
        $keyMaxLen = mcrypt_get_key_size($this->_cipher, $this->_mode);

        // 如果输入的密钥长度不合法,则直接报错
        if (strlen($key) > $keyMaxLen) {
          throw new Exception("error");
        }
    }

    // 加密数据
    public function encrypt($data)
    {
        // 获取特定算法和分组模式的分组长度
        $blockSize = mcrypt_get_block_size($this->_cipher, $this->_mode);

        // PKCS#7 填充标准
        $pad = $blockSize - (strlen($data) % $blockSize);

        // 生成随机的初始化向量
        $iv = mcrypt_create_iv($this->_ivSize, MCRYPT_DEV_URANDOM);

        // 密文包含初始化向量,而且是不加密的
        return $iv . mcrypt_encrypt(
          $this->_cipher,
          $this->_key,
          $data . str_repeat(chr($pad), $pad),
          $this->_mode,
          $iv
          );
      }

      // 解密
      public function decrypt($encryptedData)
      {
          // 从密文中获取初始化向量,初始化向量的长度等于分组长度
          $iv = substr($encryptedData, 0, $this->_ivSize);

          $data =  mcrypt_decrypt(
              $this->_cipher,
              $this->_key,
              substr($encryptedData, $this->_ivSize),
              $this->_mode,
              $iv
          );

          // 去除填充
          $pad = ord($data[strlen($data) -1]);
          return substr($data, 0, -$pad);
      }
    }

    // 密钥基于口令使用Hash算法生成
    $hash = hash('SHA256', "password", true);

    // 密钥长度是 128 比特
    $key = substr($hash, 0, 16);

    $obj = new AES128Encryptor($key);
    // 加密
    $d = $obj->encrypt("hello");

    // 解密
    echo $obj->decrypt($d);

总结:

◎密钥的生成很关键,尽量保证足够随机,生成方式有很多种。

◎初始化向量值要足够随机,不要有规律,初始化向量和密文一起发送给接收者。

◎从例子可以看出密钥、初始化向量、分组长度、填充机制是如何有效结合的。

◎对于PHP语言来说,尽量不要使用mcrypt库,可以使用PHP的OpenSSL库函数,本例只是为了说明如何使用AES算法进行加密和解密。