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';改这个字符串会让此前所有密文失效,所以代码里注明:任何变更必须 bumpFORMAT_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:前缀即代际选择器
- 统一 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. 迁移步骤
- 先让读路径支持多版本:解密时解析前缀 → 从 key ring 取对应密钥;未知版本返回明确的
unsupported version错误,不要 fallback 到当前密钥。 - 写路径切到新版本(双读单写),观察一段时间:解密失败率、
unsupported version计数、按版本分组的行数。 - 后台重加密(rewrap):按主键分批
逐行 decrypt → encrypt → 同一事务SELECT id, ct FROM secrets WHERE ct NOT LIKE 'v3:%' ORDER BY id LIMIT 500;UPDATE ... WHERE id=? AND ct=<旧值>(乐观并发,避免覆盖同时发生的业务写入)。 - 断点续跑:记录
last_id或进度表,失败行单独入队,不整批回滚;每批打印成功/失败/跳过计数。 - 收尾校验:按版本
GROUP BY计数,旧版本应为 0;抽样解密;备份保留到确认后再删旧 KEK。 - 旧密钥不要立刻删:GCM 下「解不开」和「被篡改」不可区分,删早了没有回退路径。保留只读代际,等下线窗口结束再撤销。
6. 排查顺序(解密失败时)
- 密文有没有版本前缀?缺失通常是从旧库/旧接口进来的明文或老格式。
- 前缀解析出的代际在 key ring 里吗?常见是配置只注入了新密钥。
- 密钥是 base64 还是 hex?32 字节还是 16 字节(AES-128 会被某些库静默接受,但 tag 永远对不上)。
- 格式里字段顺序是否与写入端一致(salt/tag 位置颠倒是最常见的跨语言错位)。
- base64 标准与 URL-safe、有无填充。
- 最后才怀疑数据被篡改。
把这几类错误在日志里分开打 tag,不要都收敛成 decrypt failed,否则轮换期间根本定位不了。