PEP 456 – 安全且可互换的哈希算法
- 作者:
- Christian Heimes <christian at python.org>
- BDFL 委托:
- Alyssa Coghlan
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2013年9月27日
- Python 版本:
- 3.4
- 发布历史:
- 2013年10月6日,2013年11月14日,2013年11月20日
- 决议:
- Python-Dev 消息
摘要
本 PEP 提议将 SipHash 作为默认的字符串和字节哈希算法,以一劳永逸地正确修复哈希随机化。它还提议修改 Python 的 C 代码,以统一哈希代码并使其易于互换。
基本原理
尽管上次尝试 [issue13703],CPython 仍然容易受到哈希冲突 DoS 攻击 [29c3] [issue14621]。当前的哈希算法及其随机化对攻击没有弹性。只有适当的加密哈希函数才能阻止秘密随机化密钥的提取。尽管尚未发现针对基于 Python 的服务的实际攻击,但必须修复此弱点。Jean-Philippe Aumasson 和 Daniel J. Bernstein 已经展示了如何恢复当前实现的种子 [poc]。
此外,当前的哈希算法是硬编码的,并为字节和三种不同的 Unicode 表示形式 UCS1、UCS2 和 UCS4 多次实现。这使得嵌入者无法在不修补和重新编译解释器大部分代码的情况下将其替换为不同的实现。嵌入者可能希望选择更合适的哈希函数。
最后,当前的实现代码性能不佳。在常见情况下,它每个周期只处理一两个字节。在现代 64 位处理器上,代码可以很容易地调整为一次处理八个字节。
本 PEP 提议对字符串和字节的哈希代码进行三项重大更改
- SipHash [sip] 被引入作为默认哈希算法。尽管其具有加密特性,但它快速且小巧。由于它是由知名安全和密码专家设计的,因此可以放心地认为它在不久的将来是安全的。
- 对于没有 64 位数据类型的平台,保留现有的 FNV 代码。该算法经过优化,每个周期处理更大的块。
- 字符串和字节的哈希计算被移到一个 API 函数中,而不是 Objects/object.c和Objects/unicodeobject.c中的多个专用实现。该函数接受一个 void 指针和长度,并返回其哈希值。
- 该算法可以在编译时选择。FNV 保证在所有平台上都存在。SipHash 在大多数现代系统上都可用。
哈希函数的要求
- 它必须能够哈希任意大的内存块,从 1 字节到最大 ssize_t值。
- 它在 32 位平台上必须产生至少 32 位,在 64 位平台上必须产生至少 64 位。(注意:较大的输出可以压缩,例如使用 v ^ (v >> 32)。)
- 它必须支持哈希未对齐内存以支持 hash(memoryview)。
- 强烈建议输入长度影响结果,以便 hash(b'\00') != hash(b'\x00\x00')。
哈希函数和 tp_hash 槽之间的内部接口代码实现了零长度输入和返回值为 -1 的特殊情况。长度为 0 的输入映射到哈希值 0。输出 -1 映射到 -2。
使用修改后的 FNV 的当前实现
CPython 目前使用 Fowler-Noll-Vo 哈希函数 [fnv] 的变体。该变体已修改,以减少常见字符串的哈希冲突数量和成本。字符串的第一个字符被添加两次,第一次位移 7 位。输入字符串的长度与最终值进行异或运算。原始 FNV 算法的这两个偏差都减少了短字符串的哈希冲突数量。
最近 [issue13703] 添加了随机前缀和后缀,以尝试随机化哈希值。为了保护哈希秘密,对于零长度输入,代码仍然返回 0。
C 代码
Py_uhash_t x;
Py_ssize_t len;
/* p is either 1, 2 or 4 byte type */
unsigned char *p;
Py_UCS2 *p;
Py_UCS4 *p;
if (len == 0)
    return 0;
x = (Py_uhash_t) _Py_HashSecret.prefix;
x ^= (Py_uhash_t) *p << 7;
for (i = 0; i < len; i++)
    x = (1000003 * x) ^ (Py_uhash_t) *p++;
x ^= (Py_uhash_t) len;
x ^= (Py_uhash_t) _Py_HashSecret.suffix;
return x;
大致翻译成 Python
def fnv(p):
    if len(p) == 0:
        return 0
    # bit mask, 2**32-1 or 2**64-1
    mask = 2 * sys.maxsize + 1
    x = hashsecret.prefix
    x = (x ^ (ord(p[0]) << 7)) & mask
    for c in p:
        x = ((1000003 * x) ^ ord(c)) & mask
    x = (x ^ len(p)) & mask
    x = (x ^ hashsecret.suffix) & mask
    if x == -1:
        x = -2
    return x
FNV 是一种简单的乘法和异或算法,不具备加密特性。随机化不是初始哈希代码的一部分,而是作为 oCERT-2011-003 [ocert] 中解释的针对哈希冲突攻击的对策而添加的。由于 FNV 不是加密哈希算法,并且 dict 实现未针对侧信道分析进行强化,因此远程攻击者可以计算随机化秘密。本 PEP 的作者坚信非加密哈希函数的性质使得隐藏秘密成为不可能。
检查过的哈希算法
本 PEP 的作者研究了几种被认为是现代、快速和最先进的哈希算法。
SipHash
SipHash [sip] 是一种具有 128 位种子和 64 位输出的加密伪随机函数。它由 Jean-Philippe Aumasson 和 Daniel J. Bernstein 设计,作为一种快速且安全的密钥哈希算法。它被 Ruby、Perl、OpenDNS、Rust、Redis、FreeBSD 等使用。C 参考实现已根据 CC0 许可证(公共领域)发布。
SipHash 网站的引述
SipHash 是一系列伪随机函数(又称密钥哈希函数),针对短消息的速度进行了优化。目标应用包括网络流量认证和防御哈希泛洪 DoS 攻击。
siphash24 是推荐的性能最佳的变体。它每个消息块使用 2 轮,4 轮最终化。除了参考实现之外,还有其他几种实现可用。有些是单次函数,另一些使用类似于 Merkle-Damgård 构造的方法,带有 init、update 和 finalize 函数。Marek Majkowski 的 C 实现 csiphash [csiphash] 定义了该函数的原型。(注意:k 被分成两个 uint64_t)
uint64_t siphash24(const void *src, unsigned long src_sz, const char k[16])
SipHash 需要 64 位数据类型,并且不兼容纯 C89 平台。
MurmurHash
MurmurHash [murmur] 是 Austin Appleby 开发的一系列非加密密钥哈希函数。Murmur3 是 MurmurHash 的最新且快速的变体。C++ 参考实现已发布到公共领域。它具有 32 位或 128 位输出,带有 32 位种子。(注意:输出参数是一个缓冲区,包含 1 或 4 个字节。)
Murmur3 的函数原型是
void MurmurHash3_x86_32(const void *key, int len, uint32_t seed, void *out)
void MurmurHash3_x86_128(const void *key, int len, uint32_t seed, void *out)
void MurmurHash3_x64_128(const void *key, int len, uint32_t seed, void *out)
128 位变体需要 64 位数据类型,并且不兼容纯 C89 平台。32 位变体完全兼容 C89。
Aumasson、Bernstein 和 Boßlet 已经证明 [sip] [ocert-2012-001] Murmur3 对哈希冲突攻击没有弹性。因此,Murmur3 不能再被视为安全的算法。如果哈希冲突攻击不重要,它仍然可能是一个替代方案。
CityHash
CityHash [city] 是 Geoff Pike 和 Jyrki Alakuijala 为 Google 开发的一系列非加密哈希函数。C++ 参考实现已根据 MIT 许可证发布。该算法部分基于 MurmurHash,并声称更快。它支持 64 位和 128 位输出,带有 128 位种子,以及不带种子的 32 位输出。
带 128 位种子的 64 位 CityHash 的相关函数原型是
uint64 CityHash64WithSeeds(const char *buf, size_t len, uint64 seed0,
                           uint64 seed1)
CityHash 还为长输入提供带有 CRC32 intrinsic 的 SSE 4.2 优化。除 CityHash32 之外的所有变体都需要 64 位数据类型。CityHash32 仅使用 32 位数据类型,但不支持 seeding。
与 MurmurHash 类似,Aumasson、Bernstein 和 Boßlet 已经展示了 [sip] CityHash 中类似的弱点。
DJBX33A
DJBX33A 是 Daniel J. Bernstein 的一个非常简单的乘法和加法算法。它速度快,设置成本低,但对哈希冲突攻击不安全。它的特性使其成为短字符串哈希优化的可行选择。
其他
HMAC、MD5、SHA-1 或 SHA-2 等加密算法太慢,并且设置和最终化成本很高。由于这些原因,它们不被认为适合此目的。现代 AMD 和 Intel CPU 具有 AES-NI(AES 指令集)[aes-ni] 以加速 AES 加密。带有 AES-NI 的 CMAC 可能是一个可行的选择,但它可能太慢,不适合日常操作。(需要测试)
结论
SipHash 提供了速度和安全性的最佳组合。其他著名项目的开发者也得出了相同的结论。
短字符串优化
像 SipHash24 这样的哈希函数具有昂贵的初始化和最终化代码,对于非常短的字符串,这些代码可能会主导算法的速度。另一方面,Python 经常计算短字符串的哈希值。一个简单快速的函数,特别是用于哈希短字符串的函数,可以对性能产生可测量的影响。例如,这些测量是在运行 Python 的回归测试期间进行的。其他代码的额外测量也显示了类似的分布。
| 字节 | 哈希() 调用 | 部分 | 
|---|---|---|
| 1 | 18709 | 0.2% | 
| 2 | 737480 | 9.5% | 
| 3 | 636178 | 17.6% | 
| 4 | 1518313 | 36.7% | 
| 5 | 643022 | 44.9% | 
| 6 | 770478 | 54.6% | 
| 7 | 525150 | 61.2% | 
| 8 | 304873 | 65.1% | 
| 9 | 297272 | 68.8% | 
| 10 | 68191 | 69.7% | 
| 11 | 1388484 | 87.2% | 
| 12 | 480786 | 93.3% | 
| 13 | 52730 | 93.9% | 
| 14 | 65309 | 94.8% | 
| 15 | 44245 | 95.3% | 
| 16 | 85643 | 96.4% | 
| 总计 | 7921678 | 
然而,像 DJBX33A 这样快速的函数不如 SipHash24 安全。大约 5 到 7 字节的截止值应该能提供足够的安全裕度和同时加速。PEP 的参考实现提供了一个带有 Py_HASH_CUTOFF 的截止值。由于几个原因,该优化默认禁用。一方面,其安全影响尚不清楚,应在默认启用该优化之前进行彻底研究。其次,性能优势各不相同。在具有 Intel Core i7 的 64 位 Linux 系统上,多次运行 Python 的基准测试套件 [pybench] 显示,对于 django_v2、mako 和 etree 等基准测试,当截止值为 7 时,平均加速在 3% 到 5% 之间。在同一台机器上使用 X86 二进制文件和 Windows X86_64 构建的基准测试在短字符串优化下略慢。
Python 3.4 的 beta 阶段将评估短字符串优化的状态。该功能将要么以适当的值启用,要么在 beta 2 发布之前删除代码。
C API 附加
所有 C API 扩展修改都不属于稳定 API。
哈希密钥
Python 2.6 到 3.3 的 _Py_HashSecret_t 类型有两个成员,每个成员的长度为 32 位或 64 位。SipHash 需要两个 64 位无符号整数作为密钥。typedef 将更改为 union,保证在所有体系结构上大小为 24 字节。该 union 为 SipHash24 和 FNV 提供 128 位随机密钥,并为可选的短字符串优化和 pyexpat 种子提供额外的 64 位值。额外的 64 位种子确保 pyexpat 或短字符串优化不能泄露 SipHash24 种子的位。
64 位系统上的内存布局
cccccccc cccccccc cccccccc  uc -- unsigned char[24]
pppppppp ssssssss ........  fnv -- two Py_hash_t
k0k0k0k0 k1k1k1k1 ........  siphash -- two PY_UINT64_T
........ ........ ssssssss  djbx33a -- 16 bytes padding + one Py_hash_t
........ ........ eeeeeeee  pyexpat XML hash salt
32 位系统上的内存布局
cccccccc cccccccc cccccccc  uc -- unsigned char[24]
ppppssss ........ ........  fnv -- two Py_hash_t
k0k0k0k0 k1k1k1k1 ........  siphash -- two PY_UINT64_T (if available)
........ ........ ssss....  djbx33a -- 16 bytes padding + one Py_hash_t
........ ........ eeee....  pyexpat XML hash salt
新类型定义
typedef union {
    /* ensure 24 bytes */
    unsigned char uc[24];
    /* two Py_hash_t for FNV */
    struct {
        Py_hash_t prefix;
        Py_hash_t suffix;
    } fnv;
#ifdef PY_UINT64_T
    /* two uint64 for SipHash24 */
    struct {
        PY_UINT64_T k0;
        PY_UINT64_T k1;
    } siphash;
#endif
    /* a different (!) Py_hash_t for small string optimization */
    struct {
        unsigned char padding[16];
        Py_hash_t suffix;
    } djbx33a;
    struct {
        unsigned char padding[16];
        Py_hash_t hashsalt;
    } expat;
} _Py_HashSecret_t;
PyAPI_DATA(_Py_HashSecret_t) _Py_HashSecret;
_Py_HashSecret_t 在 Python/random.c:_PyRandom_Init() 中启动时只初始化一次。
哈希函数定义
实施
typedef struct {
    /* function pointer to hash function, e.g. fnv or siphash24 */
    Py_hash_t (*const hash)(const void *, Py_ssize_t);
    const char *name;       /* name of the hash algorithm and variant */
    const int hash_bits;    /* internal size of hash value */
    const int seed_bits;    /* size of seed input */
} PyHash_FuncDef;
PyAPI_FUNC(PyHash_FuncDef*) PyHash_GetFuncDef(void);
autoconf
一个新的测试被添加到配置脚本中。当它检测到需要对齐内存访问整数的平台时,该测试设置 HAVE_ALIGNED_REQUIRED。当前大多数平台,如 X86、X86_64 和现代 ARM,不需要对齐数据。
一个新选项 --with-hash-algorithm 允许用户在配置步骤中选择哈希算法。
哈希函数选择
宏 Py_HASH_ALGORITHM 的值定义了内部使用的哈希算法。它可以设置为三个值之一:Py_HASH_SIPHASH24、Py_HASH_FNV 或 Py_HASH_EXTERNAL。如果根本未定义 Py_HASH_ALGORITHM,则选择最佳可用算法。在不需要对齐内存访问(未定义 HAVE_ALIGNED_REQUIRED)且具有无符号 64 位整数类型 PY_UINT64_T 的平台上,使用 SipHash24。在没有 64 位数据类型的严格 C89 平台或 SPARC 等体系结构上,选择 FNV 作为回退。可以通过 autoconf 选项选择哈希算法,例如 ./configure --with-hash-algorithm=fnv。
值 Py_HASH_EXTERNAL 允许第三方在编译时提供自己的实现。
实施
#if Py_HASH_ALGORITHM == Py_HASH_EXTERNAL
extern PyHash_FuncDef PyHash_Func;
#elif Py_HASH_ALGORITHM == Py_HASH_SIPHASH24
static PyHash_FuncDef PyHash_Func = {siphash24, "siphash24", 64, 128};
#elif Py_HASH_ALGORITHM == Py_HASH_FNV
static PyHash_FuncDef PyHash_Func = {fnv, "fnv", 8 * sizeof(Py_hash_t),
                                     16 * sizeof(Py_hash_t)};
#endif
Python API 附加
sys 模块
sys 模块已经有一个 hash_info 结构序列。更多字段被添加到对象中,以反映活动的哈希算法及其属性。
sys.hash_info(width=64,
              modulus=2305843009213693951,
              inf=314159,
              nan=0,
              imag=1000003,
              # new fields:
              algorithm='siphash24',
              hash_bits=64,
              seed_bits=128,
              cutoff=0)
C 代码的必要修改
_Py_HashBytes() (Objects/object.c)
_Py_HashBytes 是一个内部辅助函数,它为 bytes、memoryview 和 datetime 类提供哈希代码。它目前为 unsigned char * 实现 FNV。
该函数被移至 Python/pyhash.c 并修改为通过 PyHash_Func.hash() 使用哈希函数。函数签名已更改为将 const void * 作为第一个参数。 _Py_HashBytes 还处理特殊情况:它将零长度输入映射到 0,并将返回值为 -1 映射到 -2。
bytes_hash() (Objects/bytesobject.c)
bytes_hash 使用 _Py_HashBytes 为 bytes 对象提供 tp_hash 槽函数。该函数将继续使用 _Py_HashBytes,但没有类型转换。
memory_hash() (Objects/memoryobject.c)
memory_hash 为只读内存视图提供 tp_hash 槽函数,如果原始对象也hashable。它是唯一在未来必须支持哈希未对齐内存段的函数。该函数将继续使用 _Py_HashBytes 但没有类型转换。
unicode_hash() (Objects/unicodeobject.c)
unicode_hash 为 unicode 提供 tp_hash 槽函数。目前它为 unsigned char*、Py_UCS2 和 Py_UCS4 实现了 FNV 算法三次。该函数的重新实现必须注意使用正确的长度。由于宏 PyUnicode_GET_LENGTH 返回 unicode 字符串的长度而不是其以八位字节为单位的大小,因此长度必须乘以内部 unicode 种类的大小
if (PyUnicode_READY(u) == -1)
    return -1;
x = _Py_HashBytes(PyUnicode_DATA(u),
                  PyUnicode_GET_LENGTH(u) * PyUnicode_KIND(u));
generic_hash() (Modules/_datetimemodule.c)
generic_hash 作为 _Py_HashBytes 的包装器,用于 date、time 和 datetime 类型的 tp_hash 槽。timedelta 对象由其状态(天、秒、微秒)进行哈希,而 tzinfo 对象不可哈希。date、time 和 datetime 类型的结构体的数据成员未 void* 对齐。这可以通过将四到十个字节复制到对齐缓冲区轻松修复。
性能
总的来说,带有 SipHash24 的 PEP 456 代码与旧的 FNV 代码速度大致相同。SipHash24 似乎更好地利用了现代编译器、CPU 和大 L1 缓存。多项基准测试显示,在 Intel Core i5 和 Intel Core i7 处理器等 64 位 CPU 上,速度略有提高。在 AMD Athlon X2 等旧 CPU 上,32 位构建和基准测试在 SipHash24 下略慢。性能的提高或降低非常小,不应影响任何应用程序代码。
基准测试在 CPython 默认分支修订 b08868fd5994 和 PEP 存储库 [pep-456-repos] 上进行。所有上游更改都合并到 pep-456 分支中。“性能”CPU 调控器已配置,几乎所有程序都已停止,以便基准测试能够尽可能地利用 TurboBoost 和 CPU 缓存。多个机器和平台的原始基准测试结果可在 [benchmarks] 获取。
哈希值分布
哈希值的良好分布对于 dict 和 set 的性能很重要。SipHash24 和 FNV 都考虑了输入的长度,因此完全由 NULL 字节组成的字符串不会具有相同的哈希值。输入的最后一个字节也倾向于影响哈希值的最低有效位。该属性减少了具有共同前缀的字符串的哈希冲突数量。
典型长度
Serhiy Storchaka 在 [issue16427] 中展示,一个修改后的 FNV 实现,每周期 64 位,能够比当前 FNV 实现快几倍地处理长字符串。
然而,根据统计 [issue19183],一个典型的 Python 程序以及 Python 测试套件,大约 50% 的哈希比率是 1 到 6 字节的短字符串。只有 5% 的字符串大于 16 字节。
Grand Unified Python 基准测试套件
使用实验性实现和 Grand Unified Python Benchmark Suite 进行的初步测试显示出微小的偏差。基准测试的总运行时间摘要在未修改的 Python 3.4 二进制文件的运行时间 1% 之内。测试在配备 64 位 Linux 安装的 Intel i7-2860QM 机器上运行。解释器使用 GCC 4.7 为 64 位和 32 位编译。
将进行更多基准测试。
向后兼容性
这些修改不改变任何现有 API。
字符串和字节的 hash() 的输出将不同。ASCII Unicode 和 ASCII 字节的哈希值将保持相等。
针对哈希冲突 DoS 的替代对策
过去讨论了三种替代的哈希冲突对策,但它们不是本 PEP 的主题。
- Marc-Andre Lemburg 曾建议字典应统计哈希冲突。如果插入操作导致太多冲突,则应引发异常。
- 一些应用程序(例如 PHP)限制 GET 和 POST HTTP 请求的密钥数量。这种方法有效地减轻了哈希冲突攻击的影响。(XXX 需要引用)
- 哈希图在键的插入和查找方面最坏情况是 O(n)。这导致在哈希冲突攻击期间的二次运行时间。引入具有 O(log n) 最坏情况行为的新附加数据结构将消除根本原因。像红黑树或前缀树(trie [trie])这样的数据结构也会有其他好处。带有字符串键的前缀树可以减少内存使用,因为公共前缀存储在树结构中。
讨论
可插拔
本 PEP 的初稿使得哈希算法在运行时可插拔。它在一个二进制文件中支持多种哈希算法,以便用户可以在启动时选择哈希算法。核心提交者 [pluggable] 认为这种方法是不必要的复杂。PEP 的后续版本旨在进行编译时配置。
非对齐内存访问
SipHash24 的实现受到了批评,因为它忽略了非对齐内存问题,因此在需要整数类型对齐的体系结构上不起作用。PEP 故意忽略了这种特殊情况,并且不支持在这些平台上使用 SipHash24。除非另有证明,否则根本不认为值得麻烦。所有主要平台,如 X86、X86_64 和 ARMv6+ 都可以处理非对齐内存,几乎没有速度影响,甚至没有影响。[alignmentmyth]
几乎每个块无论如何都是正确对齐的。目前,字节和字符串的数据总是对齐的。只有 memoryview 在极少数情况下可能指向未对齐的块。PEP 实现针对常见情况进行了优化和简化。
ASCII str / 字节哈希冲突
自 PEP 393 实现以来,字节和 ASCII 文本具有相同的内存布局。因此,新的哈希 API 将保持不变
hash("ascii string") == hash(b"ascii string")
对于 ASCII 字符串和 ASCII 字节。相等的哈希值会导致哈希冲突,因此对于带有混合键的字典和集合会造成轻微的速度损失。冲突的原因可以通过例如从字节的哈希值中减去 2 来消除。减去 -2 是因为 hash(b"") == 0 并且 -1 已保留。本 PEP 不改变哈希值。
参考资料
- 问题 19183 [issue19183] 包含一个参考实现。
版权
本文档已置于公共领域。
来源: https://github.com/python/peps/blob/main/peps/pep-0456.rst
最后修改: 2025-02-01 08:59:27 GMT