30分钟搞定AES系列(下):IV与加密语义安全性探究

从一张简单的图片开始

这是一张简单的图片,上面有一些文本内容:敏感信息 TOP SECRET

假设这张图片就是包含了高度敏感的信息,现在我需要隐藏其中的信息,我需要怎么做?

没错,我会用AES给他加密!

接下来,我们不妨尝试给它加密并看看效果。

再次强调:AES-ECB是不推荐的加密模式

在真正讨论IV对于加密效果的作用之前,不放我们先来论证下之前已经给出的一个结论:AES-ECB加密模式是不安全的,不推荐在工程中使用

那么到底有多不安全呢?我们眼见为实!

我们约定使用的密钥key = b"test_png_encrypt",同时我们约定上图中需要被加密的文件为file = "top_secret.png"

同时,为了更直观的验证加密效果并减少对图片加密细节的过分的阐述,我们这里约定:对于明文的png图片,按照行进行加密,用图片中的多行数据来模拟海量数据,用每行加密后的密文组合成的密文图片用于模拟各种加密模式在对海量数据加密后生成的密文的特征。

此时我们对文件做一次AES-ECB模式的加密:

    test_ecb = png_aes_encryption("ecb")
    test_ecb.key_value = key
    test_ecb.load(file)
    test_ecb.encrypt()
    test_ecb.save_cipher_png("cipher_ecb.png")

此时我们得到了如下效果的密文图片:

怎么形容这种感觉呢?

它看起来被加密了,但是加的并不多。

甚至,有种加了个寂寞的感觉!

AES-ECB是不需要使用IV的。单纯依靠密钥本身进行分组加密,在给定的密钥下,任何给定的明文块总是被加密到相同的密文块

这也是需要引入IV的原因。

IV使得加密过程变得更复杂,使明文的特征被更好的掩盖。

使用IV的AES-CBC模式就一定安全吗?

毫无疑问,CBC模式下引入IV后,至少可以推论出,它将不会如此完整的保留原始的明文信息。

但是,在实际工程中,仍然经常见到使用者为了省事,将同一份密钥与IV应用于海量的加密数据。

以这张图片来举例,假设整张图片代表的是海量的明文数据,那么在使用同样的密钥与IV对每一行数据加密后,我们可以得到如下的效果:

    test_cbc_fixed_iv = png_aes_encryption("cbc")
    test_cbc_fixed_iv.key_value = key
    test_cbc_fixed_iv.load(file)
    test_cbc_fixed_iv.iv_value = fixed_iv
    test_cbc_fixed_iv.encrypt(fixed_iv = True)
    test_cbc_fixed_iv.save_cipher_png("cipher_cbc_fixed_iv.png")

看起来效果似乎好了很多,已经完全无法看出来原始图片中的文字内容了。

但是,从统计学角度,当我们通过像素对比还是可以发现,密文图片中依旧保留了相当的原始图片的统计信息。

并且,通过肉眼来对比两张图片的噪声边缘,可以发现密文图片的噪声边缘几乎就是完美平滑的,与原文的文字部分几乎完美重合。

这说明,当对一个大文件(或被拆分为多个block的文件),在CBC模式下如果使用相同IV进行加密,则原始文件中的敏感信息特征可以被保留。

更准确一些来说,对于 CBC 模式,重复使用 IV 会导致:带有相同前缀的明文加密结果是相同前缀的密文。

假设两段明文长度各自是 128 字节和 160 字节,他们的前 33 字节相同。那么他们俩的密文,前 32 字节是相同的。

而如果我们在保持密钥key不变的前提下,每一行数据都是用不同的IV(这里每次都生成随机IV):

    test_cbc = png_aes_encryption("cbc")
    test_cbc.key_value = key
    test_cbc.load(file)
    test_cbc.encrypt()
    test_cbc.save_cipher_png("cipher_cbc.png")

则可以得到如下的效果:

当对每一行都是用随机IV时,整个图片的噪声分布看起来随机了很多,也就是说,从统计特征上来说,它保留的原始特征更少,肉眼可见的说明其安全性也是更好的。

必须强调:AES-GCM对于重复IV更加敏感

首先我们需要回顾下:GCM可以提供对消息的加密和完整性校验,是流式加密而非分组加密。

而流式加密的方式,其实对于重复IV是更敏感的。

    test_gcm_fixed_iv = png_aes_encryption("gcm")
    test_gcm_fixed_iv.key_value = key
    test_gcm_fixed_iv.load(file)
    test_gcm_fixed_iv.iv_value = fixed_iv
    test_gcm_fixed_iv.encrypt(fixed_iv = True)
    test_gcm_fixed_iv.save_cipher_png("cipher_gcm_fixed_iv.png")

在使用重复IV按行加密时,我们得到了如下效果的图片:

惊不惊喜?

意不意外?

如果固定IV的话,GCM模式的密文似乎跟ECB模式一样,对于原文的特征信息几乎完美保留,直接通过密文图片就可以看出来原始的文字信息。

其实,对于 OFB/CTR/GCM 等分组密码转流密码的模式,重复使用 NONCE 会将其降级至 ECB 模式。

ECB 至少需要一个分组块(AES 是 16 字节)完全相同的明文才会得到相同的密文,而 OFB/CTR/GCM 只需要 1 个比特。如果两段明文的同一个位置的值是相同的,他们在密文的对应位置的值就是相同的;同时如果明文同位置的值相反,密文对应位置的值也相反。毕竟 1 比特只有 0, 1 两种取值。

那么,如果我们使用随机IV呢?

    test_gcm = png_aes_encryption("gcm")
    test_gcm.key_value = key
    test_gcm.load(file)
    test_gcm.encrypt()
    test_gcm.save_cipher_png("cipher_gcm.png")

没错,在使用随机IV后,肉眼可见的感觉到了密文机密性更好了。

IV、密钥与机密性

IV,也就是初始化向量,其在加密算法中本身不需要保持秘密,它是可以被公开的。

Key,也就是密钥,在加密算法中是需要保持秘密的,它不可以被公开。

IV无论是对CBC模式还是对于GCM模式,对他都有一个最基本的要求:唯一性。

当IV只要求唯一性时,我们也可以叫它NONCE。

这里需要强调下:唯一性 与 随机性 是不一样的。

在GCM模式下,由于AES算子在内部会 计算ghash 把你输入的 IV(NONCE) 随机化一次,因此对于GCM模式来说,即使IV(NONCE)不是随机的也可以,也就是说,哪怕你使用GCM模式加密时输入的IV,每次都是在上一次IV的基础上加一,也是允许的,因此,在GCM模式下,一般我们不会使用IV的说法,而是直接叫NONCE,这个在常用的一些库中也可以体现:

apple的库:https://developer.apple.com/documentation/cryptokit/aes/gcm

一个很知名的C++库:https://doc.libsodium.org/secret-key_cryptography/aead/aes-256-gcm

go的库用法:https://gist.github.com/kkirsche/e28da6754c39d5e7ea10

而对于CBC模式来说,除了要求唯一性,还要求IV的随机性,什么叫随机性?

就是说,我根据当前的IV,无法猜测到下一个IV,如果根据当前的IV,我能猜测到下一个IV是把上一个IV的最后一个字节的值加一,那么它就失去了随机性。

通常来说,在工程应用上,我们保持IV的唯一性和随机性是最好的选择。

附录

部分关于png图片加密重写的代码片段,仅供参考:

def cut_list_with_step(lst: List[Any], step: int = 1):
    return [lst[i:i + step] for i in range(0, len(lst), step)]


class png_aes_encryption(object):
    def __init__(self, mode: str = "cbc"):
        self.__aes_operator = aes_encryption.aes_encryption(mode)
        self.__width: int = 0
        self.__height: int = 0
        self.__rows: List[bytes] = list()
        self.__info: Dict[str:Any] = dict()
        self.__cipher_rows: List[bytes] = list()
    
    @property
    def key_value(self) -> bytes:
        return self.__aes_operator.key_value
    
    @key_value.setter
    def key_value(self, key: bytes):
        self.__aes_operator.key_value = key
    
    @property
    def iv_value(self):
        return self.__aes_operator.iv_value
    
    @iv_value.setter
    def iv_value(self, iv: bytes):
        self.__aes_operator.iv_value = iv
    
    def load(self, filename: str):
        self.__width, self.__height, self.__rows, self.__info = png.Reader(filename = filename).read()
        print("file={filename}, file_info={info}".format(filename = filename, info = self.__info))
    
    def __generate_row_cipher(self, row_data: bytes) -> Tuple[bytes, int]:
        return self.__aes_operator.encrypt(row_data)
    
    def encrypt(self, block_size_in_bytes: int = 0, fixed_iv: bool = False):
        """
            block_size_in_bytes 默认为0, 表示按照矩阵的行进行加密
            否则将每行按照block_size_in_bytes进行拆分,然后对每个拆分的block进行加密
        """
        start = time.perf_counter()
        self.__cipher_rows = list()
        for i in self.__rows:
            
            if not fixed_iv:
                self.iv_value = bytes([random.randint(0, 255) for x in range(random.randint(8, 16))])
            
            if block_size_in_bytes <= 0:
                row_cipher, row_cipher_len = self.__aes_operator.encrypt(bytes(i))
                self.__cipher_rows.append(row_cipher)
            else:
                if block_size_in_bytes % 4 != 0:
                    raise ValueError("block_size_in_bytes has to be a multiple of 4 ")
                cut_row_cipher = bytes()
                cut_row_list = cut_list_with_step(bytes(i), block_size_in_bytes)
                for ci in cut_row_list:
                    tmp_cipher, tmp_cipher_len = self.__aes_operator.encrypt(bytes(ci))
                    cut_row_cipher += tmp_cipher
                self.__cipher_rows.append(cut_row_cipher)
        
        elapsed = round((time.perf_counter() - start), 3) * 1000
        print("mode={}, file_size={}, encryption time cost={}ms".format(self.__aes_operator.current_mode,
                                                                        self.__info["size"],
                                                                        elapsed))
    
    def save_cipher_png(self, filename: str):
        f = open(filename, 'wb')
        w = png.Writer(width = int(len(self.__cipher_rows[0]) / 4), height = len(self.__cipher_rows),
                       greyscale = self.__info['greyscale'],
                       alpha = self.__info['alpha'], bitdepth = self.__info['bitdepth'])
        print("try save png:filename={}, width={}, height={}".format(filename,
                                                                     int(len(self.__cipher_rows[0]) / 4),
                                                                     len(self.__cipher_rows)))
        w.write(f, self.__cipher_rows)
        f.close()


正文完