PassAGE/commands.go
fraggle 3552db50c2 Initial commit: PassAGE password manager
- AGE encryption with master password model
- Core commands: init, show, insert, edit, generate, rm, mv, cp, find, grep, ls
- Git integration for version control
- Clipboard support (X11 and Wayland)
- Secure password generation
- Backup and restore functionality
- Comprehensive security features
- Complete documentation
2026-01-11 18:48:01 -04:00

1214 lines
32 KiB
Go

package main
import (
"bufio"
"crypto/rand"
"flag"
"fmt"
"io"
"math/big"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"golang.org/x/term"
)
// cmdInit initializes a new password store with a master password
// Sets up everything needed to start storing passwords
func cmdInit(args []string) {
fs := flag.NewFlagSet("init", flag.ExitOnError)
var pathFlag string
fs.StringVar(&pathFlag, "path", "", "subfolder path")
fs.StringVar(&pathFlag, "p", "", "subfolder path (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() > 0 {
fmt.Fprintf(os.Stderr, "Error: passage init does not take arguments. It will prompt for a master password.\n")
fmt.Fprintf(os.Stderr, "Usage: passage init [--path=subfolder]\n")
os.Exit(1)
}
storeDir := getStoreDir()
targetDir := storeDir
if pathFlag != "" {
if err := checkSneakyPaths(pathFlag); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
pathFlag, err := sanitizePath(pathFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
targetDir = filepath.Join(storeDir, pathFlag)
}
if err := os.MkdirAll(targetDir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
// Check if already initialized
if checkStoreInitialized(targetDir) {
fmt.Fprintf(os.Stderr, "Error: password store already initialized at %s\n", targetDir)
os.Exit(1)
}
// Get the master password (twice, because typos are annoying)
fmt.Fprintf(os.Stderr, "Initializing password store...\n")
masterPassword, err := getMasterPassword("Enter master password: ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer masterPassword.Clear() // Always clean up
// Make sure they typed it right
confirmPassword, err := getMasterPassword("Confirm master password: ")
if err != nil {
masterPassword.Clear()
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer confirmPassword.Clear()
// Constant-time comparison - can't let attackers guess passwords by timing
if !constantTimeCompare(masterPassword.Bytes(), confirmPassword.Bytes()) {
masterPassword.Clear()
confirmPassword.Clear()
fmt.Fprintf(os.Stderr, "Error: passwords do not match\n")
os.Exit(1)
}
// Store password hash for verification
if err := storeMasterPasswordHash(targetDir, masterPassword.Bytes()); err != nil {
masterPassword.Clear()
confirmPassword.Clear()
fmt.Fprintf(os.Stderr, "Error: failed to store password hash: %v\n", err)
os.Exit(1)
}
fmt.Printf("Password store initialized%s\n",
func() string {
if pathFlag != "" {
return fmt.Sprintf(" (%s)", pathFlag)
}
return ""
}())
// Initialize git if requested or if .git already exists
gitDir := filepath.Join(storeDir, ".git")
if _, err := os.Stat(gitDir); err == nil {
masterPassPath := getMasterPassPath(targetDir)
gitAddFile(masterPassPath, fmt.Sprintf("Initialize password store%s.",
func() string {
if pathFlag != "" {
return fmt.Sprintf(" (%s)", pathFlag)
}
return ""
}()))
}
}
// getMasterPasswordForOperation prompts for master password and verifies store is initialized
func getMasterPasswordForOperation(storeDir string) (*SecureBytes, error) {
if !checkStoreInitialized(storeDir) {
return nil, fmt.Errorf("password store not initialized. Run 'passage init' first")
}
masterPassword, err := getMasterPassword("Enter master password: ")
if err != nil {
return nil, err
}
// Check if the password is correct
valid, err := verifyMasterPassword(storeDir, masterPassword.Bytes())
if err != nil {
// Hash file missing or corrupted - might be an old store, warn but allow
fmt.Fprintf(os.Stderr, "Warning: could not verify password hash: %v\n", err)
} else if !valid {
// Wrong password - clear it and bail
masterPassword.Clear()
return nil, fmt.Errorf("incorrect master password")
}
return masterPassword, nil
}
// cmdShow displays a password
func cmdShow(args []string) {
fs := flag.NewFlagSet("show", flag.ExitOnError)
var clipFlag string
var clipLine int
fs.StringVar(&clipFlag, "clip", "", "copy to clipboard")
fs.StringVar(&clipFlag, "c", "", "copy to clipboard (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
storeDir := getStoreDir()
var path string
if fs.NArg() > 0 {
path = fs.Arg(0)
if err := checkSneakyPaths(path); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
path, _ = sanitizePath(path) // Already checked, ignore error
}
passfile := filepath.Join(storeDir, path+passageExt)
// Check if it's a directory
if info, err := os.Stat(filepath.Join(storeDir, path)); err == nil && info.IsDir() {
cmdList(path)
return
}
if _, err := os.Stat(passfile); err != nil {
if path == "" {
// List root
cmdList("")
return
}
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", path)
os.Exit(1)
}
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer masterPassword.Clear()
decrypted, err := decryptFile(passfile, "", masterPassword.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
os.Exit(1)
}
// Check file size before reading
if err := checkFileSize(passfile); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
data, err := readLimited(decrypted, maxFileSize)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
os.Exit(1)
}
lines := strings.Split(string(data), "\n")
if clipFlag != "" {
if clipFlag == "clip" || clipFlag == "c" {
clipLine = 1
} else {
if n, err := fmt.Sscanf(clipFlag, "%d", &clipLine); err != nil || n != 1 || clipLine <= 0 {
fmt.Fprintf(os.Stderr, "Error: invalid clip line number\n")
os.Exit(1)
}
}
if clipLine < 1 || clipLine > len(lines) {
fmt.Fprintf(os.Stderr, "Error: line %d does not exist\n", clipLine)
os.Exit(1)
}
clipToClipboard(lines[clipLine-1], path)
} else {
fmt.Print(string(data))
}
}
// cmdList lists passwords in a directory
func cmdList(path string) {
if path != "" {
if err := checkSneakyPaths(path); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
path, _ = sanitizePath(path) // Already checked
}
storeDir := getStoreDir()
targetDir := storeDir
if path != "" {
targetDir = filepath.Join(storeDir, path)
}
if path == "" {
fmt.Println("Password Store")
} else {
fmt.Println(path)
}
entries, err := os.ReadDir(targetDir)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: password store is empty. Try \"passage init\"\n")
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
for _, entry := range entries {
if entry.IsDir() {
fmt.Printf("%s/\n", entry.Name())
} else if strings.HasSuffix(entry.Name(), passageExt) {
name := strings.TrimSuffix(entry.Name(), passageExt)
if name != ".passage-id" {
fmt.Printf("%s\n", name)
}
}
}
}
// cmdFind searches for passwords by name
func cmdFind(args []string) {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: passage find pass-names...\n")
os.Exit(1)
}
storeDir := getStoreDir()
// Validate all search terms for path traversal
for _, term := range args {
if err := checkSneakyPaths(term); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
terms := strings.Join(args, "|")
pattern := regexp.MustCompile("(?i)" + regexp.QuoteMeta(terms))
err := filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(path, passageExt) {
return nil
}
relPath, err := filepath.Rel(storeDir, path)
if err != nil {
return nil
}
name := strings.TrimSuffix(relPath, passageExt)
if pattern.MatchString(name) {
fmt.Println(name)
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// cmdGrep searches within password contents
func cmdGrep(args []string) {
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "Usage: passage grep [GREPOPTIONS] search-string\n")
os.Exit(1)
}
storeDir := getStoreDir()
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
err = filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
if !strings.HasSuffix(path, passageExt) {
return nil
}
decrypted, err := decryptFile(path, "", masterPassword.Bytes())
if err != nil {
return nil // Skip files we can't decrypt
}
// Check file size before reading
if err := checkFileSize(path); err != nil {
return nil // Skip files that are too large
}
data, err := readLimited(decrypted, maxFileSize)
if err != nil {
return nil
}
// Simple grep - just check if search string is in content
searchStr := args[len(args)-1]
if strings.Contains(string(data), searchStr) {
relPath, _ := filepath.Rel(storeDir, path)
name := strings.TrimSuffix(relPath, passageExt)
fmt.Printf("%s:\n", name)
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if strings.Contains(line, searchStr) {
fmt.Printf(" %d: %s\n", i+1, line)
}
}
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// cmdInsert inserts a new password
func cmdInsert(args []string) {
fs := flag.NewFlagSet("insert", flag.ExitOnError)
multiline := fs.Bool("multiline", false, "multiline entry")
multilineShort := fs.Bool("m", false, "multiline entry (short)")
force := fs.Bool("force", false, "overwrite without prompting")
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() != 1 {
fmt.Fprintf(os.Stderr, "Usage: passage insert [--multiline] [--force] pass-name\n")
os.Exit(1)
}
path := fs.Arg(0)
storeDir := getStoreDir()
passfile := filepath.Join(storeDir, path+passageExt)
if !*force && !*forceShort {
if _, err := os.Stat(passfile); err == nil {
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", path)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
os.Exit(1)
}
}
}
if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var password string
if *multiline || *multilineShort {
fmt.Printf("Enter contents of %s and press Ctrl+D when finished:\n\n", path)
data, err := readLimited(os.Stdin, maxPasswordLength)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read input: %v\n", err)
os.Exit(1)
}
password = string(data)
if err := checkPasswordLength(password); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
} else {
fmt.Printf("Enter password for %s: ", path)
bytePassword, err := readPassword()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read password: %v\n", err)
os.Exit(1)
}
fmt.Println()
password = string(bytePassword)
}
// Validate password length
if err := checkPasswordLength(password); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Create temporary file with password in secure location
tmpFile, err := createSecureTempFile("passage-insert-", ".tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
os.Exit(1)
}
defer os.Remove(tmpFile)
if err := os.WriteFile(tmpFile, []byte(password), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
os.Exit(1)
}
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
os.Exit(1)
}
gitAddFile(passfile, fmt.Sprintf("Add given password for %s to store.", path))
fmt.Printf("Password for %s added to store.\n", path)
}
// cmdEdit edits a password
func cmdEdit(args []string) {
if len(args) != 1 {
fmt.Fprintf(os.Stderr, "Usage: passage edit pass-name\n")
os.Exit(1)
}
path := args[0]
if err := checkSneakyPaths(path); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
path, err := sanitizePath(path)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
storeDir := getStoreDir()
passfile := filepath.Join(storeDir, path+passageExt)
if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Create temporary file in secure location
tmpFile, err := createSecureTempFile("passage-edit-", ".tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
os.Exit(1)
}
defer func() {
// Ensure cleanup even on panic
os.Remove(tmpFile)
}()
// Decrypt existing file if it exists
if _, err := os.Stat(passfile); err == nil {
decrypted, err := decryptFile(passfile, "", masterPassword.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
os.Exit(1)
}
data, err := readLimited(decrypted, maxFileSize)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
os.Exit(1)
}
if err := checkPasswordLength(string(data)); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
os.Exit(1)
}
}
// Edit file
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
// Paranoid check - don't let users inject commands through EDITOR
// Only simple editor names allowed, no paths or shell metacharacters
if strings.Contains(editor, "/") || strings.Contains(editor, "..") || strings.Contains(editor, "|") || strings.Contains(editor, "&") || strings.Contains(editor, ";") {
fmt.Fprintf(os.Stderr, "Error: invalid editor command\n")
os.Exit(1)
}
cmd := exec.Command(editor, tmpFile)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: editor failed: %v\n", err)
os.Exit(1)
}
// Check if file was modified
if _, err := os.Stat(tmpFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: new password not saved\n")
os.Exit(1)
}
// Validate edited content length
editedData, err := os.ReadFile(tmpFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read edited file: %v\n", err)
os.Exit(1)
}
if err := checkPasswordLength(string(editedData)); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Encrypt and save
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
os.Exit(1)
}
action := "Add"
if _, err := os.Stat(passfile); err == nil {
action = "Edit"
}
gitAddFile(passfile, fmt.Sprintf("%s password for %s using %s.", action, path, editor))
fmt.Printf("Password for %s %sd.\n", path, strings.ToLower(action))
}
// cmdGenerate generates a new password
func cmdGenerate(args []string) {
fs := flag.NewFlagSet("generate", flag.ExitOnError)
noSymbols := fs.Bool("no-symbols", false, "don't use symbols")
noSymbolsShort := fs.Bool("n", false, "don't use symbols (short)")
clip := fs.Bool("clip", false, "copy to clipboard")
clipShort := fs.Bool("c", false, "copy to clipboard (short)")
force := fs.Bool("force", false, "overwrite without prompting")
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
inPlace := fs.Bool("in-place", false, "replace only first line")
inPlaceShort := fs.Bool("i", false, "replace only first line (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() < 1 || fs.NArg() > 2 {
fmt.Fprintf(os.Stderr, "Usage: passage generate [--no-symbols] [--clip] [--in-place | --force] pass-name [pass-length]\n")
os.Exit(1)
}
path := fs.Arg(0)
if err := checkSneakyPaths(path); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
path, err := sanitizePath(path)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
length := 25
if fs.NArg() == 2 {
if n, err := fmt.Sscanf(fs.Arg(1), "%d", &length); err != nil || n != 1 || length <= 0 {
fmt.Fprintf(os.Stderr, "Error: pass-length must be a positive number\n")
os.Exit(1)
}
// Prevent integer overflow and DoS
const maxLength = 10000
if length > maxLength {
fmt.Fprintf(os.Stderr, "Error: pass-length too large (max %d)\n", maxLength)
os.Exit(1)
}
}
storeDir := getStoreDir()
passfile := filepath.Join(storeDir, path+passageExt)
if err := os.MkdirAll(filepath.Dir(passfile), 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !*force && !*forceShort && !*inPlace && !*inPlaceShort {
if _, err := os.Stat(passfile); err == nil {
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", path)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
os.Exit(1)
}
}
}
// Generate password
var charset string
if *noSymbols || *noSymbolsShort {
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
} else {
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"
}
password := generatePassword(length, charset)
if *inPlace || *inPlaceShort {
// Read existing file and replace first line
if _, err := os.Stat(passfile); err != nil {
fmt.Fprintf(os.Stderr, "Error: file does not exist for in-place edit\n")
os.Exit(1)
}
decrypted, err := decryptFile(passfile, "", masterPassword.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
os.Exit(1)
}
data, err := io.ReadAll(decrypted)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
os.Exit(1)
}
lines := strings.Split(string(data), "\n")
if len(lines) > 0 {
lines[0] = password
} else {
lines = []string{password}
}
newContent := strings.Join(lines, "\n")
if err := checkPasswordLength(newContent); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
tmpFile, err := createSecureTempFile("passage-generate-", ".tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
os.Exit(1)
}
defer os.Remove(tmpFile)
if err := os.WriteFile(tmpFile, []byte(newContent), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
os.Exit(1)
}
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
os.Exit(1)
}
} else {
// Create new file
tmpFile, err := createSecureTempFile("passage-generate-", ".tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
os.Exit(1)
}
defer os.Remove(tmpFile)
if err := os.WriteFile(tmpFile, []byte(password), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
os.Exit(1)
}
if err := encryptFile(tmpFile, passfile, masterPassword.Bytes()); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
os.Exit(1)
}
}
verb := "Add"
if *inPlace || *inPlaceShort {
verb = "Replace"
}
gitAddFile(passfile, fmt.Sprintf("%s generated password for %s.", verb, path))
if *clip || *clipShort {
clipToClipboard(password, path)
} else {
fmt.Printf("The generated password for %s is:\n%s\n", path, password)
}
}
// cmdDelete removes a password
func cmdDelete(args []string) {
fs := flag.NewFlagSet("rm", flag.ExitOnError)
recursive := fs.Bool("recursive", false, "recursive delete")
recursiveShort := fs.Bool("r", false, "recursive delete (short)")
force := fs.Bool("force", false, "delete without prompting")
forceShort := fs.Bool("f", false, "delete without prompting (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() != 1 {
fmt.Fprintf(os.Stderr, "Usage: passage rm [--recursive] [--force] pass-name\n")
os.Exit(1)
}
path := fs.Arg(0)
if err := checkSneakyPaths(path); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
var err error
path, err = sanitizePath(path)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
storeDir := getStoreDir()
passfile := filepath.Join(storeDir, path+passageExt)
passdir := filepath.Join(storeDir, path)
var target string
if info, err2 := os.Stat(passfile); err2 == nil && !info.IsDir() {
target = passfile
} else if info, err2 := os.Stat(passdir); err2 == nil && info.IsDir() {
target = passdir
} else {
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", path)
os.Exit(1)
}
if !*force && !*forceShort {
fmt.Printf("Are you sure you would like to delete %s? [y/N] ", path)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
os.Exit(1)
}
}
if *recursive || *recursiveShort {
err = os.RemoveAll(target)
} else {
err = os.Remove(target)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to delete: %v\n", err)
os.Exit(1)
}
gitRemoveFile(target, fmt.Sprintf("Remove %s from store.", path))
fmt.Printf("Removed %s\n", path)
}
// cmdMove moves/renames a password
func cmdMove(args []string) {
fs := flag.NewFlagSet("mv", flag.ExitOnError)
force := fs.Bool("force", false, "overwrite without prompting")
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() != 2 {
fmt.Fprintf(os.Stderr, "Usage: passage mv [--force] old-path new-path\n")
os.Exit(1)
}
oldPath := fs.Arg(0)
newPath := fs.Arg(1)
if err := checkSneakyPaths(oldPath); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
if err := checkSneakyPaths(newPath); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
oldPath, err := sanitizePath(oldPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
newPath, err = sanitizePath(newPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
storeDir := getStoreDir()
oldFile := filepath.Join(storeDir, oldPath+passageExt)
newFile := filepath.Join(storeDir, newPath+passageExt)
if _, err := os.Stat(oldFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", oldPath)
os.Exit(1)
}
if _, err := os.Stat(newFile); err == nil && !*force && !*forceShort {
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", newPath)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
os.Exit(1)
}
}
if err := os.MkdirAll(filepath.Dir(newFile), 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
// Get master password for re-encryption
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Decrypt old file
decrypted, err := decryptFile(oldFile, "", masterPassword.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
os.Exit(1)
}
data, err := io.ReadAll(decrypted)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
os.Exit(1)
}
// Validate data length
if err := checkPasswordLength(string(data)); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Encrypt with new recipients
tmpFile, err := createSecureTempFile("passage-mv-", ".tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
os.Exit(1)
}
defer os.Remove(tmpFile)
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
os.Exit(1)
}
if err := encryptFile(tmpFile, newFile, masterPassword.Bytes()); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
os.Exit(1)
}
// Remove old file
if err := os.Remove(oldFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to remove old file: %v\n", err)
os.Exit(1)
}
gitMoveFile(oldFile, newFile, fmt.Sprintf("Rename %s to %s.", oldPath, newPath))
fmt.Printf("Renamed %s to %s\n", oldPath, newPath)
}
// cmdCopy copies a password
func cmdCopy(args []string) {
fs := flag.NewFlagSet("cp", flag.ExitOnError)
force := fs.Bool("force", false, "overwrite without prompting")
forceShort := fs.Bool("f", false, "overwrite without prompting (short)")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if fs.NArg() != 2 {
fmt.Fprintf(os.Stderr, "Usage: passage cp [--force] old-path new-path\n")
os.Exit(1)
}
oldPath := fs.Arg(0)
newPath := fs.Arg(1)
if err := checkSneakyPaths(oldPath); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
if err := checkSneakyPaths(newPath); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
oldPath, err := sanitizePath(oldPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
newPath, err = sanitizePath(newPath)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
storeDir := getStoreDir()
oldFile := filepath.Join(storeDir, oldPath+passageExt)
newFile := filepath.Join(storeDir, newPath+passageExt)
if _, err := os.Stat(oldFile); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s is not in the password store\n", oldPath)
os.Exit(1)
}
if _, err := os.Stat(newFile); err == nil && !*force && !*forceShort {
fmt.Printf("An entry already exists for %s. Overwrite it? [y/N] ", newPath)
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
os.Exit(1)
}
}
if err := os.MkdirAll(filepath.Dir(newFile), 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create directory: %v\n", err)
os.Exit(1)
}
// Get master password for re-encryption
masterPassword, err := getMasterPasswordForOperation(storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Decrypt old file
decrypted, err := decryptFile(oldFile, "", masterPassword.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to decrypt: %v\n", err)
os.Exit(1)
}
data, err := io.ReadAll(decrypted)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read decrypted data: %v\n", err)
os.Exit(1)
}
// Validate data length
if err := checkPasswordLength(string(data)); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
// Encrypt with new recipients
tmpFile, err := createSecureTempFile("passage-mv-", ".tmp")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create temporary file: %v\n", err)
os.Exit(1)
}
defer os.Remove(tmpFile)
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write temporary file: %v\n", err)
os.Exit(1)
}
if err := encryptFile(tmpFile, newFile, masterPassword.Bytes()); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to encrypt: %v\n", err)
os.Exit(1)
}
gitAddFile(newFile, fmt.Sprintf("Copy %s to %s.", oldPath, newPath))
fmt.Printf("Copied %s to %s\n", oldPath, newPath)
}
// cmdGit runs git commands
func cmdGit(args []string) {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: passage git git-command-args...\n")
os.Exit(1)
}
storeDir := getStoreDir()
gitDir := filepath.Join(storeDir, ".git")
if args[0] == "init" {
cmd := exec.Command("git", append([]string{"-C", storeDir, "init"}, args[1:]...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: git init failed: %v\n", err)
os.Exit(1)
}
return
}
if _, err := os.Stat(gitDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: the password store is not a git repository. Try \"passage git init\"\n")
os.Exit(1)
}
cmd := exec.Command("git", append([]string{"-C", storeDir}, args...)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: git command failed: %v\n", err)
os.Exit(1)
}
}
// Helper functions
func readPassword() ([]byte, error) {
if term.IsTerminal(int(os.Stdin.Fd())) {
return term.ReadPassword(int(os.Stdin.Fd()))
}
// Fallback for non-terminal input
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
return []byte(strings.TrimSpace(line)), nil
}
func generatePassword(length int, charset string) string {
password := make([]byte, length)
max := big.NewInt(int64(len(charset)))
for i := range password {
n, err := rand.Int(rand.Reader, max)
if err != nil {
// crypto/rand failed? That's... concerning. Fallback to deterministic (not ideal)
password[i] = charset[i%len(charset)]
} else {
password[i] = charset[n.Int64()]
}
}
return string(password)
}
// clipToClipboard is now in clipboard.go
func gitAddFile(path, message string) {
storeDir := getStoreDir()
gitDir := filepath.Join(storeDir, ".git")
if _, err := os.Stat(gitDir); err != nil {
return // Not a git repo
}
relPath, err := filepath.Rel(storeDir, path)
if err != nil {
return
}
// Clean up the commit message - don't let users inject commands
message = sanitizeGitMessage(message)
cmd := exec.Command("git", "-C", storeDir, "add", relPath)
if err := cmd.Run(); err != nil {
// Git failed? Oh well, not critical - passwords are still saved
return
}
cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message)
cmd.Run() // Git is optional, so failures are fine
}
func gitRemoveFile(path, message string) {
storeDir := getStoreDir()
gitDir := filepath.Join(storeDir, ".git")
if _, err := os.Stat(gitDir); err != nil {
return
}
relPath, err := filepath.Rel(storeDir, path)
if err != nil {
return
}
// Sanitize commit message
message = sanitizeGitMessage(message)
cmd := exec.Command("git", "-C", storeDir, "rm", "-r", relPath)
if err := cmd.Run(); err != nil {
return
}
cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message)
cmd.Run()
}
func gitMoveFile(oldPath, newPath, message string) {
storeDir := getStoreDir()
gitDir := filepath.Join(storeDir, ".git")
if _, err := os.Stat(gitDir); err != nil {
return
}
oldRel, err := filepath.Rel(storeDir, oldPath)
if err != nil {
return
}
newRel, err := filepath.Rel(storeDir, newPath)
if err != nil {
return
}
// Sanitize commit message
message = sanitizeGitMessage(message)
cmd := exec.Command("git", "-C", storeDir, "mv", oldRel, newRel)
if err := cmd.Run(); err != nil {
return
}
cmd = exec.Command("git", "-C", storeDir, "commit", "-m", message)
cmd.Run()
}
// sanitizeGitMessage removes potentially dangerous characters from git commit messages
// Because git commit messages shouldn't be attack vectors
func sanitizeGitMessage(message string) string {
// Strip out newlines and null bytes - they could be used for injection
message = strings.ReplaceAll(message, "\n", " ")
message = strings.ReplaceAll(message, "\r", " ")
message = strings.ReplaceAll(message, "\x00", "")
// Reasonable length limit - if you need more, write a blog post instead
const maxMessageLength = 1000
if len(message) > maxMessageLength {
message = message[:maxMessageLength]
}
return message
}