521 lines
15 KiB
Go
521 lines
15 KiB
Go
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
|
||
}
|