Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

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.SystemRandomRandom.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.SystemRandomsecrets 用于安全目的。
  • random 模块(除 random.SystemRandom 外)用于所有其他用途。

在 os.urandom() 中引发 BlockingIOError

提议

PEP 522:允许在 Linux 上的安全敏感 API 中出现 BlockingIOError。.

Python 不应该为开发人员决定如何处理 错误:如果 os.urandom() 将要阻塞,则立即引发 BlockingIOError 使开发人员可以选择如何处理这种情况。

  • 捕获异常并回退到不安全的熵源:在 Linux 上读取 /dev/urandom,使用 Python random 模块(完全不安全),使用时间,使用进程标识符等。
  • 不要捕获错误,程序将使用此致命异常失败。

更一般地说,该异常有助于通知何时出现错误。当应用程序开始等待 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() 使用以下函数

在 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