Python AES-GCM 加密手机号并用用户 ID 绑定密文
问题场景
数据库用户表中常有 phone 字段。一个常见做法是使用 AES-GCM 加密手机号,再把密文以 base64 存入数据库。AES-GCM 能提供机密性和完整性,但如果加密时只处理手机号本身,不绑定记录上下文,就可能出现下面的问题:攻击者获得数据库写权限后,把用户 A 的 phone_cipher 整段复制到用户 B 的记录中。解密 B 的记录时仍然使用同一密钥,GCM 校验通过,B 用户记录就显示出 A 的手机号,造成越权数据错配。
解决办法是把用户 ID 作为 AAD(Additional Authenticated Data,附加认证数据)参与 GCM 标签计算。加密时认证 user_id,解密时必须提供同一个 user_id。密文被搬到其他用户记录后,AAD 不匹配,解密应失败。AAD 本身不进入密文,但会被认证,因此它必须能从记录上下文中稳定重建。
密文格式与密钥加载
示例采用如下格式:version(1 字节) || nonce(12 字节) || ciphertext+tag,整体做 base64url 编码后存入 TEXT 或足够长的 VARCHAR 字段。密钥为 32 字节,对应 AES-256,从环境变量读取 base64 编码值。生产环境不要把密钥硬编码在代码或配置文件中,应由密钥管理服务或专用密钥系统注入。
AAD 规范化为 user:{user_id},优先使用数据库主键,不要使用可变的用户名、邮箱或手机号本身。每次加密都使用 os.urandom(12) 生成新的 nonce。解密失败时统一抛出业务异常,不向前端暴露 InvalidTag 等底层细节。
最小可运行示例
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
VERSION = b'\x01'
NONCE_SIZE = 12
TAG_SIZE = 16
def load_key():
raw = os.environ.get('PHONE_ENC_KEY_B64')
if not raw:
raise RuntimeError('未设置 PHONE_ENC_KEY_B64 环境变量')
key = base64.b64decode(raw, validate=True)
if len(key) != 32:
raise ValueError('密钥必须为 32 字节(AES-256)')
return key
def aad_for_user(user_id):
uid = str(user_id).strip()
if not uid:
raise ValueError('user_id 不能为空')
return ('user:' + uid).encode('utf-8')
def encrypt_phone(phone, user_id, key):
phone = phone.strip()
if not phone:
raise ValueError('手机号不能为空')
aesgcm = AESGCM(key)
nonce = os.urandom(NONCE_SIZE)
ct = aesgcm.encrypt(nonce, phone.encode('utf-8'), aad_for_user(user_id))
token = VERSION + nonce + ct
return base64.urlsafe_b64encode(token).decode('ascii')
def decrypt_phone(token_b64, user_id, key):
try:
token = base64.urlsafe_b64decode(token_b64.encode('ascii'))
except Exception as exc:
raise ValueError('密文格式无效') from exc
if len(token) < 1 + NONCE_SIZE + TAG_SIZE:
raise ValueError('密文长度无效')
if token[:1] != VERSION:
raise ValueError('不支持的密文版本')
nonce = token[1:1 + NONCE_SIZE]
ct = token[1 + NONCE_SIZE:]
aesgcm = AESGCM(key)
try:
plain = aesgcm.decrypt(nonce, ct, aad_for_user(user_id))
except Exception as exc:
# 对外统一为业务失败,避免泄露底层校验细节
raise ValueError('解密失败:密文被篡改或用户不匹配') from exc
return plain.decode('utf-8')
def main():
key = load_key()
phone = '13800000000'
user_a = '1001'
user_b = '1002'
token = encrypt_phone(phone, user_a, key)
print('密文:', token)
print('解密:', decrypt_phone(token, user_a, key))
tampered = token[:5] + ('A' if token[5] != 'A' else 'B') + token[6:]
cases = [
('复制到 B 用户', token, user_b),
('篡改密文', tampered, user_a),
]
for label, tok, uid in cases:
try:
decrypt_phone(tok, uid, key)
print(label, '意外成功')
except ValueError as exc:
print(label, '失败:', exc)
if __name__ == '__main__':
main()
运行方法
先安装依赖,再生成一个占位密钥并导出环境变量:
python -m venv .venv
source .venv/bin/activate
pip install cryptography
python -c 'import os,base64;print(base64.b64encode(os.urandom(32)).decode())'
export PHONE_ENC_KEY_B64='粘贴上一步输出'
python demo.py
示例预期结果是:A 用户能正常解密;把同一密文交给 B 用户解密会失败;修改密文中的一个字符也会失败。不要把示例中的手机号和用户 ID 当成真实数据,实际业务中应使用占位值替换。
篡改验证与失败处理
代码中有两条失败路径。第一条是把 A 用户密文传入 B 用户的 user_id,此时密文本身没变,但 AAD 从 user:1001 变为 user:1002,GCM 标签校验失败。第二条是直接修改 base64 密文中的一个字符,同样会导致解密失败。base64 解码错误、长度不足、版本不匹配也应在解密入口被拦截。
生产环境中建议记录受控的审计事件,例如记录用户 ID、操作类型和失败原因分类,但不要记录手机号明文、密钥或完整密文。错误提示对前端应保持模糊,避免把“用户不匹配”和“密文损坏”的细节直接暴露给攻击者。
边界与限制
- nonce 必须每次加密随机生成,不能固定或复用。同一密钥下 nonce 重用会破坏 AES-GCM 的安全边界。
- AAD 必须可稳定重建。若用用户 ID,要统一类型和格式,避免
'1001'与1001产生不同 AAD。用户 ID 变更时需要重新加密或迁移。 - 密文会比明文长,数据库列长度要预留版本、nonce、认证标签和 base64 编码开销。
- 加密后的手机号不能直接做等值查询和索引。若业务需要查询,可另行设计受控的检索字段,并评估其泄露风险。
- AES-GCM 不提供重放防护。攻击者可能把同一用户的历史密文放回,需要结合更新时间、版本号或业务校验处理。
- 当前格式只有版本字节。需要密钥轮换时,应增加密钥标识或通过版本映射密钥,并安排存量数据重加密。
- 示例使用环境变量加载密钥,仅适合演示。生产环境应限制数据库账号权限,把密钥与数据库分离,并做好备份恢复和密钥轮换预案。
- 字段加密不能替代访问控制、审计和最小权限。手机号规范化、脱敏展示和日志过滤仍需要在业务层实现。
- 点赞
- 收藏
- 关注作者
评论(0)