diff --git a/backend/internal/secretstore/store.go b/backend/internal/secretstore/store.go index 9d678c7..92ea090 100644 --- a/backend/internal/secretstore/store.go +++ b/backend/internal/secretstore/store.go @@ -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 } diff --git a/backend/internal/secretstore/store_test.go b/backend/internal/secretstore/store_test.go index fb54e6d..72fbc64 100644 --- a/backend/internal/secretstore/store_test.go +++ b/backend/internal/secretstore/store_test.go @@ -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) + } +}