做项目以来,看过太多把 AES 加密解密当“黑匣子”的代码:拿一个在线工具生成的密文,复制一段 StackOverflow 上的代码,跑通了就当完事。结果换一种语言对接、换一台机器部署、或者只是把密钥从 16 字节改成 32 字节,整个流程就崩得七零八落。其实 AES 加密解密代码没那么玄乎,核心就三件事:算法参数选对、字节拼接拿准、密钥管住。这篇文章我会从算法原理讲到可运行的代码,再把跨平台对接、常见报错、安全底线这些细节一次说透,适合刚接手加密需求的开发者,也适合想把手头加密代码加固的工程师。
1. 为什么 AES 这么“敏感”:先搞懂它到底是什么
1.1 AES 本质上是把“对称锁”
AES 的全称是 Advanced Encryption Standard,中文叫高级加密标准。它是公开征集、经过多轮评审后胜出的一个分组加密算法,标准化之后就成了全球用得最多的对称加密算法之一。
对称加密的核心特征就是:加密和解密用的是同一把密钥。你可以把它想成一把实体锁:锁门的时候用钥匙,开门的时候也用同一把钥匙。发消息的人和收消息的人必须事先共享这把钥匙,这就是“对称”的含义。
AES 本身处理的是固定大小的数据块,一个块是 128 位,也就是 16 字节。不管你要加密的是 1 个字节还是 1GB 的文件,算法内部都会把数据切成长度为 16 字节的块,逐块做加密运算。这个“块大小 16 字节”是整个 AES 体系里最基础的数字,后面几乎所有坑都和它有关。
密钥长度则是另一个核心参数,AES 支持 128 位、192 位和 256 位三种。密钥越长,理论上的暴力破解空间越大。128 位已经是安全底线,256 位是生产环境里更常见的选择。但请注意,密钥长度和工作模式是两码事,很多人以为只要把 key 写长一点就安全了,模式不加区分地乱用,照样会翻车。
1.2 四个参数决定你写的加密代码对不对
AES 加密不是“一个函数传入明文输出密文”那么简单,它是一整套参数组合。我把最容易搞错的四个参数列出来:
| 参数 | 作用 | 选错的后果 |
|---|---|---|
| 密钥长度 | 决定加密强度 | 参数错误、加解密不一致 |
| 工作模式 | 决定数据块如何串联处理 | 轻则泄露结构,重则无法解密 |
| 填充方式 | 决定末尾不完整块怎么补 | 解密时 Padding 报错 |
| IV / Nonce | 保证相同明文产生不同密文 | IV 复用会让密文泄露明文关系 |
密钥长度好理解,128 还是 256,加密端和解密端对齐就行。工作模式稍微复杂一点,但它是整个 AES 代码里最需要动脑的部分。填充方式是一个很容易被忽略但杀伤力极大的细节,因为 AES 每块固定 16 字节,如果你的明文长度不是 16 的倍数,最后一块就必须补齐,否则算法直接报错。
IV 是初始化向量,很多资料里叫它 Initialization Vector,GCM 模式里更常见的叫法是 Nonce。它存在的意义是让“同样的明文、同样的密钥”加密出来的密文不一样。如果没有 IV,攻击者通过观察加密结果就能推断出数据里的重复片段,这在很多场景下是致命的。IV 本身不保密,通常和密文一起存储或传输,但它必须是随机生成的,同一个密钥下绝对不能重复使用同一个 IV。
这套参数组合就像四把锁扣环环相扣,任何一把没对上,加解密就必然对不上。我在后面的章节会逐个展开怎么选、怎么用。
2. 加密解密代码的核心骨架:模式、填充和 IV
2.1 Python 里的 AES 代码长什么样
先看一套最常见的 Python 实现。整个生态里,我几乎只用 pycryptodome 这个库,API 设计清晰,文档齐全,跨 Python 版本兼容性也做得不错。安装也很简单:
pip install pycryptodome导入的部分通常长这样:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import os import base64这几个导入分别负责什么,我简单说一下:
Crypto.Cipher.AES是 AES 算法本身的入口,负责创建加密和解密对象。pad和unpad是标准填充工具,默认实现 PKCS7 填充。os提供os.urandom,这是生成安全随机数的最佳途径,用来生成 IV。base64用于把加密后的二进制密文编码成可传输的字符串。
注意,pycryptodome 的 API 和旧版 PyCrypto 很像,但 PyCrypto 早就停止维护了,新项目一律选 pycryptodome,别再踩这个坑。
2.2 工作模式怎么选:从 ECB 到 GCM
AES 工作模式五花八门,但实际项目里常见的就五种。我直接用一张表对比:
| 模式 | 处理方式 | 是否需要 IV | 安全性 | 推荐场景 |
|---|---|---|---|---|
| ECB | 每个块独立加密 | 不需要 | 危险,相同明文块产生相同密文块 | 教学示例,不推荐生产 |
| CBC | 每个明文块先与前一密文块异或再加密 | 需要 16 字节 | 安全,但无完整性保护 | 经典通用场景 |
| CFB / OFB | 把块密码变成流密码 | 需要 | 安全,但无完整性保护 | 特定流式场景 |
| CTR | 计数器模式,可并行加密 | 需要 Nonce | 安全,但无完整性保护 | 高性能场景 |
| GCM | 自带认证标签的加密模式 | 需要 Nonce | 安全,且能检测篡改 | 现代推荐首选 |
ECB 为什么是反面典型?因为它每个块独立加密,相同的明文块永远得到相同的密文块。举一个很直观的例子:如果你用 ECB 加密一张有规整色块的图片,加密后图像里相同颜色的区域仍然会显现出轮廓。这是因为统计结构完全没被打散,攻击者不需要解密就能看出原始信息的大致布局。所以哪怕只是为了练手,我也不建议在生产环境里写 ECB,它唯一的价值就是让你理解分组加密的基本形态。
CBC 是目前存量系统里最常见的模式,它把每个明文块和前一个密文块做异或,再交给 AES 加密。第一个块没有“前一个密文块”,就用 IV 来代替。这样相同明文块在不同位置就会产生不同密文。CBC 安全性足够,但有个短板:它只加密,不认证。攻击者如果能够翻转密文中的某些位,解密后对应的明文位也会被翻转。所以真正严谨的生产方案要么用 CBC 再叠加 HMAC,要么直接换 GCM。
GCM 是我现在给所有新项目的默认推荐。它在加密的同时输出一个认证标签,解密时先验证标签,标签校验通过才说明密文没有被篡改过。一个模式同时搞定机密性和完整性,省心很多。GCM 的 Nonce 通常推荐 12 字节,这也是官方文档明确建议的长度。
2.3 IV 和 Nonce 不是随便填的 16 个字节
关于 IV,我见过太多想当然的写法:有人把它设成全零,有人写死成固定字符串,还有人干脆从随机数里截一段却不统计长度。这些都是问题。
先记住几条硬规矩:
- CBC、CFB、OFB、CTR 模式的 IV 长度等于 AES 块大小,也就是 16 字节。
- GCM 模式的 Nonce 推荐 12 字节,最长不要超过 16 字节,过长的 Nonce 反而会引入内部哈希问题。
- IV 必须对每次加密都是新的,尤其不能在一个密钥下复用。
- IV 不要求保密,可以明文拼在密文前一起存储或传输。
为什么 IV 复用会出事?在 CTR 模式里,如果两次加密用了同一个 Nonce 和同一把密钥,那么两次加密的密钥流完全相同。这时把两段密文异或,密钥流会直接抵消,剩下的就是两段明文的异或。明文一旦有规律,攻击者很快就能还原出原始内容。CBC 模式也类似,IV 固定会让相同明文开头产生相同密文开头,给分析留下口子。
所以代码里生成 IV 的标准做法就是:
iv = os.urandom(16)一句话,安安静静拿到 16 个安全随机字节,每次调用都不一样。不要自己设计随机规则,不要用时间戳然后补零,直接用系统提供的密码学安全随机源。
3. 一个完整可运行的 AES 加密解密示例
3.1 先约定好这套代码的数据格式
为了不让示例流于片段,我先把整个加密方案约定清楚:
- 算法:AES-128-CBC
- 填充:PKCS7
- 密钥:16 字节二进制数据
- IV:每次加密随机生成 16 字节
- 密文存储格式:base64(IV + ciphertext)
为什么把 IV 和密文拼在一起再做 base64?因为 IV 反正是要公开的,拼在一起,解密端只需要从 base64 解码后按长度切片,就能同时拿到 IV 和密文,不用额外设计字段来存 IV,省掉一个非常容易出错的环节。
这个方案里我故意选了 CBC 而不是 GCM,原因是 CBC 加 PKCS7 是目前存量代码里出现频率最高的一套组合,先把这个吃透,再换 GCM 只改模式名和参数。文章后面也会给出 GCM 的关键差异。
3.2 加密端代码逐步拆解
下面是完整的加密函数:
def encrypt(plaintext: bytes, key: bytes) -> str: iv = os.urandom(16) cipher = AES.new(key, AES.MODE_CBC, iv=iv) padded_data = pad(plaintext, AES.block_size) ciphertext = cipher.encrypt(padded_data) return base64.b64encode(iv + ciphertext).decode('utf-8')逐行说明:
os.urandom(16)生成 16 字节随机 IV。这行不是随便写的,os.urandom在主流操作系统上直接调用系统级加密随机源,安全强度有保障。
AES.new(key, AES.MODE_CBC, iv=iv)创建加密对象。key必须是字节串,不能是普通字符串。第一个参数是密钥,第二个是模式,第三个是 IV。CBC 模式下 IV 是必填的,不填直接报错。
pad(plaintext, AES.block_size)是 PKCS7 填充。AES.block_size是常数 16。注意pad的第二个参数是块大小,不是密钥长度。这一步保证传入encrypt的数据长度一定是 16 的倍数。
cipher.encrypt(padded_data)执行真正的加密,输出长度和padded_data相同的密文字节串。
base64.b64encode(iv + ciphertext)把 IV 和密文打包,再做 base64 编码。iv + ciphertext是字节串拼接,先 IV 后密文,解密端就知道前 16 字节是 IV,剩下全是密文。
一个很容易犯的错是在encrypt里直接传字符串明文。所有加密 API 都只接受字节串,正确写法是调用方自行处理编码,比如plaintext.encode('utf-8')。
3.3 解密端代码与错误对应
解密函数是这个:
def decrypt(data: str, key: bytes) -> bytes: raw = base64.b64decode(data) iv = raw[:16] ciphertext = raw[16:] cipher = AES.new(key, AES.MODE_CBC, iv=iv) padded_data = cipher.decrypt(ciphertext) return unpad(padded_data, AES.block_size)解密逻辑就是加密的逆过程。base64.b64decode把存储字符串还原成字节串。按长度切片提取 IV 和密文,顺序必须和加密端一致,我上面加密端先拼 IV 再拼密文,这里就先切 IV 再切密文。
cipher.decrypt返回的是填充后的明文,所以最后必须用unpad去掉 PKCS7 填充。unpad的第二个参数同样是 16。
如果这段代码解密时报Padding is incorrect,基本可以断定下面几种情况之一:密钥不对、密文被截断、base64 解码前后内容不一致、或者加解密端的填充方式不一致。核心原因背后就一句话:解密出来的数据最后一段不满足 PKCS7 填充的规则,unpad校验不过。
我在实际排查时,第一个动作永远是重新检查密钥是不是同一个字节串,第二个动作是确认密文有没有被传输或存储过程改写,第三个才轮到怀疑填充配置。
3.4 跨语言对接时最容易踩的坑
很多人加密解密在自己开发的系统里跑得欢,一到跨语言就懵了。AES 算法是公开标准,理论上任何语言实现出来后结果都应该一致,但“一致”的前提是参数完全对齐。
真正跨语言对接时,我建议把协议约定写到接口文档里,写清楚这五点:
- 算法确切名称:比如
AES/CBC/PKCS5Padding。注意一个细节,Java 里的PKCS5Padding和 Python 里的 PKCS7 实际上是同一种填充方式,因为两个标准在 8 字节块和 16 字节块上的表现一致。这个命名差异每年都坑不少人。 - 密钥格式:16 字节还是 32 字节,是原始字节还是 base64 编码后的字符串。
- IV 的生成与传递方式:谁生成、怎么传给解密端。
- 明文编码:统一用 UTF-8。
- 密文编码:统一用 base64,还是 base64url。
举几个典型例子:
Java 端创建密钥:
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(ivBytes));这里keyBytes和ivBytes都是原始字节数组,不要拿字符串去构造。
Node.js 端:
const crypto = require('crypto'); const decipher = crypto.createDecipheriv('aes-128-cbc', keyBytes, ivBytes);aes-128-cbc表示密钥 16 字节,aes-256-cbc表示密钥 32 字节,字符串别写错。
跨语言翻车最集中的场景是密钥。很多新手直接把字符串"mysecretkey"传给加密函数,系统隐式按 UTF-8 转成字节。可一旦另一个语言的处理逻辑不同,比如截断了或者自动补零,密钥就变了,解密出来的数据永远是一堆乱码。所以跨语言时密钥必须先约定成具体的字节表示,比如“密钥为 32 字节,以 base64 形式配置,使用时解码成字节”。
4. 高频报错与排查实录:这些坑我都踩过
4.1 报错速查表
加密解密相关的报错看起来很吓人,但归纳起来就那么几类。我直接整理一张速查表:
| 报错信息 | 实际原因 | 解决办法 |
|---|---|---|
| ValueError: IV must be 16 bytes long | IV 长度不是 16 | 检查切片位置或 IV 生成方式 |
| ValueError: Padding is incorrect | 密钥不对、密文损坏、填充方式不一致 | 逐步排除密钥和密文 |
| TypeError: Only byte strings can be passed | 传了字符串而不是字节串 | 调用前做 encode / decode |
| TypeError: data must be padded to 16 byte boundary | 加密时没做填充 | 加密前调用 pad |
| UnicodeDecodeError | 解密成功但还原成字符串时失败 | 检查密钥、模式是否一致 |
第一个报错最常见的场景是解密端切片位置写错。比如密文格式约定成base64(iv + ciphertext),解密端切片却写成了raw[:32],IV 长度自然对不上。
第二个报错是排查成本最高的。我的经验是先拿同一段数据做“加密后立即解密”的闭环测试,如果闭环都失败,说明代码逻辑有问题;如果闭环成功但生产环境失败,问题几乎都出在密钥或密文传输。密钥还可能因为环境变量里有不可见字符,比如换行符没去掉,导致字节串和预期不一致。这种问题极其隐蔽,打印密钥又会无意中泄露敏感信息,正确的检查方式是打印密钥长度,对比长度而不是内容。
第三个和第四个报错都属于基本数据类型错误,常见于新手把encrypt('hello')写成直接传字符串,或者加密前忘了 pad。记住 AES 的加密入口只接受字节,且长度必须是 16 的倍数。
4.2 生产环境才会暴露的那些细节
代码能跑通是一回事,在生产环境长期稳定运行又是另一回事。下面几个问题,都是我在线上系统里真正遇到过的。
base64 编码在 URL 场景下会被特殊字符干扰。标准 base64 包含+、/和末尾的=,如果密文要放进 URL 参数、作为文件名、或者放进 JSON 之外的某种文本协议,这仨字符容易引起解析问题。解决办法是换用 base64url 编码,Python 里可以这样处理:
url_safe = base64.urlsafe_b64encode(data).decode('utf-8')解密前用对应的urlsafe_b64decode还原。
数据库存储字段长度要提前算好。AES-CBC 加密后密文长度是 16 的倍数,再加上 16 字节 IV,base64 编码后长度还可以再粗略估算:base64长度 = ceil(原始字节数 / 3) * 4。比如原文 100 字节,pad 后就变成 112 字节密文,加 IV 变成 128 字节,base64 后是 ceil(128/3)*4 = 172 字节。如果数据库字段定义成 VARCHAR(128),数据根本存不进去。所以设计表结构时一定要按加密后的最大长度预留字段。
日志里千万不要打印密钥和明文。排查问题时我看着同事把print(f"key: {key}")留在代码里,日志系统一采集,密钥就明文躺在了日志平台上,这和把密码贴在服务器机箱上没区别。真要调试,就打印密钥长度和密文长度,这些信息足够定位大部分问题。
给密文加版本前缀是一个非常值得养成的习惯。比如统一存成v1:xxxxxxxx格式,以后算法升级、密钥轮换,代码读到前缀就知道用哪一套解密逻辑。旧数据不会因为升级而全部失效,迁移时也清楚哪些是新格式哪些是旧格式。
批量加密场景里,每个数据项都必须有自己的独立 IV。有些人为了“省事”,把一次生成的 IV 用在所有字段上,这在 CBC 和 GCM 模式下都会拉低整个加密方案的安全等级。生成随机 IV 的成本极低,压根不值得省。
5. 代码跑通只是开始:安全底线要守住
5.1 别拿简单哈希当密钥派生函数
项目里见过最快的“优化”是把用户输入的密码直接转成字节当 AES 密钥,或者用 MD5 把字符串变成 32 字节再用。这个做法非常危险。
原因有两层。第一,MD5 这类通用哈希函数速度太快,攻击者可以在一秒内尝试上亿个候选密码,字典攻击效率极高。第二,哈希结果是确定性的,相同密码永远得到相同密钥,连盐(salt)都没有,彩虹表一击即中。密钥派生要用专门为口令设计的慢速 KDF,比如 PBKDF2、scrypt 或 Argon2,它们会强制引入盐并且迭代很多次,让每个候选密码的尝试成本高到攻击者无法承受。
Python 里用 PBKDF2 派生密钥的写法:
from Crypto.Protocol.KDF import PBKDF2 salt = os.urandom(16) key = PBKDF2(password.encode('utf-8'), salt, dkLen=32, count=100_000)注意:盐要保存下来,因为解密端派生密钥时也需要同一个盐。把盐和密文放一起,不违反任何安全原则,盐本来就不需要保密。
5.2 密钥管理三条军规
实现层面讨论得再多,密钥如果裸奔,一切加密都是摆设。这三条军规我建议直接贴进团队代码规范:
- 密钥绝不能硬编码进源码。源码会进入版本控制、CI、日志归档,一旦泄露就等于密钥泄露。配置文件里的密钥也要用环境变量占位,生产环境的密钥通过部署平台注入。
- 密钥必须支持轮换。不要锁定一把密钥用三年,轮换机制至少要保证新密钥能加密,旧密钥还能解密存量数据。用前面提到的版本前缀,轮换时就能平稳过渡。
- 访问密钥要有权限边界。只有真正需要加解密的服务才有权访问密钥,开发调试环境用独立的测试密钥,不要复用生产的。
5.3 给新手的加密方案升级路线
如果刚开始接触 AES,我建议按这条路线循序渐进:
第一层:CBC 模式加 PKCS7,随机 IV,这套方案能保证基础的机密性。代码就是前面示例里的样子。但你要清楚它的边界,它不防篡改。
第二层:换 GCM 模式,让加密自带认证。明文不用手动填充,GCM 本身不依赖填充。密钥、Nonce、认证标签的组织方式需要重新约定,但换来的是更强的完整性保障。核心代码大概是:
cipher = AES.new(key, AES.MODE_GCM, nonce=os.urandom(12)) ciphertext, tag = cipher.encrypt_and_digest(data) # 存储/传输时按约定拼接 nonce + ciphertext + tag解密时用decrypt_and_verify,标签验证失败会直接抛异常,告诉你密文被改过。
第三层:把密钥放到专门的密钥管理服务里,程序只拿到内存中的密钥句柄,不接触明文密钥文件。这一层已经是业务系统基础设施的范畴了。
5.4 什么时候不推荐手写 AES
我还想非常直白地说一句:不是所有加密需求都应该手写 AES。文件加密优先选择成熟的加密文件格式,让现成实现处理细节;网络传输安全直接交给 TLS;云上数据库字段加密,优先用云平台密钥管理服务提供的加密能力。
但这不代表了解 AES 加密解密代码没有价值。恰恰相反,只有亲手写过、踩过坑,才知道加密方案里哪些地方容易出错,才能在选型时判断一个现成方案是否靠谱。加密不是简单地“调函数”,而是“制定规则”,把密钥怎么来、IV 怎么存、模式怎么选、数据怎么编码这些规则定清楚,代码自然就稳了。
我自己在实际项目里最大的体会是:AES 部分通常写起来不超过五十行,真正花时间的全在约定和排查。与其在网上零散地搜各种报错,不如一开始就把密钥、IV、模式、填充、编码这五个要素当成正式协议来设计。这样无论以后是跨语言对接,还是接口升级,你手里都有一份清晰的基准,而不是一堆排过不重样的雷。