Files
passv/cmd_write.go
T
unkinben 06da0a668a
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
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.
2026-07-16 22:25:31 +10:00

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()
}