Scert/backend/internal/secretstore/store.go
xuans bac67b68fd 完成敏感凭据生命周期与主密钥轮换保护
增加凭据启用禁用、主密钥缺失保护与轮换逻辑,并补充生命周期测试。
2026-09-29 23:20:59 +08:00

521 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package secretstore
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
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"`
}
type fileData struct {
Version int `json:"version"`
Items map[string]encryptedItem `json:"items"`
}
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"`
}
func Open(configDir string) (*Store, error) {
if configDir == "" {
return nil, errors.New("敏感信息目录不能为空")
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("创建敏感信息目录失败:%w", err)
}
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, keyPath: keyPath, filePath: filePath}, nil
}
func (s *Store) Put(ctx context.Context, reference string, value map[string]string) error {
if s == nil {
return errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return err
}
if reference == "" {
return errors.New("敏感信息引用不能为空")
}
plaintext, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("编码敏感信息失败:%w", err)
}
block, err := aes.NewCipher(s.key)
if err != nil {
return fmt.Errorf("初始化敏感信息加密器失败:%w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("初始化敏感信息加密模式失败:%w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return fmt.Errorf("生成敏感信息随机数失败:%w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, []byte(reference))
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return err
}
data.Items[reference] = encryptedItem{
Nonce: base64.RawStdEncoding.EncodeToString(nonce),
Ciphertext: base64.RawStdEncoding.EncodeToString(ciphertext),
Status: StatusActive,
UpdatedAt: time.Now().UTC(),
}
return s.writeLocked(data)
}
func (s *Store) Get(ctx context.Context, reference string) (map[string]string, error) {
if s == nil {
return nil, errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return nil, err
}
item, ok := data.Items[reference]
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)
}
ciphertext, err := base64.RawStdEncoding.DecodeString(item.Ciphertext)
if err != nil {
return nil, fmt.Errorf("解析敏感信息密文失败:%w", err)
}
block, err := aes.NewCipher(s.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
}
// Has 检查敏感信息引用是否存在,但不解密内容。
func (s *Store) Has(ctx context.Context, reference string) (bool, error) {
if s == nil {
return false, errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return false, err
}
if reference == "" {
return false, errors.New("敏感信息引用不能为空")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return false, err
}
_, ok := data.Items[reference]
return ok, nil
}
// List 按引用前缀返回敏感信息元数据,不解密或返回敏感内容。
func (s *Store) List(ctx context.Context, prefix string) ([]Metadata, error) {
if s == nil {
return nil, errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return nil, err
}
result := make([]Metadata, 0)
for reference, item := range data.Items {
if prefix == "" || strings.HasPrefix(reference, prefix) {
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 {
return errors.New("敏感信息存储未初始化")
}
if err := ctx.Err(); err != nil {
return err
}
reference = strings.TrimSpace(reference)
if reference == "" {
return errors.New("敏感信息引用不能为空")
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readLocked()
if err != nil {
return err
}
if _, ok := data.Items[reference]; !ok {
return fmt.Errorf("敏感信息引用不存在:%s", reference)
}
delete(data.Items, reference)
return s.writeLocked(data)
}
func (s *Store) readLocked() (fileData, error) {
data := fileData{Version: storeVersion, Items: map[string]encryptedItem{}}
content, err := os.ReadFile(s.filePath)
if errors.Is(err, os.ErrNotExist) {
return data, nil
}
if err != nil {
return fileData{}, fmt.Errorf("读取敏感信息文件失败:%w", err)
}
if err := json.Unmarshal(content, &data); err != nil {
return fileData{}, fmt.Errorf("解析敏感信息文件失败:%w", err)
}
if data.Version != storeVersion {
return fileData{}, fmt.Errorf("敏感信息文件版本不兼容:%d", data.Version)
}
if data.Items == nil {
data.Items = map[string]encryptedItem{}
}
return data, nil
}
func (s *Store) writeLocked(data fileData) error {
content, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("编码敏感信息文件失败:%w", err)
}
content = append(content, '\n')
if err := writeAtomicFile(s.filePath, content, 0o600, os.Rename); err != nil {
return fmt.Errorf("写入敏感信息文件失败:%w", err)
}
if err := os.Chmod(s.filePath, 0o600); err != nil {
return fmt.Errorf("设置敏感信息文件权限失败:%w", err)
}
return nil
}
func writeAtomicFile(path string, content []byte, mode fs.FileMode, renameFile func(string, string) error) error {
if renameFile == nil {
renameFile = os.Rename
}
directory := filepath.Dir(path)
temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".tmp-*")
if err != nil {
return fmt.Errorf("创建敏感信息临时文件失败:%w", err)
}
temporaryPath := temporary.Name()
closed := false
committed := false
defer func() {
if !closed {
_ = temporary.Close()
}
if !committed {
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(mode); err != nil {
return fmt.Errorf("设置敏感信息临时文件权限失败:%w", err)
}
if _, err := temporary.Write(content); err != nil {
return fmt.Errorf("写入敏感信息临时文件失败:%w", err)
}
if err := temporary.Sync(); err != nil {
return fmt.Errorf("同步敏感信息临时文件失败:%w", err)
}
if err := temporary.Close(); err != nil {
closed = true
return fmt.Errorf("关闭敏感信息临时文件失败:%w", err)
}
closed = true
if err := renameFile(temporaryPath, path); err != nil {
return fmt.Errorf("原子替换敏感信息文件失败:%w", err)
}
committed = true
if err := syncDirectory(directory); err != nil {
return fmt.Errorf("同步敏感信息目录失败:%w", err)
}
return nil
}
func syncDirectory(path string) error {
if runtime.GOOS == "windows" {
return nil
}
directory, err := os.Open(path)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}
func loadOrCreateKey(path string) ([]byte, error) {
content, err := os.ReadFile(path)
if err == nil {
key, decodeErr := base64.RawStdEncoding.DecodeString(string(content))
if decodeErr != nil || len(key) != 32 {
return nil, errors.New("配置主密钥格式无效")
}
if chmodErr := os.Chmod(path, 0o600); chmodErr != nil {
return nil, fmt.Errorf("收紧配置主密钥权限失败:%w", chmodErr)
}
return key, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("读取配置主密钥失败:%w", err)
}
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("生成配置主密钥失败:%w", err)
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, fmt.Errorf("创建配置主密钥失败:%w", err)
}
encoded := base64.RawStdEncoding.EncodeToString(key)
if _, err := file.WriteString(encoded); err != nil {
_ = file.Close()
return nil, fmt.Errorf("写入配置主密钥失败:%w", err)
}
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
}