Add passv: a Vault-backed drop-in for pass
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.
This commit is contained in:
+196
@@ -0,0 +1,196 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user