哈希 / HMAC / AEAD — 摘要、消息认证与加密选型
这一篇覆盖 crypto 体系里日常真正会写的几类代码:算摘要(crypto/sha*、hash/crc32 等)、消息认证(crypto/hmac)、认证加密(crypto/aes + crypto/cipher 的 GCM)、防时序侧信道(crypto/subtle)、编码层(encoding/hex、encoding/base64、encoding/pem)、内部数据结构哈希(hash/maphash)、密钥派生(crypto/pbkdf2、crypto/hkdf),以及 Go 1.24–1.27 陆续进场的后量子包(crypto/mlkem、crypto/mldsa、crypto/hpke、crypto/sha3)。TLS/证书里这些算法怎么被协商起来用,见 03-网络编程/crypto-tls.md。
以下所有行为断言都在 go1.27.0 windows/amd64 实测,输出为程序原样打印。
1. 哈希选型地图
先分清两条世界线。加密哈希(sha256/sha512/sha3)解决"防篡改、防伪造";非加密校验和(crc32/crc64/adler32/fnv)只解决"传输出错没有",几行代码就能伪造出相同校验和,绝不能用于安全场景。
| 要干什么 | 用什么 | 输出 |
|---|---|---|
| 安全场景摘要(签名前哈希、文件完整性) | crypto/sha256 / crypto/sha512 |
32 / 64 字节 |
| 需要更长摘要或抗长度扩展的构造 | crypto/sha3(Go 1.24 进标准库) |
sha3.Sum512 返回 [64]byte |
| 兼容老系统的 token/协议 | crypto/sha1、crypto/md5 |
仅限遗留,见下 |
| 传输/落盘校验和 | hash/crc32、hash/crc64、hash/adler32 |
uint32/uint64 |
| 哈希表、分片路由、快速散列 | hash/fnv、hash/maphash |
uint32/uint64 |
crypto/sha1 与 crypto/md5 的包文档原文措辞完全一致:cryptographically broken and should not be used for secure applications——官方直接替你下了结论,不需要再论证。MD5 仍然适合做"非对抗性指纹"(比如把图片尺寸拼成缓存 key),但要清楚它连"两个不同输入撞出同一摘要"都已被廉价地工程化。
hash/crc32 的多项式要选对:IEEE 是默认(zip/gzip/以太网用的那个),ChecksumIEEE 是快捷入口;iSCSI 等用的是 Castagnoli 多项式(源码注释原文 “Castagnoli’s polynomial, used in iSCSI”,值为 0x82f63b78)。同一份数据两种表算出来的结果不同,对接协议时选错表会校验永远对不上:
godata := []byte("some payload")ieee := crc32.ChecksumIEEE(data)cast := crc32.Checksum(data, crc32.MakeTable(crc32.Castagnoli))fmt.Printf("IEEE=%X Castagnoli=%X same=%v\n", ieee, cast, ieee == cast)// 输出: IEEE=FFF66B83 Castagnoli=676FE7D0 same=false
hash/fnv 提供 New32/New32a/New64/New64a 和 128 位两版:a 后缀是 FNV-1a(先异或再乘),是不加说明时的默认选择;New128() 返回的是一般 hash.Hash(标准库没有 Hash128 接口,用 Sum(nil) 取 16 字节)。实测 fnv.New32a() 对 “some payload” 得 BC65DA33,fnv.New32()(非 1a)得不同值——两者混用同样会"校验对不上"。
2. hash.Hash 接口的三个设计细节
hash.Hash 是所有这些包共用的接口,文档里有两句话值得逐字读:
Write (via the embedded io.Writer interface) adds more data to the running hash. It never returns an error. Sum appends the current hash to b and returns the resulting slice. It does not change the underlying hash state.
Write 永不返回错误——因为哈希只是纯计算,没有 I/O。n, err := h.Write(p) 里的 err 永远为 nil,if err != nil 是死代码(接口继承 io.Writer 不得不写,但不用检查)。
“Sum 不改状态"反直觉但极实用:可以在流式处理中途随时取摘要继续写。实测验证:
goh := sha256.New()h.Write([]byte("hello"))s1 := h.Sum(nil)s2 := h.Sum(nil)h.Write([]byte(" world")) // Sum 之后继续写s3 := h.Sum(nil)want := sha256.Sum256([]byte("hello world"))fmt.Println(hex.EncodeToString(s1) == hex.EncodeToString(s2)) // true: Sum 幂等fmt.Println(hex.EncodeToString(s3) == hex.EncodeToString(want[:])) // true: 状态未被 Sum 改变
于是有两种惯用法:Sum(nil) 每次分配一个新切片(哈希恰好 32 字节,调用频繁时是纯垃圾);Sum(buf[:0]) 把结果追加进自己预留的底层数组,实测 buf := make([]byte, 5, 100) 后 h.Sum(buf[:0]) 返回 len=32、cap=100 的切片,零分配。热路径(比如逐行算日志指纹)用后者。
固定长度输入优先用包级函数:sha256.Sum256(data) 返回 [32]byte(实测 %T 为 [32]uint8),是栈上的定长数组,不经堆分配;sha256.New() 返回接口类型 hash.Hash,要 Sum(nil) 才落到切片。短消息签名/比对一律 Sum256,流式或超大输入才需要 New()。
Go 1.25 新增了 hash.Cloner 接口(Clone() (Cloner, error),实现 hash.Hash 的超集),stdlib 所有哈希都实现它(GOFIPS140 模式下除外),相当于"哈希状态快照”——适合"公共前缀 + 各分支后缀"的场景(如 Merkle 树、按前缀派生签名),不用重算前缀:
goc := h.(hash.Cloner)h2, _ := c.Clone()h2.Write([]byte("more"))// h 与 h2 互不影响,实测二者 Sum 不同: clone independent: true
hash.Hash 文档还说明 stdlib 实现同时支持 encoding.BinaryMarshaler/BinaryAppender/BinaryUnmarshaler:哈希中间状态可以序列化后跨进程续算(断点续哈希)。注意文档的提醒——状态里可能残留部分原文,按敏感数据处理。
3. HMAC:键的顺序、长键与验证
HMAC 是"带密钥的摘要",解决摘要做不了的事:没有密钥的人不能伪造 MAC(消息认证码)。构造只要一行:
gomac := hmac.New(sha256.New, key) // 第一个参数传构造函数, 不是实例mac.Write(msg)tag := mac.Sum(nil)
为什么不能用 sha256(msg || key) 或 sha256(key || msg) 这种裸拼接代替 HMAC?对 MD5/SHA-1/SHA-2 这类 Merkle–Damgård 结构,已知 H(K || M) 和 len(M) 就可以在不知道 K 的情况下继续推出 H(K || M || pad || M') 的值(长度扩展),即"给定一段合法 MAC,可伪造它的延长消息的 MAC"。HMAC 的内层/外层双填充结构正是为了消除这类攻击面而设计的;用 HMAC 是结论,不用展开推导。
两个实测事实:
hmac.New(sha256.New, key)返回的仍是hash.Hash,所以Size()得 32(输出标签字节数,即 SHA-256 的摘要长度)、BlockSize()得 64(内层分组大小,对应sha256.BlockSize)。- 超过分组长度的 key 会先被哈希再使用。用 100 字节的 key(>64 字节分组)实测,它与"先把 key 做 SHA-256、再拿 32 字节摘要当 key"算出的 MAC 完全一致:
golongkey := bytes.Repeat([]byte{0xAB}, 100)mac1 := hmac.New(sha256.New, longkey); mac1.Write(msg); out1 := mac1.Sum(nil)hk := sha256.Sum256(longkey)mac2 := hmac.New(sha256.New, hk[:]); mac2.Write(msg); out2 := mac2.Sum(nil)fmt.Println(bytes.Equal(out1, out2)) // true
所以 key 长度本身不构成安全差异——超过 64 字节的部分只是被折成了摘要。但反过来,短 key(比如 8 字节口令)不会因此变强,HMAC 的强度由 key 的熵决定,密钥本身请用 crypto/rand 生成满 32 字节。
验证端必须用 hmac.Equal(mac1, mac2)——文档原文:compares two MACs for equality without leaking timing information。== 按字节短路比较,在第几个字节不同时耗时不同,理论上允许攻击者逐字节试探正确 MAC(口令验证同理,见 §5)。
4. AES-GCM:认证加密主力
要"加密 + 防篡改"一起解决,就不要再手动"CBC 加密 + HMAC 签名"拼组合(顺序错一个就是 padding oracle 这类经典事故),直接用 AEAD。Go 的 GCM 组合:
gopackage mainimport ( "crypto/aes" "crypto/cipher" "crypto/rand" "fmt" "log")func main() { key := make([]byte, 32) // AES-256-GCM; 16/24/32 对应 AES-128/192/256 rand.Read(key) block, err := aes.NewCipher(key) if err != nil { log.Fatal(err) } gcm, err := cipher.NewGCM(block) if err != nil { log.Fatal(err) } nonce := make([]byte, gcm.NonceSize()) // 默认 12 字节 rand.Read(nonce) plaintext := []byte("attack at dawn") aad := []byte("msg-version:v1") // 关联数据: 认证但不加密 ct := gcm.Seal(nil, nonce, plaintext, aad) pt, err := gcm.Open(nil, nonce, ct, aad) if err != nil { log.Fatal(err) } fmt.Println(string(pt))}
几个必须记牢的语义(均实测/引自接口注释):
Seal(dst, ...)是追加。接口注释原文 “appends the result to dst, returning the updated slice”,且 “To reuse plaintext’s storage for the encrypted output, use plaintext[:0] as dst”。实测gcm.Seal([]byte("PREFIX|"), ...)返回的切片前 7 字节仍是PREFIX|,密文跟在后面——这正是"nonce 拼在密文前"惯用法的实现基础;Seal(nil,...)与Seal(make([]byte,0,256),...)结果字节完全相同,区别只是后者复用已有底层数组。gcm.NonceSize()默认 12、gcm.Overhead()为 16(实测),密文 = 明文 + 16 字节认证标签。AAD 不占密文长度、不出现在密文里,只参与认证。Open失败只返回一个笼统错误。实测篡改任意一个字节、或对错 AAD、或用错密钥,错误文本全部是"cipher: message authentication failed"。注意:Go 1.27 标准库没有cipher.ErrOpen这个哨兵(go doc crypto/cipher.ErrOpen报 no symbol,GOROOT 源码 grep 无此符号),别写errors.Is(err, cipher.ErrOpen)——编译不过。判定方式就是err != nil;文档还警告 “Even if the function fails, the contents of dst, up to its capacity, may be overwritten”,失败后别去读你传入的 dst。- nonce 重用对同一个密钥是灾难。GCM 的加密部分是 CTR 模式,密钥流只由 (key, nonce) 决定。同一 nonce 加密两条等长明文,实测密文异或等于明文异或:
gon := bytes.Repeat([]byte{0x07}, 12)x1 := gcm.Seal(nil, n, []byte("AAAAAAAA"), nil)x2 := gcm.Seal(nil, n, []byte("BBBBBBBB"), nil)// 实测: 前 8 字节 x1^x2 = 03 03 03 03 03 03 03 03 == "AAAAAAAA"^"BBBBBBBB"
拿到两条密文就能推出两条明文的差异;重用次数多了连密钥流本身都能被解出。正确写法只有一条:每次加密都 rand.Read 生成全新 nonce(32 位计数器在 96 位区间自增的写法则要求明确管理计数器,容易出错,不推荐手写)。
- 96 位随机 nonce 的安全边界用结论表述:随机 nonce 在生日悖论下碰撞概率随加密次数平方增长,GCM 标准用法约定同一密钥加密的消息数量必须远低于 2^32。不想自己数数,就用 Go 1.24 加的托管方案
cipher.NewGCMWithRandomNonce(block):它每次Seal自动生成 96 位随机 nonce 并前缀到密文里,Open自动剥出,因此NonceSize()=0、Overhead()=28(12 nonce + 16 tag,实测),文档同时明确 “A given key MUST NOT be used to encrypt more than 2^32 messages”。包级 API 一共四个构造函数:NewGCM/NewGCMWithNonceSize/NewGCMWithTagSize/NewGCMWithRandomNonce;不存在NewGCMSealNumber、GCM类型(go doc crypto/cipher.GCM报 no symbol——拿到的是cipher.AEAD接口值)。
陷阱:
NewGCMWithTagSize允许把标签砍短(如 8 字节)。标签越短伪造通过率越高(2^-64 vs 2^-128),除非对接的协议强制规定,否则不要动默认 16。
5. crypto/subtle:一切比较都要常数时间
go doc crypto/subtle 的完整 API(Go 1.27):
gofunc ConstantTimeByteEq(x, y uint8) intfunc ConstantTimeCompare(x, y []byte) intfunc ConstantTimeCopy(v int, x, y []byte)func ConstantTimeEq(x, y int32) intfunc ConstantTimeLessOrEq(x, y int) intfunc ConstantTimeSelect(v, x, y int) intfunc WithDataIndependentTiming(f func())func XORBytes(dst, x, y []byte) int
核心是 ConstantTimeCompare:文档原文 “returns 1 if the two slices … have equal contents and 0 otherwise. The time taken is a function of the length of the slices and is independent of the contents. If the lengths of x and y do not match it returns 0 immediately.” 注意返回的是 int(1/0)不是 bool,且长度不同立即返回 0——长度本身就是泄漏面,所以被比较的 token/MAC 应定长生成(hmac 输出天然定长;API token 若变长,先各做一遍哈希再比较,抹平长度差)。XORBytes 对应"Ciphertext-HMAC"模式里常数时间地取出异或结果;WithDataIndependentTiming(较新)把一段代码裹起来尽力跑成数据无关耗时。
API 鉴权中间件的正确姿势:
gofunc tokenOK(got string, want []byte) bool { if subtle.ConstantTimeCompare([]byte(got), want) != 1 { return false } return true}
对比 MAC 用 hmac.Equal(内部即 constant-time),别 == 也别 bytes.Equal。口令哈希比对见 §8。
注意:常数时间比较防的是远程统计耗时做逐字节猜测;如果攻击者本来就能拿到无限多"对/错"反馈,你的问题往往不在比较函数,而在缺少限流和失败锁定。
6. hex / base64 / pem:编码不是加密
encoding/hex 简单,只有 EncodeToString/DecodeString(严格 2 字符一字节,奇数长度报 hex.ErrLength)和流式 NewEncoder/NewDecoder、Dump。调试打印密文/密钥一律 hex,用户可见数据用 base64。
base64 的坑全在字母表和填充上。四套预定义编码实测(数据 []byte{0xFB, 0xFF, 0x7F, 0x3E}):
gofmt.Printf("std=%q\nurl=%q\nrawstd=%q\nrawurl=%q\n", base64.StdEncoding.EncodeToString(data), base64.URLEncoding.EncodeToString(data), base64.RawStdEncoding.EncodeToString(data), base64.RawURLEncoding.EncodeToString(data))// std="+/9/Pg=="// url="-_9_Pg=="// rawstd="+/9/Pg"// rawurl="-_9_Pg"
StdEncoding 字母表用 +/(文档原文的字母表串),URLEncoding 换成 -_,Raw 系列去掉 = 填充(RawStdEncoding = StdEncoding.WithPadding(NoPadding))。解码端实测互不通用:StdEncoding.DecodeString(url串) 报 illegal base64 data at input byte 0(- 不在 std 字母表);StdEncoding.DecodeString(raw串) 报 illegal base64 data at input byte 4——缺 = 填充连默认(非 Strict)解码都不行,必须用对应 Raw 变体解。默认解码与 enc.Strict() 的差别是后者要求末尾填充位为零(RFC 4648 §3.5);换行两者都宽容——文档原文 “the input is still malleable, as new line characters (CR and LF) are still ignored”,实测 Strict().DecodeString(std串 + 换行) 照样成功。写 URL/query 用 URLEncoding 或 RawURLEncoding,存储与 API 传输注明哪套编码、是否填充——JWT 用的是 base64url 无填充,手工拼过的人都踩过 + 变空格的回环。
encoding/pem 只做"Base64 + 头尾行 + 可选 Header"的包装,不是加密——Block 结构是 Type string、Headers map[string]string、Bytes []byte,其中 Bytes 放的是原始 DER 字节(不是 base64 字符串,编码发生在 EncodeToMemory 内部),读 PEM 时 Decode 出来的也是还原后的 DER。
gopriv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)der, _ := x509.MarshalPKCS8PrivateKey(priv) // PKCS#8 DERblk := &pem.Block{Type: "PRIVATE KEY", Bytes: der}out := pem.EncodeToMemory(blk)p, _ := pem.Decode(out)
实测输出(DER 138 字节,PEM 正文 Base64 肉眼可还原,证明"PEM 私钥"毫无保密性;带 Headers 时多一行 Note: ...):
-----BEGIN PRIVATE KEY-----MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...-----END PRIVATE KEY-----
要加密的 PEM 请用 x509.EncryptPEMBlock(已 Deprecated,PEM 加密本身不安全)或直接上 PKCS#8 EncryptedKey(golang.org/x/crypto/pkcs12 等),指向 crypto-tls.md 的证书/私钥管理。
7. hash/maphash:给数据结构用,不给持久化用
Go 的内置 map 每次进程启动都换随机种子,防的就是"哈希洪水攻击"(攻击者按已知散列函数构造大量碰撞 key 拖垮 map)。maphash 把这套散列暴露出来供自建数据结构使用,包文档开宗明义:hash functions are not cryptographically secure——别拿它当内容指纹。
goseed := maphash.MakeSeed()maphash.String(seed, "abc") // 同 seed 同输入结果稳定(实测 true)maphash.Bytes(seed, b)
实测三个行为,对应三个正确用法:
- 零值
maphash.Hash在首次使用时自己随机选种子(文档原文 “A zero Hash chooses a random seed for itself”),两个实例算同一字符串结果不同——这是特性不是 bug,但意味着别指望它跨进程一致。 SetSeed后同 seed 的多个Hash输出与maphash.String/Bytes(seed, x)完全一致,适合自己实现分片表时固定 seed。Seed文档原文:“Each Seed value is local to a single process and cannot be serialized or otherwise recreated in a different process.” 所以把Sum64结果当数据库主键/幂等 ID 持久化是错误用法——换进程(甚至换 Go 版本)后同 key 对不上。持久指纹用 sha256 截断或 FNV。
近几版加的泛型 API(都要记住用途,别用错层):
| API | 引入版本(notes 核实) | 用途 |
|---|---|---|
maphash.Comparable[T](seed, v) uint64、WriteComparable |
Go 1.24 | 任意 comparable 值(含结构体)直接散列成 uint64;实测 Comparable(seed, Point{3,4}) 确定且与 WriteComparable 逐步写一致 |
Hash.Clone() (Cloner, error) |
Go 1.25 | 状态快照 |
Hasher[T] 接口、ComparableHasher[T] |
Go 1.27 | 1.27 notes 原文:defines the contract between values of a particular type and future hash-based data structures such as hash tables and Bloom filters |
Hasher[T] 就两个方法:Hash(*Hash, T) + Equal(x, y T) bool。它的意义是让不可比较的类型(切片、map 字段)和自定义等价关系(大小写不敏感的字符串 key)都能进哈希容器;ComparableHasher[T] 提供 Equal == 的默认实现(实测可赋给 var h maphash.Hasher[string])。注意这是给未来的数据结构当部件用的,业务代码日常仍用内置 map;文档自己也在示例里演示了 case-insensitive 的写法。maphash.Hash 本身在 Go 1.27 源码里没有任何 Deprecated 标记(grep 全文件无 “Deprecated”),可放心继续用。
8. KDF:口令存储与密钥派生,别用错函数
三类问题三个答案,最常见错误是用裸 sha256 存密码。
口令 → 校验值(慢,抗 GPU):crypto/pbkdf2(Go 1.24 进标准库,基于原 x/crypto 包)。签名是泛型:pbkdf2.Key(h func() Hash, password string, salt []byte, iter, keyLength int)。
gosalt := make([]byte, 16)rand.Read(salt)dk, err := pbkdf2.Key(sha256.New, userPassword, salt, 600_000, 32)// 实测: len=32 err=nil, 600_000 次迭代在本机耗时 334ms
存 iter(调参后写进 hash 前缀)、salt 和结果;验证时对同参数重算再 hmac.Equal 式常数时间比对。裸 SHA-256 存口令把离线暴力的成本几乎归零——必须选"故意慢"的 KDF。要更抗 GPU 用 Argon2id,在 golang.org/x/crypto/argon2(非标准库,需额外依赖)。
高熵共享密钥 → 子密钥(快,不需要抗暴力):crypto/hkdf(Go 1.24)。Extract 把输入密钥材料压成 PRK,Expand 按 info 标签摊成任意多个子密钥:
goprk, _ := hkdf.Extract(sha256.New, shared, salt) // 32 字节, 实测encKey, _ := hkdf.Expand(sha256.New, prk, "file-enc v1", 32)macKey, _ := hkdf.Expand(sha256.New, prk, "mac v1", 32) // 实测与 encKey 不同full, _ := hkdf.Key(sha256.New, shared, salt, "file-enc v1", 32) // 一步式, 实测 == Extract+Expand
info 标签是子密钥隔离的全部机制:不同用途用不同 info,绝不用同一把密钥直接既加密又签名。crypto/ecdh 的 ECDH() 返回的原始共享秘密也应先过 HKDF 摊成子密钥再用——HKDF 包文档的定位正是把 limited input keying material 展开成 cryptographically strong secret keys,这正是那一步。
随机文本/token:crypto/rand.Text()(Go 1.24)返回 base32(RFC 4648 字母表)字符串,文档保证"至少 128 位随机性、足以防暴力猜测",实测 26 字符如 VV5GJ4WJPWTM4O56D243BAOZ7F——一次性恢复码、验证码、nonce-like token 直接用它。同时 rand.Read 自 Go 1.24 起签名不变但语义变了:文档原文 “Read fills b with cryptographically secure random bytes. It never returns an error, and always fills b entirely”,1.24 notes 措辞 “now guaranteed not to fail. It will always return nil as the error result”(底层 CSPRNG 异常时直接崩溃进程而非返回 err)。所以 _, _ = rand.Read(b) 或干脆 rand.Read(b) 都是合规写法,if err != nil 是噪声;实测 n=32 err=<nil>。需要 UUID/幂等键(trace id、业务幂等)指向 go-std/01-基础核心包 下的 uuid.md。
9. 后量子与新型算法包速览
按版本进场(均经 go.dev release notes 核对):
| 包 | 版本 | 一句话 |
|---|---|---|
crypto/ecdh |
Go 1.20 | X25519 与 P-256/384/521 四种曲线的密钥交换 |
crypto/sha3 |
Go 1.24 | SHA-3/SHAKE/cSHAKE,FIPS 202;基于原 x/crypto/sha3 |
crypto/pbkdf2、crypto/hkdf |
Go 1.24 | 见 §8 |
crypto/mlkem |
Go 1.24 | ML-KEM(原 Kyber)密钥封装,FIPS 203 |
crypto/hpke |
Go 1.26 | HPKE(RFC 9180)混合公钥加密,已在标准库,不是 x/crypto |
crypto/mldsa |
Go 1.27 | ML-DSA(原 Dilithium)签名,FIPS 204;同版 crypto/x509、crypto/tls(MLDSA44/65/87 套件)跟进支持 |
crypto/mlkem 是"密钥封装":发送方用对方公钥随机生成一把 32 字节共享密钥和一条封装密文,接收方用私钥解封得到同一把共享密钥——给"要加密给未来"或混合 KEM 场景提供抗量子的前向秘密。实测参数尺寸(ML-KEM-768):
godk, _ := mlkem.GenerateKey768()ek := dk.EncapsulationKey()ss1, ct := ek.Encapsulate() // 注意: Encapsulate 不接收 io.Reader, 内部自带 CSPRNGss2, err := dk.Decapsulate(ct)// 实测: ek.Bytes()=1184, dk.Bytes()=64(seed, 存这个), ct=1088, 共享密钥 32 字节, 双向一致
实测对篡改的密文 Decapsulate 不返回错误(err=nil),而是返回一个不同的随机共享密钥——这是 FIPS 203 的 implicit rejection 设计,调用方必须把"解出的密钥"送进 KDF/HMAC 校验回路才能发现握手不对,不能指望 err。
crypto/mldsa 用法与 Ed25519 心法一致:GenerateKey(mldsa.MLDSA65())、sk.Sign(rand.Reader, msg, &mldsa.Options{})(Options.Context 可做签名域分离,文档要求同 context 签与验一致),mldsa.Verify(pk, msg, sig, nil)。实测 ML-DSA-65 签名 3309 字节(恰为 mldsa.MLDSA65SignatureSize),篡改消息验签返回 mldsa: invalid signature。签名体积是 RSA-2048 的两倍,用在高频小报文(如 JWT)前掂量。
crypto/hpke 提供"用对方公钥做一次性混合加密"的标准方案,KEM 列表里既有经典 DHKEM(X25519/P-256…),也有后量子与混合套件(MLKEM768、MLKEM768X25519 等),配 HKDFSHA256 KDF 与 AES256GCM AEAD。实测单次 Seal/Open(X25519 + AES256GCM)与多消息 Sender/Recipient 会话都通:
goxk, _ := ecdh.X25519().GenerateKey(rand.Reader)hp, _ := hpke.NewDHKEMPrivateKey(xk)hpub, _ := hpke.NewDHKEMPublicKey(xk.PublicKey())sealed, _ := hpke.Seal(hpub, hpke.HKDFSHA256(), hpke.AES256GCM(), info, []byte("hpke works"))// 实测 sealLen=58 (=32 封装密钥 + 10 明文 + 16 标签), Open 还原成功enc, snd, _ := hpke.NewSender(hpub, hpke.HKDFSHA256(), hpke.AES256GCM(), info)rcv, _ := hpke.NewRecipient(enc, hp, hpke.HKDFSHA256(), hpke.AES256GCM(), info)m1, _ := snd.Seal(aad, []byte("msg-one")) // 会话内每条消息 nonce 自增, 无需管理
crypto/sha3 注意返回类型:sha3.Sum512(data) 返回 [64]byte;流式 sha3.New256() 返回具体类型 *sha3.SHA3(不是 hash.Hash 接口值,但它实现 hash.Hash,实测可赋值,Size=32、BlockSize=136 即置换速率);SHAKE 用 SumSHAKE128(data, length) 定长取输出,NewCSHAKE128(N, S) 支持命名域/参数定制。
TLS 里的 X25519MLKEM768 混合密钥交换已默认启用,细节与开关见 crypto-tls.md,本篇不重复。
10. 实战建议
极简选型(要干什么 → 用哪个):
| 要干什么 | 用 |
|---|---|
| 安全摘要 / 签名前哈希 | sha256.Sum256;更长/抗扩展用 sha3 |
| 文件/帧校验和 | crc32.ChecksumIEEE 或协议指定的 MakeTable(Castagnoli) |
| 快速散列 / 分片 | fnv.New32a 或 maphash(进程内) |
| 消息认证 | hmac.New(sha256.New, key) + hmac.Equal |
| 加密且防篡改 | aes.NewCipher + cipher.NewGCM,每次新随机 nonce;怕管 nonce 用 NewGCMWithRandomNonce |
| 比 token/MAC/口令派生值 | subtle.ConstantTimeCompare / hmac.Equal |
| 存口令 | pbkdf2.Key(大 iter + 存 salt/iter)或 argon2id(x/crypto) |
| 共享密钥摊子密钥 | hkdf.Extract/Expand(info 分域) |
| 随机短码/token | rand.Text();UUID 指向 uuid.md |
| 给未来抗量子 | crypto/mlkem(封密钥)、crypto/hpke(混合加密)、crypto/mldsa(签名);TLS 默认已混合 KEX |
三条铁律,违反任何一条前面所有选型都白搭:
- nonce 绝不重用。同一密钥下每个 nonce 只用一次,宁可每次
rand.Read随机新生成并拼在密文前,也不要"图省事"固定 nonce 或复用计数器不持久化。 - 安全比较一律常数时间。MAC、口令哈希、API token 的比对走
hmac.Equal/subtle.ConstantTimeCompare,不用==/bytes.Equal。 - 口令必须过 KDF。存储走 pbkdf2/argon2,派生子密钥走 HKDF;裸 sha256 存口令、用
sha256(key||msg)当签名,都是"看起来像密码学其实没有"的典型。