Files
passv/store.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

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