抖动 (Jitterentropy):基本配置

Jitterentropy 库由 Stephan Mueller 编写,在 https://github.com/smuellerDD/jitterentropy-library 上提供,在 http://www.chronox.de/jent.html 上提供记录。在 Zircon 中,它用作简单的熵源来为系统 CPRNG 播种种子。

本文档介绍并分析了抖动熵的两个(独立)配置选项:

  1. 是否在噪声生成函数中使用可变的伪随机迭代次数。
  2. 是否使用抖动熵的内部处理例程对原始噪声样本进行后处理。

我之所以考虑这些基本配置选项,是因为这会影响抖动熵使用的基本过程。我将其与可调参数进行了对比(例如,如果循环计数不是伪随机选择,则为循环计数的精确值,或者抖动熵在内部使用的暂存内存的大小),因为可调参数不会对抖动熵收集熵的平均值造成很大影响,而只是对其收集量和所花的时间产生了很大影响。

我的结论列于本文档结尾,但总而言之,我认为我们应避免既选择伪随机迭代数字,也应避免使用抖动熵后处理数据。

抖动熵的简要说明

作者的文档采用 HTML 格式(网址为 http://www.chronox.de/jent/doc/CPU-Jitter-NPTRNG.html)或 PDF 格式(网址为 http://www.chronox.de/jent/doc/CPU-Jitter-NPTRNG.pdf)。简而言之,该库会从 CPU 指令时间的变化中收集随机位。

抖动熵可以保持 64 位数字形式的随机状态,该状态受到许多抖动熵函数的影响,并最终用作输出随机性。

有两个噪声源,这两个噪声源都是运行相对缓慢的代码块,其精确的运行时测量(使用系统时钟,需要大约纳秒的分辨率)。完成这些代码块的确切时间将有所不同。我们会测试这些时间,以确保其不可预测;虽然我们无法完全确定这些时间,但测试结果(包括下面的结果)令人鼓舞。但请注意,本文档的目的不是证明我们对抖动样本中最小熵的估算,而是为了讨论上面列出的两个配置选项。

用作噪声源的第一个代码块是 CPU 密集型 LFSR 循环,在 jent_lfsr_time 函数中实现。LFSR 逻辑的重复次数由 kernel.jitterentropy.ll cmdline 控制(“ll”代表“LFSR 循环”)。如果为 ll = 0,则使用伪随机计数,否则使用 ll 的值。查看源代码,外部循环根据 ll 参数重复。内循环使 LFSR 前进 64 步,每次从最近的时间样本中取出一个位进行 XOR 运算。以这种方式通过 LFSR 传递时间样本可用作处理步骤,通常倾向于使随机时间步变白。如熵质量测试文档中所述,在测试 CPU 时间变化的熵内容时,请务必跳过此处理。在某些情况下,启用处理会使熵估计值增大一个可疑的量(请参阅“处理原始样本的影响”部分)。

第二个噪声来源是内存访问循环,位于 jent_memaccess 函数中。内存访问循环根据 kernel.jitterentropy.ml cmdline(“ml”表示“内存循环”)重复,此时,值为 0 时同样会激活伪随机循环计数,且任何非零值都会替换伪随机计数。实际内存访问循环的每次迭代都会读取和写入一个相对较大的内存块,分成 kernel.jitterentropy.bc 个块(每个块的大小为 kernel.jitterentropy.bs 个字节)。编写当前文档时的默认值为 bc = 1024bs = 64cmdline 文档中应记录最新的默认值。为便于比较,抖动源代码中的默认值是 bc = 64bs = 32此处定义)。根据 jent_memaccess 函数上方的注释,总内存大小应大于目标机器的 L1 缓存大小。令人困惑的是,bc = 64bs = 32 产生的内存大小为 2048 字节,这比大多数 L1 缓存也小得多(我找不到任何超过 0 个字节但小于 4KB 的 L1 缓存)。使用 bs = 64bc = 1024 会产生 64KB 的内存,这通常足以使 L1 数据缓存溢出。

方法 1:伪随机循环计数

抖动熵最初被设计为使两个噪声生成函数运行伪随机次数。具体而言,jent_loop_shuffle 函数会将 (1) 从高分辨率时钟读取的时间与 (2) 抖动熵的内部随机状态进行混合,以决定运行噪声源的次数。

我们添加了替换这些伪随机循环计数的功能,并测试了使用和不使用替换时抖动的性能。“伪随机循环计数的影响”部分对这些结果进行了更深入的讨论,但总的来说:统计测试表明,伪随机循环计数增加的熵要远远超过预期。这让我不相信这些较高的熵计数,因此建议使用较低的估算值,并优先使用确定性循环计数,而不是伪随机计数。

Jitterentropy 的随机数据处理

如上所述,抖动熵可以处理其随机数据,使数据看起来“更随机”。具体而言,该处理应减少(最好移除)随机数据与均匀分布的偏差,并减少(理想情况下是移除)随机字节之间的任何互相关关系。

用于生成已处理样本的主要函数是 jent_gen_entropyjent_read_entropy 在循环中调用该函数以生成任意数量的随机字节。实质上,jent_gen_entropy 会在一个循环中调用噪声函数 64 次。每次 64 次 jent_lfsr_time 调用都会将噪声时间测量结果混合到随机抖动熵的状态。

这 64 次迭代之后,可以选择通过对“混合器”值进行 XOR 运算在 jent_stir_pool 中“搅拌”随机状态,而该随机状态本身取决于随机抖动熵。如源代码中所述,此操作无法增加或减少池中的熵(因为 XOR 是双形的),但有可能改善随机状态的统计外观。

原则上,调用噪声源函数 64 次应产生 64 倍的熵,最多可达到随机状态可以保持的最大 64 位。这假设 jent_lfsr_time 中的混音操作具有加密声音。我不是密码分析方面的专家,但 LFSR 本身不是加密安全的 RNG,因为 64 位连续位可以揭示 64 位 LFSR 的完整状态,之后可以轻松计算过去和未来的所有值。我不确定抖动熵方案(当 LFSR 发生偏移时,将时间测量值进行异或运算)到 LFSR 的“底部”这种方案是否更加安全。如果没有对此方案进行仔细的加密检查(据我所知,我没有在抖动熵文档中提到它),而是倾向于使用未处理的样本,并以已知良好的方式将它们混合到我们的系统熵池中(例如,就像我们现在所做的那样,例如 SHA-2)。

不过,我确实针对已处理的数据样本运行了 NIST 测试套件。我的结果在下面的“处理原始样本的影响”部分中。

测试流程

熵源质量测试文档介绍了运行熵源质量测试的过程。

这些初步结果是在 Raspberry Pi 3 上的 Zircon 调试 build 中收集的,该 build 基于现已过时的项目中的提交 18358de5e90a012cb1e042efae83f5ea264d1502,位于以下现已过时的项目中:https://fuchsia.googlesource.com/dircon/+/6iong"构建时,我的 local.mk 文件中设置了以下标志:

ENABLE_ENTROPY_COLLECTOR_TEST=1
ENTROPY_COLLECTOR_TEST_MAXLEN=1048576

我使用以下内核 cmdline 在 Pi 上网络启动调试内核后运行启动时间测试,并更改 $ML$LL$RAW 的值:

kernel.entropy-test.src=jitterentropy
kernel.jitterentropy.bs=64
kernel.jitterentropy.bc=1024
kernel.jitterentropy.ml=$ML
kernel.jitterentropy.ll=$LL
kernel.jitterentropy.raw=$RAW

测试结果和分析

伪随机循环计数的影响

原始数据

按照抖动源代码中的逻辑(搜索 MAX_FOLD_LOOP_BITMAX_ACC_LOOP_BIT),伪随机循环计数在以下范围内有所变化:

ml: 1 .. 128 (inclusive)
ll: 1 .. 16 (inclusive)

下表中包含了来自 NIST 套件的整体最小熵估算值,以及两个做出贡献的估算值:压缩估算值和马尔可夫估算值。NIST 最小熵估算值是至少包含 10 个不同估算值的最小值。对于具有确定循环计数的抖动原始样本,压缩估算值通常最小;在其他配置下,对于抖动熵,马尔可夫估算值通常最小。

ml ll 最小熵(位 / 字节) 压缩估算 马尔可夫估值
随机 (1 .. 128) 随机 (1 .. 16) 5.77 欧元 6.84 欧元 5.77 欧元
128 16 1.62 1.62 3.60
1 1 0.20 美元 0.20 美元 0.84 欧元

换言之,与始终使用伪随机范围内的最大值的确定性版本相比,改变循环计数会伪随机将原始样本的最小熵估算值提高 4.15 位(即 250%)。

分析

伪随机循环计数值通过为每个噪声函数添加一个额外的时间样本来确定。首先,这些时间样本与噪声函数时间测量结果不无关,因为循环计数时间样本之间的时间差与噪声函数时间测量结果可以预测对应。因此,假设它们根本不会增加输出数据的最小熵。其次,假设循环计数时间样本在某种程度上与噪声函数时间测量值一样约为随机的 250%,因为两者都依赖于相同的噪声源,只不过第一个循环计数时间样本可能会从启动系统运行测试所需的随机时间略有提升。

因此,我怀疑真实情况是,伪随机循环计数足以“欺骗” NIST 套件中一系列特定的统计测试和基于预测器的测试,但是,如果一个预测器测试具备对抖动伪随机循环计数具体如何推断出其预测结果的了解,并且其准确性要高得多。我认为伪随机循环计数测试中的“真实”最小熵(针对明确针对我们代码的攻击者)在两个确定性测试的范围内,即每字节约 0.20 到 1.62 位。

使用伪随机计数迫使我们做出额外的决定:我们保守估算每字节 0.20 位的实际熵内容(假设伪随机计数函数总是选择 ml = 1ll = 1)?或者我们是否选择平均熵内容(有一种可能比计算 1.62 位或 1.62 更为智能的平均值)如果过于保守,我们将花费更多的时间来收集熵;如果过于乐观,我们可能会发现安全漏洞。最终,这使得安全性(倾向于使用保守熵估算)和效率(优先考虑乐观熵估算)进行权衡。

处理原始样本的影响

原始数据

我重复了上面报告的三项测试,但开启了抖动熵的内部处理(使用 kernel.jitterentropy.raw = false 而不是默认值 true)。为方便起见,下表同时包含了前三行中的原始示例结果(从上面复制)和后三行中经过处理的结果(新添加)。

ml ll 原始 最小熵(位 / 字节) 压缩估算 马尔可夫估值
随机 (1 .. 128) 随机 (1 .. 16) true 5.77 欧元 6.84 欧元 5.77 欧元
128 16 true 1.62 1.62 3.60
1 1 true 0.20 美元 0.20 美元 0.84 欧元
ml ll 原始 最小熵(位 / 字节) 压缩估算 马尔可夫估值
随机 (1 .. 128) 随机 (1 .. 16) 5.79 欧元 6.59 欧元 5.79 欧元
128 16 5.78 欧元 6.97 欧元 5.78 欧元
1 1 5.77 欧元 6.71 欧元 5.77 欧元

分析

后处理最小熵估算值基本相同(有可轻松解释的细微变化),并且也等于具有伪随机循环计数的原始样本的最小熵估算值。

回想一下,抖动熵的处理熵由 64 个单独的随机数据样本组成,这些样本混合到一个 64 位内部状态缓冲区中。每个原始样本对应于 raw = true 表中的一个样本。特别是,认为如果将 64 个样本与 ml = 1ll = 1 结合后进行处理,每 8 个字节的处理输出会产生 46.2 位的熵(5.77 * 8),这意味着每 8 个字节的熵会表示 (46.2 / 64) = 0.72 位的熵,而不是每个未处理的样本值 0.72 位。

此参数适用于 ml = 1ll = 1raw = false 测量值,但不适用于 ml = 128ll = 16raw = false具体而言,将 64 个原始样本与 ml = 128ll = 16 相结合后,原则上每个处理的字节收集 (1.64 * 64 / 8) = 13.1 位的熵,当然,每字节有 8 位的硬性限制。

有趣的是,最小熵估算器从压缩估算切换到马尔可夫估算器。我的理论是,后期处理带来的额外“混淆”足以“欺骗”压缩估算。如果抖动熵处理例程中存在加密漏洞,或许可以编写类似的估算器来揭示极小的最小熵。如果我们使用通用测试来决定要收集多少原始样本才能获得 256 的最小熵,但攻击者使用定向攻击,则(相对于这种定向攻击)我们的系统在熵池中的熵可能低于预期。这是一个安全漏洞。

如果抖动熵处理例程中存在非常严重的弱点,那么实际上可能是降低抖动熵内部池中的“真实”熵。关于 ml = 1ll = 1 的算术参数表明,我们无法相信 NIST 测试套件准确测量已处理数据中的实际最小熵,因此该处理过程实际上可能会降低最小熵,而我们的工具却无法检测到损失。这会加剧上一段落中所述的漏洞。

结论

抖动的伪随机循环计数的获益效果最好的情况可能不尽人意,但如果使用它们,就会迫使我们对安全性/效率进行权衡。除非我们有令人信服的证据证明伪随机时间确实会显著增加熵估算值而不只是破坏 NIST 测试套件,我们应使用确定性循环计数,最好是针对性能进行微调的。

抖动的处理同样存在问题,因为(据我所知)它没有经过充分的加密分析和测试才可信。此外,我们无法通过 NIST 测试套件直接测量后处理数据中的最小熵,因此如果存在加密漏洞,我们就无法轻易检测到它。我认为我们应该依赖于 Zircon CPRNG(基于 SHA-2)中的熵混合代码,并停用抖动熵的处理。

TODO

  1. 针对不同版本的 Zircon 重复执行上面报告的测试,并确保熵估值保持一致。
  2. 针对不同的平台和目标重复测试(注意:x86 目标目前在前期启动期间无法访问系统时钟,因此前期启动熵测试和前期启动 CPRNG 种子在 x86 上尚不支持抖动熵)。
  3. 自动执行本文档中运行测试和生成报告的过程。具体而言,这些测试应比较:

    • 伪随机循环计数与各种确定性循环计数值
    • 原始样本与处理后的数据