293 lines
7.7 KiB
Go
293 lines
7.7 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")
|
|
|
|
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()
|
|
}
|