令牌生成算法详解:随机令牌、哈希令牌、格式保留令牌

讲令牌服务,绕不开的核心就是令牌怎么生成。你可能觉得这有什么好讲的,不就是随机一串字符吗?但实际项目里,生成方式直接决定了你的系统能不能过PCI合规,能不能对接现有收单系统,能不能扛住高并发。我这些年见过太多因为选错算法而返工的例子,真是得不偿失。

今天我就把这三种主流算法——随机令牌、哈希令牌、格式保留令牌——掰开揉碎聊清楚。每种都有适用场景,没有银弹。

令牌生成算法分类与核心特征 随机令牌 完全随机,无业务规律 哈希令牌 单向散列 + 盐值 格式保留令牌 (FPE) 保持PAN格式与校验 存储2:映射表 存储1:密钥+盐 存储2:密钥+Tweak 安全性:高(熵大) 安全性:高(单向) 安全性:中(保留格式) 适用:内部系统令牌 适用:脱敏、去标识 适用:收单系统对接 选择算法前必须评估:合规要求、系统耦合度、性能、存储成本

随机令牌:最朴素,但也最暴力

随机令牌,顾名思义就是用真随机数生成器产生一串字符。最简单,也是最常用的方式。

核心思路:每次生成一个不可预测的字符串,存入映射表,与原始PAN建立一对一的关联。查询时通过映射表反向查找原始PAN。

我早期做的第一个令牌服务,用的就是纯随机方式。现在回头看,简单有简单的好,但坑也不少。

生成流程

  1. 使用密码学安全的随机数源(如/dev/urandom)生成一块随机字节
  2. 编码为可读字符串(Base64、Base32或16进制)
  3. 检查是否与已有令牌冲突——这一步很多人忽略
  4. 如果冲突,重新生成(概率极低,但必须处理)
  5. 将令牌与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现在更常用,因为性能更好。

工作流程

  1. 确定字符集(数字0-9,或字母数字)
  2. 使用密钥(Key)和调整值(Tweak)初始化伪随机函数
  3. 将原始PAN划分为两个子串,按Feistel网络结构迭代多轮
  4. 输出结果保持长度和字符集不变
  5. 最后一步:对结果重新计算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都可以,但哈希令牌不能存储密钥(否则单向可逆)。