土法炼钢兴趣小组的算法知识备份

【密码学百科】随机性:密码学的基石

目录

在密码学的整个体系中,存在一个最容易被忽视却又最为致命的单点故障:随机数的质量。你可以使用最先进的加密算法、最严格的协议设计、最完善的安全证明,但如果你的随机数生成器有缺陷,一切都将土崩瓦解。这不是理论上的隐忧——从 Debian OpenSSL 灾难到 PlayStation 3 的私钥泄露,历史上每一次随机数失败都带来了灾难性的后果。本文将系统地探讨密码学中随机性的本质、生成机制、工程实践与历史教训,为理解后续文章中的密钥生成、数字签名和协议设计奠定关键基础。

一、随机性为什么对密码学如此重要

密码学中几乎所有操作都离不开随机数。密钥生成(Key Generation)需要随机数——AES-256 的密钥就是 256 比特的随机数据,RSA 密钥对的生成始于两个随机选取的大素数。初始化向量(IV,Initialization Vector)需要随机数——CBC 模式要求每次加密使用不同的随机 IV,否则相同的明文将产生相同的密文,泄露信息。临时数(Nonce)需要随机数——ECDSA 签名中的 k 值必须是高质量的随机数,否则私钥可以被直接计算出来。盐值(Salt)需要随机数——密码哈希中的盐必须随机生成,以防止彩虹表攻击和预计算攻击。密钥协商协议中的临时私钥、TLS 握手中的 Client Random 和 Server Random、零知识证明中的随机挑战值……随机数渗透在密码系统的每一个角落。

为什么随机性如此关键?因为密码学的安全性在本质上依赖于攻击者的无知——攻击者不知道密钥、不知道 nonce、不知道内部状态。而随机性正是制造这种”无知”的唯一手段。如果攻击者能够预测随机数生成器的输出,他就能预测密钥、预测 nonce、预测一切秘密值,整个密码系统的安全假设就不再成立。

考虑一个具体的场景:假设你使用 AES-256-GCM 加密通信,但你的随机数生成器实际上只有 20 比特的有效熵。攻击者不需要暴力搜索 2^256 个可能的密钥,他只需要搜索 2^20——大约一百万个可能性,一台普通笔记本电脑在一秒钟之内就能完成。算法本身的强度毫无意义,因为薄弱环节在随机数上。

这就是密码学中的”木桶原理”的极端体现:系统的安全性由最薄弱的环节决定,而随机数生成往往正是那块最短的板。

二、真随机与伪随机

理解随机性,首先需要区分两个根本不同的概念:真随机数(True Random Numbers)和伪随机数(Pseudorandom Numbers)。

真随机数生成器(TRNG,True Random Number Generator)从物理世界的不可预测现象中提取随机性。这些现象包括放射性衰变的时间间隔、电路中的热噪声(Johnson-Nyquist Noise)、光子通过半透镜后的路径选择、大气中的电磁噪声等。真随机性的理论基础来自量子力学——在量子层面,某些物理过程的结果在原理上是不可预测的,不是因为我们的知识不足,而是因为宇宙本身尚未”决定”结果。这是与经典物理确定论的根本分歧。

然而,真随机数生成器有几个实际问题。首先,速率通常很低——物理熵源的采集速度远远跟不上现代系统对随机数的需求。一个繁忙的 Web 服务器每秒可能需要为成千上万的 TLS 连接生成密钥材料,纯物理 TRNG 难以满足这种吞吐量。其次,物理设备可能存在偏差(bias)和相关性,原始输出并不是均匀分布的,需要后处理才能使用。第三,硬件可能退化或被物理篡改,需要持续的健康检测。

伪随机数生成器(PRNG,Pseudorandom Number Generator)则是一种确定性算法:给定一个初始种子(seed),它通过数学运算产生一个看起来”随机”的序列。严格来说,PRNG 的输出完全由种子决定,不包含任何真正的随机性。但如果算法设计得当且种子保密,攻击者在没有种子的情况下无法区分 PRNG 的输出与真随机序列。

这里需要特别区分两种不同层次的”随机性”。统计随机性(Statistical Randomness)是指序列通过了各种统计测试——频率测试、游程测试、自相关测试等——看起来没有明显的模式或偏差。NIST SP 800-22 标准定义了一套包含 15 种测试的统计测试集,用于评估序列的随机性质量,包括频率测试(Frequency Test)、块内频率测试(Block Frequency Test)、游程测试(Runs Test)、最长游程测试(Longest Run Test)、离散傅里叶变换测试(DFT Test)等。

然而,统计随机性对密码学来说远远不够。密码学随机性(Cryptographic Randomness)要求的是计算不可预测性——即使攻击者已经看到了序列中的前 n 个比特,他也无法以显著高于 1/2 的概率预测第 n+1 个比特。这是一个比统计随机性强得多的要求。

一个经典的例子是线性同余生成器(LCG,Linear Congruential Generator),其递推公式为 X_{n+1} = (aX_n + c) mod m。LCG 可以通过很多统计测试,看起来”很随机”,但只要攻击者观察到几个连续的输出值,就可以通过求解线性方程组完全恢复内部状态,从而预测所有未来的输出。同样,著名的梅森旋转算法(Mersenne Twister,MT19937)拥有 2^19937 - 1 的超长周期和优秀的统计性质,被广泛用于科学模拟和游戏开发,但它不是密码学安全的——攻击者只需要观察 624 个连续的 32 位输出就能完全恢复内部状态。

三、密码学安全伪随机数生成器(CSPRNG)

密码学安全伪随机数生成器(CSPRNG,Cryptographically Secure Pseudorandom Number Generator)是专为密码学应用设计的 PRNG,满足两个核心安全性质。

第一个性质是下一比特不可预测性(Next-bit Unpredictability):给定 CSPRNG 输出序列的前 k 个比特,不存在多项式时间算法能以显著高于 1/2 的概率预测第 k+1 个比特。这保证了攻击者即使获得了部分输出也无法推断后续输出。

第二个性质是与真随机的不可区分性(Indistinguishability from True Random):不存在多项式时间算法能够以不可忽略的优势区分 CSPRNG 的输出与等长度的真随机序列。这是一个更强的性质,它意味着 CSPRNG 的输出在计算意义上与真随机”一样好”。

Yao 在 1982 年证明了这两个性质实际上是等价的——满足下一比特不可预测性的生成器一定满足不可区分性,反之亦然。这个等价性定理为 CSPRNG 的安全性评估提供了坚实的理论基础。

相比之下,普通 PRNG 如 Mersenne Twister 虽然统计性质优秀,但不满足这两个性质。如前所述,MT19937 仅需 624 个 32 位输出就可以被完全破解。在密码学中使用 Mersenne Twister 就像用纸锁保护金库——形式上是一把锁,实质上没有任何保护。

NIST SP 800-90A 标准机制

NIST SP 800-90A 标准(Recommendation for Random Number Generation Using Deterministic Random Bit Generators)定义了三种经过审查的 DRBG(Deterministic Random Bit Generator,确定性随机比特生成器)机制。

CTR_DRBG 基于分组密码的计数器模式。其核心思想是将一个分组密码(如 AES-256)用作伪随机函数:维护一个密钥 K 和一个计数器 V,每次生成随机比特时递增 V 并计算 AES_K(V) 作为输出。定期使用新的随机数据更新 K 和 V(这个过程叫做 reseed,重新播种),以提供前向保密性。CTR_DRBG 的安全性归约到底层分组密码的安全性——如果 AES 是一个安全的伪随机置换(PRP),那么 CTR_DRBG 就是一个安全的 CSPRNG。由于 AES 在现代处理器上有硬件加速指令(AES-NI),CTR_DRBG 的性能非常出色。

HMAC_DRBG 基于 HMAC(Hash-based Message Authentication Code)。它维护一个密钥 K 和一个值 V,通过反复计算 V = HMAC_K(V) 来产生输出,并使用额外的输入来更新 K 和 V。HMAC_DRBG 的安全性归约到底层哈希函数的安全性。相比 CTR_DRBG,HMAC_DRBG 的一个优势是它天然支持额外输入(additional input),可以在每次调用时混入新的熵,这在某些场景下提供了更好的安全边际。

Hash_DRBG 直接基于哈希函数构建。它维护一个内部种子值,通过哈希链的方式产生输出。Hash_DRBG 的设计较为复杂,涉及多次哈希迭代和状态更新,但其安全性同样归约到底层哈希函数的抗碰撞性和单向性。

Dual_EC_DRBG 丑闻

在这三种标准机制之外,NIST SP 800-90A 最初还包含了第四种机制:Dual_EC_DRBG,基于椭圆曲线上的离散对数问题。2007 年,密码学家 Dan Shumow 和 Niels Ferguson 在 CRYPTO 会议上的非正式报告中指出,Dual_EC_DRBG 标准中指定的两个椭圆曲线点 P 和 Q 之间可能存在一个只有 P 和 Q 的选择者才知道的特殊关系——如果知道这个关系(即 Q = eP 中的标量 e),就可以从少量输出中恢复内部状态,从而预测所有未来的输出。

2013 年,Edward Snowden 泄露的 NSA 文件证实了这一怀疑:NSA 确实在 Dual_EC_DRBG 中植入了后门,并通过与 RSA Security 公司的秘密协议使其成为 RSA BSAFE 库的默认随机数生成器。这一事件严重动摇了业界对标准化过程的信任,NIST 随后将 Dual_EC_DRBG 从标准中移除。这个丑闻告诉我们:即使是经过标准化的密码学组件也可能被蓄意破坏,透明和公开审查是密码学设计中不可或缺的原则。

Fortuna 与 Yarrow

除了 NIST 标准之外,学术界和工业界还提出了其他重要的 CSPRNG 设计。

Yarrow 算法由 Bruce Schneier、John Kelsey 和 Niels Ferguson 于 1999 年提出,曾被 macOS 和 FreeBSD 用作系统随机数生成器。Yarrow 引入了”熵池”(Entropy Pool)的概念——维护两个池(快池和慢池),从多个熵源收集随机数据,使用哈希函数压缩后生成种子。快池用于频繁的重新播种,慢池用于在系统熵积累足够后提供更保守的安全保证。

Fortuna 是 Yarrow 的继任者,由 Niels Ferguson 和 Bruce Schneier 在 2003 年出版的《Practical Cryptography》一书中提出。Fortuna 最重要的改进是将熵池数量从 2 个扩展到 32 个,并使用了一种精巧的重新播种调度策略:第 i 个池参与第 n 次重新播种,当且仅当 2^i 整除 n。这意味着池 0 参与每次重新播种,池 1 每两次参与一次,池 2 每四次参与一次……依此类推。这种设计确保了即使攻击者完全掌控了大部分熵源,系统也能在有限次重新播种后恢复安全状态——这就是 Fortuna 的核心安全保证。macOS 和 FreeBSD 后来都从 Yarrow 迁移到了 Fortuna。

四、操作系统的熵源与随机数架构

理论上的 CSPRNG 需要操作系统层面的基础设施来落地。现代操作系统都提供了精心设计的随机数子系统,负责从硬件收集熵、维护熵池、并通过 CSPRNG 向用户空间提供高质量的随机数。

Linux 随机数架构

Linux 的随机数子系统是最被广泛研究和讨论的。历史上,Linux 提供了两个随机设备:/dev/random/dev/urandom。传统上,/dev/random 维护一个”熵估计”计数器,当估计的可用熵耗尽时会阻塞读取操作;而 /dev/urandom(unblocked random)则永不阻塞,即使在熵池被认为”耗尽”的情况下也会持续产生输出。

这个设计引发了长达二十年的”random vs urandom”争论。一派认为 /dev/random 的阻塞行为是正确的——如果熵不够,就应该等待;另一派则认为熵估计本身就是一个有缺陷的概念——一旦 CSPRNG 被正确初始化(即收集了足够的初始熵),其输出在计算上与真随机不可区分,继续追踪熵消耗毫无意义。

现代共识支持后者。Linux 内核从 5.6 版本开始,/dev/random 的行为发生了根本性改变:它不再在 CSPRNG 初始化之后阻塞,其行为与 /dev/urandom 基本一致。唯一的区别是在系统启动早期、CSPRNG 尚未收集到足够初始熵时,/dev/random 会阻塞直到初始化完成。

现代 Linux 内核(5.17 以后)的随机数架构基于 ChaCha20 流密码。系统维护一个全局的 CRNG(Cryptographic Random Number Generator)状态,使用 ChaCha20 作为核心生成算法。熵从多个来源汇入输入池:中断时间戳(interrupt timing jitter)、磁盘 I/O 完成时间、键盘和鼠标事件的精确时间戳、以及 CPU 的抖动噪声(jitter entropy)。输入池中的数据通过 BLAKE2s 哈希函数压缩后,用于初始化和定期重新播种 ChaCha20 CRNG。

Linux 提供了 getrandom() 系统调用作为获取随机数的推荐接口。与读取 /dev/urandom 不同,getrandom() 默认会在 CRNG 初始化完成之前阻塞,确保调用者永远不会获得来自未充分播种的生成器的输出。这解决了启动早期 /dev/urandom 可能输出低质量随机数的历史问题。

硬件随机数生成器

Intel 从 Ivy Bridge 架构(2012 年)开始在 CPU 中集成了 RDRAND 和 RDSEED 指令。RDRAND 从一个基于硬件熵源的 CSPRNG 中读取随机数,而 RDSEED 则直接从硬件熵源读取,适用于为软件 CSPRNG 提供种子。

Linux 内核将 RDRAND 的输出作为熵源之一混入输入池,但不会将其作为唯一的熵源。这是一个审慎的设计决策——RDRAND 的内部实现是不透明的,用户无法验证其输出是否真正随机,也无法排除硬件后门的可能性。将其与其他独立的熵源混合使用,确保了即使 RDRAND 被完全破坏,系统的随机数质量也不会受到致命影响。

Windows 与 macOS

Windows 通过 BCryptGenRandom() API(取代了旧的 CryptGenRandom())提供密码学安全的随机数。其内部实现基于 AES-CTR_DRBG,从多种硬件和软件熵源收集初始种子。

macOS 通过 SecRandomCopyBytes() API(Security 框架)和 /dev/urandom 提供随机数。其内部实现基于 Fortuna 算法,从硬件中断、用户输入、设备传感器等多种来源收集熵。Apple 的安全隔离处理器(Secure Enclave)还提供了独立的硬件 TRNG,为最敏感的密码学操作提供额外的随机性保证。

跨平台随机数接口速查

下表汇总了主流平台的密码学安全随机数接口、底层实现机制及其关键特性,便于工程师在跨平台开发中快速定位正确的调用方式。

平台 推荐接口 底层 CSPRNG 主要熵源 启动阶段行为
Linux(5.17+) getrandom() 系统调用 ChaCha20-CRNG(BLAKE2s 播种) 中断 jitter、磁盘 I/O、RDRAND/RDSEED 初始化前阻塞(getrandom 默认),/dev/urandom 不阻塞
macOS / iOS SecRandomCopyBytes() Fortuna(AES-CTR) 硬件中断、传感器、Secure Enclave TRNG 系统启动由 Secure Enclave 提供初始熵,阻塞窗口极短
Windows(10+) BCryptGenRandom() AES-CTR_DRBG TPM、中断时间戳、RDRAND 内核启动时从 TPM 和保存的种子文件注入初始熵
嵌入式 / IoT 因平台而异(无统一标准) 通常为轻量 DRBG 或裸 TRNG 硬件 TRNG(若有),否则极有限 熵饥饿(entropy starvation)风险最高:无键盘、无磁盘、启动环境高度确定

RDRAND 信任争议

Intel RDRAND 指令自 2012 年(Ivy Bridge)起内置于 CPU,提供了硬件级别的随机数输出。然而,由于其内部实现完全封闭——用户无法审计数字随机数发生器(DRNG)的电路设计——RDRAND 自诞生之日起就伴随着信任争议。

2013 年 Snowden 泄露的 NSA 文件揭示了情报机构在密码学组件中植入后门的意图(Dual_EC_DRBG 即为先例),这进一步加剧了对 RDRAND 的质疑:如果 Intel 被要求(或自愿)在 RDRAND 的内部 CSPRNG 中引入可预测性,外部观察者将无从发现。

Linux 内核社区围绕 RDRAND 曾发生过一场重要辩论。2019 年,有开发者提议将 RDRAND 作为内核 CRNG 的唯一熵源以加速启动,但遭到了包括 Theodore Ts’o 在内的核心维护者的强烈反对。最终的共识是:RDRAND 的输出被混入熵池作为众多来源之一,但绝不作为唯一来源。即使 RDRAND 被完全破坏或产生恒定输出,只要其他独立熵源(中断 jitter、设备噪声等)正常工作,系统的随机数质量就不会受到致命影响。这一「不信任任何单一来源」的设计哲学是操作系统级随机数架构的核心原则。

从工程实践来看,启动阶段是任何密码系统最危险的时刻。在内核刚开始执行、中断尚未大量发生、用户输入为零、磁盘 I/O 尚未开始的那几秒钟里,熵池几乎是空的。嵌入式设备的情况更为严峻——许多 IoT 设备没有键盘、没有磁盘、没有用户交互,其硬件环境在每次上电后几乎完全确定。笔者认为,启动时刻的熵饥饿是密码系统中最危险却最容易被忽视的单点故障。2012 年 Heninger 等人对互联网上大量 TLS 和 SSH 主机密钥的大规模调查证实了这一点——他们发现数以万计的设备因在启动早期生成密钥时熵不足,导致不同设备之间共享了相同的素因子,私钥可以被轻松分解。这一教训说明:对于任何在启动阶段需要生成密钥的系统,要么确保有可靠的硬件 TRNG,要么使用 getrandom() 这类会在 CSPRNG 就绪前阻塞的接口——宁可启动慢几秒,也不能用未充分播种的随机数生成密钥。

五、熵的度量与收集

(Entropy)在信息论中是不确定性的度量。Shannon 熵的定义为:

H(X) = -sum(p(x) * log2(p(x)))

其中求和遍历随机变量 X 的所有可能取值,p(x) 是每个值出现的概率。对于一个均匀分布在 n 个值上的随机变量,Shannon 熵为 log2(n) 比特;对于一个完全确定(总是同一个值)的随机变量,Shannon 熵为 0。

然而,在密码学中,Shannon 熵并不是最合适的度量。考虑一个分布:以 99.99% 的概率输出 0,以 0.01% 的概率均匀选择 1 到 2^128 之间的某个值。这个分布的 Shannon 熵相当高(约 12.8 比特),但在密码学意义上它几乎毫无用处——攻击者只需要猜测 0 就能以 99.99% 的概率猜对。

最小熵(Min-entropy)是一个更保守的度量,定义为:

H_min(X) = -log2(max(p(x)))

即取概率最大的那个值,度量”最容易被猜中”的概率。在上面的例子中,最小熵几乎为零(-log2(0.9999) 约等于 0.000144 比特),正确地反映了这个分布在密码学上的脆弱性。NIST SP 800-90B 标准要求使用最小熵(而非 Shannon 熵)来评估熵源的质量。

熵的收集面临几个严峻挑战。首先是估计难题:给定一个熵源的输出序列,如何准确估计其中包含多少熵?熵是关于分布的性质,不是关于特定序列的性质。你不能通过观察一个序列来精确地确定产生它的分布有多少熵。NIST SP 800-90B 提供了一系列非参数化的最小熵估计方法,但这些方法都只是近似值。

其次是嵌入式设备的熵饥饿(Entropy Starvation)。服务器和桌面系统有丰富的熵源——用户输入、磁盘 I/O、网络中断等,但嵌入式设备和物联网(IoT)设备往往缺乏这些熵源。一个没有用户交互、没有磁盘、网络行为高度可预测的设备,从何处获取熵?这是一个至今仍未完全解决的实际问题。一些解决方案包括使用 CPU 抖动熵源(如 Jitterentropy 库)、在出厂时预注入随机种子、或者使用专用的硬件熵源芯片。

第三是启动时随机性问题(Boot-time Randomness)。操作系统在启动的最初阶段还没有收集到足够的熵,但某些服务可能已经需要随机数——例如生成用于网络协议的随机标识符。Linux 的解决方案是在关机时将随机种子保存到文件中,下次启动时读回。systemd-random-seed 服务就负责这个工作。但这对于首次启动的全新系统无效,这也是 getrandom() 默认阻塞直到 CRNG 初始化完成的原因之一。

虚拟机面临特殊的挑战:多个虚拟机可能从相同的快照恢复,导致它们的 CRNG 处于完全相同的状态。VirtIO-RNG(virtio-rng)协议允许虚拟机从宿主机的随机数设备中获取熵,解决了虚拟化环境下的熵饥饿问题。此外,现代虚拟化平台通常会在 VM 恢复快照后主动重新播种 CRNG。

健康测试(Health Tests)是确保熵源正常工作的关键机制。NIST SP 800-90B 定义了两类健康测试:启动测试(Startup Tests)在熵源初始化时执行,验证其基本功能;持续测试(Continuous Tests)在运行过程中不断监控输出,检测退化或故障。如果健康测试失败,系统应立即停止使用该熵源并发出告警。

六、随机数失败的灾难

理论上的风险在历史上多次变成了现实。以下案例深刻地揭示了随机数失败的后果。

Debian OpenSSL 灾难(2006-2008)

2006 年,一位 Debian 维护者在清理编译器警告时,注释掉了 OpenSSL 随机数生成器中两行关键代码。这两行代码负责将未初始化的内存缓冲区混入熵池——Valgrind 将其标记为使用了”未初始化的内存”,维护者认为这是一个 bug 并将其移除。然而,这两行代码是 OpenSSL 收集熵的主要来源之一。移除它们后,OpenSSL 的 PRNG 唯一的熵来源变成了进程 ID(PID)。

Linux 系统的 PID 范围默认为 1 到 32768。这意味着在这两年间,所有 Debian 及其衍生发行版(包括 Ubuntu)上生成的所有 SSL/TLS 密钥、SSH 密钥和 GPG 密钥实际上只有大约 32768 种可能。攻击者可以在几分钟内枚举所有可能的密钥。受影响的不仅是那两年内新生成的密钥——任何使用这些密钥建立的”安全”连接都可以被解密,任何使用这些密钥认证的 SSH 登录都可以被伪造。

PlayStation 3 ECDSA 签名破解

2010 年,黑客组织 fail0verflow 揭露了 Sony 在 PlayStation 3 上使用 ECDSA 签名时的致命错误。ECDSA 算法要求每次签名使用一个全新的随机数 k。Sony 的实现使用了一个固定的 k 值——可能是为了使签名可复现。

回忆 ECDSA 签名 (r, s) 的计算:s = k^(-1) * (hash(m) + r * privateKey) mod n。如果两次签名使用了相同的 k,攻击者可以通过两个签名方程联立消去 k,直接求解出私钥。Sony 的情况更为极端——k 是一个常量,攻击者仅凭一个签名就可以在知道 k 的情况下直接计算出私钥。结果是 PS3 的代码签名私钥被完全恢复,任何人都可以签署并在 PS3 上运行自制软件。

Android SecureRandom 漏洞(2013)

2013 年,安全研究人员发现 Android 系统的 Java SecureRandom 类在某些设备上由于初始化不当,并没有被正确播种。底层的 OpenSSL PRNG 没有从 /dev/urandom 获取足够的种子熵。这个漏洞导致比特币钱包应用生成了可预测的 ECDSA 签名 k 值,攻击者据此窃取了大量比特币。Google 随后发布了紧急补丁,但已经有用户遭受了不可逆的经济损失。

Cryptocat 漏洞

Cryptocat 是一个流行的加密聊天应用,旨在为用户提供端到端加密。2013 年的安全审计发现,其 JavaScript 实现在生成群聊密钥时使用了 Math.random() 而非密码学安全的随机源。Math.random() 的实现(在当时的浏览器中通常基于 xorshift128+)是完全可预测的,这意味着 Cryptocat 的加密在实质上形同虚设——任何掌握了足够输出值的攻击者都可以恢复内部状态并解密所有群聊消息。

台湾公民数字证书 RSA 密钥分解

2013 年,研究人员分析了大量台湾公民数字证书中的 RSA 公钥,发现其中相当比例的密钥共享了相同的素因子。当两个 RSA 模数 N1 = p * q1 和 N2 = p * q2 共享一个素因子 p 时,攻击者只需计算 gcd(N1, N2) = p 就能分解两个模数,进而恢复私钥。这种”共享素数”现象的根本原因是密钥生成时使用的随机数生成器熵不足,导致不同的智能卡在生成素数时使用了部分重叠的随机状态。

教训

这些案例共同揭示了几个深刻的教训。第一,随机数失败通常是沉默的——系统看起来运行正常,加密看起来在”工作”,但安全性已经荡然无存。不同于其他类型的 bug,随机数 bug 不会导致程序崩溃或产生可见的错误。第二,随机数 bug 的影响往往是追溯性的——不仅是未来的操作受到影响,过去使用了有缺陷随机数生成的所有密钥和签名都需要被视为已泄露。第三,代码审查和自动化测试很难发现随机数问题——随机数生成器的输出看起来总是”随机的”,你无法通过观察输出来判断其熵是否足够。

笔者认为,PlayStation 3 的 ECDSA 密钥恢复事件(fail0verflow,2010)是密码学史上最具戏剧性的随机数失败案例,因为它将一个看似抽象的数学要求——“每次签名使用不同的随机 k”——转化为一个任何人都能理解的后果:整个 PS3 平台的代码签名私钥从一个固定的 nonce 中被直接算出。不是通过暴力搜索,不是通过侧信道攻击,而是通过初中代数——两个方程两个未知数,联立求解。Sony 的工程师可能认为使用固定 k 可以使签名可复现、便于测试,但这个”便利”的代价是整个安全模型的彻底崩溃。这一事件之所以值得反复强调,是因为它完美地示范了密码学中的一条铁律:算法的安全性证明中每一个看似无关紧要的前提条件,在现实中都可能是一颗地雷。

一个值得深思的现象是,随机性是密码学所有原语中唯一一个”失败在使用点完全不可见”的原语。加密算法如果实现错误,输出的密文可能无法正确解密——错误会在解密端暴露。认证算法如果实现错误,MAC 校验会失败——错误会在验证端暴露。但随机数生成器如果输出了低熵的伪随机序列,产生的密钥看起来仍然是 256 比特的十六进制字符串,加密和解密仍然正常工作,签名和验证仍然匹配——一切都”正常”,只是安全性已经荡然无存。这种沉默的失败模式意味着,你不能依赖运行时检测来发现随机数问题,必须在架构层面做出正确的选择。这也是为什么确定性签名(如 EdDSA)和确定性密钥派生(如 HKDF)在现代密码学中越来越受青睐——它们通过消除对运行时随机数的依赖,将”每次都要正确”的要求转化为”实现时一次正确”的要求。

七、可验证随机函数与随机信标

在某些应用场景中,我们不仅需要随机性,还需要证明随机性的正确性——即证明输出确实是由特定的输入和密钥通过既定算法产生的,而不是被操纵的结果。

可验证随机函数(VRF,Verifiable Random Function)是一种带有验证机制的伪随机函数。VRF 的持有者拥有一对密钥(sk, pk)。给定输入 x,VRF 使用私钥 sk 计算输出 y = VRF_sk(x) 和证明 pi。任何人都可以使用公钥 pk 验证 (x, y, pi) 的正确性,但不持有 sk 的人无法预测 y 的值——即使他知道 pk 和 x。VRF 同时满足可验证性(verifiability)和伪随机性(pseudorandomness)。

VRF 的典型构造基于椭圆曲线。一种方案是:将输入 x 映射到椭圆曲线上的一个点 H = HashToCurve(x),然后计算 Gamma = sk * H,输出 y = Hash(Gamma),证明 pi 是一个离散对数等式的零知识证明(证明 Gamma 和 pk 使用了相同的标量 sk)。IETF RFC 9381 对基于椭圆曲线的 VRF 构造进行了标准化。

VRF 的应用非常广泛。在 Algorand 区块链的共识协议中,VRF 用于秘密地选择区块提议者和委员会成员——每个节点使用自己的私钥和当前轮次作为输入计算 VRF,只有输出值小于某个阈值的节点被选中。由于 VRF 的伪随机性,其他节点无法提前预测谁将被选中,这防止了针对性的拒绝服务攻击。在 DNSSEC 的 NSEC5 提案中,VRF 用于防止区域枚举攻击(zone enumeration),同时保持对不存在域名的可验证否认应答。

随机信标(Randomness Beacon)是另一种重要的公共随机性基础设施。随机信标周期性地向全世界发布可验证的随机值,任何人都可以审计这些值确实来自合法的随机过程。

NIST 随机信标(NIST Randomness Beacon)自 2013 年起运行,每 60 秒发布一个 512 比特的随机值,同时附带时间戳和数字签名。然而,NIST 信标是中心化的——你必须信任 NIST 不会操纵输出。

drand(distributed randomness)是一个去中心化的替代方案,由 EPFL 的 DEDIS 实验室开发。drand 使用门限 BLS 签名协议,由一组分布在全球各地的独立节点共同生成随机信标。只要不超过阈值数量的节点被攻陷,信标的输出就是不可预测和不可偏置的。League of Entropy 联盟运营着一个公共的 drand 网络,参与者包括 Cloudflare、Protocol Labs 等组织。

公共可验证的随机性在很多场景中都有价值:彩票开奖、选举审计、区块链共识、以及任何需要确保”没有人能操纵结果”的公平性场景。

八、量子随机数生成器

经典的 TRNG 虽然利用了物理现象,但这些现象在经典物理中原则上是确定性的——热噪声是大量粒子运动的统计结果,电路抖动源于复杂但确定的物理过程。真正在原理上不可预测的随机性来自量子力学。

量子随机数生成器(QRNG,Quantum Random Number Generator)利用量子力学的内在随机性来产生真随机数。最常见的实现方式之一是光子束分裂:一个单光子打到半透半反镜上,有 50% 的概率被透射、50% 的概率被反射。通过在两条路径上放置单光子探测器,每个光子事件产生一个真正随机的比特。这种随机性不是因为我们”不知道”光子会去哪里,而是因为在测量之前量子力学甚至没有定义一个确定的结果。

另一种方案基于真空涨落(Vacuum Fluctuations)。即使在完美的真空中,量子电磁场也存在零点能涨落。通过测量这些涨落,可以提取出高带宽的量子随机数,速率可达每秒数 Gbit。

商业 QRNG 产品已经存在。瑞士公司 ID Quantique 是该领域的先驱,其 Quantis 系列 QRNG 芯片已被集成到多种安全产品中。中国的国盾量子等公司也提供了商用 QRNG 设备。三星从 Galaxy A 系列开始在部分智能手机中集成了 QRNG 芯片。

然而,实际的 QRNG 设备面临一个根本性的信任问题:你怎么知道设备内部确实在进行量子过程,而不是在播放一段预先录制的伪随机序列?设备无关 QRNG(Device-independent QRNG)通过 Bell 不等式违反测试来解决这个问题——如果两个纠缠粒子的测量结果违反了 Bell 不等式(CHSH 不等式),就在数学上证明了这些结果不可能由任何经典确定性过程产生,从而保证了随机性的量子本源,无需信任设备的内部实现。这是量子信息理论最深刻的应用之一,尽管目前设备无关 QRNG 的实际部署仍面临技术挑战。

QRNG 与经典系统的集成通常遵循”量子播种、经典扩展”的模式:QRNG 提供高质量的种子熵,注入经典 CSPRNG(如 CTR_DRBG 或 ChaCha20-CRNG)中进行扩展。这结合了量子随机性的理论保证和经典 CSPRNG 的高吞吐量。

九、编程实践:如何正确使用随机数

所有理论最终要落实到代码中。以下是密码学随机数使用的核心原则和实践。

最核心的原则:永远使用操作系统提供的 CSPRNG 接口。 不要自己实现随机数生成器,不要使用标准库中的数学随机函数,不要试图”收集自己的熵”。操作系统的随机数子系统由专家设计、经过广泛审查和实战检验,是你能获得的最可靠的随机数来源。

以下是一个在 Linux 上使用 getrandom() 系统调用获取随机字节的正确示例:

/* 正确示例:使用 getrandom() 获取密码学安全的随机字节 */
#include <stdio.h>
#include <sys/random.h>

int get_random_bytes(void *buf, size_t len) {
    size_t total = 0;
    while (total < len) {
        /*
         * getrandom(flags=0) 从 urandom 源读取,
         * 但在 CRNG 初始化完成前会阻塞,确保输出质量。
         * 单次调用最多返回 256 字节,因此需要循环。
         */
        ssize_t ret = getrandom((char *)buf + total, len - total, 0);
        if (ret < 0) {
            if (errno == EINTR)
                continue;   /* 被信号中断,重试 */
            perror("getrandom");
            return -1;
        }
        total += (size_t)ret;
    }
    return 0;
}

int main(void) {
    unsigned char key[32]; /* 256-bit 密钥 */
    if (get_random_bytes(key, sizeof(key)) != 0)
        return 1;

    printf("随机密钥(十六进制):");
    for (size_t i = 0; i < sizeof(key); i++)
        printf("%02x", key[i]);
    printf("\n");
    return 0;
}

下面是一个 CTR_DRBG 的概念性简化实现,用于理解其工作原理(实际生产中应使用操作系统或经过审计的库实现):

/*
 * CTR_DRBG 概念示意(简化版,仅用于理解原理)
 * 实际生产代码请使用 OpenSSL 的 RAND_bytes() 或操作系统接口
 */
#include <stdint.h>
#include <string.h>

/* 假设存在一个 AES-256 加密函数 */
extern void aes256_encrypt(const uint8_t key[32],
                           const uint8_t in[16],
                           uint8_t out[16]);

typedef struct {
    uint8_t key[32];   /* AES-256 密钥 */
    uint8_t V[16];     /* 128-bit 计数器 */
} ctr_drbg_state;

/* 计数器自增 */
static void increment_counter(uint8_t ctr[16]) {
    for (int i = 15; i >= 0; i--) {
        if (++ctr[i] != 0)
            break;
    }
}

/* 生成随机字节 */
int ctr_drbg_generate(ctr_drbg_state *state,
                      uint8_t *out, size_t out_len) {
    size_t offset = 0;
    while (offset < out_len) {
        increment_counter(state->V);

        uint8_t block[16];
        aes256_encrypt(state->key, state->V, block);

        size_t chunk = out_len - offset;
        if (chunk > 16) chunk = 16;
        memcpy(out + offset, block, chunk);
        offset += chunk;
    }
    /*
     * 关键步骤:生成输出后更新内部状态(key 和 V),
     * 提供前向保密性——即使当前状态泄露,
     * 攻击者也无法恢复之前的输出。
     * 此处省略 update 逻辑以保持简洁。
     */
    return 0;
}

最后,展示一个反面教材——在密码学场景中错误地使用 rand() 生成密钥:

/*
 * !! 严重错误 !! 绝对不要在密码学中使用 rand()
 * 此代码仅作反面教材展示
 */
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void insecure_keygen(unsigned char *key, int len) {
    /*
     * 致命缺陷 1: srand(time(NULL)) 的种子只有约 31 比特熵
     *            (UNIX 时间戳的精度为秒),攻击者只需猜测
     *             程序运行的大致时间即可大幅缩小搜索空间。
     *
     * 致命缺陷 2: rand() 通常是线性同余生成器(LCG),
     *             即使种子未知,观察少量输出即可恢复内部状态。
     *
     * 致命缺陷 3: rand() 的输出范围通常只有 0 到 RAND_MAX
     *             (某些平台仅为 2^15 - 1),远小于一个字节的
     *             256 种可能值,进一步降低了有效熵。
     */
    srand((unsigned int)time(NULL));
    for (int i = 0; i < len; i++) {
        key[i] = (unsigned char)(rand() % 256);
    }
}

int main(void) {
    unsigned char key[32];
    insecure_keygen(key, 32);
    /* 这个"密钥"最多只有 ~31 比特的有效熵,形同虚设 */
    printf("不安全的密钥:");
    for (int i = 0; i < 32; i++)
        printf("%02x", key[i]);
    printf("\n");
    return 0;
}

各编程语言中获取密码学安全随机数的正确方式:

最后一点:随机数与密钥派生之间存在密切的关系。在后续关于密钥派生函数(KDF)的文章中,我们将讨论如何从随机种子材料中安全地派生出多个密钥。而在数字签名的文章中,我们将看到确定性签名方案(如 EdDSA 和 RFC 6979 中的确定性 ECDSA)如何通过消除签名过程中对随机数的依赖来规避随机数失败的风险——这正是从 PlayStation 3 事件中吸取的教训。


密码学百科系列 · 第 04 篇

← 上一篇:Kerckhoffs 原则 | 系列目录 | 下一篇:信息论入门


By .