package secrets import ( "crypto/rand" "errors" "fmt" "math/big" "os" "github.com/wild-cloud/wild-central/internal/storage" "github.com/wild-cloud/wild-central/internal/tools" "gopkg.in/yaml.v3" ) const ( // DefaultSecretLength is 32 characters DefaultSecretLength = 32 // Alphanumeric characters for secret generation alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" ) // Manager handles secret storage backed by a single YAML file. type Manager struct { secretsPath string yq *tools.YQ } // NewManager creates a new secrets manager bound to the given file path. func NewManager(secretsPath string) *Manager { return &Manager{ secretsPath: secretsPath, yq: tools.NewYQ(), } } // GenerateSecret generates a cryptographically secure random alphanumeric string. func GenerateSecret(length int) (string, error) { if length <= 0 { length = DefaultSecretLength } result := make([]byte, length) for i := range result { num, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumeric)))) if err != nil { return "", fmt.Errorf("generating random number: %w", err) } result[i] = alphanumeric[num.Int64()] } return string(result), nil } // GetSecret retrieves a secret value by dot-notation key. func (m *Manager) GetSecret(key string) (string, error) { if !storage.FileExists(m.secretsPath) { return "", fmt.Errorf("secrets file not found: %s", m.secretsPath) } value, err := m.yq.Get(m.secretsPath, fmt.Sprintf(".%s", key)) if err != nil { return "", fmt.Errorf("getting secret %s: %w", key, err) } if value == "" || value == "null" { return "", fmt.Errorf("secret not found: %s", key) } return value, nil } // SetSecret sets a secret value by dot-notation key. func (m *Manager) SetSecret(key, value string) error { if !storage.FileExists(m.secretsPath) { return fmt.Errorf("secrets file not found: %s", m.secretsPath) } lockPath := m.secretsPath + ".lock" return storage.WithLock(lockPath, func() error { if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil { return err } return storage.EnsureFilePermissions(m.secretsPath, 0600) }) } // DeleteSecret removes a secret by dot-notation key. func (m *Manager) DeleteSecret(key string) error { if !storage.FileExists(m.secretsPath) { return fmt.Errorf("secrets file not found: %s", m.secretsPath) } lockPath := m.secretsPath + ".lock" return storage.WithLock(lockPath, func() error { if err := m.yq.Delete(m.secretsPath, fmt.Sprintf(".%s", key)); err != nil { return err } return storage.EnsureFilePermissions(m.secretsPath, 0600) }) } // GetAll reads and returns the full secrets map. func (m *Manager) GetAll() (map[string]any, error) { data, err := os.ReadFile(m.secretsPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return map[string]any{}, nil } return nil, fmt.Errorf("reading secrets: %w", err) } var secretsMap map[string]any if err := yaml.Unmarshal(data, &secretsMap); err != nil { return nil, fmt.Errorf("parsing secrets: %w", err) } if secretsMap == nil { secretsMap = map[string]any{} } return secretsMap, nil } // MergeUpdate merges the provided key-value pairs into the existing secrets file. func (m *Manager) MergeUpdate(updates map[string]any) error { lockPath := m.secretsPath + ".lock" return storage.WithLock(lockPath, func() error { existing, err := m.GetAll() if err != nil { return err } for key, value := range updates { existing[key] = value } yamlContent, err := yaml.Marshal(existing) if err != nil { return fmt.Errorf("marshaling secrets: %w", err) } return storage.WriteFile(m.secretsPath, yamlContent, 0600) }) }