如果你接入的是一套已经规定 nonce 字段长度的外部协议,Go 的 cipher.NewGCMWithNonceSize 可以用来配合它;如果协议没有这个约束,通常应继续使用 cipher.NewGCM。两者最大的兼容代价不在构造函数这一行,而在 AEAD.NonceSize() 会改变,进而影响 Seal、Open 和报文拆分位置。
本文只讨论 AES-GCM 的 nonce 长度和报文边界,不展开密钥生成,也不把自定义长度当成“更安全”的选项。目标是让你能判断:现有数据格式是否真的要求自定义长度,以及迁移时哪些字段必须一起改。
先分清默认构造和兼容构造
Go 官方文档把 NewGCM 定义为使用标准 nonce 长度的构造方式,把 NewGCMWithNonceSize 定义为接受指定长度的兼容方式,并明确建议只有在已有密码系统使用非标准 nonce 长度时才选择后者。也就是说,size 首先是协议约束,不是随手调整的性能参数。
两种构造都接收一个 128 位分组密码块。通常先用 AES 密钥创建 cipher.Block,再选择 GCM 构造:
package main
import (
"crypto/aes"
"crypto/cipher"
"fmt"
)
func newAEAD(key []byte, nonceSize int) (cipher.AEAD, error) {
// AES 密钥长度必须是 16、24 或 32 字节。
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("创建 AES block 失败: %w", err)
}
if nonceSize == 0 {
// 没有外部协议约束时,使用 Go 的标准 GCM nonce 长度。
return cipher.NewGCM(block)
}
// 只有已有格式固定为非标准长度时,才显式传入该长度。
return cipher.NewGCMWithNonceSize(block, nonceSize)
}
这里的 0 只是示例函数内部的“选择默认构造”标记,并不是传给 NewGCMWithNonceSize 的合法长度。官方文档明确要求自定义长度不能为零;实际项目中更建议用两个命名清楚的构造函数,避免把“默认”误读成“自定义零字节 nonce”。
让 AEAD 暴露协议长度
构造完成后,不要在封装代码里到处散落 12、8 这类数字。AEAD.NonceSize() 是后续 Seal 和 Open 必须遵守的长度,AEAD.Overhead() 则告诉你密文末尾包含的认证开销。把这两个值集中读取,能让构造函数和协议解析保持同一份契约。
func describeAEAD(aead cipher.AEAD) string {
// NonceSize 决定 Seal/Open 接收的 nonce 切片长度。
// Overhead 用于估算密文尾部的认证开销,不应拿来推断 nonce 长度。
return fmt.Sprintf("nonce=%d overhead=%d", aead.NonceSize(), aead.Overhead())
}
如果发送端使用默认构造,而接收端使用自定义长度构造,即使密钥、明文和认证附加数据都一致,双方也不会共享同一个输入边界。接收端会按自己的 NonceSize() 解释字节,最终表现为 nonce 长度不符或认证失败。
编写固定格式的封装与解封
为了看清兼容代价,可以规定一个简单的线缆格式:nonce || ciphertext。Seal 的结果包含密文主体和认证标签,封装函数只负责把 nonce 放到结果前面;解封函数按同一个 NonceSize() 切开,再把剩余部分交给 Open。
func sealRecord(aead cipher.AEAD, nonce, plaintext, aad []byte) ([]byte, error) {
// 先检查协议层 nonce 长度,避免把切片错误推迟到 Seal。
if len(nonce) != aead.NonceSize() {
return nil, fmt.Errorf("nonce 长度为 %d,期望 %d", len(nonce), aead.NonceSize())
}
// 复制 nonce,避免调用方后续修改导致线缆报文内容变化。
record := append([]byte(nil), nonce...)
// Seal 返回密文和认证标签,追加到 nonce 后形成固定格式报文。
return aead.Seal(record, nonce, plaintext, aad), nil
}
func openRecord(aead cipher.AEAD, record, aad []byte) ([]byte, error) {
// nonce 字段的长度必须由同一份 AEAD 契约决定。
n := aead.NonceSize()
if len(record) < n+aead.Overhead() {
return nil, fmt.Errorf("报文长度不足:%d", len(record))
}
nonce, ciphertext := record[:n], record[n:]
// Open 会同时校验认证标签和附加数据,失败时不要使用明文结果。
plaintext, err := aead.Open(nil, nonce, ciphertext, aad)
if err != nil {
return nil, fmt.Errorf("GCM 认证失败: %w", err)
}
return plaintext, nil
}
这段封装最关键的不是拼接动作,而是“发送端和接收端使用同一个报文契约”。如果历史协议把 nonce 定义为 8 字节,迁移时不能只把构造函数换成 NewGCMWithNonceSize(block, 8),还要保证所有读取端都用 8 字节切片,并把旧数据与新数据的格式版本区分开。
比较默认长度和自定义长度的兼容代价
| 选择 | 适用条件 | 需要同步的地方 | 主要代价 |
|---|---|---|---|
NewGCM | 新协议或内部数据格式没有历史 nonce 约束 | 只需统一 AEAD 与报文编码 | 不兼容已经固定为非标准长度的旧系统 |
NewGCMWithNonceSize | 已有密码系统明确要求某个非标准长度 | 构造函数、NonceSize、封装、拆包、版本协商 | 更容易把长度常量散落到多个实现,迁移测试面更大 |
这里的“兼容”至少包含三层:一是字节格式兼容,接收端能在正确偏移处取出 nonce;二是 API 约束兼容,双方传给 Seal 和 Open 的 nonce 长度一致;三是部署兼容,灰度期间旧服务和新服务是否同时处理两种格式。如果只满足第一层而没有版本标记,服务仍可能在升级后把同一段字节解释成不同字段。
扩展实验:迁移时怎样保留旧格式
如果确实要从旧的自定义长度迁移到默认构造,可以给报文增加一个明确的格式版本,而不是让接收端根据长度猜测。伪代码可以这样组织:
type WireVersion byte
const (
// 版本号只标识报文格式,不代表密码强度等级。
legacyVersion WireVersion = 1
standardVersion WireVersion = 2
)
func aeadForVersion(key []byte, version WireVersion) (cipher.AEAD, error) {
// 版本到 nonce 长度的映射必须固定,并与发送端共享。
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
switch version {
case legacyVersion:
// 这里的 8 仅代表既有协议的示例约束。
return cipher.NewGCMWithNonceSize(block, 8)
case standardVersion:
// 新格式使用默认构造,避免继续扩大自定义协议面。
return cipher.NewGCM(block)
default:
return nil, fmt.Errorf("未知报文版本 %d", version)
}
}
迁移期间可以先让新版本只读旧格式,再在确认所有发送方都切到新格式后停止写旧格式。文章不把这个过程称为自动安全升级:密钥轮换、nonce 生成策略、重放保护和密钥生命周期仍需要由完整协议单独规定。
最后按这张清单做选择
- 先问协议是否已经规定了非标准 nonce 长度;没有就优先
NewGCM。 - 若必须兼容旧系统,让构造函数返回的
NonceSize()成为封装和拆包的唯一长度来源。 - 把 nonce、密文主体和认证标签的边界写进报文格式,不要让接收端靠猜测偏移。
- 迁移时增加格式版本,分别处理旧格式与新格式,避免只改一端。
- 不要把更长或更短的 nonce 宣传成自动更安全;自定义构造的核心价值是互操作。
相关问题
NewGCMWithNonceSize 能不能传 0?
不能把 0 当成有效自定义长度。没有外部格式约束时,应调用 NewGCM;如果是自定义构造,则传入已有协议明确规定的非零长度。
只改 nonce 长度,密文标签也要重新设计吗?
不应只看标签字段。nonce 长度会改变报文拆分边界,原有封装和解封代码都要同步;认证标签的处理仍由同一个 AEAD 构造负责。
为什么双方密钥相同仍然 Open 失败?
优先检查两端的构造函数、NonceSize()、nonce 字节、附加数据和报文切片是否完全一致。只要其中一项不同,认证就可能失败。

Go chacha20poly1305.New 怎样创建 AEAD 并检查密钥长度
