完成敏感凭据生命周期与主密钥轮换保护
增加凭据启用禁用、主密钥缺失保护与轮换逻辑,并补充生命周期测试。
This commit is contained in:
parent
03ae0001c1
commit
bac67b68fd
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user