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 }