第3章 密钥生成:随机数生成、密码学安全随机数

说到密钥生成,我第一个想到的就是随机数。你想想看,如果密钥是用一个可预测的序列生成的,那加密还有什么意义?

有一次我接手一个安全审计项目,结果发现对方用 random.randint() 生成 AES 密钥。当时我就倒吸一口凉气——这等于把保险柜的密码写在门上了。所以,咱们先从最基础的问题讲起:什么样的随机数才算“安全”?

3.1 随机数的本质与分类

我们说的随机数,其实分三种。我把它们整理成了表格,方便对比。

类型 生成方式 特点 适用场景
真随机数 物理噪声(硬件熵源、量子效应) 不可预测、不可重现,但速度慢 种子生成、一次性乱码本
伪随机数 数学算法 + 初始种子 可重现、速度快,但不安全(如线性同余) 模拟、游戏、测试
密码学安全伪随机数 (CSPRNG) 算法 + 高熵种子 + 状态不可逆 不可预测、通过统计测试、抗攻击 密钥生成、IV、随机数挑战

说白了,密钥生成只能用第三类——密码学安全伪随机数生成器(CSPRNG)。

核心原则:生成密钥的随机数必须满足“即使知道之前所有输出,也无法推算下一个输出”。这是信息论安全的基本要求。

3.2 常见的 CSPRNG 实现

实际操作中,你不需要从零写一个 - 操作系统已经提供了。我在项目中常用的几个渠道:

  • /dev/urandom(Linux / macOS):非阻塞,熵池不够时复用已有熵,但仍然是 CSPRNG。我个人习惯用这个,不阻塞,够安全。
  • /dev/random(Linux):阻塞等待足够熵,极端场景下可能卡住。嗯,我建议普通场景用 urandom 就够了。
  • BCryptGenRandom(Windows):Windows 下的标准 CSPRNG,直接调用就好。
  • getrandom() 系统调用:比读文件更底层,速度更快。

Python 里怎么用?看下面:

import os

# 生成16字节(128位)随机数
key_bytes = os.urandom(16)
print(key_bytes.hex())

# Python 3.6+ 推荐用 secrets 模块(专门为安全设计的)
import secrets
key = secrets._bytes(16)   # 128位
print(key.hex())

提示:永远不要用 random 模块生成密钥。它底层是 Mersenne Twister,虽然随机性好,但可以反向推算状态,属于“伪随机”而非“安全随机”。标准库里 secrets 模块就是专门干这个的。

3.3 种子与熵源——根子在这里

CSPRNG 的核心是“种子”——也就是初始状态。种子必须来自真随机源(硬件熵)。操作系统启动时会收集鼠标移动、键盘延迟、磁盘中断等信息,累积到熵池里。

我曾经遇到过一个嵌入式设备,内核熵池初始化时卡在 0 位,结果所有随机数都是全零,密钥当然也是一样的。排查了好久才发现是熵源驱动没加载。

警告:不要自己用 time.time() 或 pid 拼接种子。攻击者可以在毫秒级内猜到你的种子,然后重现你所有的“随机”密钥。

3.4 密钥生成实践——从熵到密钥

完整的流程我用一张图来展示:

密钥生成核心流程 硬件熵源 鼠标、磁盘、量子噪声 熵池 累加熵位 ≥ 阈值 种子提取 SHA-256 压缩 CSPRNG ChaCha20 / AES-CTR 密钥输出 128/256/512 bits ⚠ 关键点 种子必须来自硬件熵池,且每次不同 CSPRNG 内部状态绝不能泄露

你看,这个流程从物理熵源开始,经过熵池积累、种子提取、CSPRNG 状态初始化,最后生成安全的密钥。每一步都环环相扣。

生成一个典型的 AES-256 密钥(32 字节):

import secrets

# AES-256 密钥:32 字节 = 256 位
aes_key = secrets._bytes(32)
print(f"AES-256 Key (hex): {aes_key.hex()}")

# 或者生成可打印的 base64 格式
import base64
aes_b64 = base64.b64encode(aes_key).decode('ascii')
print(f"AES-256 Key (base64): {aes_b64}")

注意:每次运行生成的结果都不同,才说明你的 CSPRNG 工作正常。如果两次生成一样,那一定是种子出了问题。

3.5 常见陷阱 — 避坑指南

我经历过不少事故,下面列出典型的坑,你写代码的时候可以对照着检查:

  • 用 random.getrandbits() 生成密钥 —— random 模块不是 CSPRNG,可以被预测。我曾经审计过一个项目,用这个生成 TLS 私钥,吓得我赶紧让他们重做了。
  • 手动设置种子 —— 比如 random.seed(int(time.time())),在同一秒内生成的所有密钥相同。暴力枚举时间戳就能破解。
  • 复用生成器状态 —— 如果程序 fork 后子进程共享了 CSPRNG 状态,可能会输出相同随机数。记得手动重新播种或使用独立实例。
  • 在容器/VM 快照后未更新熵 —— 从镜像启动的虚拟机会继承相同的熵池,导致种子重复。记得在启动脚本中主动写入随机数据到 /dev/urandom。

懒人建议:直接用 secrets._bytes(),底层会调用操作系统的 CSPRNG,你不需要自己管理种子和熵。对于生产环境,这个就足够好了。

3.6 一个典型的“坏代码”vs“好代码”

我给你看两个对比,你自己体会一下差别。

# ❌ 错误做法(不要学我)
import random
key = bytes([random.randint(0, 255) for _ in range(16)])   # 线性同余,可预测!

# ✅ 正确做法(照这个来)
import os
key = os.urandom(16)   # 底层调用 CSPRNG

我记得有一次帮朋友排查一个“加密后数据能被解密”的 bug,结果发现他们用了第一种方式——因为 random 模块默认种子是固定的(Python 启动时取的当前时间+进程ID),攻击者可以轻松反向模拟出同一批密钥。最后我们全部换了 os.urandom,再也没出过问题。

3.7 本章要点速览

如果你记不住整章,记住这几条就够了:

  • 密钥生成必须使用密码学安全伪随机数生成器(CSPRNG)。
  • 种子必须来自硬件熵池,不能使用时间戳、PID 等简单源。
  • Python 里直接用 os.urandom 或 secrets 模块,别碰 random。
  • 容器/虚拟化环境注意熵池状态,避免快照复用。

好了,密钥生成这关就算过了。下一关咱们聊聊密钥的存储与生命周期管理。先不说那个,你先消化随机数这块——弄懂它,后面所有安全设计就稳了一半。