1. 项目概述从“黑盒”调用到亲手“造轮子”如果你在网上搜过“Python AES加密”大概率会看到这样的代码片段from Crypto.Cipher import AES然后调用一个new()函数传入密钥和模式接着调用encrypt()或decrypt()。几分钟就能跑通看起来很简单对吧但问题来了当程序报错“输入数据长度不是16的倍数”或者你需要和另一个系统对接对方要求“使用PKCS7填充”时你是不是就懵了你只知道调库却不知道库背后发生了什么。AES对你而言依然是个神秘的黑盒。这正是“死记硬背”式学习的典型困境。我们记住了API的调用方式却对核心原理一无所知一旦遇到变数就束手无策。今天我们就来彻底打破这个黑盒。我们不依赖任何高级加密库比如pycryptodome仅使用Python标准库从零开始动手实现AES算法最核心的ECB和CBC两种工作模式。目的不是让你去写一个生产级的加密库那需要极其严谨的实现和大量优化而是通过“造轮子”这个过程让你真正搞懂分组密码到底是如何“分组”的ECB模式为什么被称为“电子密码本”它有什么致命缺陷CBC模式如何通过一个“初始化向量”解决ECB的问题实现更强的安全性那些令人头疼的填充Padding比如PKCS7到底是怎么工作的我敢保证跟着本文走完一遍你对AES的理解会远超绝大多数只会调库的程序员。当你能亲手用代码描绘出AES的加密流程图时那些曾经需要死记硬背的概念会变得无比清晰和牢固。2. 核心原理拆解AES、分组与模式在动手写代码之前我们必须把地基打牢。这部分内容看似理论但它是我们后续每一行代码的指导思想。理解不透代码就会写成“玄学”。2.1 AES算法简述不是魔法是精密的数学变换AESAdvanced Encryption Standard高级加密标准是一种对称分组密码算法。我们聚焦几个关键点对称加密和解密使用同一把密钥。这就像你用同一把钥匙锁门和开门。分组它不像流密码那样逐位处理而是把明文切分成固定长度的“块”来处理。AES的标准块大小是128位16字节。无论你的明文是“Hello”还是整本《战争与和平》在AES眼里都会被切成一个个16字节的小块。密钥长度支持128位、192位、256位。密钥越长理论上越安全但加解密计算量也越大。为了简化我们后续实现将使用128位密钥16字节。核心轮函数AES加密过程可以看作对数据块进行多轮10/12/14轮取决于密钥长度复杂的数学变换。每一轮都包含四个步骤字节替换SubBytes用一个固定的S盒Substitution-box进行非线性替换提供混淆。行移位ShiftRows将数据块矩阵的每一行进行循环左移提供扩散。列混合MixColumns将数据块矩阵的每一列进行线性变换进一步增强扩散。轮密钥加AddRoundKey将当前轮的子密钥与数据块进行异或XOR操作。解密过程就是这些步骤的逆序逆操作。听起来复杂但当我们用代码一步步实现时你会发现它们都是非常确定的计算。2.2 工作模式如何加密“多个块”AES本身只定义了一个数据块16字节的加密。对于长明文就需要“工作模式”来定义如何链接这些块。这是理解ECB和CBC差异的关键。ECB模式电子密码本模式逻辑最简单的模式。将明文分成独立的16字节块每个块用相同的密钥独立加密。就像一本密码本每个明文块对应一个密文块。图示明文块1 - AES加密 - 密文块1明文块2 - AES加密 - 密文块2 ... 块与块之间毫无关联。问题由于相同明文块必然产生相同密文块对于有重复模式的数据如图像、结构化文本密文中会保留明文的模式。一张纯色图片用ECB加密后可能依然能看出轮廓安全性很差。CBC模式密码分组链接模式逻辑引入了一个初始化向量IV Initialization Vector。加密时第一个明文块先与IV进行XOR然后再进行AES加密得到第一个密文块接下来第二个明文块会与前一个密文块进行XOR再加密如此链式进行。图示明文块1 XOR IV - AES加密 - 密文块1明文块2 XOR 密文块1 - AES加密 - 密文块2 ...优势即使明文相同只要IV不同产生的密文就完全不同。并且一个块的错误会影响后续所有块模式被隐藏安全性远高于ECB。IV不需要保密但必须是随机的且不可预测通常随密文一起传输或存储。2.3 填充Padding解决“最后一个块”问题明文长度不总是16字节的整数倍。对于最后一个不足16字节的块需要进行填充。PKCS7是最常用的方案之一。PKCS7填充规则假设最后一个块还差N个字节到16字节。那么就用数值N填充这N个字节。例如最后一个块数据为[0x48, 0x65, 0x6c, 0x6c, 0x6f]“Hello”还差11个字节。则填充后为[0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b]。如果明文长度恰好是16的倍数则需要额外填充一个完整的16字节块每个字节都是0x1016。这是为了解密时能无歧义地移除填充。解密后读取最后一个字节的值N然后移除末尾的N个字节即可得到原始明文。注意我们这里实现的是PKCS7但填充方案有多种如ZeroPadding, ANSIX923等。在实际跨系统对接时必须确认双方使用完全相同的填充方案否则解密必然失败。这是初期调试的一个常见坑点。3. 动手实现构建我们自己的AES工具类理论铺垫完成现在进入实战环节。我们将创建一个SimpleAES类它内部实现AES-128的核心轮函数并对外提供ECB和CBC模式的加解密接口。我们依赖的只有Python标准库。3.1 基础结构与辅助函数首先定义一些常量和辅助函数。AES运算大量在字节和4x4的字节矩阵状态State之间转换。class SimpleAES: # AES-128 常量 BLOCK_SIZE 16 # 字节 ROUNDS 10 RCON [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36] # 轮常数 # AES S盒Substitution Box - 这里只列出一部分完整版很长 # 这是一个16x16的矩阵用于字节替换。实际代码中你需要完整的256个值。 S_BOX [ 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, # ... 此处应包含完整的256个值 ] # 逆S盒用于解密 INV_S_BOX [ ... ] # 同样需要完整值 def __init__(self, key: bytes): 初始化接收一个16字节的密钥 if len(key) ! self.BLOCK_SIZE: raise ValueError(f密钥长度必须为{self.BLOCK_SIZE}字节) self.key key self._key_schedule self._key_expansion(key) staticmethod def _bytes_to_matrix(block: bytes): 将16字节转换为4x4状态矩阵按列填充 # 这是AES标准定义的状态矩阵排列方式不是直观的行优先。 # 假设 block [b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15] # 状态矩阵 state[行][列] 的填充顺序是先填第一列再填第二列... # state[0][0] b0, state[1][0] b1, state[2][0] b2, state[3][0] b3 # state[0][1] b4, state[1][1] b5, ... state[3][3] b15 state [[0] * 4 for _ in range(4)] for i in range(4): for j in range(4): state[j][i] block[i * 4 j] # 注意下标j是行i是列 return state staticmethod def _matrix_to_bytes(state): 将4x4状态矩阵转换回16字节 block bytearray(16) for i in range(4): for j in range(4): block[i * 4 j] state[j][i] # 反向操作 return bytes(block) def _sub_bytes(self, state, inverseFalse): 字节替换。使用S盒或逆S盒替换状态矩阵中的每个字节。 sbox self.INV_S_BOX if inverse else self.S_BOX for i in range(4): for j in range(4): byte_val state[i][j] # S盒是一个16x16表用高4位做行索引低4位做列索引 row (byte_val 4) 0x0F col byte_val 0x0F state[i][j] sbox[row * 16 col] return state def _shift_rows(self, state, inverseFalse): 行移位。正向第0行不移第1行左移1位第2行左移2位第3行左移3位。逆向则右移。 n 4 new_state [row[:] for row in state] # 深拷贝一份 for i in range(1, n): # 第0行不动 shift i if not inverse else n - i for j in range(n): if not inverse: new_state[i][j] state[i][(j shift) % n] else: new_state[i][j] state[i][(j - shift) % n] return new_state # _mix_columns 和 _key_expansion 函数涉及有限域GF(2^8)上的乘法和更复杂的变换 # 为了文章篇幅和清晰度这里暂不展开完整代码但会说明其作用。 # 完整实现需要定义 _galois_mul 等辅助函数。实操心得1状态矩阵的填充顺序这是新手实现AES时最容易出错的地方之一。AES标准文档定义的状态矩阵是按列填充的这与我们通常处理二维数组的“行优先”直觉相反。上面的_bytes_to_matrix和_matrix_to_bytes函数清晰地体现了这一点。如果你发现加密结果与标准库对不上首先检查这里。3.2 实现PKCS7填充在实现核心加密前我们先搞定填充这个“后勤”问题。staticmethod def pkcs7_pad(data: bytes) - bytes: PKCS7填充 pad_len SimpleAES.BLOCK_SIZE - (len(data) % SimpleAES.BLOCK_SIZE) # 如果pad_len是0意味着长度是16的倍数需要填充一整个块16个0x10 if pad_len 0: pad_len SimpleAES.BLOCK_SIZE padding bytes([pad_len] * pad_len) return data padding staticmethod def pkcs7_unpad(padded_data: bytes) - bytes: PKCS7去填充 if not padded_data: raise ValueError(输入数据为空) pad_len padded_data[-1] # 简单的有效性检查pad_len必须在合理范围且末尾pad_len个字节的值都等于pad_len if pad_len 1 or pad_len SimpleAES.BLOCK_SIZE: raise ValueError(无效的PKCS7填充) for i in range(1, pad_len 1): if padded_data[-i] ! pad_len: raise ValueError(无效的PKCS7填充) return padded_data[:-pad_len]注意pkcs7_unpad中的有效性检查非常重要。如果不检查攻击者可能通过篡改密文末尾的字节诱使你的程序解出错误的明文Padding Oracle攻击的一种基础形态。生产级代码的检查会更严格。3.3 实现ECB模式有了填充和AES单块加密的基础假设我们已实现_encrypt_block和_decrypt_block方法ECB模式就非常简单了。def encrypt_ecb(self, plaintext: bytes) - bytes: ECB模式加密 padded_data self.pkcs7_pad(plaintext) ciphertext bytearray() # 分块处理 for i in range(0, len(padded_data), self.BLOCK_SIZE): block padded_data[i:i self.BLOCK_SIZE] encrypted_block self._encrypt_block(block) # 调用单块加密 ciphertext.extend(encrypted_block) return bytes(ciphertext) def decrypt_ecb(self, ciphertext: bytes) - bytes: ECB模式解密 if len(ciphertext) % self.BLOCK_SIZE ! 0: raise ValueError(密文长度必须是块大小的整数倍) plaintext_padded bytearray() for i in range(0, len(ciphertext), self.BLOCK_SIZE): block ciphertext[i:i self.BLOCK_SIZE] decrypted_block self._decrypt_block(block) # 调用单块解密 plaintext_padded.extend(decrypted_block) # 解密后去除填充 return self.pkcs7_unpad(bytes(plaintext_padded))ECB模式的代码直观地反映了其原理分块、独立处理、合并。它不涉及任何块之间的依赖。3.4 实现CBC模式CBC模式需要管理IV和块之间的XOR链。def encrypt_cbc(self, plaintext: bytes, iv: bytes) - bytes: CBC模式加密。IV必须是16字节。 if len(iv) ! self.BLOCK_SIZE: raise ValueError(fIV长度必须为{self.BLOCK_SIZE}字节) padded_data self.pkcs7_pad(plaintext) ciphertext bytearray() previous_block iv # 第一个块的前一个“密文块”就是IV for i in range(0, len(padded_data), self.BLOCK_SIZE): block padded_data[i:i self.BLOCK_SIZE] # 核心步骤明文块与前一个密文块或IVXOR block_to_encrypt bytes(a ^ b for a, b in zip(block, previous_block)) encrypted_block self._encrypt_block(block_to_encrypt) ciphertext.extend(encrypted_block) previous_block encrypted_block # 更新链 return bytes(ciphertext) def decrypt_cbc(self, ciphertext: bytes, iv: bytes) - bytes: CBC模式解密。IV必须是16字节。 if len(iv) ! self.BLOCK_SIZE: raise ValueError(fIV长度必须为{self.BLOCK_SIZE}字节) if len(ciphertext) % self.BLOCK_SIZE ! 0: raise ValueError(密文长度必须是块大小的整数倍) plaintext_padded bytearray() previous_block iv for i in range(0, len(ciphertext), self.BLOCK_SIZE): block ciphertext[i:i self.BLOCK_SIZE] decrypted_block self._decrypt_block(block) # 核心步骤解密后的块与前一个密文块XOR得到原始明文块 plain_block bytes(a ^ b for a, b in zip(decrypted_block, previous_block)) plaintext_padded.extend(plain_block) previous_block block # 更新链注意这里是密文块 return self.pkcs7_unpad(bytes(plaintext_padded))实操心得2CBC解密的链式依赖注意看decrypt_cbc函数在解密第N块时与之XOR的是第N-1块的密文previous_block block而不是第N-1块解密后的明文。这是一个关键点。因为加密时是明文 XOR 前密文 - 加密所以解密时是解密 - XOR 前密文。很多人在自己推导时容易在这里搞反。3.5 补全AES单块加密/解密由于完整的AES轮函数实现特别是_mix_columns和密钥扩展_key_expansion代码量较大且涉及较多数学细节这里给出核心流程的伪代码和说明。你可以根据AES标准文档FIPS-197补全它们。def _encrypt_block(self, block: bytes) - bytes: 加密一个16字节块 state self._bytes_to_matrix(block) # 初始轮密钥加 state self._add_round_key(state, self._key_schedule[0:4]) # 进行9轮标准轮函数 for round_num in range(1, self.ROUNDS): state self._sub_bytes(state) state self._shift_rows(state) state self._mix_columns(state) # 需要实现 state self._add_round_key(state, self._key_schedule[round_num*4:(round_num1)*4]) # 最后一轮不进行列混合 state self._sub_bytes(state) state self._shift_rows(state) state self._add_round_key(state, self._key_schedule[self.ROUNDS*4:(self.ROUNDS1)*4]) return self._matrix_to_bytes(state) def _decrypt_block(self, block: bytes) - bytes: 解密一个16字节块逆过程 state self._bytes_to_matrix(block) # 注意解密时轮密钥的使用顺序是反的 # 初始轮对应加密的最后一轮 state self._add_round_key(state, self._key_schedule[self.ROUNDS*4:(self.ROUNDS1)*4]) state self._inv_shift_rows(state) state self._inv_sub_bytes(state) # 中间9轮 for round_num in range(self.ROUNDS-1, 0, -1): state self._add_round_key(state, self._key_schedule[round_num*4:(round_num1)*4]) state self._inv_mix_columns(state) # 需要实现 state self._inv_shift_rows(state) state self._inv_sub_bytes(state) # 最后轮对应加密的初始轮 state self._add_round_key(state, self._key_schedule[0:4]) return self._matrix_to_bytes(state) def _add_round_key(self, state, round_key): 轮密钥加状态矩阵与轮密钥矩阵对应字节XOR for i in range(4): for j in range(4): state[i][j] ^ round_key[i][j] return state4. 测试与验证眼见为实实现完成后我们必须进行测试。最可靠的方法是使用已知的测试向量Test Vectors或者用标准库如pycryptodome的结果来验证我们自己的实现。# test_aes.py import os # 假设我们已经有了完整的SimpleAES类 def test_ecb(): print(测试 ECB 模式...) key bThisIsASecretKey # 16字节 plaintext bHello, AES! This is a test message for ECB mode. aes SimpleAES(key) # 使用我们的实现加密 my_cipher aes.encrypt_ecb(plaintext) # 使用标准库加密需安装 pycryptodome from Crypto.Cipher import AES from Crypto.Util.Padding import pad cipher_lib AES.new(key, AES.MODE_ECB) lib_cipher cipher_lib.encrypt(pad(plaintext, AES.block_size)) print(f明文: {plaintext[:20]}...) print(f我们的密文 (hex): {my_cipher.hex()[:32]}...) print(f标准库密文(hex): {lib_cipher.hex()[:32]}...) print(f加密结果一致吗 {my_cipher lib_cipher}) # 解密测试 my_decrypted aes.decrypt_ecb(my_cipher) print(f解密结果一致吗 {my_decrypted plaintext}) print(- * 50) def test_cbc(): print(测试 CBC 模式...) key bThisIsASecretKey # 16字节 iv os.urandom(16) # 随机生成IV plaintext bHello, AES! This is a test message for CBC mode. It is better than ECB! aes SimpleAES(key) # 我们的实现 my_cipher aes.encrypt_cbc(plaintext, iv) # 标准库 from Crypto.Cipher import AES from Crypto.Util.Padding import pad cipher_lib AES.new(key, AES.MODE_CBC, iviv) lib_cipher cipher_lib.encrypt(pad(plaintext, AES.block_size)) print(fIV: {iv.hex()}) print(f明文: {plaintext[:20]}...) print(f我们的密文 (hex): {my_cipher.hex()[:32]}...) print(f标准库密文(hex): {lib_cipher.hex()[:32]}...) print(f加密结果一致吗 {my_cipher lib_cipher}) # 解密测试 my_decrypted aes.decrypt_cbc(my_cipher, iv) cipher_lib2 AES.new(key, AES.MODE_CBC, iviv) lib_decrypted cipher_lib2.decrypt(lib_cipher) # 标准库解密不会自动unpad需要手动操作 from Crypto.Util.Padding import unpad lib_decrypted unpad(lib_decrypted, AES.block_size) print(f解密结果一致吗 {my_decrypted lib_decrypted plaintext}) print(- * 50) def visualize_ecb_weakness(): 可视化ECB模式的弱点重复明文产生重复密文 print(可视化ECB模式弱点...) key bY * 16 # 简单密钥 # 构造一个有明显重复模式的“图像”数据用文本模拟 # 假设每16字节代表一行像素黑色用‘B’白色用‘W’ pattern_block bB * 8 bW * 8 # 一个黑白各半的块 plain_image pattern_block * 8 # 重复8行模拟一个条纹图像 aes SimpleAES(key) cipher_ecb aes.encrypt_ecb(plain_image) # 简单分析密文将密文每16字节块计算一个简短的“指纹”如前4字节的hex print(ECB密文块‘指纹’前4字节hex:) for i in range(0, len(cipher_ecb), 16): block cipher_ecb[i:i16] print(block[:4].hex(), end ) print(\n注意由于相同明文块这些‘指纹’很可能是重复的揭示了原始图像的结构。) # 对比CBC iv b\x00 * 16 # 为了演示用全零IV cipher_cbc aes.encrypt_cbc(plain_image, iv) print(CBC密文块‘指纹’前4字节hex:) for i in range(0, len(cipher_cbc), 16): block cipher_cbc[i:i16] print(block[:4].hex(), end ) print(\n注意即使明文相同CBC模式下每个密文块都不同模式被隐藏。) if __name__ __main__: # 运行前请确保SimpleAES类已完整实现 # test_ecb() # test_cbc() visualize_ecb_weakness()运行这个测试脚本你会直观地看到我们实现的SimpleAES与标准库的输出是否一致这是验证正确性的关键。ECB模式下重复的明文块导致重复的密文块“指纹”。CBC模式下即使明文重复密文也看起来是随机的。5. 常见问题与排查技巧实录在自己实现和调试的过程中我踩过不少坑。这里把典型问题和解决方法记录下来希望能帮你节省时间。5.1 密文与标准库对不上这是最常遇到的问题。请按以下清单逐步排查密钥和明文处理确认你的密钥和明文都是bytes类型且长度正确AES-128密钥为16字节。字符串记得用.encode()转换。填充方案这是头号嫌犯确认你和对比的标准库使用了完全相同的填充方案。pycryptodome默认使用PKCS7但它的pad函数在数据长度恰好为块大小整数倍时会额外填充一个完整块。你的pkcs7_pad函数也必须这么做。用只有1个字节的明文和刚好16字节的明文分别测试。IV的处理CBC模式确认IV是16字节且在加密和解密时使用的是同一个IV。IV通常是随机的需要和密文一起存储或传输。状态矩阵的填充顺序再次检查_bytes_to_matrix和_matrix_to_bytes函数。这是最容易写错的地方之一。可以用一个简单的已知序列如0x00到0x0F测试转换是否正确。S盒和逆S盒确保你使用的S盒和逆S盒数据完全正确且索引计算无误row * 16 col。列混合MixColumns这是实现中最复杂的部分涉及有限域GF(2^8)上的乘法。务必仔细核对乘法查找表或计算函数。一个错误就会导致所有后续结果全错。密钥扩展Key Expansion确保你的_key_expansion函数生成的轮密钥是正确的。可以搜索“AES test vectors”找到标准的密钥和对应的扩展密钥进行比对。解密流程确认解密是加密的精确逆过程。特别是轮密钥的使用顺序、逆列混合和逆行移位的方向。排查技巧采用“分而治之”策略。不要一次性写完所有代码再测试。先写一个_encrypt_block函数然后用一个最简单的测试向量例如全零数据和全零密钥验证单块加密。网上可以找到这些标准测试向量。单块加密正确后再测试ECB模式最后测试CBC模式。5.2 解密后得到乱码或报错“Invalid padding”密钥错误这是最可能的原因。加解密必须使用相同的密钥。IV错误CBC模式解密时使用的IV必须和加密时相同。密文被篡改哪怕只改动密文中的一个比特在CBC模式下会导致对应块及之后所有块解密失败比特错误传播并且很大概率导致最后的填充字节无效从而触发pkcs7_unpad中的有效性检查报错。填充逻辑错误检查你的pkcs7_unpad函数。解密后最后一个字节的值pad_len是否在1到16之间末尾的pad_len个字节是否都等于pad_len如果明文本身末尾就有像0x01这样的字节可能会被误认为是填充。PKCS7的这种设计就是为了避免歧义但你的检查逻辑必须严谨。工作模式不匹配用ECB加密的密文不能用CBC模式解密反之亦然。5.3 性能问题与优化方向我们为了教学实现的SimpleAES是纯Python的且未做任何优化性能会比C语言实现的标准库如pycryptodome底层是C或汇编慢几个数量级。这很正常。为什么慢Python是解释型语言循环和位运算开销大。AES的S盒替换、列混合等操作在C中可以用查表法极快完成。生产环境怎么办绝对不要在需要性能和安全性的生产环境中使用自己实现的密码学算法。务必使用经过严格审计、广泛测试的成熟库如pycryptodome、cryptography。学习的意义我们实现的目的在于理解而非应用。通过这个慢速的“显微镜”你看清了AES内部的每一个齿轮是如何转动的。今后当你使用cipher AES.new(key, AES.MODE_GCM)时你心里清楚GCM模式大概是在CTR模式基础上加了GMAC认证而不是在死记硬背一个API。5.4 安全性注意事项非常重要本实现仅供学习SimpleAES没有经过任何安全审计存在时序攻击等侧信道漏洞的风险绝不能用于保护真实数据。ECB模式不安全如演示所示ECB会泄露明文模式。在任何需要保密性的场合都应避免使用ECB。CBC模式需要正确的IVIV必须是密码学安全的随机数如os.urandom(16)且不可重复使用。使用固定IV或序列IV会严重削弱安全性。缺乏认证ECB和CBC只提供保密性不提供完整性。攻击者可以篡改密文而不被发现可能导致解密出有意义的错误明文。现代应用更推荐使用认证加密模式如GCMGalois/Counter Mode它同时提供保密性和完整性。密钥管理密钥的安全存储和分发是一个比算法实现更复杂的问题。本文不涉及。通过这个从零实现AES-ECB/CBC的过程我希望你收获的不仅仅是几行能跑的Python代码。更重要的是你亲手拆解了分组密码这个“黑盒”看到了ECB和CBC模式最本质的差异理解了填充的必要性。下次当你再遇到加密相关的问题时你脑海中的画面将不再是模糊的“调用某个库”而是一幅清晰的、由XOR、S盒、行移位构成的加密流水线图。这种深度的理解才是对抗“死记硬背”最有效的武器。