令牌生成算法详解:随机令牌、哈希令牌、格式保留令牌
讲令牌服务,绕不开的核心就是令牌怎么生成。你可能觉得这有什么好讲的,不就是随机一串字符吗?但实际项目里,生成方式直接决定了你的系统能不能过PCI合规,能不能对接现有收单系统,能不能扛住高并发。我这些年见过太多因为选错算法而返工的例子,真是得不偿失。
今天我就把这三种主流算法——随机令牌、哈希令牌、格式保留令牌——掰开揉碎聊清楚。每种都有适用场景,没有银弹。
随机令牌:最朴素,但也最暴力
随机令牌,顾名思义就是用真随机数生成器产生一串字符。最简单,也是最常用的方式。
核心思路:每次生成一个不可预测的字符串,存入映射表,与原始PAN建立一对一的关联。查询时通过映射表反向查找原始PAN。
我早期做的第一个令牌服务,用的就是纯随机方式。现在回头看,简单有简单的好,但坑也不少。
生成流程
- 使用密码学安全的随机数源(如
/dev/urandom)生成一块随机字节 - 编码为可读字符串(Base64、Base32或16进制)
- 检查是否与已有令牌冲突——这一步很多人忽略
- 如果冲突,重新生成(概率极低,但必须处理)
- 将令牌与PAN存入映射表(数据库或KV存储)
// Python 示例:生成16字节随机令牌
import secrets
def generate_random_(length=16):
# 使用secrets模块,密码学安全
random_bytes = secrets._bytes(length)
# Base32编码,不含特殊字符
= base64.b32encode(random_bytes).decode('ascii')
return [:length*2] # 截取为固定长度
我的习惯:长度至少128位(16字节),192位更安全。太短容易碰撞,太长浪费存储。Base32比Base64更友好——没有+/=等特殊字符,直接在URL中不用转义。
坑在哪?
- 映射表膨胀:每个令牌都要存一条记录,如果你的PAN数量上亿,数据库会很大。我曾经帮客户排查性能问题,发现映射表索引占了一个TB。
- 回查延迟:每次支付都要查表,高并发下DB压力巨大。Redis缓存能缓解,但成本也随之上升。
- 不能保留PAN格式:随机令牌没法和原有卡号长度一致,导致下游系统很难无缝替换。
哈希令牌:单向且不可逆
哈希令牌,不是简单对PAN做SHA-256就完了。那样太容易破解——因为PAN空间有限(16位数字),穷举就能碰撞。必须加盐,而且盐值要随机且保密。
我记得有个同事曾经把哈希令牌直接当脱敏方案,没加盐。结果一个离职员工用彩虹表几分钟就把一堆卡片还原了。血的教训。
警告:哈希令牌本质上是单向的——你不能从哈希值反算出PAN。如果需要反向查询(比如交易复盘),你必须维护一个从原始PAN到哈希令牌的映射表,或者保存盐值后重新计算。
规范做法
推荐使用HMAC(基于哈希的消息认证码)加上一个随机盐(或系统级密钥)。比如:
import hmac
import hashlib
def generate_hash_(pan, salt):
# 先对PAN做标准化:去掉空格,检查Luhn校验
normalized_pan = pan.replace(' ', '')
# 使用HMAC-SHA256,salt作为密钥的一部分
h = hmac.new(salt.encode(), normalized_pan.encode(), hashlib.sha256)
= h.hexdigest()[:32] # 取前32字符,约128位
return
这里的 salt 可以是系统级静态密钥 + 每个PAN专属随机值。我个人倾向混合:固定服务密钥 + PAN的某几位作为动态因子。
适用场景
- 数据脱敏:日志、报表中替代真实PAN,且不要求反向还原
- 去标识化:用于分析、风控,但又不能暴露原始卡号
- 令牌验证:只需校验令牌是否对应某个PAN,不需要知道PAN本身
你想想看,当你的下游系统只需要一个固定长度的“指纹”来匹配数据,哈希令牌就很合适。不需要建大映射表,节省的是真金白银。
格式保留令牌 (FPE):最难,但也最“平滑”
格式保留令牌,英文叫FF1或FF3-1算法。它的目标很明确:生成的令牌看起来跟真的卡号一样——16位数字、符合Luhn校验、甚至前6位BIN都能映射到真实发卡行。
这听起来很神奇,对吧?我第一次接触FPE时,也觉得它像个黑魔法。
实际上它是在一个有限的数据空间内做伪随机置换。NIST的SP 800-38G标准定义了FF1和FF3-1。FF3-1现在更常用,因为性能更好。
工作流程
- 确定字符集(数字0-9,或字母数字)
- 使用密钥(Key)和调整值(Tweak)初始化伪随机函数
- 将原始PAN划分为两个子串,按Feistel网络结构迭代多轮
- 输出结果保持长度和字符集不变
- 最后一步:对结果重新计算Luhn校验位(如果有需求)
# 伪代码:FPE FF1 编码(概念演示)
def fpe_ff1_encrypt(pan, key, tweak):
# 1. 将PAN转为数字串
digits = [int(c) for c in pan]
# 2. 分割为左右两部分
half = len(digits) // 2
left, right = digits[:half], digits[half:]
# 3. 10轮Feistel迭代
for round in range(10):
# 用PRF生成伪随机数,作用到右半部分
# PRF输入:key, tweak, round, left
# PRF输出:与right等长度的伪随机数字串
# 4. 交换左右
left, right = right, left ^ pseudo_random
return ''.join(str(d) for d in left + right)
重要:实际实现请使用经过验证的密码学库,如libfpe或Botan。不要自己写FF1/FF3-1。算法细节极易出错,调校位处理、字符集映射都可能导致安全漏洞。
FPE的优缺点
| 维度 | 评价 |
|---|---|
| 格式兼容 | 最强。现有系统几乎零改造 |
| 安全性 | 128位安全强度,但空间小于完整随机空间(只有10^16种可能) |
| 性能 | 需要多轮运算,比随机令牌慢约5-10倍 |
| 存储需求 | 不需要映射表(前提是解密流程可用) |
| 合规对接 | 收单侧可直接用令牌当卡号处理,但BIN映射需额外管理 |
我在一个跨境的支付网关项目中用过FPE。客户要求令牌能和原卡号一样走老系统的BIN路由规则。不用FPE的话就得改整个路由通道,工程量极大。最后我们用FF3-1,三周就上线了。当然,代价是加了几台高性能节点来跑加解密。
注意FPE的弱点:输入输出都是有限空间(10^16),理论上穷举攻击是可行的。所以一定要结合密钥轮换和访问控制。一旦攻击者拿到大量明密文对,弱密钥就可能泄露。
怎么选?我的实战考量
三种算法没有优劣,关键是看场景。我给出一个简单的决策逻辑:
- 全新系统,没有遗留包袱:选随机令牌。简单、安全、容易审计。映射表用Redis集群扛,性能不是问题。
- 需要脱敏数据但不回溯:选哈希令牌。注意一定要用HMAC + 随机盐,盐要专人保管。
- 现有收单系统改不动,又要令牌化:选FPE。但要加硬件加密机或专用节点,避免性能瓶颈。
- 合规压力大(PCI要求存储不可逆):随机令牌或FPE都可以,但哈希令牌不能存储密钥(否则单向可逆)。