PEP 524 – 使 os.urandom() 在 Linux 上阻塞
- 作者:
- 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 描述了一个用于计算 MD5 哈希的短 Python 脚本,systemd-cron,该脚本在初始化过程的早期被调用。系统初始化在这个脚本上阻塞,而这个脚本又在 getrandom(0) 上阻塞以初始化 Python。
Python 初始化需要随机字节来实现对哈希拒绝服务 (hash DoS) 的对策,参见
导入 random 模块会创建一个 random.Random 实例:random._inst。在 Python 3.5 中,random.Random 构造函数从 os.urandom() 读取 2500 字节以作为 Mersenne Twister 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 协议提供网页服务。服务器会尽快启动。
哈希 DoS 攻击的第一个目标是 Web 服务器:哈希秘密不能轻易被攻击者猜测是很重要的。
如果提供网页服务需要秘密来创建 cookie、创建加密密钥等,则秘密必须使用良好的熵创建:同样,必须难以猜测秘密。
Web 服务器需要安全性。如果在安全性和使用弱熵运行服务器之间做出选择,安全性更重要。如果没有好的熵:服务器必须阻塞或报错失败。
问题是在系统 urandom 初始化之前在主机上启动 Web 服务器是否有意义。
问题 #25420 和 #26839 仅限于 Python 启动,而不是在系统 urandom 初始化之前生成秘密。
修复系统 urandom
启动时从磁盘加载熵
收集熵可能需要几分钟。为了加速系统初始化,操作系统在关机时将熵存储在磁盘上,然后在启动时从磁盘重新加载熵。
如果系统至少收集一次足够的熵,系统 urandom 将很快初始化,一旦熵从磁盘重新加载。
虚拟机
虚拟机无法直接访问硬件,因此比裸机拥有更少的熵源。一个解决方案是添加 virtio-rng 设备,将熵从主机传递给虚拟机。
嵌入式设备
嵌入式设备的解决方案是插入硬件 RNG。
例如,树莓派有硬件 RNG,但默认不使用。参见:树莓派上的硬件 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 在启动时不会阻塞。
更改
使 os.urandom() 在 Linux 上阻塞
本节中描述的所有更改都特定于 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 获取随机字节,否则回退到 random 模块。
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)
此函数*不*可移植。例如,理论上,在系统初始化早期,os.urandom() 可能会在 FreeBSD 上阻塞。
创建一个尽力而为的 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()
此函数*不*可移植。例如,理论上,在系统初始化早期,random.SystemRandom 可能会在 FreeBSD 上阻塞。
替代方案
保持 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 年 8 月 8 日 被 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