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.
231 lines
5.7 KiB
231 lines
5.7 KiB
|
2 months ago
|
// 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
|
||
|
|
}
|