PEP 524 – 在 Linux 上使 os.urandom() 变为阻塞
- 作者:
- Victor Stinner <vstinner at python.org>
- 状态:
- 最终
- 类型:
- 标准跟踪
- 创建:
- 2016 年 6 月 20 日
- Python 版本:
- 3.6
摘要
修改 os.urandom()
以在 Linux 3.17 及更高版本上阻塞,直到操作系统 urandom 初始化以提高安全性。
另外,添加一个新的 os.getrandom()
函数(适用于 Linux 和 Solaris),以便能够选择如何处理 os.urandom()
在 Linux 上何时阻塞。
错误
原始错误
Python 3.5.0 已得到增强,可以使用 Linux 3.17 和 Solaris 11.3 中引入的新 getrandom()
系统调用。问题是,用户开始抱怨 Python 3.5 在 Linux 虚拟机和嵌入式设备上启动时会阻塞:请参见问题 #25420 和 #26839。
在 Linux 上,getrandom(0)
会阻塞,直到内核使用 128 位熵初始化 urandom。问题 #25420 描述了一个 Linux 构建平台在 import random
时阻塞。问题 #26839 描述了一个简短的 Python 脚本,用于计算 MD5 哈希值,systemd-cron,在初始化过程的早期调用的脚本。系统初始化会阻塞在该脚本上,该脚本会阻塞在 getrandom(0)
上以初始化 Python。
Python 初始化需要随机字节来实现针对哈希拒绝服务(hash DoS)的防御措施,请参见
导入 random
模块会创建一个 random.Random
实例:random._inst
。在 Python 3.5 上,random.Random 构造函数会从 os.urandom()
读取 2500 字节以播种梅森旋转器 RNG(随机数生成器)。
其他平台也可能受到此错误的影响,但在实践中,只有 Linux 系统使用 Python 脚本来初始化系统。
Python 3.5.2 中的状态
Python 3.5.2 的行为类似于 Python 2.7 和 Python 3.4。如果系统 urandom 未初始化,则启动不会阻塞,但 os.urandom()
可能会返回低质量熵(即使它不容易被猜到)。
用例
以下用例用于帮助选择安全性与实用性之间的正确折衷方案。
用例 1:初始化脚本
使用 Python 3 脚本初始化系统,例如 systemd-cron。如果脚本阻塞,则系统初始化也会卡住。问题 #26839 是此用例的一个很好的示例。
用例 1.1:不需要密钥
如果初始化脚本不需要生成任何安全密钥,则 Python 3.5.2 中已正确处理此用例:Python 启动不再阻塞系统 urandom。
用例 1.2:需要安全密钥
如果初始化脚本必须生成安全密钥,则没有安全的解决方案。
回退到弱熵是不可接受的,它会降低程序的安全性。
Python 无法自行产生安全熵,它只能等待系统 urandom 初始化。但在这种用例中,整个系统初始化都阻塞在该脚本上,因此系统无法启动。
真正的答案是系统初始化不应被此类脚本阻塞。在系统初始化时尽早启动脚本是可以的,但脚本可能会阻塞几秒钟,直到它能够生成密钥。
提醒:在某些情况下,系统 urandom 的初始化永远不会发生,因此等待系统 urandom 的程序会无限期阻塞。
用例 2:Web 服务器
运行一个 Python 3 Web 服务器,使用 HTTP 和 HTTPS 协议提供网页。服务器应尽早启动。
hash DoS 攻击的第一个目标是 Web 服务器:重要的是哈希密钥不能轻易被攻击者猜到。
如果提供网页需要密钥来创建 cookie、创建加密密钥等,则必须使用良好的熵创建密钥:同样,必须难以猜到密钥。
Web 服务器需要安全性。如果必须在安全性与使用弱熵运行服务器之间进行选择,则安全性更重要。如果没有良好的熵:服务器必须阻塞或失败并显示错误。
问题是,在系统 urandom 初始化之前,在主机上启动 Web 服务器是否有意义。
问题 #25420 和 #26839 限于 Python 启动,而不是在系统 urandom 初始化之前生成密钥。
修复系统 urandom
在启动时从磁盘加载熵
收集熵可能需要长达几分钟的时间。为了加快系统初始化速度,操作系统会在关机时将熵存储在磁盘上,然后在启动时从磁盘重新加载熵。
如果系统至少收集一次足够的熵,则系统 urandom 会很快初始化,即在熵从磁盘重新加载后立即初始化。
虚拟机
虚拟机无法直接访问硬件,因此与裸机相比,熵源较少。一种解决方案是添加一个 virtio-rng 设备 以将熵从主机传递到虚拟机。
嵌入式设备
嵌入式设备的一种解决方案是插入硬件 RNG。
例如,Raspberry Pi 具有硬件 RNG,但默认情况下不使用它。请参见:Raspberry Pi 上的硬件 RNG。
读取随机数时的拒绝服务
不要使用 /dev/random,而是使用 /dev/urandom
应仅在非常特定的用例中使用 /dev/random
设备。在 Linux 上从 /dev/random
读取可能会阻塞。用户不喜欢应用程序阻塞超过 5 秒来生成密钥。这仅在特定情况下才需要,例如显式生成加密密钥。
当系统没有可用熵时,在阻塞直到熵可用或回退到较低质量熵之间进行选择,是安全性与实用性之间的权衡。选择取决于用例。
在 Linux 上,/dev/urandom
是安全的,应使用它而不是 /dev/random
。请参见 Thomas Hühn 的 关于 /dev/urandom 的谬误:“事实:/dev/urandom 是类 UNIX 系统上首选的加密随机数源。”
getrandom(size, 0) 可能会在 Linux 上无限期阻塞
Python 问题 #26839 的根源是 Debian 错误报告 #822431:实际上,getrandom(size, 0)
在虚拟机上会无限期阻塞。系统成功启动,因为 systemd 在 90 秒后杀死了阻塞进程。
像 在启动时从磁盘加载熵 这样的解决方案降低了此错误的风险。
基本原理
在 Linux 上,读取 /dev/urandom
可能会在 urandom 完全初始化之前返回“弱”熵,即在内核收集 128 位熵之前。Linux 3.17 添加了一个新的 getrandom()
系统调用,它允许阻塞直到 urandom 初始化。
在 Python 3.5.2 中,os.urandom() 使用 getrandom(size, GRND_NONBLOCK)
,但如果 getrandom(size, GRND_NONBLOCK)
使用 EAGAIN
失败,则会回退到读取非阻塞的 /dev/urandom
。
安全专家建议使用 os.urandom()
生成加密密钥,因为它使用 加密安全伪随机数生成器 (CSPRNG) 实现。顺便说一句,os.urandom()
因各种原因优于 ssl.RAND_bytes()
。
本 PEP 提出修改 os.urandom() 以在阻塞模式下使用 getrandom()
,这样就不会返回弱熵,而且还能确保 Python 不会在启动时阻塞。
变更
在 Linux 上使 os.urandom() 变为阻塞
本节中描述的所有变更都是特定于 Linux 平台的。
变更
- 修改 os.urandom() 以阻塞,直到系统 urandom 初始化:
os.urandom()
(C 函数_PyOS_URandom()
)已修改为始终在 Linux 和 Solaris 上调用getrandom(size, 0)
(阻塞模式)。 - 添加新的私有
_PyOS_URandom_Nonblocking()
函数:尝试在 Linux 和 Solaris 上调用getrandom(size, GRND_NONBLOCK)
,但如果它使用EAGAIN
失败,则会回退到读取/dev/urandom
。 - 从非阻塞系统 urandom 初始化哈希密钥:
_PyRandom_Init()
已修改为调用_PyOS_URandom_Nonblocking()
。 random.Random
构造函数现在使用非阻塞系统 urandom:它已修改为在内部使用新的_PyOS_URandom_Nonblocking()
函数播种 RNG。
添加新的 os.getrandom() 函数
添加了新的 os.getrandom(size, flags=0)
函数:在 Linux 上使用 getrandom()
系统调用,在 Solaris 上使用 getrandom()
C 函数。
该函数附带 2 个新标志
os.GRND_RANDOM
:从/dev/random
读取字节,而不是从/dev/urandom
读取字节os.GRND_NONBLOCK
:如果os.getrandom()
会阻塞,则引发 BlockingIOError
os.getrandom()
是对 getrandom()
系统调用/C 函数的薄包装,因此继承了它的行为。例如,在 Linux 上,如果系统调用被信号中断,它可能会返回的字节少于请求的字节。
使用 os.getrandom() 的示例
尽力而为的 RNG
可移植非阻塞 RNG 函数的示例:尝试从 OS urandom 获取随机字节,或者回退到随机模块。
def best_effort_rng(size):
# getrandom() is only available on Linux and Solaris
if not hasattr(os, 'getrandom'):
return os.urandom(size)
result = bytearray()
try:
# need a loop because getrandom() can return less bytes than
# requested for different reasons
while size:
data = os.getrandom(size, os.GRND_NONBLOCK)
result += data
size -= len(data)
except BlockingIOError:
# OS urandom is not initialized yet:
# fallback on the Python random module
data = bytes(random.randrange(256) for byte in range(size))
result += data
return bytes(result)
理论上,在不支持 os.getrandom()
但 os.urandom()
可能阻塞的平台上,此函数可能会阻塞。
wait_for_system_rng()
函数等待 *timeout* 秒,直到在 Linux 或 Solaris 上初始化 OS urandom 的示例。
def wait_for_system_rng(timeout, interval=1.0):
if not hasattr(os, 'getrandom'):
return
deadline = time.monotonic() + timeout
while True:
try:
os.getrandom(1, os.GRND_NONBLOCK)
except BlockingIOError:
pass
else:
return
if time.monotonic() > deadline:
raise Exception('OS urandom not initialized after %s seconds'
% timeout)
time.sleep(interval)
此函数不可移植。例如,在 FreeBSD 上,理论上 os.urandom()
可能会在系统初始化的早期阶段阻塞。
创建尽力而为的 RNG
在 Linux 上创建非阻塞 RNG 的更简单示例:根据 getrandom(size)
是否会阻塞来选择 Random.SystemRandom
或 Random.Random
。
def create_nonblocking_random():
if not hasattr(os, 'getrandom'):
return random.Random()
try:
os.getrandom(1, os.GRND_NONBLOCK)
except BlockingIOError:
return random.Random()
else:
return random.SystemRandom()
此函数不可移植。例如,在 FreeBSD 上,理论上 random.SystemRandom
可能会在系统初始化的早期阶段阻塞。
备选方案
保持 os.urandom() 不变,添加 os.getrandom()
os.urandom() 保持不变:永远不会阻塞,但如果系统 urandom 尚未初始化,它可能会返回弱熵。
仅添加新的 os.getrandom()
函数(getrandom()
系统调用/C 函数的包装器)。
应使用 secrets.token_bytes()
函数编写可移植代码。
此更改的问题在于它期望用户充分理解安全问题并熟悉每个平台。Python 具有隐藏“实现细节”的传统。例如,os.urandom()
不是 /dev/urandom
设备的薄包装器:它在 Windows 上使用 CryptGenRandom()
,在 OpenBSD 上使用 getentropy()
,在 Linux 和 Solaris 上尝试使用 getrandom()
,或者回退到读取 /dev/urandom
。Python 已经根据平台使用了最佳的系统 RNG。
此 PEP 不会更改 API。
os.urandom()
、random.SystemRandom
和secrets
用于安全目的。random
模块(除random.SystemRandom
外)用于所有其他用途。
在 os.urandom() 中引发 BlockingIOError
提议
PEP 522:允许在 Linux 上的安全敏感 API 中出现 BlockingIOError。.
Python 不应该为开发人员决定如何处理 错误:如果 os.urandom()
将要阻塞,则立即引发 BlockingIOError
使开发人员可以选择如何处理这种情况。
- 捕获异常并回退到不安全的熵源:在 Linux 上读取
/dev/urandom
,使用 Pythonrandom
模块(完全不安全),使用时间,使用进程标识符等。 - 不要捕获错误,程序将使用此致命异常失败。
更一般地说,该异常有助于通知何时出现错误。当应用程序开始等待 os.urandom()
时,它可以发出警告。
批评
对于用例 2(Web 服务器),回退到不安全的熵是不可接受的。应用程序必须处理 BlockingIOError
:轮询 os.urandom()
直到完成。示例
def secret(n=16):
try:
return os.urandom(n)
except BlockingIOError:
pass
print("Wait for system urandom initialization: move your "
"mouse, use your keyboard, use your disk, ...")
while 1:
# Avoid busy-loop: sleep 1 ms
time.sleep(0.001)
try:
return os.urandom(n)
except BlockingIOError:
pass
为了正确性,所有必须生成安全密钥的应用程序都必须修改以处理 BlockingIOError
,即使 错误 不太可能发生。
使用 os.urandom()
但实际上不需要安全的应用程序的情况没有明确定义。也许这些应用程序一开始就不应该使用 os.urandom()
,而应该始终使用非阻塞的 random
模块。如果 os.urandom()
用于安全目的,我们回到了上面描述的用例 2:用例 2:Web 服务器。如果开发人员不想放弃 os.urandom()
,则需要修改代码。示例
def almost_secret(n=16):
try:
return os.urandom(n)
except BlockingIOError:
return bytes(random.randrange(256) for byte in range(n))
问题是 错误 是否常见到需要修改如此多的应用程序。
另一个更简单的选择是拒绝在系统 urandom 初始化之前启动。
def secret(n=16):
try:
return os.urandom(n)
except BlockingIOError:
print("Fatal error: the system urandom is not initialized")
print("Wait a bit, and rerun the program later.")
sys.exit(1)
与 Python 2.7、Python 3.4 和 Python 3.5.2 相比,在这些版本中 os.urandom() 在 Linux 上永远不会阻塞或引发异常,这种行为变化可以被视为重大倒退。
向 os.urandom() 添加可选的 block 参数
参见 问题 #27250:添加 os.urandom_block()。
向 os.urandom() 添加可选的 block 参数。默认值可以是 True
(默认情况下阻塞)或 False
(非阻塞)。
第一个技术问题是在所有平台上实现 os.urandom(block=False)
。只有 Linux 3.17(及更高版本)和 Solaris 11.3(及更高版本)具有定义明确的非阻塞 API (getrandom(size, GRND_NONBLOCK)
)。
正如 在 os.urandom() 中引发 BlockingIOError 所述,为了一个理论上的(或至少非常罕见的)用例而使 API 变得更加复杂似乎并不值得。
正如 保留 os.urandom() 不变,添加 os.getrandom() 所述,问题在于它使 API 更加复杂,从而更容易出错。
接受
PEP 已于 2016-08-08 由 Guido van Rossum 接受。
附录
操作系统随机函数
os.urandom()
使用以下函数
- OpenBSD:getentropy()(OpenBSD 5.6)
- Linux:getrandom()(Linux 3.17)——另请参见 用于随机数的系统调用:getrandom()
- Solaris:getentropy()、getrandom()(两者都需要 Solaris 11.3)
- UNIX、BSD:/dev/urandom、/dev/random
- Windows:CryptGenRandom()(Windows XP)
在 Linux 上,用于获取 /dev/random
状态的命令(结果是字节数)
$ cat /proc/sys/kernel/random/entropy_avail
2850
$ cat /proc/sys/kernel/random/poolsize
4096
为什么要使用 os.urandom()?
由于 os.urandom()
是在内核中实现的,因此它没有用户空间 RNG 的问题。例如,获取其状态要困难得多。它通常建立在 CSPRNG 上,因此即使其状态被“窃取”,也很难计算以前生成的数字。内核对熵源有很好的了解,并定期向熵池馈送熵。
这也是为什么 os.urandom()
比 ssl.RAND_bytes()
更受欢迎的原因。
版权
本文档已置于公有领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0524.rst
最后修改时间:2023-09-09 17:39:29 GMT