// Package vault owns TreatVault's encrypted source-of-truth file. package vault import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" barkfile "cloud.campbellwireless.net/git/barkstack/barkfile-parser/v2" "filippo.io/age" ) const ( documentVersion = 1 maxSecretBytes = 500 * 1024 maxDocumentBytes = 16 * 1024 * 1024 ) var ErrSecretNotFound = errors.New("secret not found") type Record struct { Revision string `json:"revision"` Value []byte `json:"value"` } type Snapshot struct { Version int `json:"version"` VaultID string `json:"vaultId"` Secrets map[string]Record `json:"secrets"` } type Metadata struct { Name string Revision string } type Store struct { path string identity *age.X25519Identity recipient age.Recipient mu sync.Mutex } func Open(path, identityPath string) (*Store, error) { identity, err := ReadIdentity(identityPath) if err != nil { return nil, err } store := &Store{path: path, identity: identity, recipient: identity.Recipient()} if _, err := store.Load(); err != nil { return nil, err } return store, nil } func New(path string, identity *age.X25519Identity) *Store { return &Store{path: path, identity: identity, recipient: identity.Recipient()} } func ReadIdentity(path string) (*age.X25519Identity, error) { contents, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read age identity: %w", err) } identity, err := age.ParseX25519Identity(strings.TrimSpace(string(contents))) if err != nil { return nil, fmt.Errorf("parse age identity: %w", err) } return identity, nil } func GenerateIdentity(path string) (string, error) { identity, err := age.GenerateX25519Identity() if err != nil { return "", fmt.Errorf("generate age identity: %w", err) } if _, err := os.Stat(path); err == nil { return "", fmt.Errorf("identity file %q already exists", path) } else if !errors.Is(err, os.ErrNotExist) { return "", err } if err := writeFileAtomic(path, []byte(identity.String()+"\n"), 0o600); err != nil { return "", fmt.Errorf("write age identity: %w", err) } return identity.Recipient().String(), nil } func Initialize(path string, identity *age.X25519Identity) error { if _, err := os.Stat(path); err == nil { return fmt.Errorf("encrypted file %q already exists", path) } else if !errors.Is(err, os.ErrNotExist) { return err } store := New(path, identity) vaultID, err := randomHex(8) if err != nil { return err } return store.save(Snapshot{Version: documentVersion, VaultID: vaultID, Secrets: map[string]Record{}}) } func (s *Store) Path() string { return s.path } func (s *Store) Load() (Snapshot, error) { s.mu.Lock() defer s.mu.Unlock() return s.load() } func (s *Store) Set(name string, value []byte) (Snapshot, error) { if !barkfile.ValidSecretReference(name) { return Snapshot{}, fmt.Errorf("invalid secret name %q", name) } if len(value) == 0 { return Snapshot{}, errors.New("secret value must not be empty") } if len(value) > maxSecretBytes { return Snapshot{}, fmt.Errorf("secret value exceeds Docker's %d-byte limit", maxSecretBytes) } s.mu.Lock() defer s.mu.Unlock() snapshot, err := s.load() if err != nil { return Snapshot{}, err } if existing, ok := snapshot.Secrets[name]; ok && bytes.Equal(existing.Value, value) { return snapshot, nil } revision, err := randomHex(16) if err != nil { return Snapshot{}, err } snapshot.Secrets[name] = Record{Revision: revision, Value: append([]byte(nil), value...)} if err := s.save(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } func (s *Store) Delete(name string) (Snapshot, error) { s.mu.Lock() defer s.mu.Unlock() snapshot, err := s.load() if err != nil { return Snapshot{}, err } if _, ok := snapshot.Secrets[name]; !ok { return Snapshot{}, ErrSecretNotFound } delete(snapshot.Secrets, name) if err := s.save(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } func (s *Store) load() (Snapshot, error) { encrypted, err := os.Open(s.path) if err != nil { return Snapshot{}, fmt.Errorf("open encrypted file: %w", err) } defer encrypted.Close() plaintext, err := age.Decrypt(encrypted, s.identity) if err != nil { return Snapshot{}, fmt.Errorf("decrypt encrypted file: %w", err) } decoder := json.NewDecoder(io.LimitReader(plaintext, maxDocumentBytes+1)) decoder.DisallowUnknownFields() var snapshot Snapshot if err := decoder.Decode(&snapshot); err != nil { return Snapshot{}, fmt.Errorf("decode encrypted file: %w", err) } if err := validateSnapshot(snapshot); err != nil { return Snapshot{}, fmt.Errorf("validate encrypted file: %w", err) } return cloneSnapshot(snapshot), nil } func (s *Store) save(snapshot Snapshot) error { if err := validateSnapshot(snapshot); err != nil { return err } var encrypted bytes.Buffer writer, err := age.Encrypt(&encrypted, s.recipient) if err != nil { return fmt.Errorf("initialize encryption: %w", err) } encoder := json.NewEncoder(writer) encoder.SetEscapeHTML(false) if err := encoder.Encode(snapshot); err != nil { return fmt.Errorf("encode encrypted file: %w", err) } if err := writer.Close(); err != nil { return fmt.Errorf("finish encryption: %w", err) } if err := writeFileAtomic(s.path, encrypted.Bytes(), 0o600); err != nil { return fmt.Errorf("replace encrypted file: %w", err) } return nil } func validateSnapshot(snapshot Snapshot) error { if snapshot.Version != documentVersion { return fmt.Errorf("unsupported document version %d", snapshot.Version) } if len(snapshot.VaultID) != 16 { return errors.New("vault ID must be 16 hexadecimal characters") } if _, err := hex.DecodeString(snapshot.VaultID); err != nil { return errors.New("vault ID must be hexadecimal") } if snapshot.Secrets == nil { return errors.New("secrets map is required") } for name, record := range snapshot.Secrets { if !barkfile.ValidSecretReference(name) { return fmt.Errorf("invalid secret name %q", name) } if len(record.Value) == 0 || len(record.Value) > maxSecretBytes { return fmt.Errorf("secret %q has invalid value length", name) } if len(record.Revision) != 32 { return fmt.Errorf("secret %q has invalid revision", name) } if _, err := hex.DecodeString(record.Revision); err != nil { return fmt.Errorf("secret %q has invalid revision", name) } } return nil } func cloneSnapshot(snapshot Snapshot) Snapshot { clone := Snapshot{Version: snapshot.Version, VaultID: snapshot.VaultID, Secrets: make(map[string]Record, len(snapshot.Secrets))} for name, record := range snapshot.Secrets { record.Value = append([]byte(nil), record.Value...) clone.Secrets[name] = record } return clone } func randomHex(bytesCount int) (string, error) { value := make([]byte, bytesCount) if _, err := rand.Read(value); err != nil { return "", fmt.Errorf("generate random identifier: %w", err) } return hex.EncodeToString(value), nil } func writeFileAtomic(path string, contents []byte, mode os.FileMode) error { directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0o700); err != nil { return err } temporary, err := os.CreateTemp(directory, ".treatvault-*") if err != nil { return err } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(mode); err != nil { temporary.Close() return err } if _, err := temporary.Write(contents); err != nil { temporary.Close() return err } if err := temporary.Sync(); err != nil { temporary.Close() return err } if err := temporary.Close(); err != nil { return err } if err := os.Rename(temporaryPath, path); err != nil { return err } directoryHandle, err := os.Open(directory) if err != nil { return err } defer directoryHandle.Close() return directoryHandle.Sync() }