API网关后端那台服务器被刷了几个小时,日志里全是同一个请求的密文重放,最后排查发现是某个内部服务在重试时把同一包数据原样扔了回来——这就是不设计防重放、不区分加密和签名的典型后果。做接口安全这些年,我见过太多把"AES加密一把梭"当成全部方案的团队,结果密钥硬编码在代码里、IV固定、日志照样打明文,防护形同虚设。这篇就把API数据加密这件事从原理到落地拆开讲清楚,适合后端开发、服务端架构师,以及所有被安全评审逼着"上加密"却不知道从哪下手的同学。
1. 先想明白:API场景下要防的到底是什么
1.1 你的API在"裸奔"还是"穿衣服"
很多团队对接口安全的理解停留在"加了HTTPS就安全了"。HTTPS确实解决了传输层的问题——数据在网络上不被窃听、不被篡改,但到了服务端这边,请求被日志记录、被网关转发、被下游服务消费,这一路上全是明文。你能保证公司内部日志系统、监控系统、消息队列里的数据都不可见吗?你能保证数据库被人拖走时,里面的敏感字段不是赤裸裸的明文吗?
我见过某家做电商的团队,用户手机号在数据库里是明文存储,接口返回到前端也是明文传输,只是前端页面上做了个中间四位打星号的显示处理。结果某次合作方接口泄露了全量订单数据,用户手机号直接暴露。这就是典型的"HTTP时代思路"——只做视图层脱敏,不做链路加密。
API数据加密要解决的是:数据从客户端发出后,到最终被服务端业务逻辑消费的整条路径上,即使中间某个环节被攻破,攻击者拿到的也只是一堆无法解密的密文。这个"整条路径"包括:客户端打包参数、网络传输、网关转发、日志记录、服务端反序列化、数据库落盘。
1.2 加密解决的四类问题
拆开来讲,API接口需要防护的威胁模型有四类,对应不同的密码学手段:
| 威胁 | 攻击方式 | 对应手段 |
|---|---|---|
| 窃听 | 抓包、日志泄露、数据库拖走 | 加密(对称加密) |
| 篡改 | 中间人改参数、请求重放修改数据 | 签名(MAC/数字签名) |
| 伪装 | 伪造请求、冒用身份 | 认证(Token/签名) |
| 重放 | 重复提交同一合法请求 | 时间戳 + nonce + 序号 |
这里要澄清一个很容易混淆的点:加密≠防篡改。AES只保证机密性,不保证完整性——攻击者把你的密文某一位翻转了,解密出来的结果虽然是一堆乱码,但某些场景下乱码本身就能造成破坏(比如把金额字段的某一位翻转,解密失败后进入异常分支)。所以现代加密方案(比如AES-GCM)把加密和认证合在一起,同时解决机密性和完整性问题。单用AES-CBC的旧方案必须额外加HMAC,否则就是漏了一半。
2. 算法选型:哪些加密方案真正适合API场景
2.1 三类密码学原语先理清
对称加密:加密和解密用同一个密钥。快、高效,适合加密大量数据。API场景里,业务报文本身用对称加密,这是主力。
非对称加密:公钥加密、私钥解密。慢、有长度限制,不适合直接加密大报文。但在API场景里有个不可替代的用途:协商对称密钥、数字签名。
哈希摘要:单向不可逆,用于校验数据完整性(防篡改),或者存储密码指纹。它不是"加密"(因为没有解密过程),但做防篡改签名时必须要用到。
我在实际项目里常用的组合是:AES-256-GCM(对称加密业务数据)+ ECC/RSA(加密AES密钥、做握手)+ HMAC-SHA256(对请求做整体签名)。下面逐个说清楚为什么这么选。
2.2 AES-GCM 为什么是业务数据加密的首选
AES有三种常见工作模式:ECB、CBC、GCM。ECB模式因为有"相同明文块产生相同密文块"的硬伤,直接从选项里划掉。CBC模式需要填充(padding)、需要处理IV(初始化向量),而且只加密不认证,容易遭受padding oracle攻击。GCM模式把加密和完整性认证做在一起,一次算出"密文 + 认证标签(tag)",同时保证机密性和完整性,是目前API数据加密的主流选择。
GCM的核心参数有三个:
- 密钥长度:128、192、256位。现在新系统直接上256,别犹豫。
- Nonce(也叫IV):12字节(96位)是推荐长度。这个值每次加密必须不一样,不能复用。GCM的安全强度有个著名的"悲剧性底线"——同一个密钥下如果nonce复用两次,攻击者就能解出认证密钥,整个加密体系直接崩溃。
- 认证标签TAG:16字节(128位)是默认值。用于校验密文是否被篡改,解密时必须校验,否则等于没认证。
AES-GCM还有一个很实用的特性:支持附加认证数据AAD。你可以把请求头里的固定字段(比如商户ID、API版本号)作为AAD传入,这样这些字段一旦被篡改,tag校验就会失败,等于给整个请求"上锁"。
2.3 非对称加密在API里的正确打开方式
非对称加密因为性能原因,不适合直接加密几KB的报文。但它在API安全里有两个不可替代的位置:
第一,密钥交换。假设客户端和服务端要使用同一个AES密钥加密数据,这个AES密钥怎么安全地给到对方?如果直接写在代码里就是硬编码密钥,git泄露一次全完蛋。正确的做法是用服务端的RSA公钥把随机生成的AES密钥加密,然后传给服务端,服务端用私钥解出AES密钥——这就是"数字信封"技术。
第二,数字签名。客户端持有私钥,对请求体计算签名(通常是先哈希再签名);服务端持有公钥验签。签名保证了请求确实来自持有私钥的一方,且内容未经篡改。RSA-2048是目前兼容性最好的选择,但新项目建议直接用ECC(比如P-256曲线),密钥更短、运算更快、安全强度更高。
注意:非对称加密在API里最常见的使用误区是用RSA直接加密请求体。因为RSA-2048最多只能加密245字节的数据(2048位密钥=256字节,减去OAEP填充的开销41字节),一个大一点的JSON就超了。所以正确的姿势永远是把AES密钥用RSA加密,业务数据用AES加密。
2.4 HMAC:被忽略的请求签名
很多人做接口签名用的是"把参数排序拼接后MD5"这种古老方案,比如给参数拼接一个盐值,然后MD5算出来当签名。这种方案有两个问题:一是MD5本身已经不安全,二是盐值如果写死在客户端代码里,反编译一次就泄露。
正规做法是用HMAC-SHA256:用一个只有客户端和服务端共享的密钥,对请求参数做哈希运算。它的优势是:
- HMAC不是简单的"拼接后哈希",他有两个内部填充(ipad/opad),能抵御长度扩展攻击。
- 密钥不参与传输,只参与本地计算,抓包拿不到。
- SHA256作为底层哈希函数,碰撞攻击难度极高。
HMAC用在API签名里,通常配合时间戳一起用:客户端把当前时间戳放进请求头,然后对"时间戳+请求体"做HMAC。服务端收到后,先检查时间戳是否在允许的窗口内(比如5分钟),再做HMAC校验。这样即使请求被重放,超过5分钟窗口就会失败;如果同一个请求在窗口内被重放多次,还需要配合nonce做幂等校验。
3. 核心实操:一个完整的加密通信方案落地
3.1 数字信封:混合加密的完整流程
说了这么多原理,直接上方案。我项目里用的标准流程是这样:
- 客户端本地生成一个随机的32字节AES密钥(每次会话都重新生成,或者每次请求重新生成,看业务对性能的要求)。
- 用服务端的RSA公钥对这个AES密钥加密,得到"信封"。
- 用AES密钥加密业务报文,得到密文。
- 把"密文 + 加密后的AES密钥(信封) + 时间戳 + 签名"组装成最终请求体,发给服务端。
- 服务端收到请求后,先用私钥解出AES密钥,再用AES密钥解出业务报文,然后校验时间戳和签名。
这样设计的核心逻辑是:AES密钥通过RSA公钥加密传输,即使中间人拿到了请求包,没有私钥就解不出AES密钥;而AES密钥是一次性的,这次请求被截获了,下次请求换个新密钥,之前的密文依然解不开。
如果把这套流程简化一下,做企业内部服务间通信,可以不搞"每次请求都交换密钥",而是约定一个静态AES密钥,配合HMAC签名和时间戳使用。性能更好,安全性略降(密钥泄露风险增加),但配合定期轮换也够用。
3.2 代码落地:Python版完整封装
这是我在项目里实际使用的封装代码,去掉了业务字段,只保留加密核心逻辑。先说清楚库的选择:加密部分用cryptography,这是Python生态里维护最积极、标准最规范的密码学库;签名部分可以直接用cryptography里面内置的HMAC,不需要额外引库。
import os import time import json import base64 import hashlib import hmac from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import rsa class ApiCrypto: """API加密组件:AES-GCM加密业务数据 + RSA数字信封 + HMAC签名""" def __init__(self, private_key_path: str, aes_key: bytes = None): # 加载RSA私钥 with open(private_key_path, "rb") as f: self._private_key = serialization.load_pem_private_key( f.read(), password=None ) self._public_key = self._private_key.public_key() # AES密钥:默认每次请求重新生成 self._aes_key = aes_key or AESGCM.generate_key(bit_length=256) self._aesgcm = AESGCM(self._aes_key) def encrypt_request(self, payload: dict, api_key: str) -> dict: # 1. 生成12字节nonce nonce = os.urandom(12) # 2. 将payload序列化为字节 plaintext = json.dumps(payload, sort_keys=True).encode("utf-8") # 3. AES-GCM加密,返回密文+tag的组合体(ciphertext_with_tag) ciphertext = self._aesgcm.encrypt(nonce, plaintext, None) # 4. RSA加密AES密钥(数字信封) enc_aes_key = self._public_key.encrypt( self._aes_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) # 5. 组装请求 body = { "data": base64.b64encode(ciphertext).decode(), "key": base64.b64encode(enc_aes_key).decode(), "nonce": base64.b64encode(nonce).decode(), "ts": int(time.time()) } # 6. 对整体请求做HMAC签名 sign_str = f"{body['ts']}.{body['data']}.{api_key}" body["sign"] = self._hmac_sign(sign_str, api_key) return body def decrypt_request(self, body: dict, api_key: str) -> dict: # 1. 校验时间戳(容差±300秒) ts = body["ts"] if abs(time.time() - ts) > 300: raise ValueError("请求已过期") # 2. 校验HMAC签名 sign_str = f"{body['ts']}.{body['data']}.{api_key}" expected_sign = self._hmac_sign(sign_str, api_key) if not hmac.compare_digest(body["sign"], expected_sign): raise ValueError("签名校验失败") # 3. 解密AES密钥 enc_aes_key = base64.b64decode(body["key"]) aes_key = self._private_key.decrypt( enc_aes_key, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) ) # 4. 解密业务数据 ciphertext = base64.b64decode(body["data"]) nonce = base64.b64decode(body["nonce"]) aesgcm = AESGCM(aes_key) plaintext = aesgcm.decrypt(nonce, ciphertext, None) return json.loads(plaintext.decode("utf-8")) def _hmac_sign(self, msg: str, api_key: str) -> str: return hmac.new( api_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256 ).hexdigest()这段代码的关键细节:
compare_digest是常数时间比较函数,不能用==直接比较签名字符串,防止时序攻击。- 时间戳校验是防重放的第一道闸门,签名校验是第二道。
sort_keys=True保证payload序列化时的键顺序一致,不然签名校验会因键顺序不同而失败。- AES-GCM的
encrypt方法返回的是"密文+tag"拼接的结果,解密时decrypt会自动校验tag,校验失败会抛异常——这个异常要在最外层捕获,禁止让它泄露任何解密内部信息给调用方。
3.3 参数选择背后的数学理由
为什么nonce是12字节、而不是16字节?因为GCM内部用的是CTR模式(计数器模式),nonce会作为初始计数器。12字节(96位)是NIST推荐值,这个长度足够随机(2^96的碰撞概率在千万亿级别的请求数下可忽略),同时可以和大块明文直接拼接成128位计数器块,不需要额外计算。
为什么RSA填充要用OAEP而不是PKCS1v15?PKCS1v15填充方式历史上有过Bleichenbacher攻击(利用填充格式错误返回的差异来还原明文),现在安全的协议都要求用OAEP。OAEP有随机性和可证明安全性,代价是密文长度比纯明文多41字节,这在接口传输中完全可以接受。
为什么AES密钥每次请求都重新生成?一次一密(one-time pad)的思想延伸。如果长期使用同一个AES密钥,即使加密算法本身安全,也扛不住攻击者通过大量密文进行侧信道分析。每次请求换AES密钥,即使这次请求的密钥被攻破,攻击者也只能解这一条请求的密文,影响面被限制到最小。
4. 密钥管理:加密链路里最容易翻车的环节
4.1 密钥从哪来、放哪里
方案设计得再漂亮,密钥泄露一切归零。我见过最离谱的例子是某团队把RSA私钥打了包,做成了Android SDK的一部分,直接跟着APK分发出去。别人拆包反编译,私钥拿到手,整个平台的所有加密全部失效。
正确的密钥存储原则是三句话:
- 私钥永不离服务器。RSA私钥只存在服务端,绝不进客户端代码,更不能入库。服务端也要做权限控制,只有运行API服务的账号能读取,普通开发账号都不应该能cat出来。
- 客户端不存AES密钥。每次请求动态生成AES密钥,用RSA公钥加密后传给服务端,客户端本地不留存。这样客户端被逆向、被XSS抓取,攻击者拿到的也只是当次的AES密文和密钥,影响范围只有一条请求。
- 动态密钥优先于静态密钥。如果能做到每次请求都换AES密钥,就不做静态密钥;如果性能要求高必须做静态密钥,至少做到天级轮换。
服务端存储密钥的设施,从低到高有几种方案:环境变量、配置文件、配置中心、KMS密钥管理服务。前两种简单粗暴但安全性一般,适合中小项目;配置中心(比如Nacos、Apollo)可以做权限控制和变更审计;KMS是正规军,密钥以密文形态存储在HSM硬件里,服务进程在内存中解密使用,即使硬盘被拖走,攻击者拿到的也是密文。
4.2 密钥轮换与版本化
密钥轮换是个经常被忽视的运维问题。密钥不轮换,一旦泄露,攻击者可以无限期地解密历史数据。轮换策略的核心设计是版本化:
- 每个密钥分配一个版本号(比如 key_v1、key_v2)。
- 请求头里带上密钥版本号,服务端根据版本号找到对应密钥解密。
- 新旧密钥并存一段时间(比如轮换后30天内,老密钥只负责解密,不负责加密;新密钥负责新数据的加密)。
- 老密钥到期后彻底删除,保存的密文如果还需要解密,必须在删除前完成重加密。
轮换周期怎么定?我自己的经验是:服务间通信的AES密钥建议90天轮换一次;RSA密钥对建议一年换一次;如果发生过人员离职、代码仓库泄露、硬盘报废这类事件,必须立即强制轮换,不管距离上次换密多久。
注意:密钥轮换这件事最怕的是"换了一半"。比如A密钥切到B密钥,结果某个下游服务还在用A密钥加密数据,服务端换成了B密钥解密,直接解密失败。灰度发布在这里同样适用:先让上游用新版本号发送,服务端两个版本密钥都能解;确认没有旧版本流量后再切到只解新版。
5. 典型坑位与排查实录
5.1 我在实际部署里踩过的坑
坑一:Base64在URL里的字符转义。有一次前端小哥把加密后的请求体直接拼在URL的query参数里发,结果密文中出现+、/字符,被浏览器/网关做了URL解码,变成了空格和路径分隔符,服务端Base64解码直接失败。排查了半天,最后发现是传输层没处理Base64的URL安全变体。解决方案:Base64要替换+为-、/为_,去掉末尾的=填充(Python的base64.urlsafe_b64encode就是干这个的)。或者更稳妥——把加密参数放请求体,不要放URL。
坑二:nonce复用导致的"灾难性失败"。GCM模式对nonce复用极为敏感,同一密钥下两个nonce相同的加密,攻击者可以通过异或运算恢复出明文。我在一个项目里踩过:客户端代码把nonce写死为固定值,调试的时候没觉得有问题——用的AES密钥每次变,解密也正常,只是没发现nonce固定。后来安全团队做审计,用工具验证才发现。修复就是改成os.urandom(12)随机生成,一行代码的事。
坑三:调试日志把明文打出去了。这是最隐蔽也最致命的坑。团队在联调阶段为了排查问题,在很多服务里打了log.info("收到请求: {}", JSON.toJSONString(payload)),后面上线加密模块时没同步清理这些日志。客户端的密文到了网关解密成明文,网关把这明文继续往下游传的时候,日志照打不误。结果数据库没泄露,日志系统倒是把明文送得满天飞。安全评审看到日志里全是手机号和身份证号,直接判定"加密等于没加"。
坑四:签名范围没包含时间戳。如果签名只覆盖请求体,不包含时间戳,攻击者把历史请求的签名原样扣下来,替换请求体里的时间戳为当前时间,签名字段也原样附上——服务端校验时间戳(因为攻击者已经改成当前时间了)和签名(因为签名和请求体内容匹配)都通过,防重放等于没防。正确的签名范围必须是签名 = HMAC(时间戳 + 请求体 + API密钥),并且服务端校验签名的同时也要校验时间戳窗。
坑五:解密失败把内部信息抛给客户端了。有的同学图省事,直接在异常里返回e.getMessage(),结果攻击者通过不断改变密文、观察不同的报错信息,可以进行oracle式攻击。正确做法:解密失败统一返回401或者400,错误信息只有一句话"解密失败",所有具体原因只记日志,不给客户端。
5.2 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 客户端加密成功,服务端解密报错 | Base64编码不一致 | 确认客户端用标准Base64还是URL-safe Base64,解密前对齐 |
| 请求偶尔解密失败,重试就成功 | 时间戳窗口设置过窄 | 检查服务端当前时间和客户端时间差,放宽到±300秒 |
| 签名校验一直失败 | 签名串拼接顺序不一致 | 确认客户端和服务端的拼接字段顺序、分隔符完全一致 |
| 解密成功但数据是乱码 | RSA私钥与公钥不匹配 | 确认加载的证书和加密使用的公钥是否同一对 |
| 重放攻击防御无效 | 签名没覆盖时间戳/没做nonce | 检查签名是否包含时间戳、是否有幂等表 |
| AES-GCM解密异常 | nonce泄漏或密钥不匹配 | 检查nonce是否随机、AES密钥是否被意外覆盖 |
| 压测时CPU飙高 | RSA加解密性能瓶颈 | 考虑ECC替代RSA,或加一层会话密钥缓存 |
这里额外说一个防重放的非签名手段:服务端维护一个最近处理过的nonce集合,同一nonce的请求一旦被处理过,再次出现直接拒绝。集合用Redis做,带过期时间(比如5分钟),这样即使攻击者在时间戳窗口内重放,也过不了nonce去重这一关。
5.3 一个典型的排查过程记录
有次线上环境突然出现一批"解密失败"告警,比例约占全部请求的3%。第一反应看时间戳,发现没问题;看签名,也过了;看Base64解码,报的是binascii.Error: Incorrect padding。翻代码发现,客户端SDK里对base64.b64encode的结果没有做去=处理,而服务端用的是base64.b64decode,这个库对缺少=填充的输入是能自动补=的——但问题出在密文通过网络传输后,某个网关做了URL解码,把+转成了空格,把=截断了。最后是让客户端统一走POST请求体传输,彻底避开URL编码这一层,问题消失。排查过程没什么高深技巧,就是一层层确认"谁动了我的密文"——传输层、网关层、应用层,逐层抓包对比。
6. 一些补充实践:字段级加密与日志脱敏
最后补充两个我在项目中经常被问到的话题。
字段级加密 vs 全报文加密。有的接口只需要保护个别字段(比如身份证号、银行卡号),不需要整个请求体都加密。这时候可以只对敏感字段单独加密,其余字段明文传输。优点是便于调试、便于排查问题,缺点是攻击者仍能通过其余明文字段推断业务语义。全报文加密正好相反——保护最彻底,但排查问题困难,网关无法做内容过滤。我的经验是:能全报文加密就全报文加密,如果实在要字段级加密(比如为了支持某些网关层审计需求),至少保证敏感字段用单独的AES密钥加密,不要和普通字段用同一个key。
日志脱敏一个顺手的小方案。加密模块上线后,日志系统里依然可能出现明文敏感数据(比如错误日志里打出了原始请求体)。可以加一个全局的日志过滤器,对常见的身份证正则、手机号正则做脱敏替换,再配合日志平台的告警规则,检测到明文敏感字段就告警。这个配置不复杂,但能避免"加密做了,日志还在漏"的尴尬局面。
我个人的经验是:API加密这件事,方案设计只占20%的工作量,剩下80%都在跟密钥管理、版本兼容、日志清理、问题排查这些"不性感"的环节死磕。与其追求花样繁多的加密算法组合,不如把AES-GCM + RSA数字信封 + HMAC签名这套经典组合用扎实,再配上一套靠谱的密钥轮换机制,就已经能挡住绝大多数现实中的攻击场景了。
最后分享一个实用小技巧:给所有被加密的接口加一个统一的响应头,比如X-Encryption-Algo: AES-256-GCM,调试的时候一眼就能看出哪些接口走了加密链路、哪些接口还在裸奔,做安全评审的时候也方便对照——毕竟安全评审最常问的一句话就是:"你这个接口到底加密了吗?"有了这个响应头,让评审者直接看响应头就行,省去一大堆解释。