Files
treatvault/internal/vault/store.go
Shaun Campbell 4256b7a50e
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m2s
feat: run TreatVault as a service image
2026-09-17 10:29:36 -04:00

294 lines
7.8 KiB
Go

// 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")
ErrAlreadyInitialized = errors.New("encrypted file already exists")
)
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
}
// OpenOrInitialize opens an encrypted file, creating an empty vault when it
// does not exist. A TreatVault service owns this bootstrap path; operators
// never need a separate vault-management executable.
func OpenOrInitialize(path, identityPath string) (*Store, error) {
identity, err := ReadIdentity(identityPath)
if err != nil {
return nil, err
}
store := New(path, identity)
if _, err := os.Stat(path); err == nil {
if _, err := store.Load(); err != nil {
return nil, err
}
return store, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("stat encrypted file: %w", err)
}
if err := Initialize(path, identity); err != nil && !errors.Is(err, ErrAlreadyInitialized) {
return nil, err
}
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 Initialize(path string, identity *age.X25519Identity) error {
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%w: %q", ErrAlreadyInitialized, 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()
}