2.3 crypto/hkdf、pbkdf2、sha3
在 Go 1.24 之前,想用 HKDF、PBKDF2 或 SHA-3,你只有两条路:引入 golang.org/x/crypto,或者自己实现。前者意味着多一个(虽然是半官方的)依赖,后者基本等于自找麻烦。Go 1.24 把这三个家族一次性收进了标准库,从此它们享有标准库的兼容性承诺与 FIPS 相关的内部保证。
这三个包虽然都属于「密码学」,用途却完全不同:sha3 是哈希算法本身,hkdf 和 pbkdf2 是密钥派生函数(KDF),只是派生方式截然不同。本节把它们放在一起,正是因为「什么时候该用哪个」是实际工程里最容易选错的地方。
本节要回答:这三个包怎么用、输出对不对、哪个版本引入、彼此如何选择?结论:
crypto/sha3、crypto/hkdf、crypto/pbkdf2均在 Go 1.24 进入标准库(api 清单有据),SHA3.Clone在 1.25 追加;实测 SHA3-256(“abc”) 与 NIST 向量一致,HKDF/PBKDF2 派生输出确定可复现。
2.3.1 sha3:SHA-3 与 SHAKE
crypto/sha3 提供两类东西:
- SHA-3 固定长度哈希:
New224/New256/New384/New512,以及一次性Sum224/Sum256/Sum384/Sum512; - SHAKE 可扩展输出函数(XOF):
NewSHAKE128/NewSHAKE256,以及一次性SumSHAKE128(data, n)/SumSHAKE256(data, n)——你可以要任意长度的输出。
还有一个容易忽略的 NewCSHAKE128 / NewCSHAKE256,即「可定制 SHAKE」,允许传入函数名和定制字符串。
package main
import (
"crypto/sha3"
"encoding/hex"
"fmt"
)
func main() {
sum := sha3.Sum256([]byte("abc"))
fmt.Println("sha3-256(abc) =", hex.EncodeToString(sum[:]))
sum512 := sha3.Sum512([]byte("abc"))
fmt.Println("sha3-512(abc) =", hex.EncodeToString(sum512[:]))
out := sha3.SumSHAKE128([]byte("abc"), 32)
fmt.Println("shake128(abc,32)=", hex.EncodeToString(out))
}
实测输出(GOTOOLCHAIN=go1.27.0 go run .):
sha3-256(abc) = 3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532
sha3-512(abc) = b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0
shake128(abc,32)= 5881092dd818bf5cf8a3ddb793fbcba74097d5c526a6d35f97b83351940f2cc8
sha3-256("abc") = 3a985da74fe225b2... 是 NIST 公开的 SHA3-256 测试向量,实测逐字节一致——这是「实现没跑偏」的直接证据,也是我选 "abc" 当输入的原因。SHAKE128 输出 32 字节,想要 64 字节就传 64,XOF 的输出长度由调用方决定。
2.3.2 hkdf:从已有密钥材料派生多个密钥
HKDF(RFC 5869)解决的是「我有一段共享密钥,但需要多个用途不同的密钥(加密密钥、IV、MAC 密钥)」这类问题。它分两步:Extract 把输入密钥材料浓缩成固定长度的伪随机密钥,Expand 再把它扩展成所需长度。日常用得最多的是把两步合一的 Key:
func Key[Hash hash.Hash](h func() Hash, secret, salt []byte, info string, keyLen int) ([]byte, error)
注意签名是泛型的——这是 Go 1.24 版本特有的写法,用类型参数约束哈希构造函数,比 x/crypto 时代的 interface{} 更安全。
k, err := hkdf.Key(sha256.New, secret, salt, string(info), 42)
fmt.Println("hkdf-sha256 =", hex.EncodeToString(k), "err:", err)
实测输出:
hkdf-sha256 = 534af6513fc323d3eb46f37f001cb90931c32ac104b367ee4a8bdbd983e31e0cd36c325b4ac8ead7aa9b err: <nil>
42 字节的输出长度正是签名里 keyLen 参数指定。info 是上下文绑定字符串:同样一段密钥材料,info 不同派生出的密钥就不同,因此可以用 info="enc"、info="mac" 从同一份材料派生出两把互不相关的密钥。
也可以把两步拆开,先 Extract 出伪随机密钥(PRK),再从同一个 PRK 派生出多个用途不同的密钥:
prk, err := hkdf.Extract(sha256.New, secret, salt)
enc, _ := hkdf.Expand(sha256.New, prk, "enc", 16)
mac, _ := hkdf.Expand(sha256.New, prk, "mac", 32)
实测输出:
Extract PRK (32B) = 6c3109ad60806d3a79dbcd9adb7bc740379733cf7fddaf7412fddd6dcf8cfedf err: <nil>
Expand enc(16) = f6de7397e09afe4285578d40951d8e8e
Expand mac(32) = 3bf5c9a63b858728fad44d6e683fb8ea4dc09b975114c62057ef5f8484e4b788
enc != mac: true
这就是「一个 PRK 派生多把密钥」的标准姿势:Extract 只做一次(把低质量材料浓缩),Expand 按用途各做一次。info 不同 ⇒ 密钥不同,是 HKDF 实现密钥分离(key separation)的机制,不需要为每个用途准备不同的输入材料。
2.3.3 pbkdf2:从口令派生密钥
PBKDF2(RFC 8018)解决的是另一类问题:输入是低熵的口令(人类输入的密码),所以必须用大量迭代来拖慢暴力破解。它的签名同样是泛型的:
func Key[Hash hash.Hash](h func() Hash, password string, salt []byte, iter, keyLen int) ([]byte, error)
dk, err := pbkdf2.Key(sha256.New, pw, []byte("salty"), 4096, 32)
fmt.Println("pbkdf2-sha256 =", hex.EncodeToString(dk), "err:", err)
实测输出:
pbkdf2-sha256 = c29977542706368d0e5ca881e2b1c0499eda3a5fa4d294a8eea787454b708ec0 err: <nil>
注意 iter 参数(这里 4096)和 salt(这里 "salty")——它们不是可选的。真实系统里 salt 必须是每个用户独立的随机值,迭代次数应该按当时的硬件能力取到「约 100ms 一次派生」的量级(现代建议往往在 60 万次以上)。上面用 4096 只是为了演示跑得快,绝不能照抄进生产代码。
2.3.4 完整 API 一览
三个包的导出符号不多,列全了更容易记住边界:
crypto/sha3:
| 类别 | 符号 |
|---|---|
| 固定长度哈希构造 | New224、New256、New384、New512 |
| 一次性哈希 | Sum224、Sum256、Sum384、Sum512 |
| SHAKE(XOF) | NewSHAKE128、NewSHAKE256、SumSHAKE128、SumSHAKE256 |
| cSHAKE | NewCSHAKE128、NewCSHAKE256 |
*SHA3 方法 | Write、Sum、Reset、Size、BlockSize、MarshalBinary、UnmarshalBinary、AppendBinary |
*SHAKE 方法 | 同上,另加 Read(把 XOF 当 io.Reader 用) |
crypto/hkdf:Key、Extract、Expand。crypto/pbkdf2:Key。
SHAKE.Read 是个容易被忽略的好东西——它让 XOF 能直接当随机流读取:
x := sha3.NewSHAKE256()
x.Write([]byte("seed"))
buf := make([]byte, 64)
n, err := io.ReadFull(x, buf)
fmt.Println("read n =", n, "err =", err)
fmt.Printf("first 8 bytes: %x\n", buf[:8])
实测输出:
read n = 64 err = <nil>
first 8 bytes: 4fd6800b5ddf6532
想要多少字节就读多少,XOF 没有「输出长度」这个概念——这正是 SHAKE 与固定长度哈希的本质区别。
注意 *SHA3 与 *SHAKE 都实现了 encoding.BinaryMarshaler / UnmarshalBinary,可以把哈希的中间状态序列化后再恢复——做「可续传的哈希」或 checkpoint 时有用。1.25 追加的 SHA3.Clone 是同一思路的另一种形式(返回 hash.Cloner)。
2.3.5 从 x/crypto 迁移
这三个包原本在 golang.org/x/crypto 下,标准库版与 x 版包路径一一对应,迁移基本是改 import:
| 旧路径(x/crypto) | 新路径(标准库) | 引入版本 |
|---|---|---|
golang.org/x/crypto/sha3 | crypto/sha3 | Go 1.24 |
golang.org/x/crypto/hkdf | crypto/hkdf | Go 1.24 |
golang.org/x/crypto/pbkdf2 | crypto/pbkdf2 | Go 1.24 |
两点需要留意:
- API 不完全相同。x 版
hkdf.New(hash, secret, salt, info)返回一个io.Reader(流式),标准库版是hkdf.Key(...)(一次性返回[]byte)。x 版pbkdf2.Key的哈希参数是func() hash.Hash,标准库版是泛型func() Hash。迁移时不能只改 import,签名对不上的地方要改调用。 - 标准库自己也在 vendoring x/crypto。
go list std的差分显示 1.27 的标准库里有vendor/golang.org/x/crypto/hkdf——说明标准库内部仍有依赖 x 版的地方,但你的代码应该用标准库路径,以享受兼容性承诺与 FIPS 相关的内部保证。
实测 go doc 拿到的 x 版签名(golang.org/x/crypto v0.58.0):
func New(hash func() hash.Hash, secret, salt, info []byte) io.Reader
func Extract(hash func() hash.Hash, secret, salt []byte) []byte
func Expand(hash func() hash.Hash, pseudorandomKey, info []byte) io.Reader
对比标准库版(hkdf.Key / Extract / Expand 都返回 ([]byte, error)),差异一目了然:x 版返回 io.Reader,标准库版返回 []byte。pbkdf2 的 go doc 更是直接写明它是包装层:
This package is a wrapper for the PBKDF2 implementation in the crypto/pbkdf2
package. It is frozen and is not accepting new features.
「frozen and is not accepting new features」这句是官方态度:新代码一律用标准库,x 版只为兼容旧代码而保留。
2.3.6 版本归属
三个包都能在 api/go1.24.txt 里找到,这是最硬的证据:
| 包 / 符号 | 引入版本 | 核实命令与结果 |
|---|---|---|
crypto/hkdf(Key/Extract/Expand) | Go 1.24 | grep -ln "^pkg crypto/hkdf," api/go1.*.txt → go1.24.txt(#61477) |
crypto/pbkdf2(Key) | Go 1.24 | grep -ln "^pkg crypto/pbkdf2," api/go1.*.txt → go1.24.txt(#69488) |
crypto/sha3(Sum256 等) | Go 1.24 | grep -ln "^pkg crypto/sha3," api/go1.*.txt → go1.24.txt(#69982) |
crypto/sha3(SHA3.Clone) | Go 1.25 | 同命令命中 go1.25.txt(#69521) |
原始 api 行:
pkg crypto/hkdf, func Key[$0 hash.Hash](func() $0, []uint8, []uint8, string, int) ([]uint8, error) #61477
pkg crypto/pbkdf2, func Key[$0 hash.Hash](func() $0, string, []uint8, int, int) ([]uint8, error) #69488
pkg crypto/sha3, func Sum256([]uint8) [32]uint8 #69982
pkg crypto/sha3, method (*SHA3) Clone() (hash.Cloner, error) #69521
一个有意思的细节:hkdf.Key 和 pbkdf2.Key 的签名里都带类型参数([$0 hash.Hash]),说明它们从引入之初就是泛型 API,不是后来泛型化的。1.26 与 1.27 上这三个包的输出与 1.24 逐字节一致(我用 go1.24.0 与 go1.27.0 分别跑过,十六进制完全相同),没有行为漂移。
2.3.7 三个包怎么选(决策表)
这是本节最实用的一张表。三者经常被混用,但适用面几乎不重叠:
| 你的输入是什么 | 该用哪个 | 原因 |
|---|---|---|
| 一段高熵共享密钥(如 KEM 输出) | hkdf | 输入已足够随机,只需扩展与上下文绑定 |
| 人类口令 | pbkdf2(或 Argon2) | 输入熵低,必须用高迭代成本拖慢暴力破解 |
| 需要任意长度输出(XOF) | sha3 的 SHAKE | 固定长度哈希做不到「要多少给多少」 |
| 需要 SHA-3 家族哈希 | sha3 | 直接就是算法本身 |
| 已有密钥要派生子密钥 | hkdf | info 参数天然支持多用途分离 |
| 存储口令校验值 | pbkdf2 + 随机 salt | 高迭代是唯一防线 |
一句话判据:输入是高熵密钥用 hkdf,输入是低熵口令用 pbkdf2,需要可变长输出用 SHAKE。把 pbkdf2 用在共享密钥上是浪费 CPU(密钥本来就不怕暴力破解),把 hkdf 用在口令上是严重的安全缺陷(迭代次数远远不够)。
2.3.8 小结
crypto/sha3、crypto/hkdf、crypto/pbkdf2均在 Go 1.24 进入标准库,api/go1.24.txt可查(#69982、#61477、#69488)。- 实测 SHA3-256(“abc”) 与 NIST 向量逐字节一致;HKDF 与 PBKDF2 输出确定可复现。
hkdf.Key与pbkdf2.Key从引入起就是泛型签名。- 选择判据:高熵密钥用
hkdf,低熵口令用pbkdf2(高迭代 + 随机 salt),可变长输出用 SHAKE。 SHA3.Clone在 Go 1.25 追加,是 1.25 相对 1.24 的唯一sha3变化。
第 2 章结束。下一章回到并发与运行时:先用 testing/synctest 把「靠 time.Sleep 的脆弱并发测试」变成确定性的。
阅读导航:上一节:2.2 crypto/mlkem 后量子密钥交换 · 下一节:3.1 testing/synctest 确定性并发测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。