509 lines
14 KiB
Go
509 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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/daemon"
|
|
)
|
|
|
|
var cachedPassword []byte
|
|
|
|
var (
|
|
keyFilePath string
|
|
dbFilePath string
|
|
)
|
|
|
|
func init() {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
log.Fatalf("Failed to get user home directory: %v", err)
|
|
}
|
|
keyFilePath = filepath.Join(homeDir, ".local/cache/envvault/key")
|
|
dbFilePath = filepath.Join(homeDir, ".local/cache/envvault/db.json.enc")
|
|
}
|
|
|
|
type EnvStore struct {
|
|
Vars map[string]string `json:"vars"`
|
|
}
|
|
|
|
type CLI struct {
|
|
Init InitCmd `cmd:"" help:"Initialize the vault."`
|
|
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"`
|
|
Rekey RekeyCmd `cmd:"" help:"Change the master password."`
|
|
Daemon DaemonCmd `cmd:"" help:"Start the password caching daemon."`
|
|
}
|
|
|
|
type InitCmd struct{}
|
|
type ListCmd struct {
|
|
Values bool `short:"v" help:"Show values of environment variables." default:"false"`
|
|
}
|
|
type AddCmd struct {
|
|
Name string `arg:"" help:"Name of the environment variable."`
|
|
Value string `arg:"" optional:"" help:"Value of the environment variable."`
|
|
}
|
|
type RmCmd struct {
|
|
Name string `arg:"" help:"Name of the environment variable to remove."`
|
|
}
|
|
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."`
|
|
}
|
|
type RekeyCmd struct{}
|
|
type DaemonCmd struct {
|
|
Timeout int `help:"Password cache timeout in minutes." default:"5"`
|
|
}
|
|
|
|
func main() {
|
|
internal.Log.Println("Starting envvault")
|
|
|
|
cli := CLI{}
|
|
ctx := kong.Parse(&cli)
|
|
|
|
command := ctx.Command()
|
|
internal.Log.Printf("Executing command: %s", command)
|
|
|
|
switch command {
|
|
case "init":
|
|
subcommandInitVault()
|
|
case "add <name>":
|
|
subcommandAddEnvVar(cli.Add.Name, cli.Add.Value)
|
|
case "add <name> <value>":
|
|
subcommandAddEnvVar(cli.Add.Name, cli.Add.Value)
|
|
case "rm <name>":
|
|
subcommandRmEnvVar(cli.Rm.Name)
|
|
case "list":
|
|
subcommandListEnvVars(cli.List)
|
|
case "exec <cmd>":
|
|
subcommandExecCommand(cli.Exec)
|
|
case "exec <cmd> <args>":
|
|
subcommandExecCommand(cli.Exec)
|
|
case "rekey":
|
|
subcommandRekeyVault()
|
|
case "daemon":
|
|
subcommandStartDaemon(cli.Daemon.Timeout)
|
|
default:
|
|
internal.Log.Printf("Unknown command: %s", command)
|
|
log.Fatal("Unknown command")
|
|
}
|
|
|
|
internal.Log.Printf("Command completed: %s", command)
|
|
}
|
|
|
|
func subcommandInitVault() {
|
|
internal.Log.Println("Initializing vault")
|
|
|
|
if _, err := os.Stat(keyFilePath); os.IsNotExist(err) {
|
|
internal.Log.Println("Key file does not exist, creating new vault")
|
|
|
|
fmt.Fprint(os.Stderr, "Enter a new master password: ")
|
|
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
internal.Log.Printf("Failed to read password: %v", err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
fmt.Fprint(os.Stderr, "Enter a new master password (again): ")
|
|
password2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
internal.Log.Printf("Failed to read confirmation password: %v", err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if !bytes.Equal(password, password2) {
|
|
internal.Log.Println("Passwords do not match")
|
|
log.Fatal("Passwords do not match")
|
|
}
|
|
internal.Log.Println("Passwords match, caching password")
|
|
cachedPassword = password
|
|
|
|
// Generate a random key
|
|
internal.Log.Println("Generating random key")
|
|
var key [32]byte
|
|
if _, err := rand.Read(key[:]); err != nil {
|
|
internal.Log.Printf("Failed to generate random key: %v", err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Encrypt the key with the password
|
|
internal.Log.Println("Encrypting key with password")
|
|
encryptedKey := encryptKeyWithPassword(key[:], password)
|
|
|
|
internal.Log.Printf("Creating key directory: %s", filepath.Dir(keyFilePath))
|
|
if err := os.MkdirAll(filepath.Dir(keyFilePath), 0700); err != nil {
|
|
internal.Log.Printf("Failed to create key directory: %v", err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
internal.Log.Printf("Writing encrypted key to: %s", keyFilePath)
|
|
if err := os.WriteFile(keyFilePath, encryptedKey, 0600); err != nil {
|
|
internal.Log.Printf("Failed to write key file: %v", err)
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Initialize the db file
|
|
internal.Log.Println("Initializing empty environment store")
|
|
emptyStore := &EnvStore{Vars: make(map[string]string)}
|
|
saveEnvStore(emptyStore)
|
|
|
|
internal.Log.Println("Vault initialized successfully")
|
|
fmt.Fprintln(os.Stderr, "Vault initialized successfully.")
|
|
} else {
|
|
internal.Log.Println("Vault already initialized")
|
|
fmt.Fprintln(os.Stderr, "Vault already initialized.")
|
|
}
|
|
}
|
|
|
|
func subcommandAddEnvVar(name, value string) {
|
|
store := loadEnvStore()
|
|
if value == "" {
|
|
fmt.Fprintf(os.Stderr, "Enter value for %s: ", name)
|
|
inputValue, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
value = string(inputValue)
|
|
}
|
|
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)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Environment variable '%s' not found.\n", name)
|
|
}
|
|
}
|
|
|
|
func subcommandListEnvVars(cmdArgs ListCmd) {
|
|
store := loadEnvStore()
|
|
for k, v := range store.Vars {
|
|
if cmdArgs.Values {
|
|
fmt.Printf("%s=%s\n", k, v)
|
|
} else {
|
|
fmt.Printf("%s\n", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func subcommandExecCommand(cmdArgs ExecCmd) {
|
|
store := loadEnvStore()
|
|
envVars := os.Environ()
|
|
for candidateEnvName, candidateEnvValue := range store.Vars {
|
|
if len(cmdArgs.Env) == 0 {
|
|
// If no env vars are specified, add all env vars
|
|
envVars = append(envVars, fmt.Sprintf("%s=%s", candidateEnvName, candidateEnvValue))
|
|
continue
|
|
}
|
|
if slices.Contains(cmdArgs.Env, candidateEnvName) {
|
|
envVars = append(envVars, fmt.Sprintf("%s=%s", candidateEnvName, candidateEnvValue))
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(cmdArgs.Cmd, cmdArgs.Args...)
|
|
cmd.Env = envVars
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func loadEnvStore() *EnvStore {
|
|
key := loadKey()
|
|
if err := os.MkdirAll(filepath.Dir(dbFilePath), 0700); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(dbFilePath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &EnvStore{Vars: make(map[string]string)}
|
|
}
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var store EnvStore
|
|
if len(data) > 0 {
|
|
var nonce [24]byte
|
|
copy(nonce[:], data[:24])
|
|
decrypted, ok := secretbox.Open(nil, data[24:], &nonce, &key)
|
|
if !ok {
|
|
log.Fatal("Decryption failed")
|
|
}
|
|
if err := json.Unmarshal(decrypted, &store); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
} else {
|
|
return &EnvStore{Vars: make(map[string]string)}
|
|
}
|
|
return &store
|
|
}
|
|
|
|
func saveEnvStore(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(dbFilePath, encrypted, 0600); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func loadPassword() []byte {
|
|
internal.Log.Println("Loading password")
|
|
|
|
// 1. Check for globally cached password
|
|
if cachedPassword != nil {
|
|
internal.Log.Println("Using cached password from memory")
|
|
return cachedPassword
|
|
}
|
|
|
|
internal.Log.Println("No cached password in memory, trying daemon")
|
|
|
|
// 2. Try to get password from daemon
|
|
socketPath := daemon.GetSocketPath()
|
|
internal.Log.Printf("Using socket path: %s", socketPath)
|
|
client := daemon.NewClient(socketPath)
|
|
|
|
// Check if daemon is running
|
|
if !client.IsRunning() {
|
|
internal.Log.Println("Daemon not running, attempting to start it")
|
|
// Spawn daemon in background
|
|
cmd := exec.Command(os.Args[0], "daemon")
|
|
cmd.Stdout = nil
|
|
cmd.Stderr = nil
|
|
if err := cmd.Start(); err != nil {
|
|
internal.Log.Printf("Failed to start daemon: %v", err)
|
|
log.Printf("Failed to start daemon: %v", err)
|
|
} else {
|
|
internal.Log.Printf("Started daemon process with PID: %d", cmd.Process.Pid)
|
|
// Detach the process
|
|
cmd.Process.Release()
|
|
|
|
// Give daemon time to start
|
|
internal.Log.Println("Waiting for daemon to initialize")
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
} else {
|
|
internal.Log.Println("Daemon is already running")
|
|
}
|
|
|
|
// Try to retrieve password from daemon if it's running
|
|
if client.IsRunning() {
|
|
internal.Log.Println("Attempting to retrieve password from daemon")
|
|
password, err := client.RetrievePassword()
|
|
if err == nil {
|
|
internal.Log.Println("Successfully retrieved password from daemon")
|
|
// Cache the password
|
|
cachedPassword = password
|
|
return password
|
|
}
|
|
internal.Log.Printf("Failed to retrieve password from daemon: %v", err)
|
|
} else {
|
|
internal.Log.Println("Daemon still not running after attempt to start it")
|
|
}
|
|
|
|
internal.Log.Println("Falling back to terminal input for password")
|
|
// 3. Fall back to terminal input
|
|
fmt.Fprint(os.Stderr, "Enter master password: ")
|
|
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
internal.Log.Printf("Failed to read password from terminal: %v", err)
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Fprintln(os.Stderr) // Ensure newline after password input
|
|
|
|
internal.Log.Println("Password read from terminal, caching in memory")
|
|
// Cache the password
|
|
cachedPassword = password
|
|
|
|
// Store in daemon if it's running
|
|
if client.IsRunning() {
|
|
internal.Log.Println("Storing password in daemon")
|
|
if err := client.StorePassword(password); err != nil {
|
|
internal.Log.Printf("Failed to store password in daemon: %v", err)
|
|
log.Printf("Failed to store password in daemon: %v", err)
|
|
} else {
|
|
internal.Log.Println("Successfully stored password in daemon")
|
|
}
|
|
}
|
|
|
|
return password
|
|
}
|
|
|
|
func loadKey() [32]byte {
|
|
password := loadPassword()
|
|
|
|
encryptedKey, err := os.ReadFile(keyFilePath)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
decryptedKey, err := decryptKeyWithPassword(encryptedKey, password)
|
|
if err != nil {
|
|
// Clear cached password on error
|
|
cachedPassword = nil
|
|
log.Fatal("Invalid password or corrupted key file")
|
|
}
|
|
|
|
var key [32]byte
|
|
copy(key[:], decryptedKey)
|
|
return key
|
|
}
|
|
|
|
func encryptKeyWithPassword(key, password []byte) []byte {
|
|
var nonce [24]byte
|
|
if _, err := rand.Read(nonce[:]); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
passwordDerivedKey, err := scrypt.Key(password, nonce[:], 32768, 8, 1, 32)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
encrypted := secretbox.Seal(nonce[:], key, &nonce, (*[32]byte)(passwordDerivedKey))
|
|
return encrypted
|
|
}
|
|
|
|
func decryptKeyWithPassword(encryptedKey, password []byte) ([]byte, error) {
|
|
var nonce [24]byte
|
|
copy(nonce[:], encryptedKey[:24])
|
|
encrypted := encryptedKey[24:]
|
|
|
|
passwordDerivedKey, err := scrypt.Key(password, nonce[:], 32768, 8, 1, 32)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
decrypted, ok := secretbox.Open(nil, encrypted, &nonce, (*[32]byte)(passwordDerivedKey))
|
|
if !ok {
|
|
return nil, fmt.Errorf("decryption failed")
|
|
}
|
|
|
|
return decrypted, nil
|
|
}
|
|
|
|
func subcommandStartDaemon(timeoutMinutes int) {
|
|
internal.Log.Printf("Starting daemon with timeout: %d minutes", timeoutMinutes)
|
|
|
|
timeout := time.Duration(timeoutMinutes) * time.Minute
|
|
socketPath := daemon.GetSocketPath()
|
|
internal.Log.Printf("Using socket path: %s", socketPath)
|
|
|
|
// Ensure socket directory exists
|
|
socketDir := filepath.Dir(socketPath)
|
|
internal.Log.Printf("Creating socket directory: %s", socketDir)
|
|
if err := os.MkdirAll(socketDir, 0700); err != nil {
|
|
internal.Log.Printf("Failed to create socket directory: %v", err)
|
|
log.Fatalf("Failed to create socket directory: %v", err)
|
|
}
|
|
|
|
fmt.Printf("Starting password daemon (timeout: %s)\n", timeout)
|
|
fmt.Printf("Socket path: %s\n", socketPath)
|
|
|
|
internal.Log.Println("Calling daemon.StartDaemon")
|
|
// Start the daemon
|
|
if err := daemon.StartDaemon(socketPath, timeout); err != nil {
|
|
internal.Log.Printf("Failed to start daemon: %v", err)
|
|
log.Fatalf("Failed to start daemon: %v", err)
|
|
}
|
|
}
|
|
|
|
func subcommandRekeyVault() {
|
|
// Ask for current master password
|
|
fmt.Fprint(os.Stderr, "Enter current master password: ")
|
|
currentPassword, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Load and decrypt the master key with current password
|
|
encryptedKey, err := os.ReadFile(keyFilePath)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
masterKey, err := decryptKeyWithPassword(encryptedKey, currentPassword)
|
|
if err != nil {
|
|
log.Fatal("Invalid password or corrupted key file")
|
|
}
|
|
|
|
// Ask for new master password
|
|
fmt.Fprint(os.Stderr, "Enter new master password: ")
|
|
newPassword, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Ask for new master password confirmation
|
|
fmt.Fprint(os.Stderr, "Enter new master password (again): ")
|
|
newPassword2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
fmt.Fprintln(os.Stderr)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Verify passwords match
|
|
if !bytes.Equal(newPassword, newPassword2) {
|
|
log.Fatal("Passwords do not match")
|
|
}
|
|
|
|
// Encrypt the master key with the new password
|
|
newEncryptedKey := encryptKeyWithPassword(masterKey, newPassword)
|
|
|
|
// Backup the current key file
|
|
backupPath := keyFilePath + ".bak"
|
|
if err := os.Rename(keyFilePath, backupPath); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Backed up key file to %s\n", backupPath)
|
|
|
|
// Write the new encrypted key
|
|
if err := os.WriteFile(keyFilePath, newEncryptedKey, 0600); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Update cached password
|
|
cachedPassword = newPassword
|
|
|
|
fmt.Fprintln(os.Stderr, "Master password changed successfully.")
|
|
}
|