AES-128实现
写在前面
众所周知,代打密码学实验只有一次和无数次(逃)。一开始其实蛮期待下一个密码学实验的,大抵是由于上一次做实验 0-3 大数运算实在太痛苦,花了大量时间调试,最优的那个算法还是没有调试出来,博客也没法写,所以导致这次看到 AES 和马上就要到的提交代码 DDL 就有点犹豫。
可惜,因为有同学考公申请迟交,提交代码的期限延长了。说干就干,结果花了一天一夜就完成了所有 AES 实验(包括选做部分)。一眼丁真,评价为比大数运算简单。
实验描述
完整的实验描述可以在实验网站上看到。完整实验代码放到了GitHub上。
AES 是一种分组密码,意味着它将数据分成固定大小的块,然后对每个块应用加密算法。AES 支持的分组大小为 128 位,而密钥长度可以是 128 位、192 位或 256 位。我们这次要实现的是 AES-128,也就是分组大小和密钥长度都是 128 位。为了简便,下面将 32 位称为一个字,128 位称为一个块。
抛开各种工作模式的差异,AES 的核心就是用某个密钥对某个(明文或者处理过的)分组进行加密/解密。加密过程可以描述如下:
-
对原始密钥进行拓展,最终生成 11 个轮密钥,每个轮密钥 128 位,依次用于接下来的每轮加密过程。
-
第 0 轮,进行轮密钥加,也即将分组与轮密钥逐位异或。
-
第 1-9 轮,每轮用上一轮过后的分组,依次进行字节替代、行移位、列混淆和轮密钥加四个步骤。
-
第 10 轮,进行字节替代、行移位和轮密钥加,最后得到密文。
解密过程可以从后往前逆着回去,操作都用相应的逆操作。也有另一种等价的解密过程,操作顺序与加密过程一致,只是每一步的操作都用逆操作,且部分轮密钥要额外经过一次列混淆。
以上加密解密过程可以参考来自 NIST 的 AES 标准文档FIPS 197。
AES-128 CBC 模式的实现
以 CBC 模式为例,实现普通的 AES-128,这对应着实验 3-1,以及仓库中的aes.c
。
输入输出的处理
与之前的实验相同,本次实验的数据也是二进制给出的。由于明文、密钥等都可以理解为字节流,所以并无端序的区别。但要注意的是,AES 是可以用状态机描述的,其中的每个状态都是一个 4*4 的字节矩阵,且是列优先的。
例如,若输入给出明文:
1 | 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F |
则初始状态矩阵应该为:
1 | 00 04 08 0C |
同理,轮密钥矩阵的排列方式也是相似的。所以注意一开始读入的时候要处理好,直接用uint8_t[4][4]
来存储,并注意是列优先的。(一开始我用的是uint8_t[16]
来读入,中间用uint32_t[4]
存储状态,还弄成行优先了,每次都要先拆成uint8_t[4][4]
再运算,被自己蠢哭,而且直到写这篇文章的时候才意识到这点。鉴定为没上过理论课,并且不认真学前置知识导致的)
笔者写到这里的时候,又花了两个小时去调整了所有的接口/函数参数类型,再一次被自己蠢哭。
总之,最终主函数中处理输入输出的代码如下:
1 | int main(void) |
其中有一些奇怪的变量和函数,例如IV
、init_tables()
、padding()
等,这些我们后面会提到,现在先不用管。
上述代码中,key
、IV
我们都特别处理成了列优先的形式,整个加密过程中,也是以列优先方式进行的。因为多次调用fread
和fwrite
的开销较大,所以最开始读入data
是用uint8_t[16]
,然后再转成uint8_t[4][4]
。最后输出res
的时候,也是提前转成了行优先的形式,这样就可以只调用一次fwrite
。
密钥拓展
伪代码可以参考《密码学原理与实践(第三版)》一书的算法 3.6:
-
一共有 11 个轮密钥,也即 44 个字。前四个字到就是原始密钥。
-
计算。首先令,如果,则依次进行字循环、字节替代、轮常量异或,否则不进行任何操作;最后再与异或。
字循环
也即一个字内以字节为单位,左循环移位一位:
1 | void rot_word(uint8_t word[4]) |
字节替代
也对输入的每个字节,高四位作为行号,低四位作为列号,查表替换:
1 | const uint8_t SBOX[16][16] = { |
这里的字节替代和稍后加密过程中的字节替代,用的是同一张表SBOX
,称为 S 盒。S 盒的构造涉及到有限域中的运算。
如果记输入的字为,则经过 利用 S 盒对输入做字节代换等价一个仿射变换:
其中:
-
表示在有限域中的逆元
-
是仿射变换矩阵,它就是把下面的过程:
写成矩阵运算形式的结果:
-
是仿射变换的平移项,它是一个常数向量:
所以如果将一个矩阵初始化为:
然后:
- 将矩阵中的每个元素映射为其在有限域中的逆(0x00 映射为自身)
- 对矩阵中的每个元素应用仿射变换
就得到了我们代码中的常量表SBOX
。我们这里直接用现成的,没有自己构造。
以上关于 S 盒构造的内容参考了这篇文章。
轮常量异或
有一个轮常量数组,是一系列 32 位的常量。与对应的轮常量逐位异或即可:
1 | const uint32_t RCON[11][4] = {{0x00, 0x00, 0x00, 0x00}, |
实际用到的只有RCON[1]
到RCON[10]
。
完整密钥拓展代码
写完以上几个函数后,可以写出完整的拓展代码:
1 | void key_expand(uint8_t key[4][4], uint8_t expand_key[11][4][4]) |
本质上还是按照伪代码一个字一个字处理的。务必注意,我们存储的时候是列优先的,所以这里给temp
赋值包括将结果写回expand_key
数组时,要注意下标。
加密过程
有了轮密钥过程就可以进行密钥拓展了。
填充
由于 CBC 模式要求明文/密文的长度都必须是 16 字节的倍数(与密钥长度一致),所以需要有填充机制。实验要求采用 PKCS#7 Padding:如果需要填充 N 个字节长度才是 16 bytes 的倍数,则需要填充 N 个 0x0N
。
例如:明文长度为 10 字节,需要填充 6 个 0x06
;明文长度为 20 字节,就需要填充 12 个 0x0C
。特别地,若明文刚好是 16 字节的倍数,也要填充 16 个 0x10
。
代码如下:
1 | void padding(uint8_t *data, uint32_t padding_len) |
轮密钥加
有限域内的加法就是异或:
1 | void add_round_key(uint8_t state[4][4], uint8_t round_key[4][4]) |
字节替代
具体原理就不再赘述了,只需再写一个函数方便处理一个块就行:
1 | void sub_bytes(uint8_t state[4][4]) |
行移位
将状态矩阵的第 i 行循环左移 i 个字节:
1 | void shift_row(uint8_t state[4][4]) |
列混淆
列混淆的本质是做有限域内的矩阵乘法。列混淆矩阵是固定的:
用其左乘状态矩阵即可:
1 | void mix_column(uint8_t state[4][4]) |
其中gf_mul()
是有限域内的乘法运算。虽然之前写过一个有限域运算的视线,但相比那个,这里的有限域太小了,可以直接利用生成元构造正表和反表,将有限域乘法转化为查表。
生成元是指有限域中的一个元素,它的幂次可以生成整个有限域,或者说有限域中的每个元素都能够被表示为生成元的幂次(0 除外)。对于有限域中的两个元素和,它们的乘法可以表示为:
所以只需构造一张正表,能通过指数得到,再构造一张反表,能通过得到,就可以实现有限域的乘法运算。
我们选取生成元。有限域中乘以,相当于先左移一位,再加上原数。如果有进位,就减去 ,也即与0x11b
异或。所以可以写出生成正表和反表的代码:
1 | void init_tables() |
当然,你也可以生成一次之后,将其作为常量数组写进代码里。这样效率并无明显提高,所以最终还是选择了动态生成。
最终,可以写出有限域乘法的代码:
1 | uint32_t gf_mul(uint32_t x, uint32_t y) |
完整加密代码
由于 CBC 模式下,每一个明文块被加密前,要先与上一个密文块异或。第一个明文块与初始随机向量IV
疑惑。
我们用last
这个变量来存储,并将其初始化为IV
。每次加密完一个块后,将其赋值给last
,以便下一次异或。
1 | void encrypt(uint8_t state[4][4], uint8_t key[4][4], uint8_t last[4][4], uint8_t res[4][4]) |
解密过程
解密过程我实现的是普通的“逆着回去”,沿着加密过程的反方向来。首先要实现一系列的逆操作
逆轮密钥加
根据异或的性质,再异或一个相同的数就可以还原,直接用原来的add_round_key()
函数即可
逆字节替代
有 S 盒自然也有逆 S 盒,也是直接查表即可
1 | const uint8_t INV_SBOX[16][16] = { |
逆行移位
原来是左移,逆操作就右移回去即可:
1 | void inv_shift_row(uint8_t state[4][4]) |
逆列混淆
列混淆是乘一个举证,逆操作就是左乘逆矩阵。列混淆矩阵的逆矩阵如下:
所以可写出代码:
1 | void inv_mix_column(uint8_t state[4][4]) |
完整解密代码
是 CBC 模式,记得与上一个密文块异或才得到原来的明文块:
1 | void decrypt(uint8_t state[4][4], uint8_t key[4][4], uint8_t last[4][4], uint8_t res[4][4]) |
SIMD 加速
可以利用 AES-NI 中的专用指令来加速,这份参考资料中有详细的说明以及 C++示例代码。我将其改写为了 C 语言版本。
需要以下头文件:
1 |
拓展密钥
1 | void key_expand_simd(uint8_t *key, __m128i *expand_key) |
其中_mm_aeskeygenassist_si128()
是生成轮密钥的辅助函数。总之按EXPAND
宏的定义,经过一系列操作就可以得到下一个轮密钥。
加密
1 | void encrypt_simd(uint8_t *data, uint8_t *key, __m128i *last, uint8_t *res) |
可以发现,key
和data
无需调整端序/行优先,直接用按字节顺序读入的uint8_t
数组就可以正确地初始化所需的__m128i
,非常方便(当然,要在主函数调整传入的参数)。
中间轮用一个函数_mm_aesenc_si128()
就可以完成,最后一轮没有列混淆,所以用_mm_aesenclast_si128()
。
还要注意,这里的last
变为了__m128i
类型,主函数在做初始化的时候,也要做相应的调整。
解密
AES-NI 仅支持等价的解密过程:操作顺序与加密过程中相同,只是操作要换为相应的逆操作,且用到的轮密钥不同。
先要生成解密过程中用的轮密钥:
1 | void inv_key_expand_simd(uint8_t *key, __m128i *expand_key) |
其中_mm_aesimc_si128()
是进行逆列混淆。
具体的解密过程如下:
1 | void eq_decrypt_simd(uint8_t *data, uint8_t *key, __m128i *last, uint8_t *res) |
可见与加密的代码几乎完全一样。其中_mm_aesdec_si128()
和_mm_aesdeclast_si128()
分别是解密过程中的中间轮和最后一轮。
性能比较
至此 AES-128 CBC 模式的普通版本和 SIMD 加速版本就都实现完了。前者在实验平台的评测机上,通过所有样例的耗时是 1751ms;后者的耗时则是 143ms,提升了一个数量级。
另外,这个版本中只需要实现一个模式,因此一些预处理过程与核心加密解密过程耦合了,直接写到了主函数中,这样是不太好的。接下来实现所有模式,会有所改进。
AES-128 所有模式实现
对应着实验 3-2,以及仓库中的aes_all.c
。
解耦
首先我们将核心加密/解密过程解耦出来,让加密函数只管对一个块进行加密,输出密文块;解密函数只管对一个块进行解密,输出明文块。
加密和解密函数修改如下:
1 | void encrypt_simd(__m128i *data, __m128i *expand_key, __m128i *res) |
这就非常舒服!
主函数
解耦后,在主函数中,只根据类型进行判断,调用相应的加密解密函数:
1 | int main(void) |
判断调用那种密钥拓展函数,后面会介绍到。
CBC 模式
CBC 模式要求,每一个明文块在加密前,要先与上一个密文块异或。第一个明文块与初始向量IV
异或。其他特点可以自行搜索,这里只讲述与实现相关的部分。
CBC 模式已经实现过了,我们只要简单修改一下参数类型即可。
1 | void cbc_encrypt(__m128i *expand_key, uint8_t *IV, uint32_t len, FILE *in, FILE *out) |
ECB 模式
ECB 模式是最简单的模式,每一个明文块都独立加密即可。与 CBC 模式一样,明文也需要填充,使明文长度是 16 字节的倍数。
1 | void ecb_encrypt(__m128i *expand_key, uint32_t len, FILE *in, FILE *out) |
CFB 模式
CFB 模式是一种流模式。记明文块为,密文块为,加密函数为,。第一个明文块是。
通过加密以前的密文分组得到密钥流元素:
然后让密钥流元素与明文异或得到密文:
解密过程也是先计算出密钥流元素,然后与密文异或得到明文。
1 | void cfb_encrypt(__m128i *expand_key, uint8_t *IV, uint32_t len, FILE *in, FILE *out) |
CFB 模式不要求明文长度是 16 字节的倍数,所以不需要填充。由于初始与 IV 异或,得到的密文块是 128 位的,之后的密文块会与 128 位的上一个密文块异或。所以密文块总是 128 位的。
解密时,要记得根据长度截断。
OFB 模式
OFB 模式也是一种流模式但是是通过反复加密 IV 得到密钥流元素。
记,则有:
然后让密钥流元素与明文异或得到密文:
1 | void ofb_encrypt(__m128i *expand_key, uint8_t *IV, uint32_t len, FILE *in, FILE *out) |
同样,解密的时候要注意截断。
CTR 模式
CTR 模式也是一个流模式,反复加密一个不断递增的计时器得到密钥流元素。
实验中给的 CTR 模式下的样例是来源于 NIST 的文档 Recommendation for Block Cipher Modes of Operation附录。在样例中,计时器的初始值就是 IV,且是以大端序给出。计时器递增就是直接加 1。
网络上也有资料,说计时器由长度相同的两部分组成。前半部分是一个随机向量nonce
,后半部分是一个计数器counter
。计数器从 0 开始,每次加 1。
本次实验中,我们采用前一种方式。后面一种方式只要做简单的修改就能实现了。
具体而言,记初始计数器,则有:
密钥流元素:
密文:
1 | void ctr_encrypt(__m128i *expand_key, uint8_t *IV, uint32_t len, FILE *in, FILE *out) |
解密函数中,计时器是大端读入的,所以要用_mm_shuffle_epi8()
调整一下。然后利用_mm_add_epi64()
实现计数器加 1。
至此所有模式都实现完了。在实验平台上成为第二个通过所有样例的程序。