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:
2026-07-16 22:23:16 +10:00
parent bacfc8925a
commit 408eacf7e2
26 changed files with 2117 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
#
# Package the (already built) passv binary into an RPM with nfpm.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}"
DIST="dist"
if [ ! -f "${DIST}/passv" ]; then
echo "ERROR: ${DIST}/passv not found; run 'make build' first" >&2
exit 1
fi
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/passv"
export PACKAGE_LICENSE="MIT"
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm.yaml"
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
echo "Built:"
ls -1 "${DIST}"/*.rpm
Executable
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# End-to-end test for passv against a real Vault dev server running the
# vault-plugin-secrets-gpg engine. Exercises the full pass workflow: init,
# insert, show, generate, edit, mv/cp (with cross-gpg-id re-encryption), find,
# grep, rm — proving the store round-trips through Vault.
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PLUGIN_BIN="${PLUGIN_BIN:-${ROOT_DIR}/../vault-plugin-secrets-gpg/dist/vault-plugin-secrets-gpg}"
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m==> %s\033[0m\n' "$*"; }
fail() { red "FAIL: $*"; exit 1; }
command -v vault >/dev/null || fail "vault binary not found"
[ -x "${PLUGIN_BIN}" ] || fail "plugin binary not found at ${PLUGIN_BIN} (build it: make -C ../vault-plugin-secrets-gpg build)"
WORK="$(mktemp -d)"
PLUGIN_DIR="${WORK}/plugins"
export PASSWORD_STORE_DIR="${WORK}/store"
mkdir -p "${PLUGIN_DIR}" "${PASSWORD_STORE_DIR}"
cp "${PLUGIN_BIN}" "${PLUGIN_DIR}/vault-plugin-secrets-gpg"
export VAULT_ADDR="http://127.0.0.1:8281"
export VAULT_TOKEN="root"
cleanup() {
[ -n "${VAULT_PID:-}" ] && kill "${VAULT_PID}" 2>/dev/null || true
rm -rf "${WORK}"
}
trap cleanup EXIT
blue "Starting Vault dev server"
vault server -dev -dev-root-token-id=root -dev-listen-address=127.0.0.1:8281 \
-dev-plugin-dir="${PLUGIN_DIR}" >"${WORK}/vault.log" 2>&1 &
VAULT_PID=$!
for i in $(seq 1 30); do
vault status >/dev/null 2>&1 && break
sleep 0.5
[ "$i" = 30 ] && fail "vault did not become ready"
done
blue "Enabling gpg secrets engine + creating keys"
vault secrets enable -path=gpg vault-plugin-secrets-gpg >/dev/null
vault write -field=public_key gpg/keys/personal algorithm=rsa-2048 identity='Personal <me@unkin.net>' >/dev/null
vault write -field=public_key gpg/keys/work algorithm=ed25519 identity='Work <work@unkin.net>' >/dev/null
green "engine ready with keys gpg/personal, gpg/work"
PASSV="${ROOT_DIR}/dist/passv"
blue "Building passv"
make -C "${ROOT_DIR}" build >/dev/null
p() { "${PASSV}" "$@"; }
blue "init + insert + show"
p init gpg/personal
printf 's3cr3t\n' | p insert --echo email/gmail
printf 'hunter2\n' | p insert --echo email/work
[ "$(p show email/gmail)" = "s3cr3t" ] || fail "show email/gmail mismatch"
[ "$(p show email/work)" = "hunter2" ] || fail "show email/work mismatch"
# Files on disk must be real binary OpenPGP (magic byte 0x85/0x84... gpg packet).
head -c1 "${PASSWORD_STORE_DIR}/email/gmail.gpg" | od -An -tx1 | grep -qiE '8[45c]|c[1-9a-f]' \
|| fail "email/gmail.gpg is not a binary OpenPGP message"
green "insert/show round-trip via Vault OK"
blue "generate (+ in-place)"
gen="$(p generate -n banking/pin 12 | tail -1)"
[ "${#gen}" = 12 ] || fail "generated length wrong: '${gen}'"
[ "$(p show banking/pin)" = "${gen}" ] || fail "generated password not stored"
green "generate OK (${gen})"
blue "grep + find"
p grep s3cr3t | grep -q 'email/gmail' || fail "grep did not find secret"
p find gmail | grep -q 'email/gmail' || fail "find did not match"
green "grep/find OK"
blue "mv within same key (verbatim ciphertext move)"
p mv email/gmail email/personal-gmail
[ "$(p show email/personal-gmail)" = "s3cr3t" ] || fail "mv lost content"
p show email/gmail 2>/dev/null && fail "source still present after mv"
green "mv OK"
blue "cp across keys (re-encrypt personal -> work)"
p init -p projects gpg/work
p cp email/work projects/shared-login
[ "$(p show projects/shared-login)" = "hunter2" ] || fail "cross-key cp lost content"
# The copy must now be decryptable by the WORK key specifically.
ct="$(base64 -w0 "${PASSWORD_STORE_DIR}/projects/shared-login.gpg")"
[ "$(vault write -field=plaintext gpg/decrypt/work ciphertext="${ct}" | base64 -d)" = "hunter2" ] \
|| fail "copy was not re-encrypted to the work key"
green "cross-key cp re-encrypted correctly"
blue "rm"
p rm -f banking/pin
p show banking/pin 2>/dev/null && fail "entry present after rm"
green "rm OK"
blue "tree listing"
p ls | grep -q 'projects' || fail "tree missing projects dir"
if command -v gpg >/dev/null; then
blue "dual-mode: one entry readable by BOTH local gpg and Vault"
export GNUPGHOME="${WORK}/gnupg"
mkdir -p "${GNUPGHOME}"; chmod 700 "${GNUPGHOME}"
gpg --batch --passphrase '' --quick-generate-key 'Local Test <local@unkin.net>' default default never >/dev/null 2>&1
LOCAL_FPR="$(gpg --list-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')"
# Import the Vault key's public half so gpg can encrypt to it too.
vault read -field=public_key gpg/keys/personal | gpg --batch --import >/dev/null 2>&1
VAULT_FPR="$(vault read -field=fingerprint gpg/keys/personal)"
mkdir -p "${PASSWORD_STORE_DIR}/shared"
printf '%s\n%s\n' "${LOCAL_FPR}" "${VAULT_FPR}" > "${PASSWORD_STORE_DIR}/shared/.gpg-id" # for plain pass/gpg
echo 'gpg/personal' > "${PASSWORD_STORE_DIR}/shared/.vault-id" # for passv
# Encrypt to BOTH recipients, the way `pass` would in a multi-id store.
printf 'dualsecret' | gpg --batch --yes --trust-model always \
-r "${LOCAL_FPR}" -r "${VAULT_FPR}" --encrypt \
--output "${PASSWORD_STORE_DIR}/shared/db.gpg"
[ "$(gpg --batch --decrypt "${PASSWORD_STORE_DIR}/shared/db.gpg" 2>/dev/null)" = "dualsecret" ] \
|| fail "local gpg could not decrypt the dual-recipient entry"
[ "$(p show shared/db)" = "dualsecret" ] \
|| fail "passv (Vault) could not decrypt the dual-recipient entry"
green "dual-mode OK: same file opened by local gpg AND Vault"
fi
green "ALL PASSV END-TO-END CHECKS PASSED"