编程 AES-256-GCM 密钥轮换:把版本号和密钥代际写进密文的第一天

2026-10-24 00:03:32

AES-256-GCM 密钥轮换:把版本号和密钥代际写进密文的第一天

1. 换 ENCRYPTION_KEY 不是改个环境变量

第一次换密钥时,旧密文会全部解不开。GCM 认证失败只给一个统一的 verification failed,看不出是密钥错了、密文被篡改,还是格式本来就不对。加 try/catch 解决不了这个问题,修法是在密文格式里预留两样东西:版本前缀和密钥 ID(或密钥代际号)。这两个字段要在第一天就有,事后补意味着全量重加密。

RFC 5116 §5.1.1 (Nonce Reuse):同一密钥下两次加密用了相同 nonce 且明文不同,会同时破坏机密性与完整性——攻击者能重构两条明文的按位异或,还能恢复 GHASH 密钥,从而伪造后续消息。所以换密钥不只是合规动作,也是 nonce 碰撞后的唯一出路:一旦确认 nonce 重用,密钥必须废弃,不能修补。

NIST SP 800-38D §8.3 给了量化边界:随机 IV 下同一密钥最多 2^32 次加密;确定性 nonce(计数器)最多 2^48 条消息。超过就该换密钥代。

2. 两种轮换,成本差一个数量级

信封加密(Google Cloud KMS 文档):DEK 数据加密密钥本地生成,每次写入都生成新的 DEK,用 DEK 加密数据,用 KEK 封装 DEK,KEK 永不离开 KMS,存储「密文 + 封装后的 DEK」。因此 DEK 不需要轮换——每次写就是新的;需要轮换的是 KEK,轮换后只需重新封装(rewrap)DEK,不必重加密业务数据。

对照:直接用一个主密钥加密所有字段的系统,轮换主密钥 = 全表解密再加密,成本随数据量线性上升,还要停机或双写。

3. n8n 的 CipherAes256GCM:per-message 派生

对应 n8n issue #28916。

  • 新增 CipherAes256GCM,与遗留 CipherAes256CBC 并列实现 CipherWrapper 接口,Cipher 只做分发。
  • 让 nonce 重用这种失败模式不可达:每次加密生成 32 字节随机 salt,用 HKDF-SHA256 派生 per-message key 和 12 字节 IV。即使两条消息派生出的 IV 撞了,它们也在不同密钥下运行,触发不了 GCM 的灾难性伪造。这是标准的 nonce-misuse-resistant 构造。
  • 密文格式:base64( version(1) || salt(32) || authTag(16) || ciphertext )。首字节版本 0x01,用于日后改构造(换 HKDF info、加宽 salt、换 KDF)而不破坏旧密文解密。
  • HKDF info 固定为 'n8n-encryption-v1';改这个字符串会让此前所有密文失效,所以代码里注明:任何变更必须 bump FORMAT_VERSION 并保留 v1 解码路径。
  • 严格密钥校验:GCM 要求 64 位十六进制字符串(32 原始字节),在进入任何 crypto 调用之前就拒绝其他形式;CBC 保持旧的宽松 KDF 行为以兼容存量密文。
  • 分发用 assertUnreachable 做编译期穷尽检查,新增算法必须补 match 分支。

Go 里同构的写法,注意 GCM 的 tag 默认跟在密文尾部,而上面的格式把 tag 放在 ciphertext 之前,需要拆开再拼:

const formatV1 byte = 0x01
const hkdfInfo = "n8n-encryption-v1" // 改动必须 bump formatV1,并保留 v1 解码路径

func derive(dek, salt []byte) (key, iv []byte, err error) {
	r := hkdf.New(sha256.New, dek, salt, []byte(hkdfInfo))
	buf := make([]byte, 32+12)
	if _, err = io.ReadFull(r, buf); err != nil {
		return nil, nil, err
	}
	return buf[:32], buf[32:], nil
}

// v1: base64( version(1) || salt(32) || authTag(16) || ciphertext )
func Seal(dek, plaintext []byte) (string, error) {
	salt := make([]byte, 32)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	key, iv, err := derive(dek, salt)
	if err != nil {
		return "", err
	}
	gcm, err := newGCM(key)
	if err != nil {
		return "", err
	}
	sealed := gcm.Seal(nil, iv, plaintext, nil)
	body, tag := sealed[:len(sealed)-16], sealed[len(sealed)-16:]

	out := make([]byte, 0, 1+32+16+len(body))
	out = append(out, formatV1)
	out = append(out, salt...)
	out = append(out, tag...)
	out = append(out, body...)
	return base64.StdEncoding.EncodeToString(out), nil
}

4. ZigBase:前缀即代际选择器

源码:zigbase/src/aead.zig。

  • 统一 AEAD 信封:"v" ++ N ++ ":" ++ base64url(nonce(12) ‖ ciphertext ‖ tag(16)),AES-256-GCM,AAD 为空,每次 seal 用 12 字节随机 nonce。
  • 解密 fail-closed:任何篡改、错密钥、格式错误、缺前缀都返回 error.BadEnvelope,绝不返回部分明文。
  • 密钥轮换挂在 v: 前缀上:版本号就是密钥代际选择器。seal 打上写入代,open 由调用方按 parseVersion 解析出的代际取对应密钥。field_policy.Cipher 维护 key ring,把每一代映射到各自的 HKDF 派生密钥,所以部署可以写 v2: 同时读 v1:,zigbase rewrap 负责把旧代前滚重加密。
  • 不同调用方(OAuth client secret、加密记录字段)用不同 domain 字符串派生密钥,即使底层 secret 复用,两个密钥空间也不重叠。
  • parseVersion 对 v/N/冒号做严格解析,非信封(无前缀的遗留明文)返回 null;长度不足 12+16 直接 BadEnvelope,避免切片越界。

对应的 Go key ring 与读取路径:

var ErrUnsupportedVersion = errors.New("unsupported envelope version")

type KeyRing struct{ keys map[byte][]byte } // 代际 -> 32 字节密钥

func (kr *KeyRing) Open(enc string) ([]byte, error) {
	raw, err := base64.StdEncoding.DecodeString(enc)
	if err != nil || len(raw) < 1+32+16 {
		return nil, ErrBadEnvelope // 失败关闭,不返回部分明文
	}
	ver, salt, tag, body := raw[0], raw[1:33], raw[33:49], raw[49:]

	dek, ok := kr.keys[ver]
	if !ok {
		return nil, ErrUnsupportedVersion // 不 fallback 到当前密钥
	}
	key, iv, err := derive(dek, salt)
	if err != nil {
		return nil, err
	}
	gcm, err := newGCM(key)
	if err != nil {
		return nil, err
	}
	ct := append(append(make([]byte, 0, len(body)+16), body...), tag...)
	pt, err := gcm.Open(nil, iv, ct, nil)
	if err != nil {
		return nil, ErrBadEnvelope
	}
	return pt, nil
}

5. 迁移步骤

  1. 先让读路径支持多版本:解密时解析前缀 → 从 key ring 取对应密钥;未知版本返回明确的 unsupported version 错误,不要 fallback 到当前密钥。
  2. 写路径切到新版本(双读单写),观察一段时间:解密失败率、unsupported version 计数、按版本分组的行数。
  3. 后台重加密(rewrap):按主键分批
    SELECT id, ct FROM secrets WHERE ct NOT LIKE 'v3:%' ORDER BY id LIMIT 500;
    
    逐行 decrypt → encrypt → 同一事务 UPDATE ... WHERE id=? AND ct=<旧值>(乐观并发,避免覆盖同时发生的业务写入)。
  4. 断点续跑:记录 last_id 或进度表,失败行单独入队,不整批回滚;每批打印成功/失败/跳过计数。
  5. 收尾校验:按版本 GROUP BY 计数,旧版本应为 0;抽样解密;备份保留到确认后再删旧 KEK。
  6. 旧密钥不要立刻删:GCM 下「解不开」和「被篡改」不可区分,删早了没有回退路径。保留只读代际,等下线窗口结束再撤销。

6. 排查顺序(解密失败时)

  • 密文有没有版本前缀?缺失通常是从旧库/旧接口进来的明文或老格式。
  • 前缀解析出的代际在 key ring 里吗?常见是配置只注入了新密钥。
  • 密钥是 base64 还是 hex?32 字节还是 16 字节(AES-128 会被某些库静默接受,但 tag 永远对不上)。
  • 格式里字段顺序是否与写入端一致(salt/tag 位置颠倒是最常见的跨语言错位)。
  • base64 标准与 URL-safe、有无填充。
  • 最后才怀疑数据被篡改。

把这几类错误在日志里分开打 tag,不要都收敛成 decrypt failed,否则轮换期间根本定位不了。

推荐文章

程序员茄子在线接单