令牌生成策略:随机令牌、哈希令牌、格式保留令牌(FPE)的实现思路

说到令牌生成,很多人第一反应就是“随机生成一串字符就完事了”。但实际做审计日志脱敏的时候,你很快会发现:不同的场景对令牌的要求完全不一样。有些要防碰撞、有些要可逆、有些还必须保留原字段的格式——比如银行卡号脱敏后还得是16位数字。

下面我结合自己踩过的坑,把三种主流策略的实现思路掰开揉碎了讲。

1. 随机令牌 —— 最简单的方案,也最容易出事

随机令牌就是用安全的随机数生成器产出一段字符串,然后跟原始值做个映射存起来。查的时候反向去找原值。

我记得在刚做脱敏系统那会儿,图省事直接用 Python 的 random.choices 去拼令牌。结果上线第一天就碰上了重复令牌——导致两条不同用户的日志被映射到了同一个脱敏值。排查了半天才发现,原来 random 模块不是密码学安全的,种子容易被预测。

关键坑: 一定要用 secrets._hex() 或者 os.urandom() 这类密码学安全的随机源。别用 random,也尽量别用 uuid4 直接映射——UUID 虽然唯一性高,但长度固定为32位十六进制,对于某些需要短令牌的场景不合适。

实现思路:

  • 收到原始值后,先查映射表看是否已有令牌。
  • 没有则生成一段 N 字节的随机字节,转成十六进制或 base64。
  • 存入映射表(通常用 Redis 或 MySQL),设置 TTL 过期策略。
  • 返回令牌。
import secrets

def generate_random_(length_bytes=16):
    return secrets._hex(length_bytes)  # 32字符十六进制

# 映射表:{: original_value}
_map = {}

def get_or_create_(original):
    for tok, val in _map.items():
        if val == original:
            return tok
     = generate_random_()
    _map[] = original
    return 

说白了,随机令牌适合那种“令牌值本身就是唯一标识”的场景——比如会议 ID、临时授权码。但缺点也很明显:你没法从令牌反推任何原始信息,必须依赖外部存储。一旦存储挂了,所有脱敏数据都失效。

我的建议: 如果原始值本身就不需要反查(如日志中的一次性 Session ID),直接用随机令牌,性能最好。但要做好映射表的备份和容灾。

2. 哈希令牌 —— 一致性脱敏的利器

哈希令牌就是拿原始值做哈希,取前 N 位作为令牌。同一个原始值永远得到同样的令牌。这样即使不查映射表,也能保证跨系统的日志关联性。

我自己在做一个跨数据中心审计日志合并项目时,就全靠哈希令牌——两个机房各自脱敏,结果同一个用户的 IP 地址在两个系统里产出的令牌居然一模一样,调研起来那叫一个爽。

但这里有个大坑:哈希碰撞。虽然 SHA-256 碰撞概率极低,但如果你只取前 8 位,碰撞概率就不是闹着玩的了。我曾经在 100 万条 IP 地址上做过测试,取前 6 位十六进制(24 位),碰撞率接近 0.5%。对于金融审计来说,这个概率是不能接受的。

import hashlib

def hash_(original, prefix_len=16):
    digest = hashlib.sha256(original.encode()).hexdigest()
    return digest[:prefix_len]  # 取前16位十六进制

# 例子:IP "192.168.1.1"  -> "a8f5f167f44f4964" (前16位)

实现时我习惯这样选:

场景推荐前缀长度最小碰撞概率(百万级数据)
非敏感数据(如用户名首字符脱敏)8 位(32 位)约 0.01%
常规审计字段(IP、邮箱)16 位(64 位)可忽略
高安全要求(身份证号)32 位(128 位)极低

你可能会问:那能不能加盐?当然可以。对于同一个字段,加固定盐值可以防止彩虹表攻击。但记得:盐值必须是业务相关的静态值(比如表名+应用ID),不能每次随机——否则就无法保证一致性了。

核心结论: 哈希令牌最适合“确定性脱敏 + 零存储”的场景。但注意:原始值的分布越均匀,碰撞率越低。如果原始值本身就只有几十种离散值(比如状态码),那哈希就没用了——随便取几位都一样。

3. 格式保留令牌(FPE)—— 最优雅,也最折腾

FPE 的目标:脱敏后的令牌和原始值保持相同格式——银行卡号还是 16 位数字,邮箱还是 xxxxx@xxx.com 的格式。这样下游系统不需要改表结构,不需要加校验逻辑。

说白了,FPE 就是在一个有限字符集上做加密置换。原始值属于某个空间(比如 10^16),FPE 用 AES 加密后映射到同一个空间的另一个值。因为空间有限,所以天生不会扩大字符长度。

我记得第一次团队做信用卡脱敏时,业务方强硬要求“脱敏后还得是 16 位数字,不能以 0 开头”。我查了半天,最后选了 FF1 算法(NIST SP 800-38G)。它支持任意字符集(数字、字母、自定义符号),并且保证置换是双射的——不会出现碰撞。

# 示例:用 pyffx 库实现 16位数字 FPE
from pyffx import Integer

# 定义字符集:数字0-9,长度16
fpe = Integer(b'secret-key-32bytes', radix=10, length=16)

original = "1234567890123456"  # 16位数字
 = fpe.encrypt(int(original))
print(f"{:016d}")  # 输出:类似 4408126578934215

# 解密
recovered = fpe.decrypt()
print(f"{recovered:016d}")  # 回到原值

实现 FPE 有几个关键点:

  • Tweak(调整值):类似 AES 的 IV。对于同一个原始值,使用不同的 Tweak 可以生成不同的令牌。我一般用字段名加行号做 Tweak,这样既能保证同一字段不同行的令牌不同,又能在需要时反向查。
  • 字符集限制:FF1 要求字符集半径(radix)小于 2^16,且长度必须达到最小要求(通常至少 8 个字符)。对于短值(如性别字段、2位状态码),FPE 就不适用了。
  • 性能:FPE 比普通哈希慢不少。我实测 16位数字的加密在 Python 下大约 15μs/次,比 SHA-256 慢 5 倍。不过对于审计日志的批量处理,这个开销可以接受。

特别注意: FPE 的密钥管理必须严格。密钥泄露意味着所有脱敏数据可以逆向还原。建议使用 KMS 或 Hardware Security Module 管理密钥,并且定期轮换。另外,FPE 算法本身是确定性的——同一原始值、同一密钥、同一 Tweak 会得到同一令牌。如果你需要屏蔽这个特性,可以在 Tweak 中加入业务时间戳。

三种策略各有适用面。我把对比整理成一个表格,方便你选型:

策略存储依赖可逆性格式保留碰撞风险典型场景
随机令牌必须可逆(查表)否低(映射表控制)临时授权、短时效脱敏
哈希令牌不需要不可逆否有(取决于截取长度)跨系统一致性脱敏
格式保留令牌不需要可逆(密钥解密)是无(双射加密)银行卡、电话、邮箱等格式敏感字段

完整知识图谱:三种策略的权衡

下面这张图把三种策略的核心要素串起来。你看一眼就能明白,哪个环节该关注什么:

令牌生成策略对比与选择流程 令牌生成策略选择思维导图 令牌生成策略 随机令牌 哈希令牌 格式保留令牌 需映射表 高安全性 零存储 有碰撞风险 格式保留 密钥管理 选择建议 • 需要临时映射 + 可逆 → 随机令牌(但要做好存储可靠性) • 需要一致性 + 零存储 → 哈希令牌(控制好截取长度) • 需要保留格式 + 可逆解密 → 格式保留令牌(密钥安全是命门)

实战中的选型建议

最后总结几条我在实际项目中沉淀下来的经验:

  1. 先看业务约束:下游系统能不能接受不定长字符串?如果答案是“不行”,直接锁定 FPE。否则再看第二点。
  2. 要不要反向查询:需要查原始值?选随机令牌。不需要查原始值,只想保证一致性?哈希令牌更省事。
  3. 性能敏感度:高并发实时脱敏(比如 API 网关),用哈希令牌,几乎零开销。批量离线清洗,FPE 的耗时基本可以接受。
  4. 安全合规:金融行业 PCI-DSS 通常要求 FPE 或格式保留加密,且密钥要托管给 KMS。随机令牌只能作为辅助方案。

我记得有一次,业务方要求对邮箱格式脱敏,又要求能通过令牌恢复原始邮箱。当时哈希不可逆、随机令牌格式不对,最终只能上 FPE。但 FPE 的实现比想象中复杂——字符集要包含@和.,长度还不能压缩。还好 pyffx 库支持自定义字符集,最后用 alphabet='abcdefghijklmnopqrstuvwxyz0123456789@.' 搞定了。

嗯,选型没有银弹。先把三种策略的优劣摸透,再结合你手头实际的数据字段和业务要求,自然能做出合理判断。我推荐的判断路径很简单:先决定是否要保留格式,再决定是否要零存储,最后看密钥管理的成本。