Jonathan Apodaca ffda2a4792
All checks were successful
CI / build (push) Successful in 45s
initial commit
2025-05-08 21:10:32 -06:00

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.")
}