编程 HKDF 的 salt 不是上下文:Borg 把 sessionid 塞错参数,长度也隔不开两把钥匙

2026-10-30 00:04:16

HKDF 的 salt 不是上下文:Borg 把 sessionid 塞错参数,长度也隔不开两把钥匙

两段代码从同一个主密钥派生子密钥:一段把变化的量放进 salt,一段放进 info。两边都能跑出「不同的密钥」,但只有后者站在 HKDF 的证明范围内。borgbackup 的 issue #7953 就是这条线上的一次真实争论。

1. 先看两个参数在 HMAC 里的位置

RFC 5869 的定义,摘要成这样:

PRK     = HMAC-Hash(key = salt, msg = IKM)                        # Extract
T(i)    = HMAC-Hash(key = PRK,  msg = T(i-1) || info || byte(i))  # Expand
OKM     = T(1) || T(2) || ... 的前 L 字节,L <= 255 * HashLen

角色是互换的:Extract 里 salt 是 HMAC 的 key、IKM 是消息;Expand 里 PRK 才是 key。由此得到两条推论:

  • salt 只影响 PRK,一旦变化,后面所有输出都变;
  • info 参与每一块 T(i) 的计算,是唯一「每次派生都可以变、又不需要保密」的输入。

RFC 给 salt 的职责只有两个:抽取熵、跨系统域分离。它明确要求 salt 独立于 IKM,且不能被攻击者操纵;也明确说同一 IKM 下换子密钥应该换 info。

2. Borg #7953:sessionid 应该在 info 里

borgbackup/borg 的新加密方案里,主密钥 crypt_key 用来派生 session 级密钥,代码把随机 sessionid 传给了 salt 参数。issue #7953 的讨论指出:

  • HKDF 的安全证明覆盖的是「同一组 (IKM, salt) 配不同 info 做多次 Expand」;
  • 不覆盖「同一 IKM、变化 salt」。同一把主密钥在同一上下文里派生子密钥,应该改 info。

顺手还有一个化简:borg 的 IKM 是 512 位均匀随机值,按 RFC 5869 §3.3 本就可以整段跳过 Extract(Extract 是唯一用到 salt 的一步),而这里只需要一个 32 字节输出块,于是:

session_key = truncate32(HMAC-SHA512(crypt_key, sessionid || b"borg-session-key-" || suite || 0x01))

即 Extract 是纯开销。重点不在「谁写错了」,而在参数位置决定了你的用法落在不在证明里。

3. salt 当域分离的两个能实测的坑

3.1 HMAC 的零填充会吞掉尾部的零

HMAC 对短于 block size(SHA-256 是 64 字节)的 key 做右侧补零,所以 salt "abc" 与 "abc\x00" 会算出同一个 PRK:

saltA := []byte("abc")
saltB := []byte("abc\x00")
ka, _ := hkdf.Key(sha256.New, secret, saltA, "app-v1", 32)
kb, _ := hkdf.Key(sha256.New, secret, saltB, "app-v1", 32)
fmt.Println(bytes.Equal(ka, kb)) // true

任何「靠 salt 区分两个上下文」的设计在这一点上就已经漏了。

3.2 攻击者可控的 salt:65 字节撞上哈希

HMAC 对长于 block size 的 key 先算 H(key)。Trail of Bits 在 key derivation 的实践文章里给了个例子:如果系统拿用户名当 salt(hkdf(ikm=master, salt=username, info=purpose)),

  • 用户 A 的用户名是 b"A"*65;
  • 用户 B 的用户名是 sha256(b"A"*65) 的原始 32 字节。

两者的 HMAC key 完全相同,于是 B 派生出和 A 一样的密钥。salt 要么固定成常量(RFC 允许全零),要么随机生成后存储、随密文一起走,总之不能让用户决定。

4. 长度不构成隔离(前缀性质)

OKM 取的是 T(1)||T(2)... 的前 L 字节,所以同一 info、不同 keyLength 时,短的一定是长的前缀:

k32, _ := hkdf.Key(sha256.New, secret, salt, "app-v1", 32)
k16, _ := hkdf.Key(sha256.New, secret, salt, "app-v1", 16)
// bytes.Equal(k16, k32[:16]) == true

AES-128 和 AES-256 两把钥匙不能只靠长度区分。TLS 1.3 的做法是把长度写进 info——RFC 8446 §7.1 的 HKDF-Expand-Label 里,label 结构体带 uint16 length,外面再加 "tls13 " 前缀。自己写协议时照这个习惯:用途、版本、方向、算法、输出长度都编码进 info,用长度前缀或定长字段拼接,别把可变长字符串裸拼。

5. Go 1.24 换了包,迁移时的三个具体差异

Go 1.24(2025 年 2 月)把 golang.org/x/crypto/hkdf 并入了标准库 crypto/hkdf。

golang.org/x/crypto/hkdfcrypto/hkdf(Go 1.24+)
入口New / Expand 返回 io.ReaderKey / Extract / Expand 直接返回 []byte
输出长度边读边生成,可任意长keyLength 定长,超过 255*HashLen 返回 error
info 类型[]bytestring
salt 类型[]byte[]byte
状态已有弃用提案(golang/go#74245)标准库
key, err := hkdf.Key(sha256.New, secret, salt, "app-v1|enc|aes-256", 32)
if err != nil {
    return nil, err
}

两个迁移细节值得知道:

  • info 从 []byte 变 string,salt 仍是 []byte。类型不同正是提案里刻意做的,防止把 salt 和 info 传反——这类错误在老 API 里编译器帮不上忙。
  • 老代码的 io.Reader 用法绝大多数只有一次 Read 或 io.ReadFull,换成 Key 即可;真要流式输出当 XOF 用,才需要留在 x/crypto。

回归测试直接用 RFC 5869 附录 A 的 Test Case 1:IKM 为 22 个 0x0b,salt 为 000102030405060708090a0b0c,info 为 f0f1f2f3f4f5f6f7f8f9,L=42,期望 PRK 与 OKM:

PRK = 077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5
OKM = 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
func TestHKDFCase1(t *testing.T) {
	ikm := bytes.Repeat([]byte{0x0b}, 22)
	salt := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c}
	want, _ := hex.DecodeString("077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5")

	prk, err := hkdf.Extract(sha256.New, ikm, salt)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Equal(prk, want) {
		t.Fatalf("prk mismatch: %x", prk)
	}
}

Key 不暴露 PRK,想逐步核对结构就用 Extract + Expand。

6. PHP 与命令行:参数顺序和长度上限

PHP 7.1.2 起有 hash_hkdf:

hash_hkdf(string $algo, string $key, int $length = 0, string $info = "", string $salt = "")

三处容易看错:

  • 位置顺序是 info 在 salt 前面,和 RFC 里写的 (salt, info) 相反,review 时扫一眼很难发现传反了;
  • $key 其实是 IKM,不是「密钥」;传空字符串抛 ValueError;
  • $length = 0 表示取摘要长度;超过 255 × HashLen 时,PHP 8 抛 ValueError,PHP 7 是返回 false 并给 E_WARNING——两代行为不同,升级时值得留意。

官方示例是标准的 encrypt-then-MAC 拆两把钥匙:同一 $salt、不同 $info。

OpenSSL 3.0 起可以用 openssl kdf:

openssl kdf -keylen 32 -kdfopt digest:SHA2-256 \
  -kdfopt hexkey:000102030405060708090a0b0c \
  -kdfopt salt:app-salt -kdfopt info:app-v1-enc HKDF

-kdfopt key: / salt: / info: 收的是可打印字符串,十六进制要用 hexkey: / hexsalt: / hexinfo:。这两类搞混不会报错,只是算出来的钥匙不同——跨机器复现对不上时先查这里。

7. 落地时值得逐条过的几件事

  1. salt 能不能固定?能就固定成常量(全零也可以接受);必须随机就随密文一起存,且不能让用户或外部输入决定。
  2. info 保证每把子密钥唯一,把用途、版本、方向、算法、输出长度都编进去,用长度前缀拼接。
  3. 不要用 salt 做域分离,尤其是不要用可变长字符串做 salt。
  4. HKDF 只有两次 HMAC,别拿来处理密码:密码走 Argon2id 或 PBKDF2。
  5. 需要超过 255 × HashLen 字节的输出,说明该用流密码或 DRBG,而不是更长的 Expand。
  6. PRK 与原始 IKM 同等级敏感,别打进日志,能做零化就零化。
复制全文 生成海报 HKDF 密钥派生 RFC5869 Gohkdf 加密

推荐文章

程序员茄子在线接单