跳转到主要内容

批处理密钥轮换:持久化恢复 KMS 加密上下文与 UTF-8 往返完整性防护

本文记录 cmd/batch-rotate.go 中一处继承自上游 MinIO 的 SSE-KMS 关联数据(Associated Data)静默丢失缺陷及其修复设计,对应 #346。

状态: 已通过提交 467ce760e 与 4a9bdb540 合入 main,将随 20261109 之后的下一版 Server 发布。SILO Server 20261109 及更早版本,以及自 RELEASE.2023-04-07T05-28-58Z 以来的所有上游原版 MinIO 均受影响。

简短回答

在批处理框架的 keyrotate(批量密钥轮换)作业中,管理员可以为 sse-kms 类型配置 encryption.context(一段经 base64 编码的字符串键值 JSON 对象,例如 {"tenant":"acme","compliance":"pci"}),将其作为认证加密关联数据(AAD)绑定到新生成的数据加密密钥(DEK)与对象元数据上。

然而自上游 MinIO 于 2023 年 4 月合入该功能起,func (e BatchJobKeyRotateEncryption) Validate() error 一直使用值接收者(value receiver)解码 e.Context 并写入 e.kmsContext。函数返回瞬间,栈上的值拷贝即被丢弃,导致外层 KeyRotate 读取到的 r.Encryption.kmsContext 永远为 nil——每一次配置了 encryption.context 的 SSE-KMS 批量密钥轮换实际上都静默丢弃了该上下文,改用空上下文重新封装了对象密钥!即便把接收者改成指针,kmsContext 字段本身带有 msg:"-" 标签(不参与持久化),在作业清单重载、失败重试或节点重启后依然会丢失。此外,一旦修复使 encryption.context 真正参与加解密,jsoniter 允许 base64 解码出的原始非法 UTF-8 字节直接送入 KMS 封装密钥,而后续写入对象元数据时标准 JSON 序列化会将非法 UTF-8 替换为 U+FFFD(甚至将两个不同的畸形键折叠为同一个键),造成写入与读取的 AAD 不一致,使轮换后的对象永久无法解密。

修复方案彻底移除了非持久化的 kmsContext 缓存字段,在作业提交校验、执行、重试与重启恢复时一律从持久化的 e.Context 字符串无状态解码全新的 kms.Context,在 Unmarshal 前强制校验 utf8.Valid,并在调用 KMS 探针前先克隆独立 Map 以隔离探针专用键值。

定性

方面 评估
问题性质 Go 值接收者赋值丢失 + 非持久化派生字段(msg:"-")导致 encryption.context 全程失效,叠加 jsoniter 与标准 JSON 序列化之间的非法 UTF-8 归一化差异;继承自上游 MinIO。
上游来源 由上游提交 3158f2d12(2023-04-04,minio/minio#16844,“Add support for batch key rotation”)引入,首次发布于 RELEASE.2023-04-07T05-28-58Z。
影响范围 所有将 encryption.type 设为 sse-kms 且显式指定了非空 encryption.context 的 keyrotate 批处理作业。sse-s3 轮换与未指定 encryption.context 的 sse-kms 轮换不受影响。
故障表现 轮换后的对象静默以空 KMS 上下文 {} 重新封装并落盘,管理员配置的租户/合规关联数据未传给 KMS,也未写入对象元数据。
数据安全 既有已被轮换的对象仍可正常读取(GET 读取对象 xl.meta 中实际保存的空上下文解封);升级后重新执行一次 keyrotate 即可补齐预期的 encryption.context 绑定。

一、上游溯源与根因分析

1.1 值接收者拷贝丢弃与 msg:"-" 非持久化缓存(3158f2d12)

上游提交 3158f2d12 在 BatchJobKeyRotateEncryption 结构体上定义了一个未导出且不参与 msgpack 序列化的缓存字段 kmsContext kms.Context,并试图在 Validate() 中为其赋值:

type BatchJobKeyRotateEncryption struct {
    Type       BatchKeyRotationType `yaml:"type" json:"type"`
    Key        string               `yaml:"key" json:"key"`
    Context    string               `yaml:"context" json:"context"`
    kmsContext kms.Context          `msg:"-"`
}

func (e BatchJobKeyRotateEncryption) Validate() error {
    // ... 解码 e.Context 至 ctx ...
    e.kmsContext = kms.Context{}
    maps.Copy(e.kmsContext, ctx)
    ctx["MinIO batch API"] = "batchrotate"
    // ... 调用 GlobalKMS.GenerateKey 执行连通性探针 ...
}

而在逐对象执行轮换的 (*BatchJobKeyRotateV1).KeyRotate 中,调用链为:

if err = r.Encryption.Validate(); err != nil {
    return err
}
newKeyID = strings.TrimPrefix(r.Encryption.Key, crypto.ARNPrefix)
newKeyContext = r.Encryption.kmsContext

这条路径同时存在三处耦合缺陷:

  1. 值接收者修改在返回时直接丢弃: Validate() 采用值传递接收 BatchJobKeyRotateEncryption,e.kmsContext = ... 只修改了函数栈帧内的局部副本;返回后 r.Encryption.kmsContext 恒为 nil,导致传给 rotateKey 的 newKeyContext 永远为空。
  2. msg:"-" 派生字段跨持久化边界丢失且并发不安全: 批处理作业定义需要序列化保存在 .minio.sys 中以支持重试、节点接管与进程重启恢复。任何标记为 msg:"-" 的内存派生字段在重载后都会变回零值;且若改为指针接收者在共享作业结构体上反复原地写 e.kmsContext,还会在并发对象轮换 worker 之间制造数据竞争(data race)。
  3. 校验探针原地污染上下文 Map: Validate() 直接向解码出的 ctx 写入 ctx["MinIO batch API"] = "batchrotate" 用于 KMS 测试探针;一旦拷贝顺序调整,该内部探针字段就会混入真实对象的加密上下文中。

1.2 KMS 封装与元数据落盘之间的非法 UTF-8 关联数据漂移

当我们将 encryption.context 真正打通至 rotateKey 后,JSON 解码器与元数据持久化之间的编码边界暴露出第二个严重隐患:

  • jsoniter.ConfigCompatibleWithStandardLibrary.Unmarshal 在解析由 base64 直接解码出的原始字节切片时,允许字符串键或值中包含非法的原始 UTF-8 字节序列。若直接将其作为 AssociatedData 传给 GlobalKMS.GenerateKey,KMS 会严格按这组原始非法字节封装 DEK。
  • 紧接着 rotateKey 将 KMS 上下文序列化为 JSON 写入对象内部元数据(X-Minio-Internal-Server-Side-Encryption-S3-Kms-Context)时,标准 JSON 编码器会将非法 UTF-8 字节序列强制替换为 Unicode 替换字符 U+FFFD(\xef\xbf\xbd);更极端地,同一个 JSON 对象中两个不同的非法字节键(例如 {"\xff":"1","\xfe":"2"})在落盘序列化后会坍缩合并成同一个键 "\ufffd"!
  • 随后客户端发起 GET 下载时,服务端从 xl.meta 读出已被归一化为 U+FFFD 的上下文去请求 KMS 解封 DEK,由于 AEAD 关联数据与封装时的原始字节不再匹配,解密将永久报错失败,导致对象锁死不可读。

二、修复方案:无状态解码与前置 UTF-8 完整性校验

提交 467ce760e 与 4a9bdb540 摒弃了易失的结构体缓存字段,改为直接从持久化配置无状态解码并前置校验:

  1. 删除 kmsContext,统一由 batchKeyRotateContext 无状态解码: BatchJobKeyRotateEncryption 仅保留持久化的 Type、Key 与 Context 三个字段。batchKeyRotateContext(encoded) 对 base64 解码后的字节切片首先执行 utf8.Valid(b) 校验(含非法 UTF-8 字节立即返回 errInvalidArgument),再反序列化为全新的 kms.Context(map[string]string),并将空串、{} 与 JSON null 统一归一化为空非 nil 的 kms.Context{}。
  2. 克隆隔离校验探针的关联数据(validate): 内部方法 func (e BatchJobKeyRotateEncryption) validate() (kms.Context, error) 解码得到纯净的 ctx 后,通过 probe := maps.Clone(ctx) 克隆出独立副本,仅在 probe 上注入 probe["MinIO batch API"] = "batchrotate" 以执行 GlobalKMS.GenerateKey 权限与连通性校验,随后将未受污染的 ctx 直接返回给 KeyRotate 用于真实对象密钥封装。
  3. 提交阶段与持久化重载阶段双重前置拦截: 公开方法 Validate() 复用 validate(),使非法 base64、非法 UTF-8、非对象 JSON 或非字符串成员值在作业提交阶段即被明确拒绝;同时对于升级前已落盘的持久作业清单,在重载执行、触碰任何对象加密元数据之前同样先完成校验拦截。

为什么该设计既必要又充分

  • 必要性:
    • 仅把 Validate() 改为指针接收者无法跨 .minio.sys 持久化重载保留 kmsContext,还会在多协程执行时引发共享字段并发写竞争;
    • 不在生成密钥前拦截 !utf8.Valid(b),任何无法无损通过 UTF-8 JSON 序列化的字节序列都会在 KMS 封装与 xl.meta 落盘之间产生不可逆的 AAD 失配,直接损坏对象可读性。
  • 充分性: e.Context 本身随 YAML/JSON/msgpack 全程持久化。每次校验、首次轮换、临时 KMS 故障重试与进程重启恢复均从同一份 e.Context 解码出经过 UTF-8 校验的全新 kms.Context,从根源上保证了 KMS 密钥生成、xl.meta 落盘与后续 GET 解密三处的关联数据逐字节完全一致(见 cmd/batch-rotate-context_test.go)。

三、利弊权衡、潜在代价与边界

权衡维度 工程取舍与运维代价
逐对象重复解码 base64 + JSON 的开销 每个待轮换版本都重新解码已存储的上下文。这增加了解码工作,但避免依赖不会写入作业清单的可变缓存;此处不提供未经基准测试的延迟上限。
严格拒绝含非法 UTF-8 的上下文编码 base64 解码后含原始非 UTF-8 字节时,提交端返回 400 XMinioAdminInvalidArgument;从持久化清单恢复执行时也会在修改对象密钥前拒绝该上下文。合法 UTF-8 Unicode(包括以合法 UTF-8 编码传入的 U+FFFD)仍受支持。
旧版本已轮换过的 SSE-KMS 对象处理 旧路径丢弃请求上下文并用空上下文加密时,解密仍使用 xl.meta 中记录的空上下文。只要原密钥可用,这些对象仍可读取;这不代表能恢复其他元数据或密钥损坏。若 KMS 策略或审计要求预期的 encryption.context,升级后重新运行 keyrotate 作业。

验证

cmd/batch-rotate-context_test.go 使用真实内置 KMS 端到端验证了直接执行、.minio.sys 持久清单重载、KMS 临时错误重试恢复、探针字段隔离(对象元数据不含 "MinIO batch API")、非法 UTF-8 键冲突与非法值前置拒绝、合法 Unicode 字符保留,以及轮换后对象的完整认证解密读取。另见批量密钥轮换管理文档与 Server 候选兼容性说明。