HMAC-based Extract-and-Expand Key Derivation Function,也就是常说的HKDF,是现代密码学工程里出现频率极高的一个构件。TLS 1.3彻底抛弃了旧版协议中基于PRF的密钥派生方式,全面转向HKDF,OpenSSL从1.1.1开始也在底层增加了EVP_KDF框架来统一管理各类密钥派生函数。对于使用Ruby做安全相关开发的工程师来说,理解EVP_KDF与HKDF的关系,不仅有助于排查TLS握手问题,也能在需要自定义密钥协议的场景中直接复用这套经过验证的算法。

HKDF的基本原理:提取与扩展
HKDF的设计目标很明确:接收一段可能不够均匀的输入密钥材料(IKM),输出若干段密码学上独立、长度可控的输出密钥(OKM)。它分为两个阶段,第一个阶段叫提取,第二个阶段叫扩展。提取阶段使用HMAC把IKM压缩成一个固定长度的伪随机密钥PRK。这里有个容易忽略的细节:HMAC的密钥参数在这一步填的是盐值salt,而消息参数填的才是输入密钥材料。如果调用者没有提供盐值,标准建议使用一串长度等于哈希函数输出长度的零字节代替,而不是空字符串。
扩展阶段则从PRK出发,通过反复调用HMAC生成任意长度的输出。具体做法是把上一次的输出块拼接在本次的info参数前面,再附上一个从1开始的单字节计数器,循环拼接直到满足目标长度。info参数在TLS 1.3里被用来给每一段派生密钥打标签,比如字符串"c exp master"就对应客户端侧的导出主密钥,这种标签机制保证了不同用途的密钥即使来自同一个PRK也互不相同。
为什么TLS 1.3要选择HKDF而不是延续原来的PRF?主要原因是HKDF的安全证明更完善,提取阶段能够修复输入密钥材料分布不均匀的问题,而扩展阶段的输出长度不再受哈希函数输出长度限制,可以按需生成多把用途隔离的密钥。
TLS 1.3中的密钥调度与EVP_KDF的关系
TLS 1.3握手过程本质上是一张密钥调度表的执行过程。从早期密钥(Early Secret)到握手密钥(Handshake Secret)再到主密钥(Master Secret),每一层都调用HKDF-Extract,然后又通过HKDF-Expand-Label派生出具体的读写密钥。整个链条可以用下面的伪代码概括:
Early Secret = HKDF-Extract(salt=NULL, IKM=PSK或全零) Derived = Derive-Secret(Early Secret, "derived", "") Handshake Secret = HKDF-Extract(salt=Derived, IKM=ECDSA/ECDHE共享密钥) Derived = Derive-Secret(Handshake Secret, "derived", "") Master Secret = HKDF-Extract(salt=Derived, IKM=全零) client_handshake_traffic_secret = Derive-Secret(Handshake Secret, "c hs traffic", transcript) server_handshake_traffic_secret = Derive-Secret(Handshake Secret, "s hs traffic", transcript)
其中Derive-Secret是HKDF-Expand-Label的封装,它把标签、握手上下文哈希和输出长度编码成结构化的info字节串。EVP_KDF是OpenSSL 1.1.1引入的C层抽象,它把HKDF、PBKDF2、SSKDF等派生函数统一到同一个EVP接口下,应用程序不需要直接链接具体的KDF实现。Ruby的openssl标准库底层依赖libcrypto,当你的系统OpenSSL版本足够新时,Ruby侧的OpenSSL::KDF模块实际上就能受益于这套框架。
Ruby代码实战:用OpenSSL::KDF执行HKDF
Ruby的openssl gem封装了HKDF,入口是OpenSSL::KDF.hkdf方法。下面这个例子演示了完整的提取加扩展流程:
require 'openssl'
# 输入密钥材料,实际场景中通常来自ECDHE共享密钥计算结果
ikm = OpenSSL::Random.random_bytes(32)
salt = OpenSSL::Random.random_bytes(32)
info = "tls13 c ap traffic" # TLS 1.3风格的应用流量密钥标签
# 一步完成extract-then-expand,输出32字节密钥
okm = OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: 32, hash: 'SHA256')
puts okm.unpack1('H*')
# 只执行提取阶段,得到伪随机密钥PRK
prk = OpenSSL::KDF.hkdf_extract(ikm, salt: salt, hash: 'SHA256')
# 只执行扩展阶段,从PRK派生多段密钥
keys = OpenSSL::KDF.hkdf_expand(prk, info: "tls13 key", length: 16, hash: 'SHA256')
需要特别注意salt和info参数的语义区别。salt影响提取阶段的随机性,而info只参与扩展阶段的标签区分。如果你要模拟TLS 1.3的Derive-Secret,info必须严格按照RFC 8446第7.1节的结构编码:先是两字节长度前缀的标签(前缀为"tls13 "字符串),再是上下文哈希,最后是两字节的输出长度。手工拼接这个结构时容易把字节序写反,建议用pack('n')生成大端序长度。
验证正确性有个简单办法:对照RFC 5869附录A的测试向量。固定IKM、salt和info后,HKDF-SHA256的输出应该是确定的字节串,如果你的Ruby代码跑出的十六进制结果与测试向量不一致,多半是参数顺序或者编码方式出了问题。这也是排查自研密钥协议与对端不一致时的标准手段。
版本兼容与常见坑
Ruby侧能否使用HKDF取决于两个条件:Ruby自带的openssl gem版本和系统libcrypto版本。OpenSSL 1.1.1之前的版本没有完整的HKDF实现,此时OpenSSL::KDF.hkdf会抛出NotImplementedError。可以用下面的代码做运行时检测:
require 'openssl'
begin
OpenSSL::KDF.hkdf('x' * 32, salt: '', info: '', length: 32, hash: 'SHA256')
puts "HKDF可用,OpenSSL版本:#{OpenSSL::OPENSSL_VERSION}"
rescue NotImplementedError, NoMethodError => e
puts "当前环境不支持HKDF:#{e.message}"
end
另一个高频错误是length参数设置过大。HKDF的扩展阶段单次循环最多生成255乘以哈希输出长度个字节,对SHA256来说上限是8160字节,超出这个范围会抛出RuntimeError。正常业务中很少需要这么长的密钥,但如果你的协议设计要求一次派生大量密钥材料,就需要自行分段调用并拼接。此外,info参数只接受ASCII-8BIT编码的字符串,传入UTF-8编码且包含多字节字符时可能报编码错误,稳妥的做法是先用force_encoding('ASCII-8BIT')处理,或者直接用字节数组pack生成。
最后提醒一点:HKDF本身不包含任何慢速因子,它适合处理已经是高熵的共享密钥材料,绝对不能直接拿来做用户密码的哈希存储。处理密码请使用PBKDF2、scrypt或者argon2这类专门抵抗暴力破解的函数。混淆这两类派生函数的用途,是安全工程中相当常见也相当致命的错误。
总结
HKDF以极简的结构解决了密钥材料到会话密钥的转换问题,提取阶段处理输入的不均匀性,扩展阶段配合info标签实现一源多钥。TLS 1.3的密钥调度体系完全建立在这两个原语之上,而Ruby通过OpenSSL::KDF模块把这些能力暴露给了上层脚本。掌握HKDF的参数语义和RFC测试向量的验证方法,无论是调试TLS握手还是构建自定义密钥协议,都会让你对密钥的来源和去向有更清晰的把握。
Ruby OpenSSLEVP_KDFHKDF密钥派生修改时间:2026-09-15 06:52:35