4、密钥派生基础:KDF概念、PBKDF2算法原理、bcrypt/scrypt/Argon2对比

密钥派生,说白了就是「从密码生成密钥」的技术。你想想看,用户输入的密码通常只有8到16个字符,而AES-256需要32字节的密钥,RSA可能需要上千字节。直接拿密码当密钥用?那是给自己挖坑。

我刚开始做安全开发时,就见过有人把用户密码直接塞进AES。结果呢?密码长度不够,补零;密码有规律,密钥也有规律。攻击者拿到密文,跑个字典就把密码猜出来了。嗯,从那以后,我再也不敢跳过KDF这一步了。

4.1 什么是KDF?为什么需要它?

KDF(Key Derivation Function,密钥派生函数)的核心任务就两个:

  • 拉伸:把任意长度的输入,变成固定长度的输出
  • 强化:让暴力破解的成本变得极高

为什么需要强化?因为人类密码的熵太低了。一个8位大小写字母加数字的密码,理论上有62^8≈218万亿种组合。听起来很多?但GPU每秒能算几十亿次哈希,跑完所有组合也就几小时。KDF的作用就是让每次尝试都变慢——慢到攻击者等不起。

核心原则:KDF不是加密,也不是哈希。它是专门为「从低熵输入生成高熵密钥」而设计的函数。普通哈希函数(如SHA-256)太快了,不适合直接用于密码存储或密钥派生。

4.2 PBKDF2:最经典的KDF

PBKDF2(Password-Based Key Derivation Function 2)是PKCS#5标准的一部分,也是目前应用最广泛的KDF。它的核心思想很简单:把哈希函数反复跑很多遍。

4.2.1 算法原理

PBKDF2的公式长这样:

DK = PBKDF2(PRF, Password, Salt, c, dkLen)

参数含义:

  • PRF:伪随机函数,通常是HMAC-SHA256
  • Password:用户输入的密码
  • Salt:随机盐值,至少16字节
  • c:迭代次数,越大越安全
  • dkLen:派生密钥的长度

具体流程:

  1. 将密码和盐值拼接,做第一轮HMAC
  2. 把上一轮的结果作为输入,再做HMAC
  3. 重复c次,把中间结果异或累加
  4. 如果需要的密钥长度超过单次输出,就增加块索引重新计算

我的经验:迭代次数不是越大越好。我在项目中遇到过把迭代次数设到1000万次的案例,结果用户登录要等5秒。一般建议:2024年的环境下,SHA-256版本的PBKDF2迭代次数不低于60万次,SHA-512版本不低于20万次。具体值要根据你的服务器性能来调。

4.2.2 代码示例

import hashlib
import os

def pbkdf2_derive(password: bytes, salt: bytes = None, 
                  iterations: int = 600000, dklen: int = 32) -> tuple:
    if salt is None:
        salt = os.urandom(16)
    
    dk = hashlib.pbkdf2_hmac(
        'sha256', password, salt, iterations, dklen
    )
    return dk, salt

# 使用示例
password = b"my_secure_password"
salt = os.urandom(16)
key, used_salt = pbkdf2_derive(password, salt)
print(f"派生密钥: {key.hex()}")
print(f"盐值: {used_salt.hex()}")

4.3 bcrypt、scrypt、Argon2:新一代KDF

PBKDF2虽然经典,但它有个致命弱点:抗GPU攻击能力差。为什么呢?因为它只依赖CPU计算,而GPU可以并行跑成千上万个HMAC。于是,内存硬化的KDF应运而生。

4.3.1 bcrypt:加盐的Blowfish

bcrypt基于Blowfish加密算法,设计时就把「慢」作为目标。它的特点:

  • 内置盐值(22个字符的Base64编码)
  • 可调成本因子(cost factor),每增加1,时间翻倍
  • 输出固定为60个字符的字符串

我曾经在审计一个旧系统时发现,他们用的bcrypt成本因子只有4。这意味着什么?2010年的机器都能在1毫秒内算完。我建议至少用10以上,2024年推荐12(约250ms)。

4.3.2 scrypt:内存硬化的先驱

scrypt引入了两个关键参数:

  • N:CPU/内存成本参数
  • r:块大小参数
  • p:并行化参数

它的核心是「顺序内存困难函数」——计算过程中需要大量内存,而且访问模式是随机的。GPU想并行?内存带宽先跟上再说。

避坑指南:scrypt的参数设置很讲究。N必须是2的幂,r和p的乘积不能太大。我曾经见过有人把N设成2^20,r设成8,结果服务器内存直接爆了。推荐值:N=2^17(131072),r=8,p=1,大约占用16MB内存,计算时间约500ms。

4.3.3 Argon2:PHC冠军

Argon2是2015年密码哈希竞赛(PHC)的冠军,目前有三个变种:

  • Argon2d:抗侧信道攻击,适合加密货币
  • Argon2i:抗时间-内存权衡攻击,适合密码哈希
  • Argon2id:混合模式,推荐使用

Argon2的参数更灵活:

  • m:内存成本(KB)
  • t:时间成本(迭代次数)
  • p:并行度

4.4 四大KDF对比

特性 PBKDF2 bcrypt scrypt Argon2id
设计年份 2000 1999 2009 2015
核心算法 HMAC迭代 Blowfish加密 PBKDF2 + Salsa20/8 Blake2b + 内存访问
抗GPU 弱 中 强 强
抗ASIC 弱 弱 中 强
内存可调 否 否 是 是
并行度可调 否 否 是 是
推荐场景 兼容性优先 传统系统 内存充足 新系统首选
2024年推荐参数 c=600000 cost=12 N=2^17, r=8, p=1 m=64MB, t=3, p=4

4.5 如何选择?

说实话,没有银弹。我的建议是:

  • 新系统:无脑选Argon2id。它是目前最安全的KDF,参数灵活,抗各种攻击。
  • 兼容旧系统:用bcrypt。几乎所有语言都有现成库,参数简单不容易配错。
  • 需要FIPS认证:PBKDF2是唯一选择。虽然慢,但合规。
  • 内存受限环境:scrypt或PBKDF2。Argon2id默认64MB内存,嵌入式设备扛不住。

重要提醒:无论选哪个KDF,盐值必须随机生成,长度至少16字节。盐值可以明文存储,但每个密码必须用不同的盐。我曾经见过一个系统所有用户共用同一个盐——攻击者拿到一个用户的哈希,就能批量破解所有人的密码。

4.6 KDF知识体系图

下面这张图展示了KDF的核心概念、主流算法和选择路径:

KDF知识体系与算法对比图 KDF知识体系与算法选择路径 KDF核心目标 拉伸:固定长度输出 强化:提高破解成本 四大主流KDF算法 PBKDF2 HMAC迭代,无内存硬化 bcrypt Blowfish加密,内置盐值 scrypt 内存硬化,Salsa20/8 Argon2id PHC冠军,Blake2b 场景化选择建议 FIPS合规 → PBKDF2 传统系统兼容 → bcrypt 内存充足环境 → scrypt 新系统首选 → Argon2id 核心原则:盐值随机 + 参数可调 + 定期评估硬件性能 没有绝对安全的KDF,只有不断升级的参数

4.7 实战建议

最后,分享几个我在项目中踩过的坑:

  • 盐值不要复用:每个密码独立生成盐值,哪怕用户改了密码也要重新生成。
  • 参数要留余量:硬件每年都在升级,你的KDF参数也要跟着涨。我习惯每年评估一次,把迭代次数或内存成本提高20%。
  • 不要自己实现:KDF的细节太多,一个字节的差异就可能导致安全漏洞。用标准库,别手写。
  • 存储格式要规范:推荐使用「$算法$参数$盐值$哈希」的格式,方便以后升级算法。

我的习惯:在数据库里存密码哈希时,我会同时存一个「算法版本号」字段。这样哪天想从PBKDF2迁移到Argon2id,只需要在用户登录时检测版本号,自动升级哈希。用户无感知,安全有保障。