Python AES-GCM 加密手机号并用用户 ID 绑定密文

举报
yd_250998817 发表于 2026/11/06 18:51:58 2026/11/06
【摘要】 数据库手机号字段加密后,若攻击者把 A 用户的密文复制到 B 用户记录,普通解密仍可能成功。本文用 Python cryptography 的 AES-GCM,把用户 ID 作为 AAD 绑定密文,给出密钥加载、密文封装、解密失败处理与篡改验证的最小示例,并说明 nonce、轮换和重放等边界。

问题场景

数据库用户表中常有 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 不提供重放防护。攻击者可能把同一用户的历史密文放回,需要结合更新时间、版本号或业务校验处理。
  • 当前格式只有版本字节。需要密钥轮换时,应增加密钥标识或通过版本映射密钥,并安排存量数据重加密。
  • 示例使用环境变量加载密钥,仅适合演示。生产环境应限制数据库账号权限,把密钥与数据库分离,并做好备份恢复和密钥轮换预案。
  • 字段加密不能替代访问控制、审计和最小权限。手机号规范化、脱敏展示和日志过滤仍需要在业务层实现。
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。