06da0a668a
Provide a password-store CLI that keeps pass's on-disk layout (<name>.gpg binary OpenPGP under $PASSWORD_STORE_DIR) but routes all encryption and decryption through a vault-plugin-secrets-gpg engine mount, so the GPG private key never lives on the client. - Implement init/show/ls/insert/generate/edit/rm/mv/cp/find/grep/git with pass-compatible flags and implicit `passv <name>`=show dispatch. - Resolve the recipient from a Vault ref: .gpg-id holds <mount>/<key> for passv-native stores, or a sibling .vault-id lets a store keep real GPG fingerprints in .gpg-id for dual gpg+Vault use. - mv/cp re-encrypt across differing recipients; same-recipient moves copy the ciphertext verbatim. - Ship the sibling build/packaging/CI: nfpm RPM to /usr/bin/passv (artifactapi rpm-internal on v* tag), Woodpecker PR/release pipelines, unit tests plus a real-Vault e2e that also proves dual gpg+Vault decryption. - README covers store creation, migrating a GPG pass store, and dual-mode.
197 lines
4.4 KiB
Go
197 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// cmdInsert adds a new entry, prompting for the secret (or reading it from stdin
|
|
// with -e/--echo, or reading multiline content with -m/--multiline).
|
|
func cmdInsert(args []string) error {
|
|
echo, args := popBool(args, "-e", "--echo")
|
|
multiline, args := popBool(args, "-m", "--multiline")
|
|
force, args := popBool(args, "-f", "--force")
|
|
if len(args) != 1 {
|
|
return &usageError{"Usage: passv insert [-e|-m] [-f] pass-name"}
|
|
}
|
|
name := strings.Trim(args[0], "/")
|
|
|
|
s, err := openStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.hasEntry(name) && !force {
|
|
if !confirm(fmt.Sprintf("An entry already exists for %s. Overwrite it?", name)) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var content []byte
|
|
if multiline {
|
|
fmt.Fprintf(os.Stderr, "Enter contents of %s and press Ctrl+D when finished:\n\n", name)
|
|
if content, err = io.ReadAll(os.Stdin); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if content, err = readSecret(name, echo); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
v, err := newVault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := encryptEntry(s, v, name, content); err != nil {
|
|
return err
|
|
}
|
|
s.commit("Add given password for " + name + " to store.")
|
|
return nil
|
|
}
|
|
|
|
// cmdGenerate creates a random password, stores it, and prints or copies it.
|
|
func cmdGenerate(args []string) error {
|
|
noSymbols, args := popBool(args, "-n", "--no-symbols")
|
|
clip, args := popBool(args, "-c", "--clip")
|
|
inPlace, args := popBool(args, "-i", "--in-place")
|
|
force, args := popBool(args, "-f", "--force")
|
|
if len(args) < 1 || len(args) > 2 {
|
|
return &usageError{"Usage: passv generate [-n] [-c] [-i|-f] pass-name [length]"}
|
|
}
|
|
name := strings.Trim(args[0], "/")
|
|
length := defaultLength()
|
|
if len(args) == 2 {
|
|
length = atoiDefault(args[1], length)
|
|
}
|
|
|
|
s, err := openStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := newVault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pw, err := genPassword(length, !noSymbols)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var content []byte
|
|
switch {
|
|
case inPlace && s.hasEntry(name):
|
|
cur, err := decryptEntry(s, v, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lines := strings.Split(string(cur), "\n")
|
|
lines[0] = pw
|
|
content = []byte(strings.Join(lines, "\n"))
|
|
default:
|
|
if s.hasEntry(name) && !force {
|
|
if !confirm(fmt.Sprintf("An entry already exists for %s. Overwrite it?", name)) {
|
|
return nil
|
|
}
|
|
}
|
|
content = []byte(pw)
|
|
}
|
|
|
|
if err := encryptEntry(s, v, name, content); err != nil {
|
|
return err
|
|
}
|
|
s.commit("Add generated password for " + name + " to store.")
|
|
|
|
if clip {
|
|
if err := copyClip(pw); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Copied %s to clipboard. Will clear in %d seconds.\n", name, clipTime())
|
|
return nil
|
|
}
|
|
fmt.Printf("The generated password for %s is:\n%s\n", name, pw)
|
|
return nil
|
|
}
|
|
|
|
// cmdEdit decrypts an entry into a temp file, opens $EDITOR, and re-encrypts.
|
|
func cmdEdit(args []string) error {
|
|
if len(args) != 1 {
|
|
return &usageError{"Usage: passv edit pass-name"}
|
|
}
|
|
name := strings.Trim(args[0], "/")
|
|
|
|
s, err := openStore()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := newVault()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var current []byte
|
|
if s.hasEntry(name) {
|
|
if current, err = decryptEntry(s, v, name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
tmp, err := secureTempFile(filepath.Base(name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp)
|
|
if err := os.WriteFile(tmp, current, 0o600); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := runEditor(tmp); err != nil {
|
|
return err
|
|
}
|
|
|
|
edited, err := os.ReadFile(tmp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if bytes.Equal(edited, current) {
|
|
fmt.Fprintf(os.Stderr, "Password unchanged for %s.\n", name)
|
|
return nil
|
|
}
|
|
if err := encryptEntry(s, v, name, edited); err != nil {
|
|
return err
|
|
}
|
|
s.commit("Edit password for " + name + ".")
|
|
return nil
|
|
}
|
|
|
|
// secureTempFile prefers /dev/shm (tmpfs, never hits disk) for the cleartext
|
|
// editor buffer, falling back to the OS temp dir.
|
|
func secureTempFile(hint string) (string, error) {
|
|
dir := os.TempDir()
|
|
if fi, err := os.Stat("/dev/shm"); err == nil && fi.IsDir() {
|
|
dir = "/dev/shm"
|
|
}
|
|
f, err := os.CreateTemp(dir, "passv-"+hint+"-*.txt")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name := f.Name()
|
|
f.Close()
|
|
return name, nil
|
|
}
|
|
|
|
func runEditor(path string) error {
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vi"
|
|
}
|
|
cmd := exec.Command("sh", "-c", editor+" \""+path+"\"")
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
|
return cmd.Run()
|
|
}
|