完成敏感凭据生命周期与主密钥轮换保护

增加凭据启用禁用、主密钥缺失保护与轮换逻辑,并补充生命周期测试。
This commit is contained in:
xuans 2026-09-29 23:20:59 +08:00
parent 03ae0001c1
commit bac67b68fd
2 changed files with 280 additions and 3 deletions

View File

@ -25,6 +25,7 @@ const storeVersion = 1
type encryptedItem struct {
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
Status string `json:"status,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -36,12 +37,21 @@ type fileData struct {
type Store struct {
mu sync.Mutex
key []byte
keyPath string
filePath string
}
const (
StatusActive = "active"
StatusDisabled = "disabled"
)
var ErrDisabled = errors.New("敏感信息已禁用")
// Metadata 仅包含敏感信息引用及更新时间,不包含密文或明文内容。
type Metadata struct {
Reference string `json:"reference"`
Status string `json:"status"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -52,11 +62,20 @@ func Open(configDir string) (*Store, error) {
if err := os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("创建敏感信息目录失败:%w", err)
}
key, err := loadOrCreateKey(filepath.Join(configDir, "master.key"))
keyPath := filepath.Join(configDir, "master.key")
filePath := filepath.Join(configDir, "secrets.json")
if _, err := os.Stat(filePath); err == nil {
if _, keyErr := os.Stat(keyPath); errors.Is(keyErr, os.ErrNotExist) {
return nil, errors.New("配置主密钥缺失,拒绝重新生成以避免凭据丢失")
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("检查敏感信息文件失败:%w", err)
}
key, err := loadOrCreateKey(keyPath)
if err != nil {
return nil, err
}
return &Store{key: key, filePath: filepath.Join(configDir, "secrets.json")}, nil
return &Store{key: key, keyPath: keyPath, filePath: filePath}, nil
}
func (s *Store) Put(ctx context.Context, reference string, value map[string]string) error {
@ -96,6 +115,7 @@ func (s *Store) Put(ctx context.Context, reference string, value map[string]stri
data.Items[reference] = encryptedItem{
Nonce: base64.RawStdEncoding.EncodeToString(nonce),
Ciphertext: base64.RawStdEncoding.EncodeToString(ciphertext),
Status: StatusActive,
UpdatedAt: time.Now().UTC(),
}
return s.writeLocked(data)
@ -118,6 +138,9 @@ func (s *Store) Get(ctx context.Context, reference string) (map[string]string, e
if !ok {
return nil, fmt.Errorf("敏感信息引用不存在:%s", reference)
}
if item.Status == StatusDisabled {
return nil, ErrDisabled
}
nonce, err := base64.RawStdEncoding.DecodeString(item.Nonce)
if err != nil {
return nil, fmt.Errorf("解析敏感信息随机数失败:%w", err)
@ -183,13 +206,166 @@ func (s *Store) List(ctx context.Context, prefix string) ([]Metadata, error) {
result := make([]Metadata, 0)
for reference, item := range data.Items {
if prefix == "" || strings.HasPrefix(reference, prefix) {
result = append(result, Metadata{Reference: reference, UpdatedAt: item.UpdatedAt})
result = append(result, Metadata{Reference: reference, Status: itemStatus(item), UpdatedAt: item.UpdatedAt})
}
}
sort.Slice(result, func(i, j int) bool { return result[i].Reference < result[j].Reference })
return result, nil
}
// Disable 禁用引用但保留密文,禁用后读取会被拒绝,可用于轮换和故障隔离。
func (s *Store) Disable(ctx context.Context, reference string) error {
return s.setStatus(ctx, reference, StatusDisabled)
}
// Enable 恢复已禁用的引用。
func (s *Store) Enable(ctx context.Context, reference string) error {
return s.setStatus(ctx, reference, StatusActive)
}
func (s *Store) setStatus(ctx context.Context, reference, status string) error {
if s == nil {
return errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return err
}
reference = strings.TrimSpace(reference)
if reference == "" {
return errors.New("敏感信息引用不能为空")
}
if status != StatusActive && status != StatusDisabled {
return errors.New("敏感信息状态无效")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return err
}
item, ok := data.Items[reference]
if !ok {
return fmt.Errorf("敏感信息引用不存在:%s", reference)
}
item.Status = status
item.UpdatedAt = time.Now().UTC()
data.Items[reference] = item
return s.writeLocked(data)
}
// RotateMasterKey 使用新主密钥重新加密全部凭据。任一写入失败时会尽力恢复旧主密钥。
func (s *Store) RotateMasterKey(ctx context.Context) error {
if s == nil {
return errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return err
}
newKey := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, newKey); err != nil {
return fmt.Errorf("生成新配置主密钥失败:%w", err)
}
reencrypted := fileData{Version: storeVersion, Items: make(map[string]encryptedItem, len(data.Items))}
for reference, item := range data.Items {
value, err := s.decryptItem(reference, item, s.key)
if err != nil {
return fmt.Errorf("重新加密敏感信息失败:%s:%w", reference, err)
}
reencryptedItem, err := encryptItem(reference, value, newKey, item.Status, item.UpdatedAt)
if err != nil {
return fmt.Errorf("重新加密敏感信息失败:%s:%w", reference, err)
}
reencrypted.Items[reference] = reencryptedItem
}
oldKey := append([]byte(nil), s.key...)
oldKeyFile, err := os.ReadFile(s.keyPath)
if err != nil {
return fmt.Errorf("读取旧配置主密钥失败:%w", err)
}
if err := writeAtomicFile(s.keyPath, []byte(base64.RawStdEncoding.EncodeToString(newKey)), 0o600, os.Rename); err != nil {
return fmt.Errorf("写入新配置主密钥失败:%w", err)
}
if err := s.writeLocked(reencrypted); err != nil {
rollbackErr := writeAtomicFile(s.keyPath, oldKeyFile, 0o600, os.Rename)
s.key = oldKey
if rollbackErr != nil {
return fmt.Errorf("写入新敏感信息文件失败且恢复旧主密钥失败:%v;原错误:%w", rollbackErr, err)
}
return fmt.Errorf("写入新敏感信息文件失败,已恢复旧主密钥:%w", err)
}
s.key = newKey
return nil
}
func itemStatus(item encryptedItem) string {
if item.Status == "" {
return StatusActive
}
return item.Status
}
func encryptItem(reference string, value map[string]string, key []byte, status string, updatedAt time.Time) (encryptedItem, error) {
plaintext, err := json.Marshal(value)
if err != nil {
return encryptedItem{}, fmt.Errorf("编码敏感信息失败:%w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return encryptedItem{}, fmt.Errorf("初始化敏感信息加密器失败:%w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return encryptedItem{}, fmt.Errorf("初始化敏感信息加密模式失败:%w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return encryptedItem{}, fmt.Errorf("生成敏感信息随机数失败:%w", err)
}
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
return encryptedItem{
Nonce: base64.RawStdEncoding.EncodeToString(nonce),
Ciphertext: base64.RawStdEncoding.EncodeToString(gcm.Seal(nil, nonce, plaintext, []byte(reference))),
Status: itemStatus(encryptedItem{Status: status}),
UpdatedAt: updatedAt,
}, nil
}
func (s *Store) decryptItem(reference string, item encryptedItem, key []byte) (map[string]string, error) {
nonce, err := base64.RawStdEncoding.DecodeString(item.Nonce)
if err != nil {
return nil, fmt.Errorf("解析敏感信息随机数失败:%w", err)
}
ciphertext, err := base64.RawStdEncoding.DecodeString(item.Ciphertext)
if err != nil {
return nil, fmt.Errorf("解析敏感信息密文失败:%w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("初始化敏感信息解密器失败:%w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("初始化敏感信息解密模式失败:%w", err)
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, []byte(reference))
if err != nil {
return nil, fmt.Errorf("解密敏感信息失败:%w", err)
}
var result map[string]string
if err := json.Unmarshal(plaintext, &result); err != nil {
return nil, fmt.Errorf("解析敏感信息失败:%w", err)
}
return result, nil
}
// Delete 原子删除指定引用;引用不存在时返回明确错误。
func (s *Store) Delete(ctx context.Context, reference string) error {
if s == nil {
@ -337,5 +513,8 @@ func loadOrCreateKey(path string) ([]byte, error) {
if err := file.Close(); err != nil {
return nil, fmt.Errorf("关闭配置主密钥文件失败:%w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return nil, fmt.Errorf("设置配置主密钥权限失败:%w", err)
}
return key, nil
}

View File

@ -158,3 +158,101 @@ func assertNoSecretTempFiles(t *testing.T, directory string) {
t.Fatalf("不应残留敏感信息临时文件:%v", matches)
}
}
func TestStoreDisablesAndEnablesValue(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("open store: %v", err)
}
reference := "secret://dns/cloudflare/lifecycle"
value := map[string]string{"api_token": "lifecycle-token"}
if err := store.Put(t.Context(), reference, value); err != nil {
t.Fatalf("put secret: %v", err)
}
if err := store.Disable(t.Context(), reference); err != nil {
t.Fatalf("disable secret: %v", err)
}
if _, err := store.Get(t.Context(), reference); !errors.Is(err, ErrDisabled) {
t.Fatalf("disabled secret should not be readable: %v", err)
}
items, err := store.List(t.Context(), reference)
if err != nil {
t.Fatalf("list metadata: %v", err)
}
if len(items) != 1 || items[0].Status != StatusDisabled {
t.Fatalf("disabled status was not persisted: %+v", items)
}
if err := store.Enable(t.Context(), reference); err != nil {
t.Fatalf("enable secret: %v", err)
}
loaded, err := store.Get(t.Context(), reference)
if err != nil {
t.Fatalf("read enabled secret: %v", err)
}
if loaded["api_token"] != value["api_token"] {
t.Fatalf("enabled secret value mismatch: %v", loaded)
}
}
func TestStoreRejectsMissingMasterKey(t *testing.T) {
directory := t.TempDir()
store, err := Open(directory)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Put(t.Context(), "secret://dns/cloudflare/missing-key", map[string]string{"api_token": "token"}); err != nil {
t.Fatalf("put secret: %v", err)
}
if err := os.Remove(filepath.Join(directory, "master.key")); err != nil {
t.Fatalf("remove test key: %v", err)
}
if _, err := Open(directory); err == nil {
t.Fatal("store must reject missing key when secrets exist")
}
}
func TestStoreRotatesMasterKeyAndPreservesValues(t *testing.T) {
directory := t.TempDir()
store, err := Open(directory)
if err != nil {
t.Fatalf("open store: %v", err)
}
reference := "secret://dns/cloudflare/rotated"
if err := store.Put(t.Context(), reference, map[string]string{"api_token": "rotated-token"}); err != nil {
t.Fatalf("put secret: %v", err)
}
if err := store.Disable(t.Context(), reference); err != nil {
t.Fatalf("disable secret: %v", err)
}
oldKey, err := os.ReadFile(filepath.Join(directory, "master.key"))
if err != nil {
t.Fatalf("read old key: %v", err)
}
if err := store.RotateMasterKey(t.Context()); err != nil {
t.Fatalf("rotate key: %v", err)
}
newKey, err := os.ReadFile(filepath.Join(directory, "master.key"))
if err != nil {
t.Fatalf("read new key: %v", err)
}
if string(oldKey) == string(newKey) {
t.Fatal("rotated key must differ from old key")
}
if _, err := store.Get(t.Context(), reference); !errors.Is(err, ErrDisabled) {
t.Fatalf("disabled status was not preserved after rotation: %v", err)
}
if err := store.Enable(t.Context(), reference); err != nil {
t.Fatalf("enable rotated secret: %v", err)
}
reopened, err := Open(directory)
if err != nil {
t.Fatalf("reopen with new key: %v", err)
}
loaded, err := reopened.Get(t.Context(), reference)
if err != nil {
t.Fatalf("read with new key: %v", err)
}
if loaded["api_token"] != "rotated-token" {
t.Fatalf("rotated secret value mismatch: %v", loaded)
}
}