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.
151 lines
4.4 KiB
Go
151 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// store is a password-store directory ($PASSWORD_STORE_DIR or ~/.password-store)
|
|
// whose entries are `<name>.gpg` binary OpenPGP files. It is layout-compatible
|
|
// with GnuPG `pass`; the only difference is that `.gpg-id` holds a Vault key
|
|
// reference ("<mount>/<key>") instead of a GPG fingerprint.
|
|
type store struct {
|
|
dir string
|
|
}
|
|
|
|
func openStore() (*store, error) {
|
|
dir := os.Getenv("PASSWORD_STORE_DIR")
|
|
if dir == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir = filepath.Join(home, ".password-store")
|
|
}
|
|
return &store{dir: dir}, nil
|
|
}
|
|
|
|
func (s *store) entryPath(name string) string {
|
|
return filepath.Join(s.dir, name+".gpg")
|
|
}
|
|
|
|
func (s *store) hasEntry(name string) bool {
|
|
fi, err := os.Stat(s.entryPath(name))
|
|
return err == nil && !fi.IsDir()
|
|
}
|
|
|
|
func (s *store) isDir(name string) bool {
|
|
fi, err := os.Stat(filepath.Join(s.dir, name))
|
|
return err == nil && fi.IsDir()
|
|
}
|
|
|
|
// vaultRef walks up from an entry's directory to the store root and returns the
|
|
// governing Vault key reference, mirroring pass's per-subtree recipient
|
|
// resolution. It prefers a `.vault-id` file — which lets a dual-mode store keep
|
|
// real GPG fingerprints in `.gpg-id` (for plain `pass`) and the Vault ref here —
|
|
// and otherwise falls back to a `.gpg-id` that itself holds the Vault ref (a
|
|
// passv-native store).
|
|
func (s *store) vaultRef(name string) (string, error) {
|
|
if ref, ok := s.lookupUp(name, ".vault-id"); ok {
|
|
return ref, nil
|
|
}
|
|
if ref, ok := s.lookupUp(name, ".gpg-id"); ok {
|
|
if _, _, err := splitRef(ref); err == nil {
|
|
return ref, nil
|
|
}
|
|
return "", fmt.Errorf(".gpg-id holds a GPG id, not a Vault ref; add a .vault-id containing <mount>/<key> to read this store with passv")
|
|
}
|
|
return "", fmt.Errorf("password store is not initialized (no .gpg-id / .vault-id); run `passv init <mount>/<key>`")
|
|
}
|
|
|
|
// lookupUp returns the first non-empty line of the nearest `file` at or above
|
|
// the entry's directory.
|
|
func (s *store) lookupUp(name, file string) (string, bool) {
|
|
dir := filepath.Dir(s.entryPath(name))
|
|
for {
|
|
if b, err := os.ReadFile(filepath.Join(dir, file)); err == nil {
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
return line, true
|
|
}
|
|
}
|
|
}
|
|
if dir == s.dir {
|
|
return "", false
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return "", false
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// readCipher returns the raw bytes of an entry's `.gpg` file.
|
|
func (s *store) readCipher(name string) ([]byte, error) {
|
|
return os.ReadFile(s.entryPath(name))
|
|
}
|
|
|
|
// writeCipher writes an entry, creating parent directories as needed.
|
|
func (s *store) writeCipher(name string, data []byte) error {
|
|
p := s.entryPath(name)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(p, data, 0o600)
|
|
}
|
|
|
|
// list returns the entry names (without the .gpg suffix) under sub, recursively.
|
|
func (s *store) list(sub string) ([]string, error) {
|
|
root := filepath.Join(s.dir, sub)
|
|
var names []string
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
base := d.Name()
|
|
if d.IsDir() {
|
|
if base == ".git" || (path != root && strings.HasPrefix(base, ".")) {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(base, ".gpg") {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(s.dir, path)
|
|
names = append(names, strings.TrimSuffix(rel, ".gpg"))
|
|
return nil
|
|
})
|
|
sort.Strings(names)
|
|
return names, err
|
|
}
|
|
|
|
// --- git integration (optional; the store may or may not be a git repo) ---
|
|
|
|
func (s *store) isGit() bool {
|
|
fi, err := os.Stat(filepath.Join(s.dir, ".git"))
|
|
return err == nil && (fi.IsDir() || fi.Mode().IsRegular())
|
|
}
|
|
|
|
// gitPassthrough runs `git -C <store> ...` with inherited stdio (for `passv git`).
|
|
func (s *store) gitPassthrough(args []string) error {
|
|
cmd := exec.Command("git", append([]string{"-C", s.dir}, args...)...)
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
// commit stages everything and commits, quietly, when the store is a git repo.
|
|
func (s *store) commit(msg string) {
|
|
if !s.isGit() {
|
|
return
|
|
}
|
|
_ = exec.Command("git", "-C", s.dir, "add", "-A").Run()
|
|
// Nothing staged (e.g. content unchanged) is not an error worth surfacing.
|
|
_ = exec.Command("git", "-C", s.dir, "commit", "-q", "-m", msg).Run()
|
|
}
|