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:
+163
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cmdRm removes an entry or (with -r) a directory.
|
||||
func cmdRm(args []string) error {
|
||||
recursive, args := popBool(args, "-r", "--recursive")
|
||||
force, args := popBool(args, "-f", "--force")
|
||||
if len(args) != 1 {
|
||||
return &usageError{"Usage: passv rm [-r] [-f] pass-name"}
|
||||
}
|
||||
name := strings.Trim(args[0], "/")
|
||||
|
||||
s, err := openStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case s.isDir(name):
|
||||
if !recursive {
|
||||
return errf("Error: %s is a directory (use -r to remove it).", name)
|
||||
}
|
||||
if !force && !confirm(fmt.Sprintf("Delete directory %s and everything in it?", name)) {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
case s.hasEntry(name):
|
||||
if !force && !confirm(fmt.Sprintf("Delete %s?", name)) {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(s.entryPath(name)); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errf("Error: %s is not in the password store.", name)
|
||||
}
|
||||
|
||||
s.commit("Remove " + name + " from store.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdMv(args []string) error { return relocate(args, false) }
|
||||
func cmdCp(args []string) error { return relocate(args, true) }
|
||||
|
||||
// relocate moves (keep=false) or copies (keep=true) an entry or directory.
|
||||
// When source and destination fall under different `.gpg-id`s, affected entries
|
||||
// are transparently decrypted and re-encrypted to the new key.
|
||||
func relocate(args []string, keep bool) error {
|
||||
force, args := popBool(args, "-f", "--force")
|
||||
if len(args) != 2 {
|
||||
verb := "mv"
|
||||
if keep {
|
||||
verb = "cp"
|
||||
}
|
||||
return &usageError{"Usage: passv " + verb + " [-f] old-path new-path"}
|
||||
}
|
||||
src := strings.Trim(args[0], "/")
|
||||
dst := strings.Trim(args[1], "/")
|
||||
|
||||
s, err := openStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := newVault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the destination is an existing directory, move into it under src's base.
|
||||
if s.isDir(dst) {
|
||||
dst = path.Join(dst, path.Base(src))
|
||||
}
|
||||
|
||||
switch {
|
||||
case s.hasEntry(src):
|
||||
if err := relocateEntry(s, v, src, dst, keep, force); err != nil {
|
||||
return err
|
||||
}
|
||||
case s.isDir(src):
|
||||
names, err := s.list(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range names {
|
||||
rel := strings.TrimPrefix(n, src+"/")
|
||||
if err := relocateEntry(s, v, n, path.Join(dst, rel), keep, force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !keep {
|
||||
_ = os.RemoveAll(filepath.Join(s.dir, src))
|
||||
}
|
||||
default:
|
||||
return errf("Error: %s is not in the password store.", src)
|
||||
}
|
||||
|
||||
action := "Rename"
|
||||
if keep {
|
||||
action = "Copy"
|
||||
}
|
||||
s.commit(fmt.Sprintf("%s %s to %s.", action, src, dst))
|
||||
return nil
|
||||
}
|
||||
|
||||
func relocateEntry(s *store, v *vaultClient, src, dst string, keep, force bool) error {
|
||||
if s.hasEntry(dst) && !force {
|
||||
if !confirm(fmt.Sprintf("%s already exists. Overwrite it?", dst)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
srcRef, err := s.vaultRef(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dstRef, err := s.vaultRef(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srcRef == dstRef {
|
||||
// Same recipient: move/copy the ciphertext verbatim, no crypto needed.
|
||||
ct, err := s.readCipher(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.writeCipher(dst, ct); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Different recipient: decrypt with the old key, re-encrypt with the new.
|
||||
plain, err := decryptEntry(s, v, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encryptEntry(s, v, dst, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !keep {
|
||||
return os.Remove(s.entryPath(src))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdGit runs git inside the store directory.
|
||||
func cmdGit(args []string) error {
|
||||
s, err := openStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.gitPassthrough(args)
|
||||
}
|
||||
Reference in New Issue
Block a user