feat: TreatVault encrypted secret manager with Docker Swarm sync and console plugin

This commit is contained in:
2026-09-16 16:06:41 -04:00
parent 61a45a4310
commit 63ee4a99e6
25 changed files with 5976 additions and 1 deletions

292
internal/vault/store.go Normal file
View File

@@ -0,0 +1,292 @@
// 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()
}