initial commit
All checks were successful
CI / build (push) Successful in 21s

This commit is contained in:
Jonathan Apodaca 2025-05-08 21:09:32 -06:00
commit 7d1becbb71
9 changed files with 987 additions and 0 deletions

23
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build
run: make

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/build
/envvault
.aider*

19
Makefile Normal file
View File

@ -0,0 +1,19 @@
envvault: .PHONY
go build -o envvault ./cmd/envvault/main.go
.PHONY:
fmt:
go fmt ./...
local-install: envvault
ln -s `pwd`/envvault ~/.local/bin/envvault
cross-compile:
mkdir -p build
GOOS=linux GOARCH=amd64 go build -o build/envvault-linux-amd64 ./...
GOOS=linux GOARCH=arm64 go build -o build/envvault-linux-arm64 ./...
GOOS=darwin GOARCH=amd64 go build -o build/envvault-darwin-amd64 ./...
GOOS=darwin GOARCH=arm64 go build -o build/envvault-darwin-arm64 ./...
GOOS=windows GOARCH=amd64 go build -o build/envvault-windows-amd64.exe ./...
GOOS=windows GOARCH=arm64 go build -o build/envvault-windows-arm64.exe ./...

508
cmd/envvault/main.go Normal file
View File

@ -0,0 +1,508 @@
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.")
}

11
go.mod Normal file
View File

@ -0,0 +1,11 @@
module git.jrop.me/jonathan/envvault
go 1.24.3
require (
github.com/alecthomas/kong v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/term v0.32.0
)
require golang.org/x/sys v0.33.0 // indirect

14
go.sum Normal file
View File

@ -0,0 +1,14 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw=
github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=

125
internal/daemon/client.go Normal file
View File

@ -0,0 +1,125 @@
package daemon
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"git.jrop.me/jonathan/envvault/internal"
)
// Client provides methods to interact with the password daemon
type Client struct {
socketPath string
}
// NewClient creates a new client for the password daemon
func NewClient(socketPath string) *Client {
return &Client{
socketPath: socketPath,
}
}
// IsRunning checks if the daemon is running
func (c *Client) IsRunning() bool {
internal.DaemonLog.Printf("Checking if daemon is running at: %s", c.socketPath)
conn, err := net.Dial("unix", c.socketPath)
if err != nil {
internal.DaemonLog.Printf("Daemon is not running: %v", err)
return false
}
conn.Close()
internal.DaemonLog.Printf("Daemon is running")
return true
}
// StorePassword sends the password to the daemon for caching
func (c *Client) StorePassword(password []byte) error {
internal.DaemonLog.Printf("Storing password in daemon")
msg := Message{
Command: "store",
Password: string(password),
}
internal.DaemonLog.Printf("Sending store message to daemon")
resp, err := c.sendMessage(msg)
if err != nil {
internal.DaemonLog.Printf("Failed to send store message: %v", err)
return err
}
if !resp.Success {
internal.DaemonLog.Printf("Daemon reported error: %s", resp.Error)
return fmt.Errorf("failed to store password: %s", resp.Error)
}
internal.DaemonLog.Printf("Password stored successfully")
return nil
}
// RetrievePassword gets the cached password from the daemon
func (c *Client) RetrievePassword() ([]byte, error) {
internal.DaemonLog.Printf("Retrieving password from daemon")
msg := Message{
Command: "retrieve",
}
internal.DaemonLog.Printf("Sending retrieve message to daemon")
resp, err := c.sendMessage(msg)
if err != nil {
internal.DaemonLog.Printf("Failed to send retrieve message: %v", err)
return nil, err
}
if !resp.Success {
internal.DaemonLog.Printf("Daemon reported error: %s", resp.Error)
return nil, fmt.Errorf("failed to retrieve password: %s", resp.Error)
}
internal.DaemonLog.Printf("Password retrieved successfully")
return []byte(resp.Data), 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)
conn, err := net.Dial("unix", c.socketPath)
if err != nil {
internal.DaemonLog.Printf("Failed to connect to daemon: %v", err)
return nil, fmt.Errorf("failed to connect to daemon: %w", err)
}
defer conn.Close()
internal.DaemonLog.Printf("Connected to daemon, encoding message")
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
if err := encoder.Encode(msg); err != nil {
internal.DaemonLog.Printf("Failed to encode message: %v", err)
return nil, fmt.Errorf("failed to encode message: %w", err)
}
internal.DaemonLog.Printf("Message sent, waiting for response")
var resp Response
if err := decoder.Decode(&resp); err != nil {
internal.DaemonLog.Printf("Failed to decode response: %v", err)
return nil, fmt.Errorf("failed to decode response: %w", err)
}
internal.DaemonLog.Printf("Response received: success=%v", resp.Success)
return &resp, nil
}
// GetSocketPath returns the default socket path for the current user
func GetSocketPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
// Fall back to current directory if home dir can't be determined
internal.DaemonLog.Printf("Failed to get user home directory: %v", err)
return filepath.Join(".local/cache/envvault", "daemon.sock")
}
runtimeDir := filepath.Join(homeDir, ".local/cache/envvault")
return filepath.Join(runtimeDir, "daemon.sock")
}

226
internal/daemon/daemon.go Normal file
View File

@ -0,0 +1,226 @@
package daemon
import (
"encoding/json"
"fmt"
"log"
"net"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"git.jrop.me/jonathan/envvault/internal"
)
// Message represents the communication protocol between client and daemon
type Message struct {
Command string `json:"command"`
Password string `json:"password,omitempty"`
}
// Response represents the daemon's response to client requests
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Data string `json:"data,omitempty"`
}
// PasswordCache manages the cached password with timeout
type PasswordCache struct {
password []byte
freshAccessTime time.Time
mutex sync.RWMutex
timeout time.Duration
}
// NewPasswordCache creates a new password cache with the specified timeout
func NewPasswordCache(timeout time.Duration) *PasswordCache {
return &PasswordCache{
timeout: timeout,
}
}
// Set stores a password in the cache
func (pc *PasswordCache) Set(password []byte) {
internal.DaemonLog.Println("Setting password in cache")
pc.mutex.Lock()
defer pc.mutex.Unlock()
pc.password = make([]byte, len(password))
copy(pc.password, password)
pc.freshAccessTime = time.Now()
internal.DaemonLog.Printf("Password set in cache, expires at: %s",
pc.freshAccessTime.Add(pc.timeout).Format(time.RFC3339))
}
// Get retrieves the password if it exists and hasn't expired
func (pc *PasswordCache) Get() ([]byte, bool) {
internal.DaemonLog.Println("Getting password from cache")
pc.mutex.Lock()
defer pc.mutex.Unlock()
if pc.password == nil {
internal.DaemonLog.Println("No password in cache")
return nil, false
}
timeSinceFreshAccess := time.Since(pc.freshAccessTime)
internal.DaemonLog.Printf("Time since fresh access: %s (timeout: %s)",
timeSinceFreshAccess, pc.timeout)
if timeSinceFreshAccess > pc.timeout {
internal.DaemonLog.Println("Password has expired, clearing")
if pc.password != nil {
// Securely clear the password by overwriting with zeros
for i := range pc.password {
pc.password[i] = 0
}
pc.password = nil
}
return nil, false
}
result := make([]byte, len(pc.password))
copy(result, pc.password)
internal.DaemonLog.Println("Returning valid password, expires at: %s",
pc.freshAccessTime.Add(pc.timeout).Format(time.RFC3339))
return result, true
}
// StartDaemon starts the password caching daemon
func StartDaemon(socketPath string, timeout time.Duration) error {
internal.DaemonLog.Println("Starting daemon with socket path: %s and timeout: %s",
socketPath, timeout)
// Ensure socket directory exists
socketDir := filepath.Dir(socketPath)
internal.DaemonLog.Println("Creating socket directory: %s", socketDir)
if err := os.MkdirAll(socketDir, 0700); err != nil {
internal.DaemonLog.Println("Failed to create socket directory: %v", err)
return fmt.Errorf("failed to create socket directory: %w", err)
}
// Remove existing socket if it exists
if _, err := os.Stat(socketPath); err == nil {
internal.DaemonLog.Println("Removing existing socket: %s", socketPath)
if err := os.RemoveAll(socketPath); err != nil {
internal.DaemonLog.Println("Failed to remove existing socket: %v", err)
return fmt.Errorf("failed to remove existing socket: %w", err)
}
}
// Create Unix domain socket
internal.DaemonLog.Println("Creating Unix domain socket")
listener, err := net.Listen("unix", socketPath)
if err != nil {
internal.DaemonLog.Println("Failed to listen on socket: %v", err)
return fmt.Errorf("failed to listen on socket: %w", err)
}
defer listener.Close()
// Set socket permissions to only allow current user
internal.DaemonLog.Println("Setting socket permissions to 0600")
if err := os.Chmod(socketPath, 0600); err != nil {
internal.DaemonLog.Println("Failed to set socket permissions: %v", err)
return fmt.Errorf("failed to set socket permissions: %w", err)
}
// Create password cache
internal.DaemonLog.Println("Creating password cache with timeout: %s", timeout)
cache := NewPasswordCache(timeout)
// Handle signals for graceful shutdown
internal.DaemonLog.Println("Setting up signal handlers for graceful shutdown")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigChan
internal.DaemonLog.Println("Received signal: %s, shutting down", sig)
os.RemoveAll(socketPath)
os.Exit(0)
}()
// Start cleanup goroutine to periodically check for expired passwords
internal.DaemonLog.Println("Starting cleanup goroutine with interval: %s", timeout/2)
go func() {
ticker := time.NewTicker(timeout / 2)
defer ticker.Stop()
for t := range ticker.C {
internal.DaemonLog.Println("Cleanup tick at %s", t.Format(time.RFC3339))
// This will trigger cleanup of expired password
_, ok := cache.Get()
internal.DaemonLog.Println("Cleanup check result: password exists=%v", ok)
}
}()
fmt.Fprintf(os.Stderr, "Password daemon started (timeout: %s)\n", timeout)
internal.DaemonLog.Println("Password daemon started successfully")
// Accept connections
internal.DaemonLog.Println("Entering accept loop")
for {
internal.DaemonLog.Println("Waiting for connections")
conn, err := listener.Accept()
if err != nil {
internal.DaemonLog.Println("Error accepting connection: %v", err)
log.Printf("Error accepting connection: %v", err)
continue
}
internal.DaemonLog.Println("Accepted connection from: %s", conn.RemoteAddr())
go handleConnection(conn, cache)
}
}
// handleConnection processes client requests
func handleConnection(conn net.Conn, cache *PasswordCache) {
defer conn.Close()
internal.DaemonLog.Println("Handling new connection")
decoder := json.NewDecoder(conn)
encoder := json.NewEncoder(conn)
var msg Message
if err := decoder.Decode(&msg); err != nil {
internal.DaemonLog.Println("Failed to decode message: %v", err)
encoder.Encode(Response{Success: false, Error: "Invalid message format"})
return
}
internal.DaemonLog.Println("Received command: %s", msg.Command)
switch msg.Command {
case "store":
if msg.Password == "" {
internal.DaemonLog.Println("Store command received with empty password")
encoder.Encode(Response{Success: false, Error: "No password provided"})
return
}
internal.DaemonLog.Println("Storing password in cache")
cache.Set([]byte(msg.Password))
internal.DaemonLog.Println("Password stored, sending success response")
encoder.Encode(Response{Success: true})
case "retrieve":
internal.DaemonLog.Println("Retrieve command received")
password, ok := cache.Get()
if !ok {
internal.DaemonLog.Println("No password available or password expired")
encoder.Encode(Response{Success: false, Error: "No password available or password expired"})
return
}
internal.DaemonLog.Println("Password retrieved, sending success response")
encoder.Encode(Response{Success: true, Data: string(password)})
default:
internal.DaemonLog.Println("Unknown command received: %s", msg.Command)
encoder.Encode(Response{Success: false, Error: "Unknown command"})
}
internal.DaemonLog.Println("Connection handled successfully")
}

58
internal/log.go Normal file
View File

@ -0,0 +1,58 @@
package internal
import (
"io"
"log"
"os"
"path/filepath"
)
// Logger constants
var (
Log *log.Logger
DaemonLog *log.Logger
)
func init() {
// Check debug mode once at startup
debugMode := os.Getenv("DEBUG") == "1"
// Initialize loggers
Log = createLogger("envvault.log", debugMode)
DaemonLog = createLogger("envvault.daemon.log", debugMode)
}
// createLogger creates a logger with the specified log file
func createLogger(logFileName string, debugMode bool) *log.Logger {
// Ensure log directory exists
homeDir, err := os.UserHomeDir()
if err != nil {
log.Printf("Failed to get user home directory: %v", err)
return log.New(os.Stderr, "", log.LstdFlags|log.Lshortfile)
}
logDir := filepath.Join(homeDir, ".local/cache/envvault")
if err := os.MkdirAll(logDir, 0700); err != nil {
log.Printf("Failed to create log directory: %v", err)
return log.New(os.Stderr, "", log.LstdFlags|log.Lshortfile)
}
// Open log file
logFilePath := filepath.Join(logDir, logFileName)
f, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
log.Printf("Failed to open log file: %v", err)
return log.New(os.Stderr, "", log.LstdFlags|log.Lshortfile)
}
// If in debug mode, log to both file and stderr
var writer io.Writer
if debugMode {
writer = io.MultiWriter(f, os.Stderr)
} else {
writer = f
}
// Create logger with timestamp, file name, and line number
return log.New(writer, "", log.LstdFlags|log.Lshortfile)
}