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.
7.9 KiB
passv
A Vault-backed, drop-in replacement for pass
(the standard unix password-store). passv keeps the exact same on-disk layout
— a tree of <name>.gpg binary OpenPGP files under $PASSWORD_STORE_DIR — but
performs all encryption and decryption through a
vault-plugin-secrets-gpg
engine mount. The GPG private key never lives on the client: it stays sealed
inside Vault, and every show/edit/grep delegates decryption to the engine.
The only visible difference from pass is what a store is initialized against:
instead of a GPG key fingerprint, .gpg-id holds a Vault key reference
"<mount>/<key>" (e.g. gpg/app).
passv show email/gmail
│ read email/gmail.gpg (binary OpenPGP)
▼
Vault gpg/decrypt/app ──▶ plaintext (private key never leaves the barrier)
Setup
export VAULT_ADDR=https://vault.example # standard Vault env
export VAULT_TOKEN=... # or ~/.vault-token / `vault login`
export PASSWORD_STORE_DIR=~/.password-store # optional (this is the default)
# create a key in the engine once (admin side)
vault write gpg/keys/app algorithm=rsa-4096 identity="Me <me@example>"
# point a store at it — like `pass init <fingerprint>`, but with a Vault ref
passv init gpg/app
Alias it over pass if you like: alias pass=passv.
Commands
Everything mirrors pass:
passv # list the whole store as a tree
passv ls work # list a subfolder
passv show email/gmail # decrypt to stdout
passv show -c email/gmail # copy first line to the clipboard (auto-clears)
passv insert email/gmail # prompt (twice, no echo) and store
passv insert -m note # multiline entry (Ctrl-D to finish)
passv generate -n wifi 32 # 32-char symbol-free password, stored + printed
passv edit email/gmail # decrypt into $EDITOR, re-encrypt on save
passv mv a b # move (re-encrypts if b is under a different .gpg-id)
passv cp a b # copy (likewise)
passv rm -r work # remove an entry or subtree
passv find gmail # list entries matching a term
passv grep -i 'aws_.*key' # regexp-search decrypted content
passv git log # run git in the store
Per-subtree keys work exactly like pass — passv init -p work gpg/team gives
everything under work/ a different Vault key; mv/cp across that boundary
transparently decrypt with the old key and re-encrypt with the new one.
Environment
| Variable | Purpose |
|---|---|
PASSWORD_STORE_DIR |
store location (default ~/.password-store) |
VAULT_ADDR, VAULT_TOKEN |
standard Vault connection (falls back to ~/.vault-token) |
PASSWORD_STORE_CLIP_TIME |
seconds before the clipboard is cleared (default 45) |
PASSWORD_STORE_GENERATED_LENGTH |
default generate length (default 25) |
EDITOR |
editor for passv edit (default vi) |
Clipboard support uses wl-copy, xclip, xsel or pbcopy if present.
Interoperability
Because entries are standard OpenPGP messages encrypted to the engine key's
public key, anyone holding that public key (e.g. gpg --import of
vault read -field=public_key gpg/keys/app) can add entries with plain gpg;
only decryption requires Vault. Conversely, a store created by real pass
against the engine's public key is readable by passv unchanged.
Recipes
1. Create a brand-new store
# (admin, once) mint a key in the engine
vault write gpg/keys/personal algorithm=rsa-4096 identity="Me <me@unkin.net>"
# point a fresh store at it — writes ~/.password-store/.gpg-id = "gpg/personal"
export PASSWORD_STORE_DIR=~/.password-store
passv init gpg/personal
# optional: version the store with git (passv auto-commits every change)
passv git init
passv git remote add origin git@git.unkin.net:me/passwords.git
# start adding secrets
passv insert email/gmail
passv generate -n wifi/home 32
passv edit notes/recovery-codes
passv # browse the tree
Different subtrees can use different Vault keys — handy for shared vs personal secrets:
passv init -p work gpg/team # everything under work/ uses gpg/team
passv generate work/ci/deploy-token 40
2. Migrate a traditional GPG pass store → passv
You have an existing ~/.password-store encrypted to a local GPG key. Two
paths, depending on whether you want to keep that key or rotate to a Vault-native
one.
Option A — import your GPG key into Vault (no re-encryption, instant).
Every existing .gpg file already decrypts once Vault holds the matching private
key; you only repoint .gpg-id.
# export the secret key that the store is encrypted to
gpg --export-secret-keys --armor you@example > /tmp/key.asc
# import it into the engine (stays sealed; not exportable)
vault write gpg/keys/mine/import private_key=@/tmp/key.asc exportable=false
shred -u /tmp/key.asc
# repoint the store: replace the fingerprint in .gpg-id with the Vault ref.
# (keep the original around until you've verified a few reads)
printf 'gpg/mine\n' > ~/.password-store/.gpg-id
passv show email/gmail # decrypts via Vault, unchanged ciphertext
Option B — rotate onto a fresh Vault key (re-encrypts everything).
Decrypt each entry with local gpg, re-encrypt to a new Vault key in a new
store, then swap it in.
OLD=~/.password-store
export PASSWORD_STORE_DIR=~/.password-store-vault
vault write gpg/keys/personal algorithm=rsa-4096 identity="Me <me@unkin.net>"
passv init gpg/personal
find "$OLD" -name '*.gpg' | while read -r f; do
name="${f#$OLD/}"; name="${name%.gpg}"
gpg --quiet --decrypt "$f" | passv insert --multiline "$name"
done
# verify, then replace the old store
passv show email/gmail
mv "$OLD" "$OLD.bak" && mv ~/.password-store-vault ~/.password-store
3. Serve one store to both gpg and Vault at the same time
Encrypt each entry to two recipients — your local GPG key and the Vault
key's public key — so it opens offline with plain pass/gpg and through
Vault with passv. This is a real OpenPGP multi-recipient message; either
private key decrypts it.
The two tools read the recipient list from different files:
| File | Read by | Contents |
|---|---|---|
.gpg-id |
pass, gpg |
GPG fingerprints (one per line) |
.vault-id |
passv |
the Vault ref <mount>/<key> |
Setup:
# 1. import the Vault key's PUBLIC half into your local gpg keyring
vault read -field=public_key gpg/keys/app | gpg --import
VAULT_FPR=$(vault read -field=fingerprint gpg/keys/app)
LOCAL_FPR=$(gpg --list-keys --with-colons you@example | awk -F: '/^fpr/{print $10; exit}')
# 2. list BOTH as recipients for pass, and the Vault ref for passv
cd ~/.password-store
printf '%s\n%s\n' "$LOCAL_FPR" "$VAULT_FPR" > .gpg-id # pass encrypts to both
echo 'gpg/app' > .vault-id # passv decrypts via Vault
Now write with pass (it encrypts to every id in .gpg-id), and read
with either:
pass insert email/gmail # multi-recipient: local key + Vault key
pass show email/gmail # offline, via your local gpg private key
passv show email/gmail # via Vault, private key never leaves the barrier
Note:
passv insert/generateencrypt to the Vault key only (the engine is own-key). In a dual-mode store, add/edit entries withpassso both recipients are included; usepassvfor Vault-side reads (e.g. from CI or a host without the private key).
Build
make build # -> dist/passv
make test # go test -race (unit tests; no Vault needed)
make e2e # full workflow against a Vault dev server + the gpg engine
make rpm # passv RPM via nfpm
CI (Woodpecker) runs pre-commit/build/lint/test on PRs and builds+publishes the
RPM to artifactapi rpm-internal on a v* tag.