一次轮换锁死了所有旧密文
2026 年 10 月,arch-lab 的支付服务把加密密钥从 pay-2026-03 换成 pay-2026-10。运维在配置中心删除了旧版本并滚动重启全部实例,看起来干净利落。第二天,退款与对账任务批量失败:历史 Payment 记录仍然用旧密钥加密,新进程的密钥环里已经没有这个版本,解密直接报错。
同一个月还发生了第二件事。合规审计要求提供“谁在什么时间改过哪条租户数据”,团队打开审计表,发现它只是一张普通表:应用账号有 UPDATE 权限,没有哈希、没有签名、没有归档。没有人能证明失败窗口内记录没有被改过。密钥轮换和审计完整性看似两件事,其实都在回答同一个问题:当系统的一部分已经不可信时,剩下的证据还能不能支撑结论。
这一节给出两个最小实现:带重叠窗口的密钥轮换状态机,以及能发现改写、重排和中间删除的审计哈希链。支付密钥的信任边界见支付信任与密钥管理 ,密钥的存储与分发见密钥管理 。
轮换要有重叠窗口
| 阶段 | 新数据写入 | 旧数据解密 | 退出条件 |
|---|---|---|---|
| active | 使用当前版本 | 允许 | 下一次轮换开始 |
| retiring | 不再写入 | 允许,直到 RetireAt | 重叠窗口结束 |
| retired | 拒绝 | 拒绝 | 只能离线归档恢复 |
轮换不是“换一个字符串”,而是一个三态状态机。新版本创建后成为唯一 active;旧版本进入 retiring,在重叠窗口内仍可解密历史数据;窗口结束后才 retired。Resolve 在 RetireAt 之前返回旧版本,之后返回 ErrKeyRetired,未知 ID 返回 ErrKeyUnknown。这样退款、对账这类读历史密文的路径有明确的截止时间,而不是在某次重启后突然失效。
状态机还要拒绝三类错误:新 ID 与已有版本重复、轮换时间没有前进、重叠窗口非正。它们分别对应配置回滚、时钟回拨和“以为留了窗口其实没有”。这些检查不能留给运维记忆,必须让 Rotate 直接失败。
审计日志要能证明未被改写
把审计表的 UPDATE 权限收走只是第一层。拥有数据库超级权限的人仍然可以改行,备份恢复也可能带回一份被篡改的数据。哈希链的作用是让“改一条记录”变成“改一条并重算后面所有记录”,再叠加外部锚点,让整段重算也留下痕迹。
每条记录包含序号、时间、主体、动作、租户、资源、前一条哈希和自身哈希。Append 由日志分配序号,调用方不能自选;VerifyChain 逐条检查序号连续、PrevHash 衔接、内容哈希一致。改写字段、交换顺序、删除中间记录都会在第一个断点失败。它发现不了的是尾部截断:删掉最后几条,剩下的链仍然自洽;也发现不了整段替换。这两类需要把链头定期写到独立系统,例如 WORM 存储、另一个账号的对象锁桶,或用发布密钥签名后外发。
| 攻击 | 哈希链能否发现 | 还需要什么 |
|---|---|---|
| 修改某条记录字段 | 能,内容哈希不匹配 | 无 |
| 交换两条记录顺序 | 能,序号或前哈希不匹配 | 无 |
| 删除中间记录 | 能,序号断号 | 无 |
| 删除尾部记录 | 不能 | 定期外发链头或签名根 |
| 整段替换并重算 | 不能 | 外部锚点、独立签名、WORM |
可运行实现
下面的 audit.go 与 10.1、10.2 共用 tenant 包。Keyring 负责 active/retiring/retired 状态与重叠窗口,AuditLog 负责追加、校验和按租户导出。
// file: internal/platform/tenant/audit.go
// tier: L1
// verify: go test ./internal/platform/tenant/ -count=1
package tenant
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
var (
ErrKeyUnknown = errors.New("audit: unknown key version")
ErrKeyRetired = errors.New("audit: key version retired")
ErrChainBroken = errors.New("audit: hash chain broken")
)
// KeyState is the lifecycle state of one encryption key version.
type KeyState string
const (
KeyActive KeyState = "active"
KeyRetiring KeyState = "retiring"
KeyRetired KeyState = "retired"
)
// KeyVersion is one key in a rotation ring. RetireAt is meaningful only while
// the version is retiring.
type KeyVersion struct {
ID string
CreatedAt time.Time
RetireAt time.Time
State KeyState
}
// Keyring holds every version still usable for decryption.
type Keyring struct {
versions []KeyVersion
}
// NewKeyring creates a ring with one active version.
func NewKeyring(firstID string, now time.Time) (*Keyring, error) {
if strings.TrimSpace(firstID) == "" || now.IsZero() {
return nil, errors.New("audit: first key id and creation time are required")
}
return &Keyring{versions: []KeyVersion{{
ID: firstID, CreatedAt: now, State: KeyActive,
}}}, nil
}
// Rotate creates a new active version and moves the old one to retiring. The
// overlap window is the only period in which both versions may decrypt.
func (k *Keyring) Rotate(id string, now time.Time, overlap time.Duration) (KeyVersion, error) {
if strings.TrimSpace(id) == "" {
return KeyVersion{}, errors.New("audit: new key id is empty")
}
if overlap <= 0 {
return KeyVersion{}, errors.New("audit: overlap must be positive")
}
active := -1
for i, v := range k.versions {
if v.ID == id {
return KeyVersion{}, fmt.Errorf("audit: duplicate key %q", id)
}
if v.State == KeyActive {
active = i
}
}
if active < 0 {
return KeyVersion{}, errors.New("audit: no active key to rotate")
}
if !now.After(k.versions[active].CreatedAt) {
return KeyVersion{}, errors.New("audit: rotation must advance time")
}
k.versions[active].State = KeyRetiring
k.versions[active].RetireAt = now.Add(overlap)
v := KeyVersion{ID: id, CreatedAt: now, State: KeyActive}
k.versions = append(k.versions, v)
return v, nil
}
// Resolve returns a version that may still decrypt data at now. A retiring key
// is refused once the overlap window has closed.
func (k *Keyring) Resolve(id string, now time.Time) (KeyVersion, error) {
for _, v := range k.versions {
if v.ID != id {
continue
}
switch v.State {
case KeyActive:
return v, nil
case KeyRetiring:
if now.Before(v.RetireAt) {
return v, nil
}
return v, fmt.Errorf("%w: %s closed at %s", ErrKeyRetired, id, v.RetireAt.Format(time.RFC3339))
default:
return v, fmt.Errorf("%w: %s", ErrKeyRetired, id)
}
}
return KeyVersion{}, fmt.Errorf("%w: %s", ErrKeyUnknown, id)
}
// Active returns the single active version at now.
func (k *Keyring) Active(now time.Time) (KeyVersion, error) {
for _, v := range k.versions {
if v.State == KeyActive {
if now.Before(v.CreatedAt) {
return KeyVersion{}, errors.New("audit: active key is not valid yet")
}
return v, nil
}
}
return KeyVersion{}, errors.New("audit: no active key")
}
// AuditEntry is one append-only audit record. PrevHash links it to the record
// before it; Hash covers every identity and payload field.
type AuditEntry struct {
Seq uint64
At time.Time
Actor string
Action string
Tenant string
Resource string
PrevHash string
Hash string
}
func entryHash(e AuditEntry) string {
h := sha256.New()
fmt.Fprintf(h, "%d\x00%s\x00%s\x00%s\x00%s\x00%s\x00%s",
e.Seq, e.At.UTC().Format(time.RFC3339Nano),
e.Actor, e.Action, e.Tenant, e.Resource, e.PrevHash)
return hex.EncodeToString(h.Sum(nil))
}
// AuditLog assigns sequence numbers and hashes on append.
type AuditLog struct {
entries []AuditEntry
}
// NewAuditLog returns an empty log.
func NewAuditLog() *AuditLog { return &AuditLog{} }
// Append fills Seq, PrevHash and Hash. Callers cannot choose the sequence, so
// reordering requires breaking the chain.
func (l *AuditLog) Append(e AuditEntry) (AuditEntry, error) {
if e.At.IsZero() || strings.TrimSpace(e.Actor) == "" ||
strings.TrimSpace(e.Action) == "" || strings.TrimSpace(e.Tenant) == "" ||
strings.TrimSpace(e.Resource) == "" {
return AuditEntry{}, errors.New("audit: entry has an empty required field")
}
e.Seq = uint64(len(l.entries) + 1)
e.PrevHash = ""
if n := len(l.entries); n > 0 {
e.PrevHash = l.entries[n-1].Hash
}
e.Hash = entryHash(e)
l.entries = append(l.entries, e)
return e, nil
}
// VerifyChain detects edited fields, reordered entries and deleted middle
// entries. Truncating the tail is invisible without an external anchor.
func VerifyChain(entries []AuditEntry) error {
prev := ""
for i, e := range entries {
if e.Seq != uint64(i+1) {
return fmt.Errorf("%w: position %d has seq %d", ErrChainBroken, i, e.Seq)
}
if e.PrevHash != prev {
return fmt.Errorf("%w: seq %d has wrong prev hash", ErrChainBroken, e.Seq)
}
if entryHash(e) != e.Hash {
return fmt.Errorf("%w: seq %d content changed", ErrChainBroken, e.Seq)
}
prev = e.Hash
}
return nil
}
// Entries returns a copy so callers cannot mutate the log through the slice.
func (l *AuditLog) Entries() []AuditEntry {
return append([]AuditEntry(nil), l.entries...)
}
// ExportTenant returns only one tenant's records. The filtered slice is for
// disclosure, not for chain verification; verify the full log first.
func (l *AuditLog) ExportTenant(tenantID string) []AuditEntry {
out := make([]AuditEntry, 0)
for _, e := range l.entries {
if e.Tenant == tenantID {
out = append(out, e)
}
}
return out
}
实现有四个边界值得强调。Rotate 在遍历时同时检查重复 ID 和唯一 active,避免出现两个 active;Resolve 用 now.Before(RetireAt) 而不是 <=,重叠窗口结束的那一刻就拒绝;entryHash 用 \x00 分隔字段并按 UTC、RFC3339Nano 格式化时间,避免拼接歧义和时区差异;ExportTenant 返回过滤后的副本,但它不是可独立验证的链,导出前必须先校验完整日志。
测试用固定时钟覆盖轮换不前进、重复密钥、重叠窗口内外的解析、未知版本,以及审计链的追加、导出、篡改、断号、尾部截断和缺字段。
// file: internal/platform/tenant/audit_test.go
// tier: L1
// verify: go test ./internal/platform/tenant/ -count=1
package tenant
import (
"errors"
"testing"
"time"
)
func TestKeyringRotation(t *testing.T) {
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
ring, err := NewKeyring("k1", t0)
if err != nil {
t.Fatalf("NewKeyring: %v", err)
}
if _, err := ring.Rotate("k2", t0, 7*24*time.Hour); err == nil {
t.Fatal("rotation without advancing time should fail")
}
if _, err := ring.Rotate("k2", t0.Add(24*time.Hour), 7*24*time.Hour); err != nil {
t.Fatalf("Rotate: %v", err)
}
if _, err := ring.Rotate("k2", t0.Add(48*time.Hour), time.Hour); err == nil {
t.Fatal("duplicate key should fail")
}
if _, err := ring.Resolve("k1", t0.Add(7*24*time.Hour)); err != nil {
t.Fatalf("overlap decrypt: %v", err)
}
if _, err := ring.Resolve("k1", t0.Add(8*24*time.Hour)); !errors.Is(err, ErrKeyRetired) {
t.Fatalf("closed overlap err=%v", err)
}
active, err := ring.Active(t0.Add(8 * 24 * time.Hour))
if err != nil || active.ID != "k2" {
t.Fatalf("active=%+v err=%v", active, err)
}
if _, err := ring.Resolve("k9", t0); !errors.Is(err, ErrKeyUnknown) {
t.Fatalf("unknown err=%v", err)
}
}
func TestAuditChain(t *testing.T) {
log := NewAuditLog()
base := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
inputs := []AuditEntry{
{At: base, Actor: "order-api", Action: "order.create", Tenant: "acme", Resource: "order-1"},
{At: base.Add(time.Second), Actor: "payment-api", Action: "payment.capture", Tenant: "acme", Resource: "order-1"},
{At: base.Add(2 * time.Second), Actor: "order-api", Action: "order.create", Tenant: "globex", Resource: "order-2"},
}
for _, in := range inputs {
if _, err := log.Append(in); err != nil {
t.Fatalf("Append: %v", err)
}
}
entries := log.Entries()
if len(entries) != 3 || entries[2].Seq != 3 || entries[1].PrevHash != entries[0].Hash {
t.Fatalf("entries=%+v", entries)
}
if err := VerifyChain(entries); err != nil {
t.Fatalf("VerifyChain: %v", err)
}
if got := log.ExportTenant("acme"); len(got) != 2 {
t.Fatalf("acme export=%d", len(got))
}
tampered := append([]AuditEntry(nil), entries...)
tampered[1].Actor = "attacker"
if err := VerifyChain(tampered); !errors.Is(err, ErrChainBroken) {
t.Fatalf("tamper err=%v", err)
}
gap := []AuditEntry{entries[0], entries[2]}
if err := VerifyChain(gap); !errors.Is(err, ErrChainBroken) {
t.Fatalf("gap err=%v", err)
}
if err := VerifyChain(entries[:2]); err != nil {
t.Fatalf("tail truncation is not detectable: %v", err)
}
if _, err := log.Append(AuditEntry{At: base}); err == nil {
t.Fatal("expected missing field error")
}
t.Logf("entries=%d acme_export=%d", len(entries), len(log.ExportTenant("acme")))
}
本机实测
命令与环境:macOS darwin/arm64、Go 1.26.0。完整输出见 .local/reports/be-arch-gov/ch10-full.txt,下面是密钥与审计相关的子集。
$ go version
go version go1.26.0 darwin/arm64
$ go test ./internal/platform/tenant/ -count=1 -v -run 'TestKeyring|TestAudit'
=== RUN TestKeyringRotation
--- PASS: TestKeyringRotation (0.00s)
=== RUN TestAuditChain
audit_test.go:80: entries=3 acme_export=2
--- PASS: TestAuditChain (0.00s)
PASS
ok archlab/internal/platform/tenant 0.252s
轮换层,k1 在 t0 创建,k2 在 t0+24h 轮换、重叠 7 天;t0+7d 仍能解析 k1,t0+8d 返回 ErrKeyRetired,此刻 active 是 k2;重复 k2、时间不前进都失败,k9 返回 ErrKeyUnknown。审计层,3 条记录序号 1..3、前哈希衔接,VerifyChain 通过,acme 导出 2 条;改写第 2 条主体后校验失败,删掉中间一条后断号失败;删掉尾部一条反而通过,证明哈希链单独无法发现截断;缺字段追加被拒绝。
边界要说清楚:这是单进程内存日志,哈希链不提供机密性,也不防止拥有写权限的人整段重算;生产需要把链头定期外发、对日志表做 WORM 或对象锁,并把审计与业务写入放在同一事务边界之外做异步补偿。0.252 秒是本机测试耗时,不代表真实日志量的校验耗时;按天分片校验时,跨分片的前哈希需要额外设计。
常见错误与边界
- 轮换时直接删除旧密钥。历史密文、备份和延迟消息仍可能引用旧版本,必须先经过 retiring 重叠窗口。
- 重叠窗口按“小时”拍脑袋。窗口要覆盖最长在途任务、备份恢复和回滚时间,并写进轮换手册,而不是沿用默认值。
- 把审计表当成审计日志。普通表可以 UPDATE、可以删行;没有哈希链和外部锚点,无法证明完整性。
- 认为哈希链能发现所有篡改。尾部截断和整段重算都不在链的证明范围内,必须依赖外部锚点与独立权限。
小结
密钥与审计是合规边界的两个支点:密钥轮换要有 active/retiring/retired 三态和明确的重叠窗口,让历史密文在截止时间前仍可解密;审计日志要让每条记录携带前哈希,使改写、重排和中间删除在第一个断点暴露。
本节实现只用标准库,两个测试函数验证了轮换边界与链校验,也明确展示了尾部截断的盲区。它不是完整的 KMS 或合规平台,而是一组可以放进发布检查的最小判据:轮换前确认旧版本仍可解析,导出前确认完整链通过,链头定期外发。下一章从租户与安全边界转向平台工程,讨论能力目录、黄金路径与自助状态机。
阅读导航:上一节:10.2 服务间身份:认证、授权与最小权限 · 下一节:11.1 平台即产品:能力边界与用户旅程 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。