《Go 语言高级编程》2.3 crypto/hkdf、pbkdf2、sha3

这三个包原本只能从 golang.org/x/crypto 拿,Go 1.24 把它们收进了标准库。本节实测 SHA-3/SHAKE 输出与 NIST 向量一致、HKDF 派生 42 字节、PBKDF2 派生 32 字节,并给出三者的版本归属(均在 1.24,SHA3.Clone 在 1.25),最后用一张决策表说清「什么时候该用哪个 KDF」。

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
cSHAKENewCSHAKE128、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/sha3crypto/sha3Go 1.24
golang.org/x/crypto/hkdfcrypto/hkdfGo 1.24
golang.org/x/crypto/pbkdf2crypto/pbkdf2Go 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.24grep -ln "^pkg crypto/hkdf," api/go1.*.txt → go1.24.txt(#61477)
crypto/pbkdf2(Key)Go 1.24grep -ln "^pkg crypto/pbkdf2," api/go1.*.txt → go1.24.txt(#69488)
crypto/sha3(Sum256 等)Go 1.24grep -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直接就是算法本身
已有密钥要派生子密钥hkdfinfo 参数天然支持多用途分离
存储口令校验值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 确定性并发测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练