土法炼钢兴趣小组的算法知识备份

【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储

目录

密钥是密码学的核心资源。前面的文章中,我们讨论了对称加密需要均匀随机的密钥,认证加密需要独立的加密密钥与认证密钥,而 Diffie-Hellman 密钥交换产生的是一个群元素而非均匀随机的比特串。这些场景都指向同一个问题:我们如何从一个”原始秘密材料”出发,安全地生成一个或多个符合密码学要求的密钥?这正是密钥派生函数(KDF,Key Derivation Function)要解决的核心问题。与此同时,另一个看似不同却深度相关的问题也摆在我们面前——如何安全地存储用户密码?密码哈希函数(Password Hashing Function)虽然与密钥派生函数在工程目标上有差异,但它们共享大量的设计哲学和技术手段。本文将系统地解析这两个领域,从 HKDF 的优雅理论到 Argon2 的内存困难设计,从协议中的密钥调度到工程中的密码存储实践。

一、密钥派生的需求

在实际的密码系统中,密钥很少以”理想形态”出现。理想的密钥应当是均匀随机的比特串,长度恰好符合算法要求,并且不同用途的密钥相互独立。但现实中,我们拿到的原始秘密材料往往达不到这些要求。理解这一差距,是理解密钥派生函数存在意义的起点。

Diffie-Hellman 输出不能直接用作密钥。 这是最经典的动机。当 Alice 和 Bob 执行一次椭圆曲线 Diffie-Hellman(ECDH)密钥交换时,他们得到的共享秘密是一个椭圆曲线上的点——准确地说,是该点的 x 坐标。这个值虽然对窃听者而言是计算困难的,但它并不是均匀随机分布的。椭圆曲线群上的元素具有特定的代数结构,其编码后的比特串在统计上存在偏差。如果你直接将 ECDH 的输出作为 AES-256 的密钥,你使用的”密钥”的实际熵就低于 256 比特,安全强度也随之下降。更具体地说,椭圆曲线上的 x 坐标并非所有 256 比特值都有可能出现——它被限制在有限域的一个子集中。类似的问题也出现在经典的有限域 Diffie-Hellman 中:共享秘密 g^{ab} mod p 是模 p 的一个元素,它不是均匀随机的 256 比特串。

密钥分离(Key Separation)是密码学的基本原则。 在一个完整的通信协议中,你往往需要多个密钥:一个用于客户端到服务器的加密,一个用于服务器到客户端的加密,一个用于消息认证,一个用于密钥更新……如果这些密钥之间存在可被利用的数学关系,攻击者可能利用一个密钥的泄露推导出其他密钥,或利用一个协议组件的弱点攻击另一个组件。因此,我们需要从单一的主密钥(Master Secret)派生出多个相互独立的子密钥(Subkey),使得知道任意一个子密钥不能推断出其他子密钥或主密钥。这就是密钥分离的要求。

密钥拉伸(Key Stretching)扩展短密钥的有效长度。 某些场景下,原始密钥材料的长度不足。例如,一个 128 比特的共享秘密需要派生出一个 256 比特的加密密钥和一个 256 比特的认证密钥,总共需要 512 比特的密钥材料。简单地复制或拼接原始密钥是不安全的——这不会增加任何熵,反而可能引入密钥之间的依赖关系。密钥派生函数通过密码学安全的方式将有限的熵”拉伸”到所需的长度,同时保证输出的每一部分在计算上看起来都是独立和随机的。

从非均匀秘密提取均匀密钥。 除了 DH 输出之外,还有很多场景会产生非均匀的秘密材料:生物特征(Biometric)数据、物理不可克隆函数(PUF,Physical Unclonable Function)的响应、多个密钥协议结果的拼接,甚至是从物理随机源采集的原始熵。这些材料虽然包含足够的熵,但分布不均匀,不能直接作为密码学密钥。我们需要一个”熵提取器”(Randomness Extractor)来将这些非均匀的高熵材料浓缩为均匀随机的密钥。

这四个需求——消除非均匀性、密钥分离、密钥拉伸、熵提取——共同定义了密钥派生函数的设计目标。一个好的 KDF 应当能够接受各种形式的输入密钥材料(IKM,Input Keying Material),输出看起来与均匀随机不可区分的密钥,并且允许通过不同的上下文信息派生出相互独立的多个密钥。

二、HKDF

HKDF(HMAC-based Extract-and-Expand Key Derivation Function)由 Hugo Krawczyk 在 2010 年提出,并被标准化为 RFC 5869。它可能是目前最重要、应用最广泛的密钥派生函数,其设计之优雅和安全证明之严谨使其成为现代密码协议的基石。

Extract-then-Expand 范式

HKDF 的核心设计理念是将密钥派生分为两个逻辑独立的阶段:提取(Extract)和扩展(Expand)。这种分离不是随意的工程选择,而是有深刻的密码学原因。

提取阶段的目标是将可能非均匀分布的输入密钥材料(IKM)压缩为一个固定长度的伪随机密钥(PRK,Pseudorandom Key)。无论 IKM 的长度、结构和分布如何,只要它包含足够的熵,提取阶段就能产生一个看起来均匀随机的 PRK。这一步解决了”熵提取”问题。

扩展阶段的目标是将固定长度的 PRK 扩展为任意长度的输出密钥材料(OKM,Output Keying Material)。扩展阶段假设输入已经是均匀随机的(由提取阶段保证),因此它只需要解决”密钥拉伸”和”密钥分离”问题。

这种两阶段设计的好处在于,每个阶段有清晰的安全假设和安全目标,可以独立分析和证明。Krawczyk 在其论文中给出了严格的安全归约:如果底层的 HMAC 是一个好的伪随机函数(PRF,Pseudorandom Function),那么 HKDF 就是一个安全的密钥派生函数。

HKDF-Extract

HKDF-Extract 的定义极为简洁:

PRK = HMAC-Hash(salt, IKM)

其中 salt 是一个可选的盐值(如果不提供,默认为与哈希输出等长的全零串),IKM 是输入密钥材料。注意,在 HMAC 的调用中,salt 被用作密钥,IKM 被用作消息。这看起来可能有些反直觉——盐值怎么能当密钥用?但从密码学角度看,这是正确的:HMAC 在密钥位置使用盐值,利用了 HMAC 的”随机化提取”(Randomness Extraction)性质。具体来说,当 HMAC 的密钥是一个独立于消息的随机值时,HMAC 的输出在计算上不可区分于均匀随机。

盐值的作用非常关键。一个好的盐值(高熵、与 IKM 独立的随机串)可以显著增强提取的安全性。即使 IKM 的熵分布不理想,盐值的随机性也能帮助”平滑”输出分布。在协议设计中,盐值通常来自协议的公开参数——例如,在 TLS 1.3 中,上一次握手的 Resumption Master Secret 被用作新握手的盐值。如果没有合适的盐值可用,RFC 5869 建议使用全零串,但这会降低安全余量。

HKDF-Expand

HKDF-Expand 的定义基于 HMAC 的迭代调用:

T(0) = 空串
T(1) = HMAC-Hash(PRK, T(0) || info || 0x01)
T(2) = HMAC-Hash(PRK, T(1) || info || 0x02)
T(3) = HMAC-Hash(PRK, T(2) || info || 0x03)
...
OKM = T(1) || T(2) || T(3) || ... (取前 L 字节)

其中 PRK 是提取阶段的输出,info 是一个可选的上下文信息(context information),用于将不同用途的密钥隔离开。每一轮的输出会作为下一轮的输入链接起来,同时每轮附加一个递增的计数器字节。

info 参数是实现密钥分离的核心机制。通过为不同用途提供不同的 info 字符串——例如 “client handshake traffic secret”、“server application traffic secret”——可以从同一个 PRK 安全地派生出多个相互独立的密钥。HKDF 的安全证明保证:如果 HMAC 是一个 PRF,那么使用不同 info 派生的密钥在计算上是独立的。

安全证明

HKDF 的安全证明是其最引人注目的特性之一。Krawczyk 在原始论文中给出了两个层次的证明。

第一层是 Extract 阶段的安全性:在”HMAC 是一个(ε, δ)-强随机化提取器”的假设下,HKDF-Extract 可以将任何包含足够最小熵(min-entropy)的 IKM 压缩为一个统计上接近均匀分布的 PRK。这个假设在实践中得到了广泛验证——基于 SHA-256 或 SHA-384 的 HMAC 已经被大量分析,没有发现违反此假设的证据。

第二层是 Expand 阶段的安全性:在”HMAC 是一个安全的 PRF”的假设下,HKDF-Expand 可以将均匀随机的 PRK 扩展为任意长度的输出,且输出与均匀随机不可区分。这个 PRF 假设是密码学中最标准、最被广泛研究的假设之一。

这种基于标准假设的安全证明,使得 HKDF 的安全性与底层哈希函数的安全性紧密关联。只要 SHA-256(或 SHA-384)保持安全,HKDF 就是安全的。这种”可归约安全性”(Provable Security)是现代密码学设计的黄金标准。

以下是 HKDF 的 Go 语言实现示例,展示了如何在实际代码中使用 Go 标准库的 HKDF 包:

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"

    "golang.org/x/crypto/hkdf"
)

func main() {
    // 模拟 ECDH 密钥交换产生的共享秘密(非均匀分布)
    inputKeyMaterial, _ := hex.DecodeString(
        "4a6f686e2044696666696520616e64204d617274696e2048656c6c6d616e")

    // salt 应当是随机或协议约定的公开值
    salt, _ := hex.DecodeString(
        "000102030405060708090a0b0c0d0e0f")

    // info 区分不同用途的密钥,实现密钥分离
    infoEncrypt := []byte("tls13 client handshake key")
    infoMAC := []byte("tls13 client handshake mac")

    // --- HKDF: Extract + Expand 一步完成 ---
    // 派生 32 字节加密密钥
    hkdfReader := hkdf.New(sha256.New, inputKeyMaterial, salt, infoEncrypt)
    encryptionKey := make([]byte, 32)
    if _, err := io.ReadFull(hkdfReader, encryptionKey); err != nil {
        panic(err)
    }

    // 用不同 info 派生 32 字节 MAC 密钥
    hkdfReader = hkdf.New(sha256.New, inputKeyMaterial, salt, infoMAC)
    macKey := make([]byte, 32)
    if _, err := io.ReadFull(hkdfReader, macKey); err != nil {
        panic(err)
    }

    fmt.Printf("Encryption Key: %x\n", encryptionKey)
    fmt.Printf("MAC Key:        %x\n", macKey)

    // --- 也可以分步调用 Extract 和 Expand ---
    prk := hkdf.Extract(sha256.New, inputKeyMaterial, salt)
    fmt.Printf("PRK:            %x\n", prk)

    expander := hkdf.Expand(sha256.New, prk, infoEncrypt)
    derivedKey := make([]byte, 32)
    if _, err := io.ReadFull(expander, derivedKey); err != nil {
        panic(err)
    }
    fmt.Printf("Derived (step): %x\n", derivedKey)
}

上述代码清晰地展示了 HKDF 的三个核心概念:IKM 来自密钥交换,salt 提供随机化,info 实现密钥分离。通过为加密和认证使用不同的 info 字符串,我们从同一个共享秘密安全地派生了两个独立的密钥。

三、HKDF 在协议中的应用

HKDF 的理论优雅性使其成为现代密码协议中密钥调度(Key Schedule)的首选工具。让我们考察几个最重要的应用场景。

TLS 1.3 密钥调度

TLS 1.3 的密钥调度是 HKDF 最引人注目的应用。与之前版本使用的 PRF 不同,TLS 1.3 完全基于 HKDF 构建其密钥派生体系,并定义了两个辅助函数:

HKDF-Expand-Label(Secret, Label, Context, Length) =
    HKDF-Expand(Secret, HkdfLabel, Length)

Derive-Secret(Secret, Label, Messages) =
    HKDF-Expand-Label(Secret, Label, Hash(Messages), Hash.length)

TLS 1.3 的密钥调度是一条从上到下的”派生链”(Derivation Chain)。它从两个输入秘密开始——PSK(Pre-Shared Key,用于 0-RTT 和会话恢复)和 ECDHE 共享秘密——通过一系列 HKDF-Extract 和 Derive-Secret 调用,逐步派生出所有需要的密钥:

  1. Early Secret = HKDF-Extract(salt=0, IKM=PSK)
  2. 从 Early Secret 派生 client_early_traffic_secret(用于 0-RTT 数据)
  3. Handshake Secret = HKDF-Extract(salt=Derive-Secret(Early Secret, “derived”, ““), IKM=ECDHE)
  4. 从 Handshake Secret 派生 client/server_handshake_traffic_secret(用于握手加密)
  5. Master Secret = HKDF-Extract(salt=Derive-Secret(Handshake Secret, “derived”, ““), IKM=0)
  6. 从 Master Secret 派生 client/server_application_traffic_secret(用于应用数据加密)
  7. 从 Master Secret 还派生 resumption_master_secret(用于会话恢复)

这条链的设计极为精妙。每一步的 Extract 都将新的秘密材料混入,每一步的 Derive-Secret 都通过 Label 和 Messages(握手消息的哈希)实现了密钥分离。握手消息的哈希作为 Context 参数被混入密钥派生过程,这意味着如果攻击者篡改了任何一条握手消息,派生出的所有密钥都会不同,从而提供了对中间人攻击的天然防御。

Signal 协议

Signal 协议(也称为 Double Ratchet 协议)在其密钥棘轮(Key Ratchet)机制中大量使用 HKDF。Signal 的密钥派生分为三层。

根密钥棘轮(Root Key Ratchet)在每次 DH 棘轮步进时触发:新的 DH 共享秘密与当前根密钥通过 HKDF 结合,产生新的根密钥和链密钥。这里 HKDF-Extract 接受当前根密钥作为盐,DH 输出作为 IKM,然后通过 HKDF-Expand 派生出新的根密钥和链密钥。

发送/接收链棘轮(Chain Key Ratchet)使用 HMAC 对链密钥进行单向迭代:每发送或接收一条消息,链密钥前进一步,同时派生出该消息的消息密钥。消息密钥再通过 HKDF 扩展为加密密钥、认证密钥和 IV。

这种层次化的 HKDF 使用为 Signal 提供了前向保密(Forward Secrecy)和未来保密(Future Secrecy,也叫 Post-compromise Security)的双重保证。

IKEv2

IPsec 的密钥协商协议 IKEv2(RFC 7296)也采用了类似的密钥派生结构。IKEv2 使用 prf+(一种基于 PRF 的密钥扩展函数)从 SKEYSEED 派生出一系列密钥——SK_d(用于子 SA 的密钥派生)、SK_ai/SK_ar(认证密钥)、SK_ei/SK_er(加密密钥)、SK_pi/SK_pr(用于 AUTH 载荷)。虽然 IKEv2 的设计早于 HKDF 的标准化,但其密钥派生结构与 HKDF 的 Extract-then-Expand 范式高度一致,后来的分析也证实了这种设计的安全性。

WireGuard

WireGuard VPN 协议在其 Noise 协议框架内使用 HKDF 作为唯一的密钥派生原语。WireGuard 的握手过程中,每一步 DH 计算的结果都通过 HKDF 混入一个不断演化的”链密钥”(Chaining Key),最终派生出数据传输所需的对称密钥。WireGuard 的设计者 Jason Donenfeld 明确指出,选择 HKDF 的原因正是其清晰的安全证明和简洁的接口。

这些协议的共同特征是:它们都将 HKDF 作为”密钥调度的通用工具”,通过不同的 salt、IKM 和 info 参数来组织复杂的密钥层次结构。HKDF 的模块化设计使协议分析者能够独立推理每一步密钥派生的安全性,极大地降低了协议设计和验证的复杂度。

四、密码哈希 vs 密码学哈希

现在我们转向一个相关但目标迥异的领域:密码哈希(Password Hashing)。虽然密码哈希函数和前面讨论的密钥派生函数在技术上有重叠,但它们的设计哲学存在根本性差异,必须清晰区分。

密码学哈希函数追求速度,密码哈希函数追求慢。 这是最核心的区别。SHA-256 被设计为尽可能快——在现代硬件上,SHA-256 每秒可以处理数 GB 的数据。这种速度对于文件完整性校验、数字签名、HMAC 等应用来说是理想的。但当你用 SHA-256 来哈希密码时,这种速度就成了致命的弱点。

为什么?因为用户密码的熵远远低于密码学密钥。一个典型的用户密码可能有 20-40 比特的有效熵(对应几千到几万亿的可能性),远低于 128 比特密钥的 2^128 种可能性。如果使用 SHA-256,攻击者用一块现代 GPU 每秒可以计算数十亿次 SHA-256,在几秒钟之内就能遍历几十比特熵空间的所有可能密码。这就是离线暴力攻击(Offline Brute-force Attack)的威力——攻击者获取了密码哈希的数据库后,可以在自己的硬件上不受任何速率限制地尝试所有可能的密码。

在线攻击与离线攻击的模型差异。 在线攻击(Online Attack)中,攻击者向服务器提交密码猜测,受制于网络延迟和服务器的速率限制(如账户锁定、验证码)。在这种模型下,即使密码哈希函数很快,攻击者每秒也只能尝试有限的次数。但在离线攻击模型中,攻击者已经获得了密码哈希值(通过数据库泄露、SQL 注入等方式),可以在自己控制的硬件上以最大速度进行暴力搜索。密码哈希函数的设计目标正是在离线攻击模型下保护用户密码。

代价参数(Cost Parameters)是密码哈希的核心设计要素。 密码哈希函数通过可调的代价参数来控制计算一次哈希所需的资源——时间、内存,或两者兼具。防御者根据自己的服务器性能选择代价参数,使得合法用户登录时的延迟在可接受范围内(通常 100-500 毫秒),而攻击者在离线攻击中的成本则被极大地放大。随着硬件性能的提升,防御者可以逐步提高代价参数,使安全水平与硬件进步保持同步。

彩虹表与盐值。 在密码哈希的历史中,彩虹表(Rainbow Table)攻击曾经是一个巨大的威胁。攻击者预计算大量常见密码的哈希值,形成一个查找表。当获得哈希数据库后,只需查表就能还原密码,不需要任何在线计算。盐值(Salt)——一个为每个用户随机生成的唯一值——使得预计算攻击变得不可行:即使两个用户使用相同的密码,由于盐值不同,它们的哈希值也完全不同。攻击者必须为每个盐值单独构建彩虹表,成本呈指数增长。现代密码哈希函数都强制要求使用盐值。

五、PBKDF2

PBKDF2(Password-Based Key Derivation Function 2)是最早被广泛标准化的密码哈希函数之一,定义在 PKCS #5 v2.0(RFC 2898)中,后来被 NIST SP 800-132 推荐。尽管它已经不是最优选择,但由于其标准化历史悠久、实现简单、合规要求,在很多系统中仍然广泛使用。

算法结构

PBKDF2 的核心思想极为直接——反复迭代 HMAC:

U_1 = HMAC(Password, Salt || INT_32_BE(BlockIndex))
U_2 = HMAC(Password, U_1)
U_3 = HMAC(Password, U_2)
...
U_c = HMAC(Password, U_{c-1})

DK_block = U_1 ⊕ U_2 ⊕ U_3 ⊕ ... ⊕ U_c

其中 c 是迭代次数(iteration count),BlockIndex 是一个从 1 开始递增的索引(用于生成多个输出块)。每个输出块是所有中间值的异或。最终的派生密钥是所需数量的块的拼接。

异或操作(而非仅取最后一次迭代的结果)有一个微妙的安全原因:如果底层 PRF 存在某种弱点导致迭代过程陷入短循环,异或操作可以部分缓解这个问题,因为它保留了所有中间状态的信息。

迭代次数选择

PBKDF2 的安全性几乎完全取决于迭代次数 c 的选择。NIST SP 800-132 在 2010 年建议的最低迭代次数为 1000 次,但这个数字在今天已经远远不够。OWASP(Open Web Application Security Project)在 2023 年建议:使用 HMAC-SHA-256 时至少 600,000 次迭代,使用 HMAC-SHA-512 时至少 210,000 次迭代。这些建议的目标是使合法服务器上单次验证耗时约 100-300 毫秒。

选择迭代次数时,应当在目标服务器硬件上实际测量。以下是一个简单的方法论:设定目标延迟(例如 250 毫秒),然后在生产环境的硬件上逐步增加迭代次数直到达到目标延迟。每年或硬件升级时重新评估。

局限性

PBKDF2 最大的问题在于它对 GPU 和 ASIC 攻击缺乏抵抗力。PBKDF2 的每次迭代只需要极少的内存(一个 HMAC 状态,不超过几百字节),这意味着攻击者可以在 GPU 上同时运行数千个 PBKDF2 实例,每个实例占用极小的内存。现代 GPU 拥有数千个计算核心和极高的内存带宽,使得 PBKDF2 的暴力破解效率比在 CPU 上高出数个数量级。ASIC 攻击者甚至可以设计专用硬件,进一步提升攻击效率。

这个缺陷的根源在于 PBKDF2 只有一个代价维度——时间(迭代次数)。它不消耗大量内存,因此攻击者可以用更多的并行计算核心来弥补时间成本。这也是后来 scrypt 和 Argon2 引入内存困难(Memory Hardness)概念的直接原因。

六、bcrypt

bcrypt 由 Niels Provos 和 David Mazières 在 1999 年提出,至今仍然是使用最广泛的密码哈希函数之一。它的设计基于一个巧妙的洞察:Blowfish 密码算法的密钥调度过程(Key Schedule)计算密集,可以被利用来构建一个慢速的密码哈希函数。

Eksblowfish 算法

bcrypt 的核心是一个改良的 Blowfish 算法,称为 Eksblowfish(Expensive Key Schedule Blowfish)。它的密钥调度过程如下:

  1. 用密码(password)初始化 Blowfish 的子密钥数组(P-array 和 S-boxes)
  2. 重复 2^cost 次:
    1. 用密码对子密钥数组进行扩展(ExpandKey)
    2. 用盐值对子密钥数组进行扩展(ExpandKey)
  3. 使用最终的子密钥数组加密一个固定的明文字符串 “OrpheanBeholderScryDoubt” 共 64 次
  4. 输出加密结果作为密码哈希

这个设计的关键在于步骤 2 的循环:每次循环都修改了 Blowfish 的 4KB 内部状态(18 个 32 位 P-array 元素和 4 个 256 元素的 S-box),并且修改是依赖于密码和盐值的。这意味着攻击者不能简单地预计算 S-box——每个密码和盐值的组合都会产生完全不同的内部状态。

bcrypt 的特点

128 位盐值。 bcrypt 使用 128 位的随机盐值,足以确保即使在极大的用户数据库中也不会出现盐值碰撞。

代价因子。 bcrypt 的代价因子(cost factor)是一个 4-31 之间的整数,内部循环次数为 2^cost。代价因子每增加 1,计算时间翻倍。2024 年的推荐值通常为 12-14(对应 4096 到 16384 次循环),取决于服务器硬件性能。

72 字节密码限制。 bcrypt 有一个众所周知的限制——它只处理密码的前 72 字节。超过 72 字节的密码部分将被截断。这是因为 Blowfish 的密钥调度只能处理最长 72 字节的密钥。在实践中,这个限制很少成为问题(72 字节的 UTF-8 文本通常可以容纳相当长的密码),但某些应用选择先对密码进行 SHA-256 哈希,然后将 32 字节的哈希值作为 bcrypt 的输入,以消除长度限制。

内存使用。 bcrypt 的每次计算需要约 4KB 的内存(Blowfish 的 S-box),这比 PBKDF2 多,在一定程度上增加了 GPU 攻击的难度——GPU 的每个计算核心需要独立的 4KB S-box 副本,这限制了并行度。然而,4KB 在现代 GPU(拥有数 GB 显存)面前仍然微不足道,因此 bcrypt 的 GPU 抵抗能力有限。

尽管如此,bcrypt 经过了超过 25 年的实战检验,没有发现严重的密码学弱点。它的安全性记录使其至今仍被许多安全专家推荐为可接受的选择,尤其是在无法使用 Argon2 的环境中。

七、scrypt

scrypt 由 Colin Percival 在 2009 年提出(后标准化为 RFC 7914),是第一个明确引入内存困难(Memory Hardness)概念的密码哈希函数。scrypt 的核心洞察是:CPU 的计算能力增长遵循摩尔定律(大约每两年翻倍),但内存价格的下降速度远慢于计算成本的下降。如果密码哈希函数需要大量内存才能计算,那么攻击者就不能简单地通过购买更多的 GPU 或 ASIC 来加速攻击——他们同时受限于内存容量和带宽。

内存困难函数的概念

一个函数是内存困难的,如果计算它所需的内存量随某个安全参数超线性增长,并且不存在显著减少内存使用的”时间-内存权衡”(Time-Memory Tradeoff,TMTO)。更精确地说,如果一个函数的”累积内存复杂度”(Cumulative Memory Complexity,CMC)——即所有计算步骤中的内存使用总和——至少为 Ω(n²)(其中 n 是安全参数),那么它在强意义上是内存困难的。

为什么内存困难性能有效抵抗 GPU 和 ASIC?因为内存带宽是一种根本性的物理瓶颈。GPU 虽然拥有数千个计算核心,但所有核心共享有限的显存带宽。如果每个核心独立需要大量内存和频繁的内存访问,GPU 的实际并行度就会被内存带宽限制,而非计算能力限制。ASIC 面临同样的问题——虽然可以设计极高速的计算逻辑,但片上 SRAM 的面积极为昂贵,而片外 DRAM 的延迟和带宽又构成瓶颈。

scrypt 的内部结构

scrypt 的算法分为三个层次:

SMix 是最外层的函数。它接受一个输入块 B 和参数 N(内存代价),执行两个阶段:

第一阶段(ROMix 的”写入”阶段):

V[0] = B
for i = 1 to N-1:
    V[i] = BlockMix(V[i-1])

这一阶段生成 N 个块并存储在内存数组 V 中,占用 N × 128r 字节的内存。

第二阶段(ROMix 的”读取”阶段):

X = V[N-1]
for i = 0 to N-1:
    j = Integerify(X) mod N
    X = BlockMix(X ⊕ V[j])

这一阶段进行 N 次迭代,每次根据当前状态 X 计算一个数据依赖的索引 j,然后访问数组 V 的第 j 个元素。由于 j 取决于 X 的值,访问模式是不可预测的——这就是”数据依赖的内存访问”(Data-dependent Memory Access)。

BlockMix 是一个基于 Salsa20/8 核心函数的混合操作,处理 2r 个 64 字节的块。

参数含义: N 是内存代价参数(通常是 2 的幂,如 2^14 = 16384 或 2^20 = 1048576),决定了所需的内存量。r 是块大小因子(通常为 8),影响每次 BlockMix 操作处理的数据量和内存访问的粒度。p 是并行度参数(通常为 1),允许独立地运行 p 个 SMix 实例以利用多核 CPU。

为什么 scrypt 是内存困难的? 关键在于 ROMix 的第二阶段。攻击者如果想减少内存使用(不存储完整的 V 数组),就需要在每次需要 V[j] 时重新计算它。但由于访问索引 j 是数据依赖的且不可预测的,重新计算 V[j] 可能需要从 V[0] 开始重新执行整个第一阶段的部分计算。Percival 证明了,对于 scrypt 而言,任何使用 S 内存的算法都需要至少 Ω(N²/S) 的计算时间。这意味着如果攻击者将内存减半,时间将增加到四倍——时间-内存乘积保持恒定,攻击者无法通过牺牲内存来廉价地获得加速。

scrypt 的局限

尽管 scrypt 在内存困难性方面开创了先河,但它也存在一些局限。首先,scrypt 的 ROMix 第二阶段使用数据依赖的内存访问,这使得它在某些硬件上容易受到缓存时序侧信道攻击(Cache-timing Side Channel Attack)——攻击者可以通过观察内存访问模式来推断密码信息。其次,scrypt 的参数调节不够灵活——N 同时控制了时间和内存,无法独立调节这两个维度。第三,scrypt 的并行度参数 p 的设计使得验证方需要的总内存为 p × N × 128r,这在某些资源受限的环境中可能过于昂贵。

八、Argon2

Argon2 由 Alex Biryukov、Daniel Dinu 和 Dmitry Khovratovich 设计,于 2015 年赢得了密码哈希竞赛(Password Hashing Competition,PHC)。这场竞赛历时两年,评审了 24 个提交方案,最终 Argon2 以其安全性、灵活性和性能的综合优势脱颖而出。Argon2 被认为是当前最先进的密码哈希函数,也是新系统设计的首选方案。

三个变体

Argon2 有三个变体,针对不同的安全需求:

Argon2d 使用数据依赖的内存访问模式。它在抵抗 GPU/ASIC 攻击方面提供了最强的保护,因为数据依赖的访问模式在专用硬件上更难优化。然而,数据依赖的访问模式也意味着它容易受到侧信道攻击——攻击者如果能观察内存访问的时序或缓存行为,可能推断出密码信息。因此 Argon2d 适用于没有侧信道威胁的场景,如加密货币挖矿。

Argon2i 使用数据无关的内存访问模式——即内存访问的地址只取决于公开参数(时间步和迭代次数),不取决于密码或任何秘密值。这使得 Argon2i 天然抵抗侧信道攻击,但代价是它对 GPU/ASIC 的抵抗力略弱于 Argon2d,因为攻击者可以预测访问模式。

Argon2id 是混合模式——第一轮迭代使用 Argon2i 的数据无关访问(提供侧信道抵抗),后续轮次切换为 Argon2d 的数据依赖访问(提供 GPU/ASIC 抵抗)。Argon2id 是当前推荐的标准变体,适用于绝大多数密码哈希和密钥派生场景。RFC 9106 明确建议除非有特殊理由,否则应当使用 Argon2id。

内部结构

Argon2 的核心是一个内存矩阵(Memory Matrix)的填充和混合过程。算法的主要参数包括:

内存矩阵被划分为 p 条”通道”(Lane),每条通道可以独立并行处理。每条通道又被划分为若干 1024 字节的块。

填充过程如下:

  1. 初始化:使用 Blake2b 对密码、盐值、参数和可选数据进行哈希,生成初始值 H_0。从 H_0 派生每条通道的前两个块。

  2. 第一轮填充:对于每个新块,使用 Argon2 的压缩函数 G 来计算其值。G 函数接受两个 1024 字节的输入块,使用基于 Blake2b 轮函数的列混合和行混合操作,产生一个 1024 字节的输出块。新块的计算引用前一个块和一个由索引函数确定的”参考块”(Reference Block)。在 Argon2d 模式下,参考块的索引取决于前一个块的值(数据依赖);在 Argon2i 模式下,参考块的索引由一个独立的地址生成器决定(数据无关)。

  3. 后续轮次:在 t > 1 时,算法再次遍历整个内存矩阵,用新计算的值与旧值进行异或。多次遍历增加了攻击者进行时间-内存权衡的难度。

  4. 最终化:将所有通道的最后一个块异或在一起,然后通过 Blake2b 长哈希函数产生最终输出。

为什么 Argon2id 是当前最佳实践

Argon2id 结合了 Argon2i 的侧信道抵抗和 Argon2d 的 GPU/ASIC 抵抗,在安全性和实用性之间取得了最佳平衡。具体来说:

可证明的内存困难性。 Argon2 的设计者证明了,对于 Argon2d 模式,任何计算它的算法至少需要 Ω(m² · t) 的累积内存复杂度(如果使用单遍 t=1),而对于 t > 1 的情况,累积内存复杂度为 Ω(m² · t)。这意味着减少内存使用将导致计算时间的超线性增长。

灵活的参数空间。 与 scrypt 不同,Argon2 允许独立调节时间和内存参数。你可以选择”低内存、高迭代”或”高内存、低迭代”,根据部署环境的资源约束来优化。

并行度控制。 参数 p 允许利用多核 CPU 的并行能力。验证方可以使用多个线程加速计算,而攻击者虽然也可以并行,但总的内存需求不会减少。

标准化与生态支持。 Argon2 被 RFC 9106 标准化,获得了 OWASP、NIST(在最新的数字身份指南中推荐)等权威机构的认可。几乎所有主流编程语言都有高质量的 Argon2 实现。

以下是一个 Python 示例,展示如何使用 Argon2 进行密码哈希,以及如何根据目标延迟调节参数:

import time
import argon2

def find_optimal_params(target_ms=300, memory_kib=65536, parallelism=4):
    """通过逐步增加时间代价找到满足目标延迟的参数组合。"""
    hasher = argon2.PasswordHasher(
        time_cost=1,
        memory_cost=memory_kib,
        parallelism=parallelism,
        hash_len=32,
        type=argon2.Type.ID,  # Argon2id
    )

    test_password = "benchmark-password-2024"
    time_cost = 1

    while True:
        hasher = argon2.PasswordHasher(
            time_cost=time_cost,
            memory_cost=memory_kib,
            parallelism=parallelism,
            hash_len=32,
            type=argon2.Type.ID,
        )
        start = time.monotonic()
        for _ in range(5):
            hasher.hash(test_password)
        elapsed_ms = (time.monotonic() - start) / 5 * 1000

        print(f"  time_cost={time_cost}, memory={memory_kib}KiB, "
              f"parallelism={parallelism} -> {elapsed_ms:.0f} ms")

        if elapsed_ms >= target_ms:
            return time_cost, memory_kib, parallelism
        time_cost += 1

    return time_cost, memory_kib, parallelism


def demo_password_hashing():
    """演示 Argon2id 密码哈希的完整流程。"""
    ph = argon2.PasswordHasher(
        time_cost=3,
        memory_cost=65536,   # 64 MiB
        parallelism=4,
        hash_len=32,
        type=argon2.Type.ID,
    )

    password = "correct horse battery staple"

    # 哈希(内部自动生成随机盐值)
    hashed = ph.hash(password)
    print(f"Hash: {hashed}")
    # 输出格式: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>

    # 验证
    try:
        ph.verify(hashed, password)
        print("密码验证成功")
    except argon2.exceptions.VerifyMismatchError:
        print("密码验证失败")

    # 检查是否需要重新哈希(参数升级时使用)
    if ph.check_needs_rehash(hashed):
        print("参数已变更,需要重新哈希")
        hashed = ph.hash(password)


if __name__ == "__main__":
    print("=== Argon2id 参数调优 ===")
    t, m, p = find_optimal_params(target_ms=300, memory_kib=65536)
    print(f"\n推荐参数: time_cost={t}, memory_cost={m}, parallelism={p}")

    print("\n=== 密码哈希演示 ===")
    demo_password_hashing()

上述代码展示了 Argon2 使用中的两个关键实践:参数调优和密码验证流程。find_optimal_params 函数通过实际测量来确定满足目标延迟的参数,这种”测量而非猜测”的方法论是密码哈希参数选择的黄金准则。check_needs_rehash 方法支持透明的参数升级,当防御者提高安全参数后,用户下次登录时自动重新哈希——这是密码存储系统中不可或缺的迁移机制。

九、密码存储工程实践

理解了各种密码哈希算法的原理之后,我们还需要关注工程实践中的诸多细节。算法选择只是密码存储安全的一部分,正确的工程实践同样关键。

盐值生成

盐值(Salt)必须满足以下要求:

唯一性。 每个用户、每次密码变更都应当生成新的盐值。绝不能在用户之间共享盐值,否则相同密码的用户将产生相同的哈希值,攻击者可以识别这一点。

随机性。 盐值应当使用密码学安全的随机数生成器(CSPRNG)生成。使用用户名或电子邮件作为盐值是不安全的——这些值是可预测的,攻击者可以提前为常见用户名预计算彩虹表。

长度。 现代密码哈希函数通常要求至少 128 位(16 字节)的盐值。128 位的随机盐值足以确保在可预见的未来不会出现碰撞。bcrypt 使用 128 位盐值,Argon2 推荐 128 位或更长。

参数调优方法论

密码哈希参数的选择不应当基于”推荐值表格”的机械套用,而应当基于对具体部署环境的实测。以下是一个系统化的方法论:

  1. 确定目标延迟。 根据应用场景设定单次密码验证的最大可接受延迟。对于交互式 Web 应用,通常为 200-500 毫秒;对于后台批处理或高安全场景,可以容忍更长的延迟。

  2. 固定一个维度,调节另一个。 对于 Argon2id,通常先固定内存参数(根据服务器的可用内存和并发用户数来确定),然后逐步增加时间参数直到达到目标延迟。OWASP 推荐的起点是:memory_cost=19456 KiB(约 19 MiB),time_cost=2,parallelism=1。如果服务器资源允许,更高的内存参数(如 65536 KiB 即 64 MiB)会提供更好的安全边际。

  3. 考虑并发。 如果服务器同时处理 100 个登录请求,每个请求使用 64 MiB 内存,总内存需求为 6.4 GB。这在高并发场景下可能不可接受。此时需要在安全性和可用性之间权衡,可以降低内存参数并增加时间参数来补偿。

  4. 实际测量。 在与生产环境相同的硬件上进行基准测试。不同的 CPU 架构、内存速度和 Argon2 实现的优化程度都会影响实际性能。

密码哈希参数速查表

以下表格汇总了主流密码哈希算法的推荐参数下限(截至 2024-2025 年,基于现代服务器硬件):

算法 推荐参数下限 目标延迟 硬件上下文 核心防御维度
PBKDF2-HMAC-SHA256 iterations >= 600,000 约 250ms 现代多核 CPU 仅时间成本
bcrypt cost >= 12(即 2^12 = 4096 轮) 约 250ms 通用 CPU;4KB 内存/实例 时间成本 + 微量内存
scrypt N = 2^20, r = 8, p = 1(约 1GB 内存) 约 500ms 需充足内存的服务器 时间 + 内存(顺序困难)
Argon2id t = 3, m = 65536(64MiB), p = 4 约 300ms 多核 CPU + 充足内存 时间 + 内存 + 并行度

笔者认为,密码哈希参数的选择是最不应该”抄作业”的环节。Stack Overflow 上五年前的回答推荐 bcrypt cost=10,那是基于 2019 年的硬件水平;而今天同样的硬件预算能买到数倍的算力。参数调优的黄金准则是:在你自己的生产硬件上实测,将单次哈希延迟调到用户体验可接受的上限(通常 200-500ms),然后每年或每次硬件升级时重新评估。Argon2 的 check_needs_rehash 机制正是为此而生——它让参数升级成为一个透明的、渐进的过程,而非一次痛苦的数据库迁移。盲目复制他人的参数,本质上是用别人的硬件环境来保护你的用户密码,这在安全工程中是不可接受的。

一个值得深思的现象是,密码哈希的参数军备竞赛在根本上是不可获胜的——防御者和攻击者之间存在一种结构性的不对称。防御者必须在用户登录时执行哈希计算,这意味着延迟直接影响用户体验;而攻击者在离线环境中执行暴力搜索,完全没有延迟约束。Argon2 的内存困难性设计之所以是一次战场转移(而非简单的升级),是因为它将竞争维度从”计算时间”转移到了”内存成本”。GPU 可以轻松并行化大量无状态的 HMAC 迭代(PBKDF2),但当每个并行实例都需要独立占用 64 MiB 内存时,GPU 的数千个核心就会被内存总量和带宽锁死。这是 Argon2 相比 PBKDF2 最深层的优势——它不是让攻击变慢,而是让攻击变贵,而且贵在攻击者最难扩展的资源维度上。

从工程实践来看,HKDF 的 Extract-then-Expand 范式中最容易被误用的恰恰是两个阶段的职责边界。Extract 负责熵浓缩——将可能非均匀分布的输入密钥材料压缩为固定长度的伪随机密钥(PRK);Expand 负责密钥扩展——将已经均匀的 PRK 拉伸为任意长度的输出。混淆这两个阶段是 KDF 误用的常见根源:如果跳过 Extract 阶段直接用非均匀的密钥材料调用 Expand,输出的”密钥”会继承输入的分布偏差;反过来,如果对已经均匀的密钥执行不必要的 Extract,虽然不会降低安全性,但会浪费一次 HMAC 调用并增加实现复杂度。TLS 1.3 的密钥调度之所以值得作为教科书范例,正是因为它严格遵守了这一分离:每次混入新的密钥材料(如 ECDHE 共享秘密)时使用 Extract,每次从已确立的主密钥派生子密钥时使用 Expand——从未混淆两者的角色。

在线攻击与离线攻击预算

在线攻击防御。 密码哈希函数不是在线攻击的唯一防线。账户锁定策略(如连续 5 次失败后锁定 15 分钟)、速率限制(如每分钟最多 10 次尝试)、验证码(CAPTCHA)、IP 信誉系统等措施共同构成了在线攻击的防御体系。密码哈希函数的代价参数不需要为在线攻击设计——在线攻击的速率已经被网络层控制住了。

离线攻击预算。 密码哈希参数应当针对离线攻击场景优化。假设攻击者拥有若干块高端 GPU(如 NVIDIA A100),并且愿意投入数周到数月的计算时间。目标是使攻击者在这个预算下无法破解大部分用户的密码。一个合理的基准:使攻击者每次猜测的成本至少为合法验证的 10 倍以上(通过内存困难性阻止 GPU 并行化实现),使得暴力搜索 40 比特熵的密码空间需要数年的 GPU 时间。

迁移策略

在实际系统中,你可能需要从旧的密码哈希方案迁移到新的方案——例如,从 MD5 迁移到 bcrypt,或从 bcrypt 迁移到 Argon2id。迁移策略通常有两种:

渐进式迁移(Lazy Migration)。 这是最常用的策略。保持旧的哈希值不变,在每次用户成功登录时,用新的算法重新哈希明文密码并替换数据库中的旧哈希值。数据库需要记录每个用户使用的哈希算法和参数版本。这种策略不需要用户做任何事情,但长期不登录的用户将继续使用旧算法的哈希值。

双重哈希(Hash Wrapping)。 对于无法等待用户登录的情况,可以将旧哈希值作为新算法的输入:new_hash = Argon2id(old_bcrypt_hash)。验证时,先用旧算法哈希密码,再用新算法哈希结果。这种方法可以立即升级所有用户的哈希,但增加了系统复杂度,并且需要确保组合后的安全性不低于新算法本身。双重哈希还有一个好处:如果旧算法是 MD5 或 SHA-256(不含盐值),双重哈希可以立即阻止针对旧哈希的直接彩虹表攻击。

参数升级。 即使不更换算法,也应当定期提高代价参数以跟上硬件发展。现代密码哈希库(如 Python 的 argon2-cffi)提供了 check_needs_rehash 方法来检测当前哈希是否使用了过时的参数。在用户下次登录时透明地重新哈希——用户完全感知不到这一过程。

Balloon Hashing 与未来方向

除了 Argon2 之外,密码学界也在探索其他内存困难函数的设计。

Balloon Hashing 由 Dan Boneh、Henry Corrigan-Gibbs 和 Stuart Schechter 于 2016 年提出。它的设计哲学与 Argon2 不同——Balloon Hashing 追求极简的设计和严格的可证明安全性。它只需要一个密码学哈希函数作为构建块(不依赖任何特定的密码学原语),并且拥有更紧的内存困难性证明。Balloon Hashing 证明了在随机预言模型(Random Oracle Model)下,任何计算 Balloon Hash 的算法的时空乘积(Space-Time Product)至少为 Ω(n² · t)。NIST 在其特别出版物中提及了 Balloon Hashing 作为值得关注的替代方案。

Yescrypt 是 scrypt 的增强版本,也参加了密码哈希竞赛并获得了”特别认可奖”。它增加了 ROM(Read-Only Memory)支持,允许验证方使用一个大的只读查找表来进一步增加攻击者的内存需求。Yescrypt 在某些 Linux 发行版中被用作默认的密码哈希算法。

后量子考量。 当前的密码哈希函数(包括 Argon2)基于经典密码学假设——主要是哈希函数的单向性和 PRF 安全性。量子计算机对这些假设的影响相对有限:Grover 算法可以将暴力搜索的复杂度降低到平方根级别(即 128 比特的安全性在量子模型下降为 64 比特),但这可以通过简单地增大参数来补偿。因此,与公钥密码学不同,密码哈希领域的后量子迁移压力相对较小。

硬件绑定方案。 另一个趋势是将密码哈希与硬件安全模块(HSM)或可信执行环境(TEE)结合。例如,在应用层使用 Argon2id 计算密码哈希后,再使用 HSM 中存储的密钥对哈希值进行 HMAC 加密(有时称为”胡椒”,pepper)。这样,即使攻击者获得了数据库的完整转储,如果没有 HSM 中的密钥,仍然无法进行离线攻击。这将密码安全从纯粹的算法防御提升到了硬件防御的层次。

密钥派生与密码哈希看似是两个不同的领域,但它们共享一个深层的主题:如何从不完美的秘密材料中安全地提取出可用的密码学价值。HKDF 解决的是”从高熵但非均匀的材料中提取均匀密钥”的问题,Argon2 解决的是”从低熵的用户密码中构建抵抗暴力搜索的防线”的问题。理解这两者的设计原理和正确使用方法,是构建安全密码系统的基础能力。在下一篇文章中,我们将转向公钥密码学的数论基础,为后续讨论 RSA、Diffie-Hellman 和椭圆曲线密码做好数学准备。


密码学百科系列 · 第 13 篇

← 上一篇:认证加密(AEAD) | 系列目录 | 下一篇:公钥密码的数论基础


By .