You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

230 lines
5.7 KiB

// Package secretstore keeps runtime credentials outside configuration exports.
// The on-device store is protected by the service account and mode 0600; it is
// deliberately a separate persistence boundary so support/config snapshots do
// not acquire secret material by accident.
package secretstore
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"tera-clone/services/tera-api/internal/storage/atomicfile"
)
const (
maxStoreBytes = 1 << 20
maxValueBytes = 64 << 10
)
var validKey = regexp.MustCompile(`^[a-z0-9][a-z0-9._:/-]{0,191}$`)
type snapshot struct {
FormatVersion int `json:"format_version"`
Values map[string][]byte `json:"values"`
}
type Store struct {
mu sync.RWMutex
path string
values map[string][]byte
}
func Open(path string) (*Store, error) {
store := &Store{path: strings.TrimSpace(path), values: make(map[string][]byte)}
if store.path == "" {
return store, nil
}
content, err := os.ReadFile(store.path)
if err != nil {
if os.IsNotExist(err) {
return store, nil
}
return nil, fmt.Errorf("read secret store: %w", err)
}
if len(content) > maxStoreBytes {
return nil, errors.New("secret store exceeds size limit")
}
if len(strings.TrimSpace(string(content))) == 0 {
return store, nil
}
var state snapshot
if err := json.Unmarshal(content, &state); err != nil {
return nil, fmt.Errorf("decode secret store: %w", err)
}
if state.FormatVersion != 1 {
return nil, fmt.Errorf("unsupported secret store format version %d", state.FormatVersion)
}
for key, value := range state.Values {
if err := validate(key, value); err != nil {
return nil, fmt.Errorf("load secret %q: %w", key, err)
}
store.values[key] = append([]byte(nil), value...)
}
return store, nil
}
// Update applies all changes in one atomic persistence operation. A nil value
// deletes a key; callers keep transaction-related secrets consistent by using
// this method instead of a sequence of Set/Delete calls.
func (s *Store) Update(changes map[string]*[]byte) error {
for key, value := range changes {
if value == nil {
if !validKey.MatchString(key) {
return errors.New("secret key is invalid")
}
continue
}
if err := validate(key, *value); err != nil {
return err
}
}
s.mu.Lock()
defer s.mu.Unlock()
previous := cloneValues(s.values)
for key, value := range changes {
if value == nil {
delete(s.values, key)
} else {
s.values[key] = append([]byte(nil), (*value)...)
}
}
if err := s.persistLocked(); err != nil {
s.values = previous
return err
}
return nil
}
func (s *Store) Get(key string) ([]byte, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
value, ok := s.values[key]
return append([]byte(nil), value...), ok
}
func (s *Store) Has(key string) bool {
_, ok := s.Get(key)
return ok
}
func (s *Store) Set(key string, value []byte) error {
if err := validate(key, value); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
previous, existed := s.values[key]
s.values[key] = append([]byte(nil), value...)
if err := s.persistLocked(); err != nil {
if existed {
s.values[key] = previous
} else {
delete(s.values, key)
}
return err
}
return nil
}
func (s *Store) Delete(key string) error {
if !validKey.MatchString(key) {
return errors.New("secret key is invalid")
}
s.mu.Lock()
defer s.mu.Unlock()
previous, existed := s.values[key]
if !existed {
return nil
}
delete(s.values, key)
if err := s.persistLocked(); err != nil {
s.values[key] = previous
return err
}
return nil
}
// DeletePrefixes removes every secret whose key starts with one of the
// supplied namespaces in one atomic persistence operation. Configuration
// transactions use this to discard pending credentials without knowing which
// users, trap targets, or port operations were removed from the staged state.
func (s *Store) DeletePrefixes(prefixes ...string) error {
if len(prefixes) == 0 {
return errors.New("at least one secret key prefix is required")
}
for _, prefix := range prefixes {
if prefix == "" || len(prefix) > 192 || !validKey.MatchString(strings.TrimRight(prefix, "./:")) {
return errors.New("secret key prefix is invalid")
}
}
s.mu.Lock()
defer s.mu.Unlock()
previous := cloneValues(s.values)
for key := range s.values {
for _, prefix := range prefixes {
if strings.HasPrefix(key, prefix) {
delete(s.values, key)
break
}
}
}
if err := s.persistLocked(); err != nil {
s.values = previous
return err
}
return nil
}
func (s *Store) Copy(source, destination string) error {
value, ok := s.Get(source)
if !ok {
return s.Delete(destination)
}
return s.Set(destination, value)
}
func validate(key string, value []byte) error {
if !validKey.MatchString(key) {
return errors.New("secret key is invalid")
}
if len(value) == 0 || len(value) > maxValueBytes {
return fmt.Errorf("secret value must contain 1 through %d bytes", maxValueBytes)
}
return nil
}
func (s *Store) persistLocked() error {
if s.path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("create secret store directory: %w", err)
}
content, err := json.Marshal(snapshot{FormatVersion: 1, Values: s.values})
if err != nil {
return fmt.Errorf("encode secret store: %w", err)
}
if len(content) > maxStoreBytes {
return errors.New("secret store exceeds size limit")
}
content = append(content, '\n')
if err := atomicfile.Write(s.path, content, 0o600); err != nil {
return fmt.Errorf("persist secret store: %w", err)
}
return nil
}
func cloneValues(source map[string][]byte) map[string][]byte {
result := make(map[string][]byte, len(source))
for key, value := range source {
result[key] = append([]byte(nil), value...)
}
return result
}