// 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 }