diff --git a/.gitignore b/.gitignore index 1ef4283..107b541 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /build /envvault .aider* +.envvault.json.enc diff --git a/cmd/envvault/main.go b/cmd/envvault/main.go index 8a252ae..3e5f9ae 100644 --- a/cmd/envvault/main.go +++ b/cmd/envvault/main.go @@ -6,16 +6,18 @@ import ( "encoding/json" "fmt" "log" + "maps" "os" "os/exec" "path/filepath" + "slices" + "strings" "time" "github.com/alecthomas/kong" "golang.org/x/crypto/nacl/secretbox" "golang.org/x/crypto/scrypt" "golang.org/x/term" - "slices" "git.jrop.me/jonathan/envvault/internal" "git.jrop.me/jonathan/envvault/internal/config" @@ -24,9 +26,14 @@ import ( var cachedPassword []byte +const ( + EnvStoreCurrentVersion = 1 +) + var ( - keyFilePath string - dbFilePath string + keyFilePath string + dbFilePath string + localDbFilePath string ) func init() { @@ -36,10 +43,79 @@ func init() { } keyFilePath = filepath.Join(homeDir, ".local/cache/envvault/key") dbFilePath = filepath.Join(homeDir, ".local/cache/envvault/db.json.enc") + localDbFilePath = ".envvault.json.enc" } type EnvStore struct { - Vars map[string]string `json:"vars"` + Vars map[string]string `json:"vars"` + Tags map[string][]string `json:"tags,omitempty"` +} + +// GetTags returns all tags for a given variable name +func (s *EnvStore) GetTags(varName string) []string { + if s.Tags == nil { + return []string{} + } + return s.Tags[varName] +} + +// SetTags sets the tags for a given variable name +func (s *EnvStore) SetTags(varName string, tags []string) { + if s.Tags == nil { + s.Tags = make(map[string][]string) + } + + // Create a union of existing and new tags + existingTags := s.Tags[varName] + unionTags := make([]string, 0, len(existingTags)+len(tags)) + + // Add existing tags to the union + for _, tag := range existingTags { + unionTags = append(unionTags, tag) + } + + // Add new tags if they don't already exist + for _, tag := range tags { + if !slices.Contains(unionTags, tag) { + unionTags = append(unionTags, tag) + } + } + + s.Tags[varName] = unionTags +} + +// RemoveTags removes the specified tags from a variable +func (s *EnvStore) RemoveTags(varName string, tagsToRemove []string) { + if s.Tags == nil || len(s.Tags[varName]) == 0 { + return + } + + existingTags := s.Tags[varName] + remainingTags := make([]string, 0, len(existingTags)) + + // Keep only tags that are not in tagsToRemove + for _, tag := range existingTags { + if !slices.Contains(tagsToRemove, tag) { + remainingTags = append(remainingTags, tag) + } + } + + s.Tags[varName] = remainingTags +} + +// HasAnyTag returns true if the variable has any of the specified tags +func (s *EnvStore) HasAnyTag(varName string, tags []string) bool { + if len(tags) == 0 { + return true // No tags specified, include all variables + } + + varTags := s.GetTags(varName) + for _, tag := range tags { + if slices.Contains(varTags, tag) { + return true + } + } + return false } type CLI struct { @@ -47,31 +123,47 @@ type CLI struct { List ListCmd `cmd:"" help:"List all environment variables."` Add AddCmd `cmd:"" help:"Add an environment variable."` Rm RmCmd `cmd:"" help:"Remove an environment variable."` - Exec ExecCmd `cmd:"" help:"Execute a command with environment variables." alias:"x"` + Tag TagCmd `cmd:"" help:"Add or remove tags from an environment variable."` + Exec ExecCmd `cmd:"" help:"Execute a command with environment variables." alias:"x"` Rekey RekeyCmd `cmd:"" help:"Change the master password."` Daemon DaemonCmd `cmd:"" help:"Start the password caching daemon."` + Lock LockCmd `cmd:"" help:"Lock the vault by stopping the password caching daemon."` } type InitCmd struct{} type ListCmd struct { - Values bool `short:"v" help:"Show values of environment variables." default:"false"` + Values bool `short:"v" help:"Show values of environment variables." default:"false"` + Local bool `short:"l" help:"List only from local vault." default:"false"` + Tags []string `short:"t" help:"Filter by tags (can be specified multiple times)."` } type AddCmd struct { Name string `arg:"" help:"Name of the environment variable."` - Value string `arg:"" optional:"" help:"Value of the environment variable."` + Value string `arg:"" help:"Value of the environment variable." optional:""` + Local bool ` help:"Add to local vault instead of global." short:"l" default:"false"` } type RmCmd struct { - Name string `arg:"" help:"Name of the environment variable to remove."` + Name string `arg:"" help:"Name of the environment variable to remove."` + Local bool ` help:"Remove from local vault only." short:"l" default:"false"` +} +type TagCmd struct { + Name string `arg:"" help:"Name of the environment variable."` + Tags []string `arg:"" help:"Tags to add or remove." optional:""` + Remove bool ` help:"Remove tags instead of adding them." short:"r" default:"false"` + Local bool ` help:"Operate on local vault only." short:"l" default:"false"` + List bool ` help:"List tags for the variable." short:"L" default:"false"` } type ExecCmd struct { - Env []string `short:"e" help:"Environment variables to set."` - Cmd string `arg:"" help:"Command to execute." passthrough:"all"` - Args []string `arg:"" optional:"" help:"Arguments for the command."` + Env []string `short:"e" help:"Environment variables to set."` + Local bool `short:"l" help:"Use only local vault." default:"false"` + Tags []string `short:"t" help:"Filter by tags (can be specified multiple times)."` + Cmd string ` help:"Command to execute." arg:"" passthrough:"all"` + Args []string ` help:"Arguments for the command." arg:"" optional:""` } type RekeyCmd struct{} type DaemonCmd struct { Timeout int `help:"Password cache timeout in minutes." default:"5"` } +type LockCmd struct{} func main() { internal.Log.Println("Starting envvault") @@ -86,11 +178,11 @@ func main() { case "init": subcommandInitVault() case "add ": - subcommandAddEnvVar(cli.Add.Name, cli.Add.Value) + subcommandAddEnvVar(cli.Add) case "add ": - subcommandAddEnvVar(cli.Add.Name, cli.Add.Value) + subcommandAddEnvVar(cli.Add) case "rm ": - subcommandRmEnvVar(cli.Rm.Name) + subcommandRmEnvVar(cli.Rm.Name, cli.Rm.Local) case "list": subcommandListEnvVars(cli.List) case "exec ": @@ -101,6 +193,12 @@ func main() { subcommandRekeyVault() case "daemon": subcommandStartDaemon(cli.Daemon.Timeout) + case "lock": + subcommandLockVault() + case "tag ": + subcommandTagEnvVar(cli.Tag) + case "tag ": + subcommandTagEnvVar(cli.Tag) default: internal.Log.Printf("Unknown command: %s", command) log.Fatal("Unknown command") @@ -165,7 +263,7 @@ func subcommandInitVault() { // {{{ // Initialize the db file internal.Log.Println("Initializing empty environment store") emptyStore := &EnvStore{Vars: make(map[string]string)} - saveEnvStore(emptyStore) + saveGlobalEnvStore(emptyStore) internal.Log.Println("Vault initialized successfully") fmt.Fprintln(os.Stderr, "Vault initialized successfully.") @@ -177,47 +275,205 @@ func subcommandInitVault() { // {{{ // }}} -func subcommandAddEnvVar(name, value string) { // {{{ - store := loadEnvStore() - if value == "" { - fmt.Fprintf(os.Stderr, "Enter value for %s: ", name) +func subcommandAddEnvVar(cmd AddCmd) { // {{{ + var store *EnvStore + if cmd.Local { + store = loadLocalEnvStore() + internal.Log.Printf("Adding environment variable '%s' to local vault", cmd.Name) + } else { + store = loadGlobalEnvStore() + internal.Log.Printf("Adding environment variable '%s' to global vault", cmd.Name) + } + + if cmd.Value == "" { + fmt.Fprintf(os.Stderr, "Enter value for %s: ", cmd.Name) inputValue, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) if err != nil { log.Fatal(err) } - value = string(inputValue) + cmd.Value = string(inputValue) + } + store.Vars[cmd.Name] = cmd.Value + + if cmd.Local { + saveLocalEnvStore(store) + fmt.Fprintf(os.Stderr, "Environment variable '%s' added to local vault.\n", cmd.Name) + } else { + saveGlobalEnvStore(store) + fmt.Fprintf(os.Stderr, "Environment variable '%s' added to global vault.\n", cmd.Name) } - store.Vars[name] = value - saveEnvStore(store) } // }}} -func subcommandRmEnvVar(name string) { // {{{ - store := loadEnvStore() - if _, exists := store.Vars[name]; exists { - delete(store.Vars, name) - saveEnvStore(store) - fmt.Fprintf(os.Stderr, "Environment variable '%s' removed.\n", name) +func subcommandRmEnvVar(name string, localOnly bool) { // {{{ + localExists := false + + // Check local store if it exists + if _, err := os.Stat(localDbFilePath); err == nil { + localStore := loadLocalEnvStore() + if _, exists := localStore.Vars[name]; exists { + delete(localStore.Vars, name) + saveLocalEnvStore(localStore) + localExists = true + fmt.Fprintf(os.Stderr, "Environment variable '%s' removed from local vault.\n", name) + } else if localOnly { + fmt.Fprintf(os.Stderr, "Environment variable '%s' not found in local vault.\n", name) + } + } else if localOnly { + fmt.Fprintf(os.Stderr, "Local vault does not exist.\n") + } + + // Check global store if not local-only + if !localOnly { + globalStore := loadGlobalEnvStore() + if _, exists := globalStore.Vars[name]; exists { + delete(globalStore.Vars, name) + saveGlobalEnvStore(globalStore) + fmt.Fprintf(os.Stderr, "Environment variable '%s' removed from global vault.\n", name) + } else if !localExists { + fmt.Fprintf(os.Stderr, "Environment variable '%s' not found in global vault.\n", name) + } + } +} // }}} + +func subcommandTagEnvVar(cmd TagCmd) { // {{{ + var store *EnvStore + + // Determine which store to use + if cmd.Local { + if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Local vault does not exist.\n") + os.Exit(1) + } + store = loadLocalEnvStore() + internal.Log.Printf("Using local vault") } else { - fmt.Fprintf(os.Stderr, "Environment variable '%s' not found.\n", name) + store = loadGlobalEnvStore() + internal.Log.Printf("Using global vault") + } + + // Check if the variable exists + if _, exists := store.Vars[cmd.Name]; !exists { + vaultType := "global" + if cmd.Local { + vaultType = "local" + } + fmt.Fprintf(os.Stderr, "Environment variable '%s' not found in %s vault.\n", + cmd.Name, + vaultType) + os.Exit(1) + } + + // If list flag is set, just list the tags and return + if cmd.List { + tags := store.GetTags(cmd.Name) + if len(tags) == 0 { + fmt.Fprintf(os.Stderr, "No tags for '%s'\n", cmd.Name) + return + } + for _, tag := range tags { + fmt.Println(tag) + } + return + } + + // If no tags provided and not in list mode, show error + if len(cmd.Tags) == 0 { + fmt.Fprintf(os.Stderr, "No tags provided. Use --list to view tags.\n") + os.Exit(1) + } + + // Process tags + if cmd.Remove { + internal.Log.Printf("Removing tags %v from variable '%s'", cmd.Tags, cmd.Name) + store.RemoveTags(cmd.Name, cmd.Tags) + fmt.Fprintf( + os.Stderr, + "Removed tags from '%s': %s\n", + cmd.Name, + strings.Join(cmd.Tags, ", "), + ) + } else { + internal.Log.Printf("Adding tags %v to variable '%s'", cmd.Tags, cmd.Name) + store.SetTags(cmd.Name, cmd.Tags) + fmt.Fprintf(os.Stderr, "Added tags to '%s': %s\n", cmd.Name, strings.Join(cmd.Tags, ", ")) + } + + // Save the store + if cmd.Local { + saveLocalEnvStore(store) + } else { + saveGlobalEnvStore(store) } } // }}} func subcommandListEnvVars(cmdArgs ListCmd) { // {{{ - store := loadEnvStore() - for k, v := range store.Vars { + var store *EnvStore + + // Determine which store to use + if cmdArgs.Local { + if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Local vault does not exist.\n") + return + } + store = loadLocalEnvStore() + } else { + // Default: load and merge stores + store = loadEnvStore() + } + + // Sort keys for consistent output + var keys []string + for k := range store.Vars { + // Filter by tags if specified + if store.HasAnyTag(k, cmdArgs.Tags) { + keys = append(keys, k) + } + } + slices.Sort(keys) + + for _, k := range keys { + v := store.Vars[k] + tags := store.GetTags(k) + tagStr := "" + if len(tags) > 0 { + tagStr = fmt.Sprintf(" (tags: %s)", strings.Join(tags, ", ")) + } if cmdArgs.Values { - fmt.Printf("%s=%s\n", k, v) + fmt.Printf("%s=%s", k, v) + fmt.Fprintf(os.Stderr, "%s", tagStr) + fmt.Println() } else { - fmt.Printf("%s\n", k) + fmt.Printf("%s", k) + fmt.Fprintf(os.Stderr, "%s", tagStr) + fmt.Println() } } } // }}} func subcommandExecCommand(cmdArgs ExecCmd) { // {{{ - store := loadEnvStore() + var store *EnvStore + + // Determine which store to use + if cmdArgs.Local { + if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Local vault does not exist.\n") + os.Exit(1) + } + store = loadLocalEnvStore() + internal.Log.Println("Using local vault only for execution") + } else { + // Default: load and merge stores + store = loadEnvStore() + internal.Log.Println("Using merged vaults for execution") + } envVars := os.Environ() for candidateEnvName, candidateEnvValue := range store.Vars { + // Skip if not matching tag filter + if !store.HasAnyTag(candidateEnvName, cmdArgs.Tags) { + continue + } + if len(cmdArgs.Env) == 0 { // If no env vars are specified, add all env vars envVars = append(envVars, fmt.Sprintf("%s=%s", candidateEnvName, candidateEnvValue)) @@ -266,6 +522,29 @@ func subcommandStartDaemon(timeoutMinutes int) { // {{{ } } // }}} +func subcommandLockVault() { // {{{ + internal.Log.Println("Locking vault by stopping daemon") + + socketPath := daemon.GetSocketPath() + client := daemon.NewClient(socketPath) + + if !client.IsRunning() { + internal.Log.Println("Daemon is not running, nothing to lock") + fmt.Println("Vault is already locked (daemon not running)") + return + } + + internal.Log.Println("Sending kill command to daemon") + err := client.KillDaemon() + if err != nil { + internal.Log.Printf("Failed to kill daemon: %v", err) + log.Fatalf("Failed to lock vault: %v", err) + } + + internal.Log.Println("Daemon stopped successfully") + fmt.Println("Vault locked successfully") +} // }}} + func subcommandRekeyVault() { // {{{ // Ask for current master password fmt.Fprint(os.Stderr, "Enter current master password: ") @@ -307,6 +586,21 @@ func subcommandRekeyVault() { // {{{ log.Fatal("Passwords do not match") } + // Check if daemon is running and kill it + socketPath := daemon.GetSocketPath() + client := daemon.NewClient(socketPath) + + if client.IsRunning() { + internal.Log.Println("Daemon is running, stopping it before rekey") + fmt.Println("Stopping password daemon before changing master password...") + err := client.KillDaemon() + if err != nil { + internal.Log.Printf("Failed to kill daemon: %v", err) + log.Fatalf("Failed to stop daemon: %v", err) + } + internal.Log.Println("Daemon stopped successfully") + } + // Encrypt the master key with the new password newEncryptedKey := encryptKeyWithPassword(masterKey, newPassword) @@ -460,15 +754,16 @@ func decryptKeyWithPassword(encryptedKey, password []byte) ([]byte, error) { // return decrypted, nil } // }}} -func loadEnvStore() *EnvStore { // {{{ +func loadStoreFile(filePath string) *EnvStore { // {{{ key := loadKey() - if err := os.MkdirAll(filepath.Dir(dbFilePath), 0700); err != nil { - log.Fatal(err) - } - data, err := os.ReadFile(dbFilePath) + + data, err := os.ReadFile(filePath) if err != nil { if os.IsNotExist(err) { - return &EnvStore{Vars: make(map[string]string)} + return &EnvStore{ + Vars: make(map[string]string), + Tags: make(map[string][]string), + } } log.Fatal(err) } @@ -484,13 +779,61 @@ func loadEnvStore() *EnvStore { // {{{ if err := json.Unmarshal(decrypted, &store); err != nil { log.Fatal(err) } + // Initialize Tags map if it's nil + if store.Tags == nil { + store.Tags = make(map[string][]string) + } } else { - return &EnvStore{Vars: make(map[string]string)} + return &EnvStore{ + Vars: make(map[string]string), + Tags: make(map[string][]string), + } } return &store } // }}} -func saveEnvStore(store *EnvStore) { // {{{ +func loadGlobalEnvStore() *EnvStore { // {{{ + if err := os.MkdirAll(filepath.Dir(dbFilePath), 0700); err != nil { + log.Fatal(err) + } + return loadStoreFile(dbFilePath) +} // }}} + +func loadLocalEnvStore() *EnvStore { // {{{ + return loadStoreFile(localDbFilePath) +} // }}} + +func loadEnvStore() *EnvStore { // {{{ + // Load global store first + globalStore := loadGlobalEnvStore() + + // Check if local store exists + if _, err := os.Stat(localDbFilePath); os.IsNotExist(err) { + // No local store, just return global + return globalStore + } + + // Load local store + localStore := loadLocalEnvStore() + + // Merge stores (local takes precedence) + mergedStore := &EnvStore{ + Vars: make(map[string]string), + Tags: make(map[string][]string), + } + + // Copy all global vars and tags + maps.Copy(mergedStore.Vars, globalStore.Vars) + maps.Copy(mergedStore.Tags, globalStore.Tags) + + // Override/add local vars and tags + maps.Copy(mergedStore.Vars, localStore.Vars) + maps.Copy(mergedStore.Tags, localStore.Tags) + + return mergedStore +} // }}} + +func saveGlobalEnvStore(store *EnvStore) { // {{{ key := loadKey() data, err := json.Marshal(store) if err != nil { @@ -507,3 +850,21 @@ func saveEnvStore(store *EnvStore) { // {{{ log.Fatal(err) } } // }}} + +func saveLocalEnvStore(store *EnvStore) { // {{{ + key := loadKey() + data, err := json.Marshal(store) + if err != nil { + log.Fatal(err) + } + + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + log.Fatal(err) + } + encrypted := secretbox.Seal(nonce[:], data, &nonce, &key) + + if err := os.WriteFile(localDbFilePath, encrypted, 0600); err != nil { + log.Fatal(err) + } +} // }}} diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 00013ac..5adaddb 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -111,6 +111,29 @@ func (c *Client) RetrievePassword() ([]byte, error) { return []byte(resp.Data), nil } +// KillDaemon sends a kill command to the daemon to stop it +func (c *Client) KillDaemon() error { + internal.DaemonLog.Printf("Sending kill command to daemon") + msg := Message{ + Command: "kill", + } + + internal.DaemonLog.Printf("Sending kill message to daemon") + resp, err := c.sendMessage(msg) + if err != nil { + internal.DaemonLog.Printf("Failed to send kill message: %v", err) + return err + } + + if !resp.Success { + internal.DaemonLog.Printf("Daemon reported error: %s", resp.Error) + return fmt.Errorf("failed to kill daemon: %s", resp.Error) + } + + internal.DaemonLog.Printf("Kill command sent successfully") + return nil +} + // sendMessage sends a message to the daemon and returns the response func (c *Client) sendMessage(msg Message) (*Response, error) { internal.DaemonLog.Printf("Connecting to daemon socket: %s", c.socketPath) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 15d84fe..526e724 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -217,6 +217,23 @@ func handleConnection(conn net.Conn, cache *PasswordCache) { internal.DaemonLog.Println("Password retrieved, sending success response") encoder.Encode(Response{Success: true, Data: string(password)}) + case "kill": + internal.DaemonLog.Println("Kill command received, shutting down daemon") + encoder.Encode(Response{Success: true}) + // Close the connection before exiting + conn.Close() + // Clean up the socket file + os.Remove(conn.LocalAddr().String()) + internal.DaemonLog.Printf("Removed socket file: %s", conn.LocalAddr().String()) + // Exit with success status + internal.DaemonLog.Println("Daemon shutting down gracefully") + // Use goroutine to allow response to be sent before exit + go func() { + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + return + default: internal.DaemonLog.Printf("Unknown command received: %s", msg.Command) encoder.Encode(Response{Success: false, Error: "Unknown command"})