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. 伪随机数是确定性函数
伪随机数生成器可以抽象为:
其中:
- 是第 步的内部状态;
- 是状态转移函数;
- 是从状态导出输出的函数;
- 是第 个输出。
只要初始状态 相同,整个序列就相同:
这正是模拟和测试需要的特性,也是安全令牌不能依赖普通 random 的根本原因。
3. 种子不是密钥
种子是用于初始化伪随机生成器的输入。例如:
import random
random.seed(20260301)
print(random.random())
print(random.randrange(100))
print(random.choice(["red", "green", "blue"]))
再次执行同样的程序,通常会得到同样的序列。种子的作用是让程序能够重建同一条伪随机序列,而不是让序列获得密码学安全性。
如果攻击者知道种子,或者能够从有限输出中推断种子,那么后续输出就可以被重建。即使种子本身来自 os.urandom(),一旦内部状态泄露,普通伪随机生成器的输出也不应被当作长期安全密钥使用。
4. “均匀”描述概率,不描述安全
均匀分布表示每个候选值概率相同。例如:
但一个攻击者完全可以预测一个“均匀分布”的值,只要生成器本身是可预测的。
因此要同时问两个问题:
- 结果是否符合目标概率分布?
- 结果是否无法被攻击者预测?
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() 接受的类型包括 None、int、float、str、bytes 和 bytearray。字符串和字节串在默认的 version=2 方案下会使用全部比特参与转换;Python 3.11 起,种子类型被限制为这些类型。(docs.python.org)
但是不能把“同一个种子得到同一个序列”理解成跨所有 Python 版本、所有函数都完全稳定。Python 文档只保证两点:
- 如果未来增加新的播种方式,会提供向后兼容的播种器;
- 使用兼容播种器和同一种子时,
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() 不再自动把 float 或 Fraction 转换为整数,传入这类值会抛出 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 位随机整数:
现在想生成 [0, 6),也就是 0 到 5。
如果写成:
R % 6
结果分布为:
| 原始值 | R % 6 |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| 6 | 0 |
| 7 | 1 |
于是:
而:
这不是均匀分布。
拒绝采样的做法是只接受完整覆盖的前缀。对于 8 个候选值和目标范围 6,可以接受 0 到 5,拒绝 6 和 7:
生成 R
├── R < 6:返回 R
└── R >= 6:丢弃,重新生成
条件概率为:
所以每个结果都严格等概率。secrets.randbelow(n) 提供安全场景下的这种范围选择;它返回 [0, n) 中的整数。(docs.python.org)
四、序列采样:choice、choices、sample 和 shuffle
“随机选择一个元素”“随机抽取若干元素”和“随机打乱序列”是不同问题,不能只看函数名中都有 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,于是单次选择概率为:
如果调用 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))
这里的“公平”还存在一个组合数学边界。长度为 的序列有 种排列,而 Mersenne Twister 的周期是:
当 大于生成器周期时,不可能生成所有排列。Python 文档指出,长度 2080 是能够放入 Mersenne Twister 周期的最大序列长度;更长序列的绝大多数排列从该生成器的状态空间中根本无法出现。(docs.python.org)
这通常不是普通程序的问题,但它说明“每次 shuffle 都等概率覆盖所有排列”需要同时考虑:
- 洗牌算法是否正确;
- 随机源是否足够;
- 生成器状态空间是否足够覆盖目标排列空间。
五、random 和 secrets 的密码学边界
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. SystemRandom 和 secrets 的数据流
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. 熵来自可能空间,而不是字符串长度
若令牌由长度为 的字符组成,每个位置从 个字符中独立选择,那么理想情况下候选空间大小是:
对应的理想熵为:
例如,长度 8、字符集大小 62 的令牌,其理想熵约为:
而 32 个随机字节的熵上限是:
但这只是随机输入的理论上限。若使用时间戳、用户 ID、递增序列或可推断的种子,实际熵会远低于表面长度。
2. 碰撞概率和生日问题
即使令牌不可预测,也可能发生碰撞。设令牌空间大小为 ,生成 个令牌。至少一次碰撞的概率近似为:
当 时,还可以近似为:
这解释了为什么 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)
这会生成 000000 到 999999 之间的等概率验证码。理论空间只有:
也就是约 20 位熵:
因此它不能只依靠“随机”获得安全性,还需要:
- 较短的有效期;
- 尝试次数限制;
- 失败锁定或逐步延迟;
- 与用户或会话绑定;
- 成功后立即失效;
- 不在响应中返回验证码;
- 不在普通日志中记录明文验证码。
如果攻击者每秒可以尝试 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. 哈希是公开函数,不提供秘密认证
密码学哈希可以表示为:
其中:
- 是消息;
- 是哈希函数;
- 是固定长度摘要。
任何知道消息的人都可以计算摘要,因此单独使用哈希只能帮助检测数据是否变化,不能证明数据来自某个特定发送者。
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 可以抽象为:
其中:
- 是秘密密钥;
- 是消息;
- 是底层哈希函数;
- 输出是消息认证码。
示例:
import hashlib
import hmac
import secrets
key = secrets.token_bytes(32)
message = b"amount=100¤cy=CNY"
tag = hmac.new(
key,
message,
hashlib.sha256,
).digest()
received_message = b"amount=100¤cy=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,使接收方能够验证:
- 消息内容未被修改;
- 发送方拥有共享密钥。
但 HMAC 本身不负责:
- 协商密钥;
- 发现对端身份;
- 防止重放;
- 建立加密通道;
- 管理证书;
- 处理密钥轮换。
因此消息中通常还需要加入时间戳、随机数或单调递增序列,并在服务端记录已处理请求。例如:
然后计算:
服务端不仅要校验 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)
十二、可运行示例:实现一个一次性重置令牌
下面示例演示一个简化的令牌生命周期:
- 生成随机令牌;
- 只把令牌摘要保存到数据库;
- 通过链接把原令牌交给用户;
- 验证时重新计算摘要;
- 使用常量时间比较;
- 成功后立即删除或标记为已使用。
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
这个示例中,数据库不保存原始令牌,而保存:
攻击者即使读到数据库,也不能直接把摘要放进重置链接中作为原令牌。验证时,服务端对用户提交的令牌执行同样的哈希,再使用 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 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python Decimal 与 Fraction:精度、舍入、上下文和金融计算
- 下一篇:Python 子进程与信号:参数传递、管道、超时、退出和回收
- 延伸:Python 数值类型:int、float、complex、bool 与运算边界
- 延伸:Python 哈希、HMAC 与 TLS:完整性、密码存储、证书和误区
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论