加密这件事,很多人第一次接触都是在“我要把一段数据安全地传出去”这个场景里。AES 几乎是绕不开的名字,但真正动手写代码的时候,问题往往不是“AES 是什么”,而是“模式怎么选”“密钥长度用多少”“为什么我加密出来的结果每次都不一样”“解密报错 padding 不对到底是谁的锅”。这篇内容就是围绕这些实际会卡住人的点展开的,从原理到代码,从模式选型到踩坑排查,尽量把我在项目里踩过的、见过的、被问过的问题都摊开讲清楚。不管你是刚接触对称加密的新手,还是已经用过但总觉得心里没底的开发者,应该都能从里面找到能直接抄作业的部分。核心关键词 AES 加密、加密模式、代码实现、踩坑排查会贯穿全文,但我不打算写成教科书,而是按一个实际做项目的人的思路来组织。
1. 先把 AES 的底子摸清楚再动手
1.1 AES 到底在做什么,为什么它值得信任
AES 全称是高级加密标准,属于对称加密算法,意思是加密和解密用同一把密钥。它的核心工作可以理解成:把明文切成固定大小的块,每块 128 位,也就是 16 个字节,然后用密钥对每一块做多轮“混淆”和“扩散”操作,最终得到密文。混淆是让密文和明文之间的关系变得非线性、难以反推,扩散是让明文的一个比特变化影响到密文的大量比特。这两件事反复做若干轮,轮数取决于密钥长度:128 位密钥做 10 轮,192 位做 12 轮,256 位做 14 轮。
为什么它值得信任?因为它的设计经过了全球密码学界二十多年的公开分析和攻击尝试,目前没有已知的、在现实条件下可行的、能绕过暴力破解的有效攻击方式。注意我这里说的是“绕过暴力破解”,暴力破解本身永远是理论可行的,只是 128 位密钥的搜索空间是 2 的 128 次方,这个数量级用现有和可预见的算力去穷举是不现实的。所以选 AES 不是因为它“神秘”,而是因为它被分析得足够透、结论足够稳。
这里有个新手常混淆的点:AES 的“块大小”永远是 128 位,不随密钥长度变化。密钥可以是 128、192、256 位,但每次处理的明文块固定 16 字节。这个固定块大小是后面理解各种加密模式的前提,因为块大小固定,当明文长度不是 16 的整数倍时,就必须有某种机制来处理最后那截“尾巴”,这就是填充(padding)问题的来源。
1.2 密钥、块、轮数三者的关系别搞混
我见过不少人把“AES-256”理解成“每次加密 256 位数据”,这是错的。256 指的是密钥长度,块大小还是 128 位。密钥长度影响的是安全强度和性能:256 位比 128 位多 4 轮运算,理论上更抗暴力破解,但速度会略慢。在实际项目里,128 位和 256 位在当下都认为是安全的,选哪个更多是合规要求或性能权衡的问题,而不是“128 位已经不安全了”。
轮数的作用是把每一轮的混淆扩散效果叠加起来。单轮操作如果被逆向,多轮叠加后逆向难度呈指数上升。你可以把它类比成揉面团:揉一次颜色可能还不均匀,揉十几次之后想还原成最初的面粉和水分层几乎不可能。轮数就是揉的次数,密钥长度决定了揉几次。
还有一个容易忽略的点:AES 的密钥不是直接拿你输入的字符串来用的。你输入的“密码”通常需要经过密钥派生,比如用 PBKDF2、scrypt 或者 Argon2 这类算法,加上盐值,迭代若干次,才能生成真正用于 AES 的定长密钥。直接拿一个短字符串当密钥,熵太低,容易被字典攻击。这一点在后面代码实现部分会具体展开。
1.3 对称加密的边界:它能解决什么,不能解决什么
AES 解决的是“数据的机密性”,也就是让别人看不懂内容。但它本身不解决“完整性”,也就是别人篡改了密文你未必知道;也不解决“身份认证”,也就是你无法确认这段密文是谁发的。很多人以为加密了就安全了,结果被中间人篡改数据还浑然不觉,这是典型的把机密性当成全部安全性的误区。
要解决完整性,需要配合消息认证码,比如 HMAC,或者使用带认证的加密模式,比如 GCM。要解决身份认证,需要数字签名或者证书体系。所以一个完整的、能抵御篡改的加密方案,通常不是“AES 加密一下就完事”,而是“AES 加密 + 认证”的组合。这个认知很重要,它直接决定了你后面选加密模式时的方向:如果只追求机密性,CBC 之类可以;如果要同时保证机密性和完整性,GCM 这类认证加密模式更合适。
2. 加密模式怎么选:一张表说清楚取舍
2.1 从 ECB 说起:为什么它是最该避开的模式
ECB 叫电子密码本模式,是最直观的模式:把明文切成 16 字节一块,每块独立用同一个密钥加密,块与块之间没有任何关联。它的优点是简单、可并行,但致命缺点是相同的明文块会得到相同的密文块。这意味着明文的模式会泄露到密文里。
最经典的例子是加密一张图片,如果图片里有大片纯色区域,ECB 加密后你依然能看出原来的轮廓,因为相同颜色的块加密结果一样。在文本场景里,如果明文里有重复的段落,密文里也会出现重复的块,攻击者可以通过统计分析推断内容。所以 ECB 在实际项目里基本应该被排除,除非你加密的是完全随机的、单块的数据,且明确知道重复块不构成风险。
我个人的态度很明确:新项目里看到 ECB 就应该警惕,除非有非常特殊的理由,否则一律换掉。它不是“不够好”,而是“在多数真实数据上会主动泄露信息”。
2.2 CBC 模式:经典但有前提
CBC 叫密码块链接模式,思路是每一块明文先和前一块的密文做异或,再加密。第一块没有“前一块密文”,所以需要一个初始向量(IV)来充当这个角色。这样相同明文块在不同位置会得到不同密文,解决了 ECB 的模式泄露问题。
CBC 的关键前提有三个。第一,IV 必须是随机的,且每次加密都要换新的,IV 不需要保密但必须不可预测,否则会削弱安全性。第二,IV 的长度必须等于块大小,也就是 16 字节。第三,CBC 只提供机密性,不提供完整性,所以需要额外加 HMAC 做认证,而且要注意“先加密后认证”的顺序,避免填充预言攻击。
CBC 的另一个特点是加密不能并行,因为每块依赖前一块的密文;但解密可以并行,因为解密时每块的密文都是已知的。这个特性在性能敏感的场景里要考虑进去。CBC 目前仍然被广泛使用,很多老系统和协议里都是它,但新项目如果有条件,我更倾向选认证加密模式。
2.3 GCM 模式:认证加密的首选
GCM 叫伽罗瓦计数器模式,它本质上是 CTR 模式加一个 GHASH 认证。CTR 模式把块密码变成一个流密码:用一个计数器值加密后和明文异或,计数器每块递增。这样加密可以完全并行,性能好。GHASH 则对密文和附加认证数据计算出一个认证标签,解密时校验标签,一旦密文被篡改,标签校验就会失败。
GCM 的优势很明显:同时提供机密性和完整性,一次运算搞定,不需要额外拼 HMAC;支持附加认证数据(AAD),可以把不加密但需要认证的头部信息带上;性能好,可并行。它的注意事项是:IV 绝对不能重复使用,同一个密钥下 IV 重复会导致严重的安全问题,甚至可能泄露认证密钥;IV 推荐 12 字节,这是 GCM 的原生长度,其他长度会多一次处理;认证标签长度通常用 16 字节,不要随意截短。
在实际项目里,如果运行环境支持,GCM 基本是我的默认选择。它把“加密”和“防篡改”两件事合并了,减少了拼装出错的空间。
2.4 模式选型对照表
| 模式 | 机密性 | 完整性 | 可并行加密 | IV/Nonce 要求 | 适用场景 |
|---|---|---|---|---|---|
| ECB | 有(弱) | 无 | 是 | 无 | 基本不推荐 |
| CBC | 有 | 无(需配 HMAC) | 否 | 随机 16 字节,每次更换 | 兼容老系统、已有 HMAC 体系 |
| CTR | 有 | 无(需配 MAC) | 是 | 唯一,不可重复 | 流式加密、需并行 |
| GCM | 有 | 有 | 是 | 唯一,推荐 12 字节 | 新项目默认首选 |
| CFB/OFB | 有 | 无 | 部分 | 唯一 | 特殊流式场景 |
这张表不是让你背,而是让你在选型时有个快速判断依据。核心逻辑就一句:要认证就选 GCM,要兼容就选 CBC 加 HMAC,ECB 尽量别碰。
3. 代码实现:从密钥派生到加解密完整链路
3.1 密钥派生:别拿字符串直接当密钥
前面提过,直接把用户输入的密码当 AES 密钥是危险的。正确做法是用密钥派生函数。以 PBKDF2 为例,它的思路是把密码、盐值、迭代次数一起做大量哈希运算,输出定长密钥。盐值保证相同密码在不同场景下派生出不同密钥,迭代次数增加暴力破解成本。
迭代次数怎么定?没有绝对标准,但可以参考 OWASP 的建议,PBKDF2-HMAC-SHA256 在较早的建议里是 60 万次左右,实际要根据你的硬件性能和可接受的延迟来调。原则是:让单次派生耗时在几百毫秒量级,既不影响用户体验,又让攻击者每次尝试都付出代价。盐值至少 16 字节,用密码学安全的随机数生成器产生,和密文一起存储,不需要保密。
下面是一段 Python 的密钥派生示例,用标准库实现,不依赖第三方:
import os import hashlib def derive_key(password: str, salt: bytes, iterations: int = 600000) -> bytes: # 使用 PBKDF2-HMAC-SHA256 派生 32 字节密钥(对应 AES-256) return hashlib.pbkdf2_hmac( 'sha256', password.encode('utf-8'), salt, iterations, dklen=32 ) # 生成随机盐值 salt = os.urandom(16) key = derive_key('my-strong-password', salt) print(len(key)) # 32这段代码里dklen=32对应 256 位密钥,如果你要用 128 位就改成 16。盐值用os.urandom生成,它是操作系统提供的密码学安全随机源,比random模块可靠得多。迭代次数我写了 60 万,实际跑起来在普通机器上大概几百毫秒,你可以根据实测调整。
3.2 GCM 模式加解密完整示例
这里用 Python 的cryptography库演示 GCM,它是目前 Python 生态里比较可靠的选择。先装库:
pip install cryptography加密部分:
import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM def encrypt_gcm(key: bytes, plaintext: bytes, aad: bytes = b'') -> tuple: # GCM 推荐 12 字节 nonce,每次加密必须唯一 nonce = os.urandom(12) aesgcm = AESGCM(key) # encrypt 返回密文 + 16 字节认证标签 ciphertext = aesgcm.encrypt(nonce, plaintext, aad) return nonce, ciphertext def decrypt_gcm(key: bytes, nonce: bytes, ciphertext: bytes, aad: bytes = b'') -> bytes: aesgcm = AESGCM(key) # 认证失败会抛 InvalidTag 异常 return aesgcm.decrypt(nonce, ciphertext, aad)调用示例:
key = derive_key('my-strong-password', salt) nonce, ct = encrypt_gcm(key, b'hello aes gcm', b'header-v1') pt = decrypt_gcm(key, nonce, ct, b'header-v1') print(pt) # b'hello aes gcm'这里有几个细节值得说。AESGCM.encrypt返回的ciphertext已经包含了认证标签,标签在末尾 16 字节,解密时库会自动拆分校验,你不需要手动处理。aad是附加认证数据,它不加密但参与认证,适合放一些头部信息,比如版本号、用户标识,这样这些信息被篡改时解密会失败。nonce 必须每次唯一,我直接用os.urandom(12)生成,和密文一起存储或传输,它不需要保密。
3.3 CBC 模式加解密示例与填充处理
如果因为兼容性必须用 CBC,那填充和 IV 管理要格外小心。下面用cryptography的 CBC 实现:
import os from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding def encrypt_cbc(key: bytes, plaintext: bytes) -> tuple: iv = os.urandom(16) # CBC 的 IV 必须是 16 字节且随机 # PKCS7 填充到 16 字节整数倍 padder = padding.PKCS7(128).padder() padded = padder.update(plaintext) + padder.finalize() cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) encryptor = cipher.encryptor() ct = encryptor.update(padded) + encryptor.finalize() return iv, ct def decrypt_cbc(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) decryptor = cipher.decryptor() padded = decryptor.update(ciphertext) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() return unpadder.update(padded) + unpadder.finalize()padding.PKCS7(128)里的 128 是块大小(位),对应 16 字节。PKCS7 的填充规则是:缺几个字节就补几个值为“缺的字节数”的字节,如果明文刚好是 16 的整数倍,会额外补一整块 16 个 0x10。这个“刚好整除也要补”的规则是很多人第一次实现时漏掉的,导致解密时填充校验失败。
CBC 只保证机密性,所以生产环境里还应该对密文做 HMAC 校验。顺序上推荐“先加密再对 IV 和密文一起做 HMAC”,解密时先验 HMAC 再解密,这样可以避免填充预言攻击。这个组合常被称作 Encrypt-then-MAC,是相对稳妥的拼装方式。
3.4 密钥和 IV 的存储与传输约定
密钥永远不能和密文放在一起明文存储,这是底线。实际项目里,密钥通常放在密钥管理服务、环境变量、或者经过保护的配置中心里,而不是硬编码在代码里。硬编码密钥一旦代码泄露,等于全部数据泄露。
IV 和 nonce 则相反,它们不需要保密,但需要和密文一起存储或传输,因为解密时必须用到。常见的做法是把 nonce 或 IV 拼在密文前面,比如nonce || ciphertext,解密时按固定长度切分。GCM 的 nonce 固定 12 字节,切分很直接;CBC 的 IV 固定 16 字节,同理。盐值也是类似处理,和密文一起存,解密前先取出盐值派生密钥。
这里有个实操建议:把“算法标识、模式、密钥派生参数、盐值、nonce、密文”打包成一个结构化的格式,比如 JSON 或者自定义的二进制头,这样后续升级算法或参数时不会乱。很多线上事故就是因为格式没有版本标识,换参数后老数据解不开。
4. 踩坑排查实录:那些让人抓狂的报错
4.1 填充错误:最常见也最容易误判
“解密报 padding 错误”几乎是 CBC 模式下的头号问题。原因通常有三类。第一类是加密端和解密端的填充方式不一致,比如一端用 PKCS7,另一端用 ZeroPadding,或者一端补了另一端没补。第二类是密钥不对,密钥错了解密出来的明文是乱码,填充校验自然失败,但报错信息往往只告诉你“填充无效”,不会告诉你“密钥错了”,容易误导排查方向。第三类是密文在传输中被截断或篡改,导致最后一块不完整。
排查顺序我一般这样走:先确认两端填充算法和块大小参数完全一致;再确认密钥派生过程一致,包括盐值、迭代次数、哈希算法、输出长度;然后确认密文完整性,比对加密端输出和解密端输入的字节长度是否一致;最后才怀疑数据本身被改。填充错误很多时候是“密钥或参数不一致”的表象,不要一上来就盯着填充代码改。
4.2 认证标签校验失败:GCM 下的典型原因
GCM 解密抛InvalidTag,说明认证校验没过。常见原因有:nonce 用错了或重复了;AAD 在加密和解密时不一致,比如加密时传了头部、解密时忘了传;密文被截断,标签不完整;密钥不对。GCM 的报错比 CBC 的填充错误更“诚实”,它明确告诉你认证失败,但不会告诉你是哪一项导致的。
我的排查习惯是:先把 AAD 统一成空或者统一成固定值测试,排除 AAD 不一致;再检查 nonce 是否原样传递,有没有在存储或传输中被编码转换改变;然后核对密钥派生参数;最后检查密文长度是否至少包含 16 字节标签。这里有个容易忽略的点:有些传输通道会对二进制做 Base64 编码,如果编码解码环节出错,密文字节会变,标签自然对不上。
4.3 每次加密结果不同:这是正常现象不是 bug
新手常问:“为什么同样的明文和密钥,每次加密出来的密文不一样?”答案是:因为 IV 或 nonce 每次都是随机的,这是设计使然,不是错误。如果每次加密结果都一样,反而说明 IV 固定了,那才是安全问题。CBC 和 GCM 都要求 IV/nonce 每次唯一,所以密文不同是正确行为。
需要警惕的是反过来的情况:如果你发现相同明文在不同次加密后密文完全相同,那要立刻检查 IV/nonce 是不是被写死了。写死 IV 在测试环境可能没人管,上线就是隐患。我在代码评审里看到iv = b'0000000000000000'这种写法,一律打回。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 解密填充错误 | 填充方式不一致 / 密钥错 / 密文截断 | 核对填充参数、密钥派生、密文长度 |
| GCM InvalidTag | nonce 重复或错 / AAD 不一致 / 密文截断 | 核对 nonce 传递、AAD、密文完整性 |
| 密文每次不同 | IV/nonce 随机(正常) | 确认是随机而非写死即可 |
| 密文每次相同 | IV/nonce 被写死(危险) | 改为每次随机生成 |
| 中文解密乱码 | 编码不一致(UTF-8/GBK) | 统一明文编码为 UTF-8 |
| 跨语言解密失败 | 填充或字节序处理差异 | 对齐填充规则和字节处理 |
| 大文件内存暴涨 | 一次性读入全部数据 | 改分块流式处理 |
4.5 跨语言对接的坑
不同语言的标准库在 AES 实现上细节有差异,跨语言对接时最容易出问题。比如 Java 的Cipher默认填充是 PKCS5Padding,它在 AES 场景下等价于 PKCS7,但名字不同容易让人困惑;Node.js 的crypto模块对 GCM 的标签处理方式和 Python 库不同,标签可能需要单独取出;Go 的cipher包要求你自己管理填充,标准库不提供自动填充。
对接时的稳妥做法是:先约定好算法、模式、密钥长度、填充方式、IV/nonce 长度和传递方式、认证标签长度和位置,写成文档;然后用固定的测试向量(比如 NIST 提供的示例向量)在两端各跑一遍,比对输出;最后再对接真实数据。不要一上来就用真实业务数据试,出了问题很难定位是数据问题还是实现问题。
5. 性能与安全加固的实操建议
5.1 大文件加密别一次性读入内存
AES 本身处理速度很快,瓶颈往往在内存。如果你把几个 G 的文件一次性读进内存再加密,很容易把进程撑爆。正确做法是分块流式处理:按固定大小(比如 64KB 或 1MB)读一块、加密一块、写一块。GCM 和 CTR 因为是流式模式,天然适合分块;CBC 也可以分块,但要注意块与块之间的链接关系,不能随意打乱顺序。
分块大小怎么选?太小会增加函数调用和 IO 次数,太大占用内存多。实测下来 64KB 到 1MB 之间比较平衡,具体看你的 IO 特性和内存预算。分块时还要注意最后一块的处理,尤其是 CBC 的填充只在最后一块做,中间块都是完整块。
5.2 密钥轮换与版本管理
长期使用同一把密钥是有风险的,密钥一旦泄露,历史数据全部暴露。实际项目里应该有密钥轮换机制:定期生成新密钥,新数据用新密钥加密,旧数据在合适时机用旧密钥解密后重新加密。为了支持这个,密文格式里要带密钥版本号,解密时根据版本号选择对应密钥。
密钥版本号可以放在密文头部,和 nonce、盐值一起。这样即使密钥轮换了,老数据依然能解。轮换周期没有统一标准,取决于数据敏感度和合规要求,常见的是按季度或按年。轮换时要注意新旧密钥的并存期,不能一刀切停用旧密钥,否则老数据直接解不开。
5.3 随机数的质量决定安全下限
IV、nonce、盐值都依赖随机数。如果随机源不够随机,整个方案的安全性会大打折扣。编程语言里的普通随机函数,比如 Python 的random、Java 的Random,是伪随机,种子可预测,绝对不能用于密码学场景。要用密码学安全的随机源:Python 用os.urandom或secrets模块,Java 用SecureRandom,Node.js 用crypto.randomBytes,Go 用crypto/rand。
这个点看起来基础,但我在实际项目里见过用时间戳当 IV 的、用自增计数器当 nonce 的,这些在特定条件下可能出问题。时间戳精度不够可能重复,自增计数器在多实例部署时可能冲突。老老实实用密码学安全随机源,是最省心的做法。
5.4 不要把加密当成万能药
最后说一个认知层面的坑。AES 解决的是数据机密性,它不解决访问控制、不解决日志泄露、不解决前端密钥暴露、不解决传输层安全。我见过把密钥放在前端 JavaScript 里做“加密”的方案,这等于把钥匙挂在锁上,毫无意义。也见过数据库字段加密了,但应用日志里把明文打出来的,加密形同虚设。
一个务实的思路是:先明确你要防的是谁。防传输被窃听,用传输层加密;防存储被拖库,用字段级加密加密钥隔离;防内部人员越权,用访问控制和审计。AES 是这些环节里的一块砖,不是整面墙。把它放对位置,它才有价值。
我在实际项目里最深的体会是,AES 的代码本身往往不是最难的部分,难的是参数约定、密钥管理、跨端对齐和错误处理这些“周边工程”。把模式选对、把随机数用对、把认证加上、把格式设计好,剩下的就是细心和测试。踩过的坑大多不是算法层面的,而是工程层面的,这也是为什么我建议新项目直接用 GCM 这类认证加密模式,少拼装、少出错。后续如果要扩展,可以考虑把密钥管理接到专门的密钥服务上,把加解密封装成统一的内部库,让业务方不用关心底层细节,这样既统一了实现,也降低了出错概率。