Python 基础体系 · 第 46/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 随机数与 secrets:伪随机、采样、种子和密码学边界

在 Python 中,“随机数”不是单一概念。random 模块解决的是模拟、测试、游戏和数值实验中的随机性;secrets 模块解决的是密码重置令牌、会话标识符、认证挑战值等安全敏感数据的不可预测性。

两者都能返回整数、浮点数或序列元素,但它们的目标不同:

  • 伪随机数:由确定性算法生成,速度快,可复现,适合模拟。
  • 系统随机数:由操作系统提供随机源,目标是让攻击者难以预测,适合安全令牌。
  • 采样:从范围、序列或带权集合中按某种概率规则选取结果。
  • 种子:初始化伪随机生成器内部状态的输入,不是“增加随机性”的密码。
  • 密码学边界:随机数、哈希、HMAC、密码存储和 TLS 各自解决不同问题,不能因为都涉及“安全”就互相替代。

Python 3.14 的 random 默认使用 Mersenne Twister;文档明确说明它是确定性的,不适合密码学用途。安全场景应使用 secrets。(docs.python.org)


一、先区分四个容易混淆的概念

1. 随机性不是不可预测性

从统计角度看,一个序列可以表现出良好的分布特征;从安全角度看,攻击者仍然可能预测它的下一个值。

例如,设一个生成器在区间 [0, 1) 上输出浮点数。我们可能检查:

  • 每个区间出现的频率是否接近;
  • 连续值之间是否存在明显相关性;
  • 是否满足某个统计分布。

这些检查只能说明它像随机数,不能证明攻击者无法推断未来输出。

密码学随机数关注的是更强的性质:

在攻击者已经观察到部分输出、了解程序结构,甚至知道部分内部信息时,剩余输出仍然难以计算。

因此,“经过随机测试”不等于“可以用作密码、令牌或密钥”。

2. 伪随机数是确定性函数

伪随机数生成器可以抽象为:

Si+1=F(Si)S_{i+1} = F(S_i)

Xi=G(Si)X_i = G(S_i)

其中:

  • SiS_i 是第 ii 步的内部状态;
  • FF 是状态转移函数;
  • GG 是从状态导出输出的函数;
  • XiX_i 是第 ii 个输出。

只要初始状态 S0S_0 相同,整个序列就相同:

S0(A)=S0(B)X0(A),X1(A),=X0(B),X1(B),S_0^{(A)} = S_0^{(B)} \Rightarrow X_0^{(A)}, X_1^{(A)}, \ldots = X_0^{(B)}, X_1^{(B)}, \ldots

这正是模拟和测试需要的特性,也是安全令牌不能依赖普通 random 的根本原因。

3. 种子不是密钥

种子是用于初始化伪随机生成器的输入。例如:

import random

random.seed(20260301)

print(random.random())
print(random.randrange(100))
print(random.choice(["red", "green", "blue"]))

再次执行同样的程序,通常会得到同样的序列。种子的作用是让程序能够重建同一条伪随机序列,而不是让序列获得密码学安全性。

如果攻击者知道种子,或者能够从有限输出中推断种子,那么后续输出就可以被重建。即使种子本身来自 os.urandom(),一旦内部状态泄露,普通伪随机生成器的输出也不应被当作长期安全密钥使用。

4. “均匀”描述概率,不描述安全

均匀分布表示每个候选值概率相同。例如:

P(X=i)=1n,i{0,1,,n1}P(X = i) = \frac{1}{n},\quad i \in \{0,1,\ldots,n-1\}

但一个攻击者完全可以预测一个“均匀分布”的值,只要生成器本身是可预测的。

因此要同时问两个问题:

  1. 结果是否符合目标概率分布?
  2. 结果是否无法被攻击者预测?

random.randrange() 主要解决第一个问题;secrets.randbelow() 同时针对安全场景提供不可预测的随机源。


二、random 的核心:状态、输出和可复现性

1. 默认生成器是模块级实例

random 模块中的函数实际上绑定到一个隐藏的 random.Random 实例。也就是说,下面的调用共享同一个模块级状态:

import random

a = random.random()
b = random.randrange(10)
c = random.choice(["a", "b", "c"])

每次调用都会消耗内部状态,后续结果取决于之前调用过哪些随机函数。于是,调试时插入一行新的随机调用,可能导致后面的所有结果改变。

如果两个组件不应该互相影响,应创建独立的生成器实例:

import random

simulation_rng = random.Random(1234)
test_rng = random.Random(1234)

print(simulation_rng.random())
print(test_rng.random())

这里两个实例拥有各自的状态。相同种子会产生相同序列,但一个实例的调用不会推进另一个实例。

Python 文档说明,模块级生成器和 Random 实例是线程安全的;不过在 free-threaded 构建中,并发访问同一个生成器可能出现竞争和性能下降,建议每个线程使用独立实例。(docs.python.org)

2. getstate()setstate() 暴露了可恢复状态

import random

rng = random.Random(42)

first = rng.random()
state = rng.getstate()

second = rng.random()
third = rng.random()

rng.setstate(state)

assert rng.random() == second
assert rng.random() == third

状态变化过程可以表示为:

初始状态 S0
   │ random()
   ▼
状态 S1,输出 first
   │ getstate()
   ▼
保存 S1
   │ random()
   ▼
状态 S2,输出 second
   │ random()
   ▼
状态 S3,输出 third

恢复 S1
   │ random()
   ▼
状态 S2,输出 second
   │ random()
   ▼
状态 S3,输出 third

这说明伪随机生成器的“随机性”来自对状态的隐藏,而不是来自不可逆的物理过程。状态一旦可获得,后续序列就可以重现。

SystemRandom 不依赖软件状态,因此不支持这种可复现机制:它的 seed() 会被忽略,getstate()setstate() 会抛出 NotImplementedError。(docs.python.org)

3. Python 对种子的保证是有限的

random.seed() 接受的类型包括 Noneintfloatstrbytesbytearray。字符串和字节串在默认的 version=2 方案下会使用全部比特参与转换;Python 3.11 起,种子类型被限制为这些类型。(docs.python.org)

但是不能把“同一个种子得到同一个序列”理解成跨所有 Python 版本、所有函数都完全稳定。Python 文档只保证两点:

  1. 如果未来增加新的播种方式,会提供向后兼容的播种器;
  2. 使用兼容播种器和同一种子时,random() 会继续产生相同序列。

其他随机算法和播种细节可能随 Python 版本变化。(docs.python.org)

生产中的可复现实验应至少记录:

  • Python 版本;
  • 生成器类型;
  • 种子;
  • 关键参数;
  • 必要时记录 getstate() 返回的状态;
  • 输入数据和数据处理顺序。

只记录一个整数种子,并不能保证整个实验环境可复现。


三、整数随机数:范围、边界和均匀性

1. randrange() 使用半开区间

import random

print(random.randrange(5))       # 可能是 0、1、2、3、4
print(random.randrange(2, 8))    # 可能是 2、3、4、5、6、7
print(random.randint(2, 8))      # 可能是 2、3、4、5、6、7、8

randrange(start, stop, step)range(start, stop, step) 中选取元素,因此 stop 不包含在内。randint(a, b)randrange(a, b + 1) 的别名,所以两端都包含。Python 3.12 起,randrange() 不再自动把 floatFraction 转换为整数,传入这类值会抛出 TypeError。(docs.python.org)

2. 为什么不能简单写成 int(random.random() * n)

一种直观写法是:

int(random.random() * n)

如果 random.random() 能在 [0, 1) 中连续均匀取值,这个式子看起来会把区间分成 n 段。但计算机中的浮点数并不是连续实数,而且 random.random() 只产生 53 位精度的浮点数。

因此,在边界映射时可能出现:

  • 某些整数对应的浮点输出数量不同;
  • 浮点舍入造成轻微偏差;
  • 大范围整数无法被浮点精确覆盖。

Python 文档特别说明,randrange() 已经改进了均匀性,不能再把旧式的 int(random() * n) 当作等价实现。(docs.python.org)

3. 拒绝采样:避免取模偏差

安全随机数中的 randbelow(n) 需要解决一个更严格的问题:如何从随机比特中均匀选出 [0, n) 的整数。

假设先生成一个 3 位随机整数:

R{0,1,2,3,4,5,6,7}R \in \{0,1,2,3,4,5,6,7\}

现在想生成 [0, 6),也就是 0 到 5。

如果写成:

R % 6

结果分布为:

原始值 R % 6
0 0
1 1
2 2
3 3
4 4
5 5
6 0
7 1

于是:

P(0)=P(1)=28P(0)=P(1)=\frac{2}{8}

而:

P(2)=P(3)=P(4)=P(5)=18P(2)=P(3)=P(4)=P(5)=\frac{1}{8}

这不是均匀分布。

拒绝采样的做法是只接受完整覆盖的前缀。对于 8 个候选值和目标范围 6,可以接受 05,拒绝 67

生成 R
 ├── R < 6:返回 R
 └── R >= 6:丢弃,重新生成

条件概率为:

P(X=i)=P(R=iR<6)=1/86/8=16P(X=i) = P(R=i \mid R<6) = \frac{1/8}{6/8} = \frac{1}{6}

所以每个结果都严格等概率。secrets.randbelow(n) 提供安全场景下的这种范围选择;它返回 [0, n) 中的整数。(docs.python.org)


四、序列采样:choicechoicessampleshuffle

“随机选择一个元素”“随机抽取若干元素”和“随机打乱序列”是不同问题,不能只看函数名中都有 random 就混用。

1. choice():一次选择,不放回这个概念不适用

import random

colors = ["red", "green", "blue"]
color = random.choice(colors)

choice(seq) 从非空序列中选择一个元素。空序列会抛出 IndexError。它不修改原序列。(docs.python.org)

安全场景中的对应函数是:

import secrets

code_char = secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")

这里的序列可以是字符串、列表或其他非空序列。

2. choices():有放回采样

import random

result = random.choices(
    population=["red", "black", "green"],
    weights=[18, 18, 2],
    k=6,
)

print(result)

choices() 每次选择后都保留整个总体,因此同一个元素可以重复出现。权重 [18, 18, 2] 的总和是 38,于是单次选择概率为:

P(red)=1838P(\text{red})=\frac{18}{38}

P(black)=1838P(\text{black})=\frac{18}{38}

P(green)=238P(\text{green})=\frac{2}{38}

如果调用 k=6,这是 6 次独立的有放回选择,而不是从 6 个不同元素中选取。

权重必须是非负且有限的数值,并且不能全部为零。choices() 内部使用浮点计算;即使不设置权重,给定相同种子时,重复调用 choice() 通常也不会得到和 choices(..., k=n) 相同的序列。(docs.python.org)

3. sample():不放回采样

import random

participants = ["A", "B", "C", "D", "E"]
winners = random.sample(participants, k=2)

print(winners)

sample() 返回长度为 k 的新列表,原序列不变,并且同一个位置不会被重复抽取。若 k 大于总体大小,会抛出 ValueError。总体元素本身不要求可哈希,也不要求彼此唯一;如果总体中有重复项,每个出现位置都被视为一次可能选择。(docs.python.org)

例如:

import random

population = ["A", "A", "B"]
print(random.sample(population, k=2))

这里两个 "A" 是两个不同的总体成员,但输出值可能看起来相同。

counts 参数可以压缩表示重复元素:

import random

sample = random.sample(
    ["red", "blue"],
    counts=[4, 2],
    k=5,
)

它等价于从:

["red", "red", "red", "red", "blue", "blue"]

中抽取 5 个位置,但不必显式构造这个扩展列表。对大整数总体进行采样时,sample(range(...), k=...) 也比创建完整列表更节省空间。(docs.python.org)

4. shuffle():就地改变状态

import random

cards = ["A", "K", "Q", "J"]
random.shuffle(cards)

print(cards)

shuffle() 原地修改列表,不返回新的打乱列表。若需要保留原对象,可以写:

shuffled = random.sample(cards, k=len(cards))

这里的“公平”还存在一个组合数学边界。长度为 nn 的序列有 n!n! 种排列,而 Mersenne Twister 的周期是:

21993712^{19937}-1

n!n! 大于生成器周期时,不可能生成所有排列。Python 文档指出,长度 2080 是能够放入 Mersenne Twister 周期的最大序列长度;更长序列的绝大多数排列从该生成器的状态空间中根本无法出现。(docs.python.org)

这通常不是普通程序的问题,但它说明“每次 shuffle 都等概率覆盖所有排列”需要同时考虑:

  • 洗牌算法是否正确;
  • 随机源是否足够;
  • 生成器状态空间是否足够覆盖目标排列空间。

五、randomsecrets 的密码学边界

1. random 的优点正是它不适合安全场景的原因

random.Random 的默认核心是 Mersenne Twister。它具有很长周期、速度快、统计性质良好等特点,但它是确定性生成器。Python 文档明确说,Mersenne Twister 完全不适合密码学用途。(docs.python.org)

下面的代码适合模拟:

import random

rng = random.Random(2026)

for _ in range(5):
    print(rng.randrange(100))

下面的代码不适合生成登录验证码、重置密码链接或 API 密钥:

import random

token = "".join(
    random.choice("abcdefghijklmnopqrstuvwxyz0123456789")
    for _ in range(32)
)

问题不在于字符集不够大,而在于底层序列可预测。扩大字符串长度只能增加输出长度,不能改变生成器的安全属性。

2. SystemRandomsecrets 的数据流

Python 提供的安全路径可以抽象为:

操作系统随机源
    │
    ▼
os.urandom()
    │
    ├── random.SystemRandom
    │       └── 安全随机选择接口
    │
    └── secrets
            ├── randbelow()
            ├── randbits()
            ├── choice()
            └── token_bytes/hex/urlsafe()

os.urandom(size) 返回适合密码学使用的随机字节;在 Linux 上,现代 Python 会使用 getrandom(),在 Windows 上使用 BCryptGenRandom()。具体随机源由操作系统提供,Python 不把普通软件状态当作随机源。(docs.python.org)

secrets 是面向应用的高层接口,目标是提供操作系统所能提供的高质量随机源。(docs.python.org)

3. secrets 的常用接口

import secrets

number = secrets.randbelow(100)
bits = secrets.randbits(128)
choice = secrets.choice(["red", "green", "blue"])

raw_token = secrets.token_bytes(32)
hex_token = secrets.token_hex(32)
url_token = secrets.token_urlsafe(32)

print(0 <= number < 100)
print(bits.bit_length() <= 128)
print(len(raw_token))
print(len(hex_token))
print(len(url_token))

这些接口的含义不同:

  • randbelow(100):产生 [0, 100) 中的安全随机整数;
  • randbits(128):产生最多 128 个随机比特的非负整数;
  • token_bytes(32):生成 32 个随机字节;
  • token_hex(32):仍然使用 32 个随机字节,但每个字节编码为两个十六进制字符,因此通常得到 64 个字符;
  • token_urlsafe(32):使用 URL 安全的 Base64 编码,长度不是固定的 64 个字符。

token_* 函数的参数单位是随机字节数,不是最终字符串长度。DEFAULT_ENTROPY 的确切数值不是稳定的 API 保证,甚至可能在维护版本中变化;需要明确长度时应显式传入 nbytes。文档以 32 字节作为典型场景下的参考值,但实际取值仍应结合令牌寿命、攻击入口和速率限制评估。(docs.python.org)


六、令牌长度、熵和碰撞概率

1. 熵来自可能空间,而不是字符串长度

若令牌由长度为 LL 的字符组成,每个位置从 AA 个字符中独立选择,那么理想情况下候选空间大小是:

N=ALN = A^L

对应的理想熵为:

H=log2(N)=Llog2(A)H = \log_2(N)=L\log_2(A)

例如,长度 8、字符集大小 62 的令牌,其理想熵约为:

8log2(62)47.6 bits8\log_2(62)\approx47.6\text{ bits}

而 32 个随机字节的熵上限是:

32×8=256 bits32\times8=256\text{ bits}

但这只是随机输入的理论上限。若使用时间戳、用户 ID、递增序列或可推断的种子,实际熵会远低于表面长度。

2. 碰撞概率和生日问题

即使令牌不可预测,也可能发生碰撞。设令牌空间大小为 NN,生成 kk 个令牌。至少一次碰撞的概率近似为:

P(collision)1ek(k1)/(2N)P(\text{collision}) \approx 1-e^{-k(k-1)/(2N)}

kNk\ll\sqrt{N} 时,还可以近似为:

P(collision)k(k1)2NP(\text{collision}) \approx \frac{k(k-1)}{2N}

这解释了为什么 32 位或 64 位的短 ID 在高并发系统中可能不够。安全令牌通常还需要数据库唯一约束或重试机制,因为“概率很低”不是“逻辑上不可能”。

一个典型的创建流程是:

import secrets

def create_token():
    return secrets.token_urlsafe(32)

数据库写入时,应让唯一索引承担最后一道碰撞检测:

生成令牌
   │
   ▼
尝试插入数据库唯一列
   │
   ├── 成功:返回令牌
   └── 唯一冲突:重新生成并重试

这里随机数负责降低碰撞概率,数据库约束负责把概率事件转换成可处理的确定性错误。

3. 令牌的安全性不只由长度决定

攻击者实际面对的搜索空间还取决于:

  • 令牌是否完整随机;
  • 是否泄露在日志、Referer、浏览器历史或监控系统中;
  • 是否有过期时间;
  • 是否只能使用一次;
  • 是否绑定用户、设备或操作上下文;
  • 服务端是否限制尝试次数;
  • 令牌验证失败是否泄露更多信息。

一个 256 位令牌如果出现在公开日志中,就不再是秘密。随机性解决的是“猜测困难”,不能解决“已经泄露”。


七、验证码和密码:随机生成不等于安全认证

1. 验证码的安全性由空间和验证策略共同决定

例如生成 6 位数字验证码:

import secrets

code = f"{secrets.randbelow(1_000_000):06d}"
print(code)

这会生成 000000999999 之间的等概率验证码。理论空间只有:

10610^6

也就是约 20 位熵:

log2(106)19.93\log_2(10^6)\approx19.93

因此它不能只依靠“随机”获得安全性,还需要:

  • 较短的有效期;
  • 尝试次数限制;
  • 失败锁定或逐步延迟;
  • 与用户或会话绑定;
  • 成功后立即失效;
  • 不在响应中返回验证码;
  • 不在普通日志中记录明文验证码。

如果攻击者每秒可以尝试 1000 次,百万空间的验证码就不应被视为高强度秘密。

2. 密码不应使用普通哈希直接保存

下面这种写法不适合保存用户密码:

import hashlib

stored = hashlib.sha256(password.encode()).hexdigest()

SHA-256 是快速密码学哈希,适合完整性摘要等用途,但密码存储需要抗离线穷举。攻击者一旦获得数据库,就可以高速尝试大量候选密码。

密码存储需要专门的慢速、带盐、可调成本的密码哈希方案,例如 Argon2、scrypt、bcrypt 或 PBKDF2。Python 标准库提供了 hashlib.pbkdf2_hmac(),但选择参数时必须根据部署环境测量并设置足够的计算成本。不能把一次 SHA-256 当成 PBKDF。

3. 盐和令牌的角色不同

密码盐通常是公开保存的随机值,其作用是让相同密码产生不同的存储结果,并阻止预计算表直接复用。盐不需要像密码一样保密,但必须独立、随机且与密码记录关联。

令牌则通常本身就是授权凭证,泄露后可能直接代表权限。因此:

密码盐:通常可公开,用于改变哈希输入
访问令牌:必须保密,用于证明持有者身份或权限

两者都可以使用 secrets.token_bytes() 生成随机字节,但用途、保存方式和泄露后果完全不同。


八、哈希、HMAC 和随机数:三种不同的安全工具

1. 哈希是公开函数,不提供秘密认证

密码学哈希可以表示为:

d=H(m)d = H(m)

其中:

  • mm 是消息;
  • HH 是哈希函数;
  • dd 是固定长度摘要。

任何知道消息的人都可以计算摘要,因此单独使用哈希只能帮助检测数据是否变化,不能证明数据来自某个特定发送者。

import hashlib

message = b"hello"
digest = hashlib.sha256(message).hexdigest()

print(digest)

如果攻击者可以同时修改消息和摘要,那么“消息 + 普通摘要”不能提供真实性。

Python 的 hashlib 提供 SHA-2、SHA-3、SHAKE、BLAKE2 等算法,也包含历史算法 MD5 和 SHA-1;文档明确警告 MD5 和 SHA-1 存在已知的碰撞弱点,不应作为新的安全完整性方案。(docs.python.org)

2. HMAC 把秘密密钥引入完整性校验

HMAC 可以抽象为:

HMACH(K,m)\operatorname{HMAC}_H(K,m)

其中:

  • KK 是秘密密钥;
  • mm 是消息;
  • HH 是底层哈希函数;
  • 输出是消息认证码。

示例:

import hashlib
import hmac
import secrets

key = secrets.token_bytes(32)
message = b"amount=100&currency=CNY"

tag = hmac.new(
    key,
    message,
    hashlib.sha256,
).digest()

received_message = b"amount=100&currency=CNY"
received_tag = tag

valid = hmac.compare_digest(
    hmac.new(key, received_message, hashlib.sha256).digest(),
    received_tag,
)

print(valid)

验证端需要重新计算 HMAC,并使用 compare_digest() 比较,而不是直接使用 ==。Python 文档说明,compare_digest() 采用旨在减少基于内容提前结束行为的比较方式,以降低时序攻击风险;两侧应使用相同类型,即都为字符串或都为字节类对象。(docs.python.org)

3. HMAC 密钥必须是秘密,随机数只负责生成它

key = secrets.token_bytes(32)

这一步生成密钥;HMAC 负责使用密钥认证消息。两者职责不同:

secrets.token_bytes()
    └── 生成不可预测的密钥

HMAC(key, message)
    └── 证明消息未被篡改,且验证者持有同一密钥

下面这些做法不能替代 HMAC:

hashlib.sha256(key + message).digest()
hashlib.sha256(message + key).digest()
hashlib.sha256(message).digest()

它们不应被自行设计为通用消息认证协议。HMAC 的构造、密钥处理和底层哈希组合有明确标准,Python 的 hmac 模块实现了 RFC 2104 所描述的算法。(docs.python.org)


九、HMAC 和 TLS 也不是一回事

1. HMAC 解决消息级认证

如果应用从一个系统向另一个系统发送:

用户 ID
订单号
金额
时间戳

可以用共享密钥计算 HMAC,使接收方能够验证:

  1. 消息内容未被修改;
  2. 发送方拥有共享密钥。

但 HMAC 本身不负责:

  • 协商密钥;
  • 发现对端身份;
  • 防止重放;
  • 建立加密通道;
  • 管理证书;
  • 处理密钥轮换。

因此消息中通常还需要加入时间戳、随机数或单调递增序列,并在服务端记录已处理请求。例如:

m=methodpathtimestampnoncebodym = \text{method} \parallel \text{path} \parallel \text{timestamp} \parallel \text{nonce} \parallel \text{body}

然后计算:

t=HMACH(K,m)t = \operatorname{HMAC}_H(K,m)

服务端不仅要校验 t,还要检查时间窗口和 nonce 是否已经使用。

2. TLS 解决通道级安全

TLS 通常负责:

  • 服务器身份认证;
  • 通道加密;
  • 传输完整性;
  • 会话密钥协商;
  • 防止网络观察者直接读取和修改数据。

因此:

TLS:保护客户端与服务器之间的通信通道
HMAC:保护应用消息或数据对象的完整性与认证

在 HTTPS 上再加 HMAC 不是自动错误,但它是不同层次的设计。常见原因包括:

  • 消息需要在多个服务之间转发并独立验证;
  • 消息需要落盘后仍可验证;
  • 接收者与 TLS 终止代理不是同一个组件;
  • 需要应用层签名或请求重放控制。

反过来,自己实现 HMAC 也不能替代 TLS,因为 HMAC 不提供通道保密性,也不自动验证远端身份。


十、secrets.compare_digest() 的边界

1. 它只解决比较阶段的问题

import secrets

expected = "a1b2c3"
provided = "a1b2c3"

if secrets.compare_digest(expected, provided):
    print("valid")

它适合比较:

  • HMAC 十六进制摘要;
  • API 签名;
  • 固定格式的认证标签;
  • 服务端保存的短认证值。

但它不能修复:

  • 令牌本身熵不足;
  • 令牌被日志泄露;
  • 验证接口没有限速;
  • 长度差异暴露在其他业务逻辑中;
  • 先执行了不安全的字符串规范化;
  • 认证成功后没有绑定用户或权限。

文档还指出,长度不同或发生错误时,时序上理论上可能泄露输入的类型和长度,但不是内容值。(docs.python.org)

2. 字符串和字节不能混比

import secrets

expected = bytes.fromhex("aabbcc")
provided = "aabbcc"

# 不应这样比较:
# secrets.compare_digest(expected, provided)

应统一表示:

import secrets

expected = "aabbcc"
provided = "aabbcc"

assert secrets.compare_digest(expected, provided)

或者统一使用字节:

import secrets

expected = bytes.fromhex("aabbcc")
provided = bytes.fromhex("aabbcc")

assert secrets.compare_digest(expected, provided)

对于 HMAC,直接比较 digest() 得到的字节通常比先转换成十六进制更自然;如果协议传输的是十六进制字符串,则两边都转换为 ASCII 字符串后比较。


十一、常见错误及其失败路径

错误一:用时间戳生成令牌

import time

token = str(int(time.time()))

失败原因是候选空间极小且高度可预测。攻击者知道令牌的大致生成时间,就可以枚举附近时间戳。

即使改成:

token = f"{int(time.time())}-{user_id}"

也只是增加了可观察字段,没有增加真正的秘密熵。

应改为:

import secrets

token = secrets.token_urlsafe(32)

错误二:用 random 生成密码重置链接

import random

token = str(random.getrandbits(64))

getrandbits() 只是从当前 Random 实例中生成随机比特。对于默认 random 生成器,它仍然是可预测的伪随机输出,不因为函数名中有 bits 就变成密码学随机数。

应使用:

import secrets

token = secrets.token_urlsafe(32)

错误三:使用模运算缩小安全随机数范围

import os

value = int.from_bytes(os.urandom(1), "big") % 10

一个字节有 256 种结果,而 256 不能被 10 整除。于是 0 到 5 会比 6 到 9 多获得一组原始值,产生偏差。

可直接使用:

import secrets

value = secrets.randbelow(10)

错误四:把十六进制长度当成熵长度

import secrets

token = secrets.token_hex(16)

这个令牌有 32 个十六进制字符,但随机输入只有 16 字节,也就是 128 位。十六进制编码只是把二进制转换成文本,不会增加熵。

若需要 256 位随机输入,应使用:

token = secrets.token_hex(32)

结果通常为 64 个十六进制字符,但真正的随机性仍然是 32 字节。

错误五:使用 hash() 生成稳定令牌或签名

token = str(hash(user_id))

Python 的 hash() 主要服务于哈希表,不是密码学哈希,也不保证跨进程、跨运行稳定。某些类型的哈希会受到进程级随机化影响,不能用于持久化协议、签名或认证。

需要摘要时使用 hashlib;需要认证时使用 HMAC;需要不可预测令牌时使用 secrets

错误六:把加密后的密码当成密码存储方案

密码可逆加密意味着拿到密钥的人可以恢复全部密码。Python secrets 文档明确建议,不应以可恢复格式保存密码,无论是明文还是加密形式;密码应使用加盐的、密码学强度的单向哈希方案。(docs.python.org)


十二、可运行示例:实现一个一次性重置令牌

下面示例演示一个简化的令牌生命周期:

  1. 生成随机令牌;
  2. 只把令牌摘要保存到数据库;
  3. 通过链接把原令牌交给用户;
  4. 验证时重新计算摘要;
  5. 使用常量时间比较;
  6. 成功后立即删除或标记为已使用。
from __future__ import annotations

import hashlib
import secrets
import time
from dataclasses import dataclass


@dataclass
class ResetRecord:
    token_digest: bytes
    user_id: int
    expires_at: int
    used: bool = False


def create_reset_token(user_id: int, lifetime_seconds: int = 900):
    token = secrets.token_urlsafe(32)

    digest = hashlib.sha256(token.encode("ascii")).digest()

    record = ResetRecord(
        token_digest=digest,
        user_id=user_id,
        expires_at=int(time.time()) + lifetime_seconds,
    )

    return token, record


def verify_reset_token(token: str, record: ResetRecord) -> int | None:
    if record.used:
        return None

    if int(time.time()) >= record.expires_at:
        return None

    presented_digest = hashlib.sha256(token.encode("ascii")).digest()

    if not secrets.compare_digest(
        presented_digest,
        record.token_digest,
    ):
        return None

    record.used = True
    return record.user_id


token, record = create_reset_token(user_id=1001)

print("发送给用户的令牌:", token)
print("验证结果:", verify_reset_token(token, record))
print("重复使用:", verify_reset_token(token, record))

一次可能的输出为:

发送给用户的令牌:<一段不可预测的 URL 安全字符串>
验证结果:1001
重复使用:None

这个示例中,数据库不保存原始令牌,而保存:

d=SHA256(token)d = \operatorname{SHA256}(\text{token})

攻击者即使读到数据库,也不能直接把摘要放进重置链接中作为原令牌。验证时,服务端对用户提交的令牌执行同样的哈希,再使用 compare_digest() 比较。

但这是一个教学级实现,生产系统还必须考虑:

  • 数据库唯一约束;
  • 令牌记录的并发消费;
  • 事务或条件更新,避免两个请求同时成功;
  • 用户枚举;
  • 访问日志和代理日志中的令牌泄露;
  • 过期清理;
  • 失败尝试限制;
  • 密码修改后的会话失效;
  • HTTPS 和正确的 Cookie 属性。

尤其是并发消费:如果两个请求同时读取 used=False,再分别验证成功,单纯的内存布尔值不能保证一次性。数据库层应使用类似“仅当 used = false 时更新为 true”的原子条件,并检查实际更新行数。


十三、如何选择 API

可以按“目标”而不是按“函数名”选择:

目标 API 原因
模拟、蒙特卡洛、随机测试 random.Random(seed) 可复现、速度快
生成 [a, b) 中的伪随机整数 rng.randrange(a, b) 明确半开区间
不放回抽样 rng.sample(population, k) 每个位置最多选一次
有放回、带权抽样 rng.choices(..., weights=..., k=...) 允许重复并支持权重
打乱列表 rng.shuffle(list) 原地修改
安全随机整数 secrets.randbelow(n) 使用操作系统随机源
安全随机元素 secrets.choice(seq) 用于安全敏感选择
随机字节 secrets.token_bytes(n) 适合密钥和令牌材料
URL 中的随机令牌 secrets.token_urlsafe(n) 文本安全且适合 URL
消息完整性认证 hmac.new() / hmac.digest() 需要共享秘密密钥
安全比较认证值 secrets.compare_digest() 减少时序泄露风险
普通摘要或内容指纹 hashlib.sha256() 不提供秘密认证

random.randbytes() 虽然能够生成字节,但 Python 文档明确要求不要用它生成安全令牌,应使用 secrets.token_bytes()。(docs.python.org)


十四、最终边界:先问“随机为了什么”

如果随机性用于数值实验,核心问题是:

  • 分布是否正确;
  • 样本是否独立;
  • 采样是否放回;
  • 是否可复现;
  • 是否存在浮点或组合空间偏差。

此时优先使用独立的 random.Random 实例,并显式管理种子和状态。

如果随机性用于安全,核心问题是:

  • 攻击者是否能预测;
  • 随机输入的熵是否足够;
  • 令牌是否会泄露;
  • 是否有过期、限速和一次性约束;
  • 验证比较是否安全;
  • 密钥、摘要和认证码是否被正确区分。

此时使用 secrets,不要使用普通 random

可以把整个判断压缩成三条规则:

模拟随机性       → random.Random
安全随机性       → secrets
消息认证         → HMAC,而不是普通哈希

种子让伪随机序列可重建;secrets 让安全令牌难以预测;哈希提供摘要;HMAC 提供带密钥的完整性认证;TLS 保护通信通道。它们都可能出现在同一个系统中,但不能因为 API 都返回“看起来随机的字符串”就认为它们可以互相替代。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。