package vault import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strings" "sync" "golang.org/x/crypto/argon2" "golang.org/x/term" ) const ( argonTime = 3 argonMemory = 64 * 1024 // 64 MB argonThreads = 4 argonKeyLen = 32 saltLen = 32 nonceLen = 12 vaultVersion = 1 ) var validName = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`) // VaultFile is the on-disk JSON structure. type VaultFile struct { Version int `json:"version"` Salt []byte `json:"salt"` Nonce []byte `json:"nonce"` Ciphertext []byte `json:"ciphertext"` } // Data is the decrypted in-memory structure. type Data struct { Projects map[string]*Project `json:"projects"` } // Project holds secrets for a single project. type Project struct { Vars map[string]string `json:"vars"` } // Vault holds the decrypted data in memory. type Vault struct { data Data mu sync.RWMutex } // DefaultVaultPath returns ~/.envault/vault.json. func DefaultVaultPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".envault", "vault.json"), nil } // Exists reports whether the vault file exists. func Exists(path string) bool { _, err := os.Stat(path) return err == nil } // ValidateName checks that a project or key name is safe. func ValidateName(name string) error { if name == "" { return errors.New("name must not be empty") } if len(name) > 128 { return errors.New("name must be 128 characters or fewer") } if !validName.MatchString(name) { return errors.New("name may only contain letters, digits, underscores, hyphens, and dots") } return nil } // Open decrypts and loads the vault. Returns an empty vault if the file does not exist. func Open(path, password string) (*Vault, error) { raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return &Vault{data: Data{Projects: make(map[string]*Project)}}, nil } return nil, fmt.Errorf("reading vault: %w", err) } var vf VaultFile if err := json.Unmarshal(raw, &vf); err != nil { return nil, fmt.Errorf("parsing vault file: %w", err) } key := deriveKey([]byte(password), vf.Salt) block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("creating cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("creating GCM: %w", err) } plaintext, err := gcm.Open(nil, vf.Nonce, vf.Ciphertext, nil) if err != nil { return nil, errors.New("could not decrypt vault: invalid password or corrupted file") } var data Data if err := json.Unmarshal(plaintext, &data); err != nil { return nil, fmt.Errorf("parsing vault data: %w", err) } if data.Projects == nil { data.Projects = make(map[string]*Project) } return &Vault{data: data}, nil } // Save encrypts and writes the vault atomically. func (v *Vault) Save(path, password string) error { v.mu.RLock() plaintext, err := json.Marshal(v.data) v.mu.RUnlock() if err != nil { return fmt.Errorf("marshaling vault data: %w", err) } salt := make([]byte, saltLen) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return fmt.Errorf("generating salt: %w", err) } nonce := make([]byte, nonceLen) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return fmt.Errorf("generating nonce: %w", err) } key := deriveKey([]byte(password), salt) block, err := aes.NewCipher(key) if err != nil { return fmt.Errorf("creating cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return fmt.Errorf("creating GCM: %w", err) } ciphertext := gcm.Seal(nil, nonce, plaintext, nil) vf := VaultFile{ Version: vaultVersion, Salt: salt, Nonce: nonce, Ciphertext: ciphertext, } out, err := json.Marshal(vf) if err != nil { return fmt.Errorf("marshaling vault file: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return fmt.Errorf("creating vault directory: %w", err) } tmp := path + ".tmp" if err := os.WriteFile(tmp, out, 0600); err != nil { return fmt.Errorf("writing temp vault: %w", err) } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return fmt.Errorf("saving vault: %w", err) } return nil } func deriveKey(password, salt []byte) []byte { return argon2.IDKey(password, salt, argonTime, argonMemory, argonThreads, argonKeyLen) } // --- CRUD --- func (v *Vault) ensureProject(project string) *Project { if _, ok := v.data.Projects[project]; !ok { v.data.Projects[project] = &Project{Vars: make(map[string]string)} } return v.data.Projects[project] } func (v *Vault) Set(project, key, value string) { v.mu.Lock() defer v.mu.Unlock() v.ensureProject(project).Vars[key] = value } func (v *Vault) Get(project, key string) (string, bool) { v.mu.RLock() defer v.mu.RUnlock() p, ok := v.data.Projects[project] if !ok { return "", false } val, ok := p.Vars[key] return val, ok } func (v *Vault) GetAll(project string) map[string]string { v.mu.RLock() defer v.mu.RUnlock() p, ok := v.data.Projects[project] if !ok { return map[string]string{} } out := make(map[string]string, len(p.Vars)) for k, val := range p.Vars { out[k] = val } return out } func (v *Vault) ListKeys(project string) []string { v.mu.RLock() defer v.mu.RUnlock() p, ok := v.data.Projects[project] if !ok { return nil } keys := make([]string, 0, len(p.Vars)) for k := range p.Vars { keys = append(keys, k) } sort.Strings(keys) return keys } func (v *Vault) Delete(project, key string) error { v.mu.Lock() defer v.mu.Unlock() p, ok := v.data.Projects[project] if !ok { return fmt.Errorf("project %q not found", project) } if _, ok := p.Vars[key]; !ok { return fmt.Errorf("key %q not found in project %q", key, project) } delete(p.Vars, key) return nil } func (v *Vault) ListProjects() []string { v.mu.RLock() defer v.mu.RUnlock() out := make([]string, 0, len(v.data.Projects)) for name := range v.data.Projects { out = append(out, name) } sort.Strings(out) return out } func (v *Vault) CreateProject(name string) error { v.mu.Lock() defer v.mu.Unlock() if _, ok := v.data.Projects[name]; ok { return fmt.Errorf("project %q already exists", name) } v.data.Projects[name] = &Project{Vars: make(map[string]string)} return nil } func (v *Vault) DeleteProject(name string) error { v.mu.Lock() defer v.mu.Unlock() if _, ok := v.data.Projects[name]; !ok { return fmt.Errorf("project %q not found", name) } delete(v.data.Projects, name) return nil } // --- Terminal helpers --- // PromptPassword reads a password without echo. Prompt goes to stderr. func PromptPassword(prompt string) (string, error) { fmt.Fprint(os.Stderr, prompt) raw, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return "", err } return strings.TrimSpace(string(raw)), nil } // PromptSecret reads a secret value without echo. Prompt goes to stderr. func PromptSecret(prompt string) (string, error) { fmt.Fprint(os.Stderr, prompt) raw, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { return "", err } return string(raw), nil }