《后端架构设计·演进与治理》10.3 密钥、审计与合规边界

从一次密钥轮换把旧密文全部锁死、审计日志又无法证明完整性的双重事故切入,给出密钥版本重叠窗口、轮换状态机、SHA-256 审计哈希链与租户导出的 Go 实现,并附篡改、断号、尾部截断和轮换边界的本机测试输出与合规判据,说明哈希链挡不住尾部截断。

一次轮换锁死了所有旧密文

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 平台即产品:能力边界与用户旅程 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查