身份证号、手机号这类敏感个人信息,必须同时做三件事:传输层强制 TLS 1.3 并对字段做二次加密与签名防重放;存储层用 AES-256-GCM 或国密 SM4 做字段级加密,密文入库、明文不落盘;密钥交给硬件加密机(HSM)或云 KMS 托管,应用只持有被主密钥包裹的数据密钥。只开 HTTPS,或只依赖数据库整盘透明加密(TDE),既挡不住拖库后的密文还原,也挡不住内部越权查询。下文给出分级标准、算法选型、可运行的加解密片段与密钥轮换步骤。
一、先做数据分级,再决定加密强度
把所有字段一刀切全加密,会让索引失效、查询雪崩;全部明文又必然踩合规红线。可行的做法是按敏感等级拆开处理。支付系统的分级实践给出了一套可直接照搬的映射关系:
| 字段 | 敏感等级 | 存储方式 | 技术实现 |
|---|---|---|---|
| 身份证号 | 高 | 密文存储 + 展示脱敏(保留前 3 后 4) | AES-256-GCM,密钥由 HSM 管理 |
| 银行卡号 | 高 | 完整密文,独立分区(对齐 PCI-DSS) | 密文库分区隔离,主密钥 HSM 隔离 |
| 手机号 | 中 | 密文存储 + 盲索引支持查询 | SM4-GCM 或 AES-GCM,配盐防彩虹表 |
| 姓名 | 中 | 密文存储 + 展示脱敏(张*三) | 与证件号绑定加密,限定服务解密 |
| 收货地址 | 低 | 一般明文,风控场景加密 | 按业务选择明文或密文 |
《个人信息保护法》第二十八条把身份证号、生物识别、医疗健康归入敏感个人信息,处理时需单独同意并采取严格保护措施。分级表的价值在于:它把”要不要加密”变成”用哪种加密”,后续架构决策都从这张表推导。
二、传输侧:TLS 只是起点
2.1 HTTPS 覆盖不到的缺口
TLS 1.3 结合 AES-256-GCM 能提供前向安全的端到端信道,抵御中间人抓包。但信道加密解决不了三类问题:客户端被抓包工具装了根证书后可明文查看请求;网关、日志、APM 探针会把解密后的报文原样记录;攻击者拿到一次合法请求就能无限重放。
2.2 字段二次加密与签名防重放
对策是在业务层再加一层。敏感字段用后端下发的临时公钥(RSA-3072 或 SM2)加密,会话密钥用 ECC-P256 协商——相同安全强度下 ECC 密钥长度比 RSA 短约 75%,移动端更划算。密钥绝不硬编码在前端产物里,必须由后端接口动态签发并带时效。
请求侧的完整校验链按下面顺序执行:
- 客户端按参数名字典序拼接待签串,附加
timestamp与随机nonce; - 用 HMAC-SHA256 与 AppSecret 计算签名,放入
X-Signature头; - 服务端先校验时间戳偏移,超过 300 秒直接拒绝;
- 用
SETNX把 nonce 写入 Redis,键的 TTL 与时间窗对齐,写入失败即判定重放; - 重算签名并用常量时间比较,避免时序侧信道;
- 校验通过后才解密敏感字段,进入业务逻辑。
import hmac, hashlib, time, secrets
def sign(params: dict, secret: str) -> dict:
params = dict(params)
params["timestamp"] = str(int(time.time))
params["nonce"] = secrets.token_hex(16)
raw = "&".join(f"{k}={params[k]}" for k in sorted(params))
params["signature"] = hmac.new(
secret.encode, raw.encode, hashlib.sha256
).hexdigest
return params
def verify(params: dict, secret: str, redis, window=300) -> bool:
if abs(int(time.time) - int(params["timestamp"])) > window:
return False
if not redis.set(f"nonce:{params['nonce']}", 1, nx=True, ex=window):
return False # nonce 已用过,判定为重放
raw = "&".join(f"{k}={params[k]}" for k in sorted(params) if k != "signature")
expect = hmac.new(secret.encode, raw.encode, hashlib.sha256).hexdigest
return hmac.compare_digest(expect, params["signature"])
三、存储侧:字段级加密的落地路径
3.1 算法选型对比
| 算法与模式 | 密钥长度 | 完整性校验 | 合规适配 | 典型用途 |
|---|---|---|---|---|
| AES-256-GCM | 256 位 | 内置 AEAD 标签 | 国际通用 | 敏感字段主力方案 |
| SM4-GCM | 128 位 | 内置 AEAD 标签 | 等保 2.0、金融政务强制 | 国密合规场景 |
| ChaCha20-Poly1305 | 256 位 | 内置 | 国际通用 | 移动端、无 AES 指令集环境 |
| AES-CBC + 随机 IV | 128/256 位 | 需外挂 HMAC | 通用 | 遗留系统兼容 |
| AES-ECB | 任意 | 无 | 不建议 | 相同明文产生相同密文,禁用 |
阿里云开发者社区的数据安全实践把结论写得很直白:AES 安全合规的密钥长度为 256 位,加密模式必须使用 GCM;SM4 分组与密钥长度均为 128 位,安全强度对齐 AES-128,是国内等保、金融、政务场景的强制项。GCM 属于认证加密,加密与完整性校验一次完成,能规避 ECB 模式的明文重放风险。
3.2 AES-256-GCM 的正确写法
工程上最容易出错的两点是 IV 复用和把 IV 写死。GCM 的 IV 一旦在同一密钥下重复,认证密钥就会泄露。正确做法是每次加密生成 12 字节随机 IV,并把 IV 前置拼在密文里一起存储。
import os, base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(plain: str, key: bytes, aad: bytes = b"") -> str:
iv = os.urandom(12) # 每条记录独立 IV,禁止复用
ct = AESGCM(key).encrypt(iv, plain.encode, aad)
return base64.b64encode(iv + ct).decode # IV || 密文 || 16 字节 tag
def decrypt(cipher_b64: str, key: bytes, aad: bytes = b"") -> str:
blob = base64.b64decode(cipher_b64)
return AESGCM(key).decrypt(blob[:12], blob[12:], aad).decode
key = AESGCM.generate_key(bit_length=256)
c = encrypt("11010519950101234X", key, aad=b"user_id=10086")
assert decrypt(c, key, aad=b"user_id=10086") == "11010519950101234X"
把 user_id 放进附加认证数据(AAD),可以防止攻击者把 A 用户的密文搬到 B 用户记录上——密文没变,但 AAD 不匹配,解密直接失败。
业务代码不该到处调用加解密工具类。Java 侧的通行做法是在实体字段上打 @Encrypted 注解,由 MyBatis TypeHandler 在写入前自动加密、查询后自动解密,业务层完全无感。JPA 用 AttributeConverter、GORM 用自定义 Scanner/Valuer 是同类思路。
3.3 信封加密与密钥轮换
单一密钥加密全库数据,等于把所有鸡蛋放进一个篮子。信封加密(Envelope Encryption)把密钥拆成两层:
- 在 KMS 或 HSM 中创建主密钥(CMK),设定不可导出,禁止落盘;
- 每个租户或每张表申请一个数据密钥(DEK),KMS 同时返回明文 DEK 与被 CMK 包裹的密文 DEK;
- 用明文 DEK 加密业务字段,随后立即从内存清除明文 DEK;
- 密文 DEK 与业务数据同库存放,解密时先调 KMS 解出 DEK,再解字段;
- 明文 DEK 在应用内存缓存并设短 TTL,减少 KMS 调用量;
- 按 90 天周期轮换密钥,密文中写入
key_version标识,支持新旧密钥并存的灰度重加密。
密码存储是另一条路。密码不需要还原,用 bcrypt 或 scrypt 加盐哈希即可;若采用 PBKDF2-HMAC-SHA256,迭代次数建议不低于 10000 次,随机盐不少于 16 字节。MD5 早已不满足要求。
四、加密之后:查询、展示与日志
4.1 密文字段怎么查
GCM 每次加密结果都不同,WHERE phone = ? 直接失效。手机号这类需要精确匹配的字段,用”确定性加密”或”盲索引”补一列:对明文加 HMAC 派生密钥算摘要,存入 phone_hash 并建索引,查询时先对入参算同样的摘要再比对。派生密钥必须与主加密密钥分离。需要模糊匹配(如按手机号前三位筛选)时可用前缀哈希索引,但要评估由此暴露的统计信息。
4.2 展示层动静分离脱敏
静态脱敏改写落库数据,身份证号 11010519950101234X 存成 110105********34X;动态脱敏按角色实时过滤,客服只看后四位,风控看完整值,靠数据库代理网关重写 SQL 自动挂规则。两者配合,才能既保住可用性又限制暴露面。
4.3 别让日志成为后门
敏感字段的操作日志需要单独处理,日志中只记录脱敏值如 138****1234,禁止明文输出;同时记录”谁在何时解密了哪条记录”,支持事后溯源。异常堆栈里打印整个请求对象,是最常见的泄露路径。
五、三个高频踩坑点
密钥和密文放在同一个数据库,拖库时一并被拿走,等于没加密。密钥必须外置到 KMS 或独立密钥库,并用不同的访问凭据。
前端加密被当成安全边界。前端代码可反编译,任何在浏览器或 App 内固化的密钥都视同公开;前端加密的作用是防止运维和中间链路直接看到明文,不能替代后端校验。
只加密不管权限。密文字段仅允许风控、支付核心等少数服务解密,按 RBAC 模型收口,并对解密动作全量审计。缺了这一层,一个越权接口就能把整库明文导出去。
常见问题(FAQ)
Q1:数据库开了 TDE,还需要字段级加密吗?
需要。TDE 只防物理文件被拷走,SQL 查询和越权访问拿到的仍是明文。
Q2:加密后手机号无法模糊查询怎么办?
增加盲索引列,存 HMAC 前缀摘要并建索引;全文模糊改走独立检索服务。
Q3:AES-GCM 的 IV 可以固定吗?
不可以。同密钥下 IV 复用会泄露认证密钥,每条记录须用 12 字节随机 IV。