Add passv: a Vault-backed drop-in for pass
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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 06da0a668a
26 changed files with 2117 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
/dist/
/passv
*.out
*.test
.env
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-mod-tidy
+14
View File
@@ -0,0 +1,14 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
+14
View File
@@ -0,0 +1,14 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
+47
View File
@@ -0,0 +1,47 @@
when:
- event: tag
steps:
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: upload
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then echo "$$FILE exists; skipping"; continue; fi
echo "Uploading $$FILE (probe $$code)"
curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" -H "Content-Type: application/x-rpm" --data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 128Mi, cpu: 100m}
limits: {memory: 512Mi, cpu: 500m}
+25
View File
@@ -0,0 +1,25 @@
when:
- event: pull_request
steps:
- name: lint
image: golang:1.25
commands:
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: test
image: golang:1.25
commands:
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
+65
View File
@@ -0,0 +1,65 @@
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go e2e
BINARY := passv
PKG := .
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
PLUGIN_DIR ?= ./dist
GO_VERSION_REQUIRED := 1.25
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
check-go:
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
fi
build: check-go tidy
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG)
install: build
install -Dm0755 $(PLUGIN_DIR)/$(BINARY) $(DESTDIR)/usr/bin/$(BINARY)
test: check-go
go test -race -count=1 ./...
lint: check-go
go vet ./...
fmt: check-go
gofmt -w .
tidy:
go mod tidy
clean:
rm -rf $(PLUGIN_DIR)
rpm: build rpm-package
rpm-package:
./scripts/build-rpm.sh $(VERSION)
# End-to-end test drives the real passv binary against a Vault dev server
# running the vault-plugin-secrets-gpg engine.
e2e:
./scripts/e2e.sh
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+210 -1
View File
@@ -1,3 +1,212 @@
# passv # passv
Vault-backed drop-in replacement for pass (password-store); routes GPG crypto through vault-plugin-secrets-gpg A Vault-backed, drop-in replacement for [`pass`](https://www.passwordstore.org/)
(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`](https://git.unkin.net/unkin/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
```sh
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`:
```sh
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
```sh
# (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:
```sh
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`.
```sh
# 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.
```sh
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:
```sh
# 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**:
```sh
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`/`generate` encrypt to the Vault key only (the engine is
> own-key). In a dual-mode store, add/edit entries with `pass` so both
> recipients are included; use `passv` for Vault-side reads (e.g. from CI or a
> host without the private key).
## Build
```sh
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.
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"strconv"
"strings"
)
// popBool removes every occurrence of the given flag spellings from args and
// reports whether any were present.
func popBool(args []string, names ...string) (bool, []string) {
var rest []string
found := false
for _, a := range args {
if contains(names, a) {
found = true
continue
}
rest = append(rest, a)
}
return found, rest
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
// parseClip pulls pass-style clipboard flags out of args: -c / --clip copy the
// first line; --clip=N or -cN copy line N (1-based).
func parseClip(args []string) (clip bool, line int, rest []string) {
line = 1
for _, a := range args {
switch {
case a == "-c" || a == "--clip":
clip = true
case strings.HasPrefix(a, "--clip="):
clip = true
if n, err := strconv.Atoi(a[len("--clip="):]); err == nil && n > 0 {
line = n
}
case strings.HasPrefix(a, "-c") && len(a) > 2:
clip = true
if n, err := strconv.Atoi(a[2:]); err == nil && n > 0 {
line = n
}
default:
rest = append(rest, a)
}
}
return clip, line, rest
}
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(s); err == nil {
return n
}
return def
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)
// clipTool is a detected clipboard backend and the command that writes stdin to
// the clipboard (the same command with empty stdin clears it).
type clipTool struct{ copy []string }
func detectClipTool() *clipTool {
candidates := [][]string{
{"wl-copy"},
{"xclip", "-selection", "clipboard"},
{"xsel", "-b", "-i"},
{"pbcopy"},
}
for _, c := range candidates {
if _, err := exec.LookPath(c[0]); err == nil {
return &clipTool{copy: c}
}
}
return nil
}
func clipTime() int {
return atoiDefault(os.Getenv("PASSWORD_STORE_CLIP_TIME"), 45)
}
// copyClip places text on the clipboard and schedules a detached job to clear it
// after PASSWORD_STORE_CLIP_TIME seconds.
func copyClip(text string) error {
tool := detectClipTool()
if tool == nil {
return errf("no clipboard tool found (install wl-clipboard, xclip, or xsel)")
}
c := exec.Command(tool.copy[0], tool.copy[1:]...)
c.Stdin = strings.NewReader(text)
if err := c.Run(); err != nil {
return fmt.Errorf("copying to clipboard: %w", err)
}
// Detached clear: new process group so it outlives this CLI invocation.
clearSh := fmt.Sprintf("sleep %d; printf '' | %s", clipTime(), strings.Join(tool.copy, " "))
clear := exec.Command("sh", "-c", clearSh)
clear.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return clear.Start()
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"os"
"path/filepath"
"strings"
)
// cmdInit points a store (or a subfolder of it) at a Vault gpg key. In place of
// pass's GPG fingerprint, the argument is a "<mount>/<key>" reference into the
// vault-plugin-secrets-gpg engine.
func cmdInit(args []string) error {
var subpath string
var refs []string
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "-p" || a == "--path":
i++
if i >= len(args) {
return &usageError{"Usage: passv init [-p subfolder] <mount>/<key>"}
}
subpath = args[i]
case strings.HasPrefix(a, "--path="):
subpath = a[len("--path="):]
default:
refs = append(refs, a)
}
}
if len(refs) == 0 {
return &usageError{"Usage: passv init [-p subfolder] <mount>/<key>"}
}
if len(refs) > 1 {
return errf("this Vault-backed store encrypts to a single key; got %d references", len(refs))
}
ref := refs[0]
if _, _, err := splitRef(ref); err != nil {
return err
}
// Verify the key is reachable before committing the store to it.
v, err := newVault()
if err != nil {
return err
}
if _, err := v.publicKey(ref); err != nil {
return err
}
s, err := openStore()
if err != nil {
return err
}
dir := filepath.Join(s.dir, subpath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, ".gpg-id"), []byte(ref+"\n"), 0o600); err != nil {
return err
}
where := "Password store"
if subpath != "" {
where = "Password store " + subpath
}
os.Stdout.WriteString(where + " initialized for " + ref + "\n")
s.commit("Initialize store for " + ref + subpathSuffix(subpath))
return nil
}
func subpathSuffix(sub string) string {
if sub == "" {
return ""
}
return " (" + sub + ")"
}
+163
View File
@@ -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)
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"fmt"
"os"
"regexp"
"strings"
)
// cmdShow decrypts an entry, or lists a directory (pass's implicit behaviour:
// no name, or a name that is a directory, lists; otherwise show).
func cmdShow(args []string) error {
clip, line, rest := parseClip(args)
name := ""
if len(rest) > 0 {
name = strings.Trim(rest[0], "/")
}
s, err := openStore()
if err != nil {
return err
}
if name == "" || s.isDir(name) {
return printTree(s, name)
}
if !s.hasEntry(name) {
return errf("Error: %s is not in the password store.", name)
}
v, err := newVault()
if err != nil {
return err
}
plain, err := decryptEntry(s, v, name)
if err != nil {
return err
}
if !clip {
os.Stdout.Write(plain)
if len(plain) == 0 || plain[len(plain)-1] != '\n' {
fmt.Println()
}
return nil
}
lines := strings.Split(string(plain), "\n")
if line-1 >= len(lines) {
return errf("There is no password on line %d of %s.", line, name)
}
if err := copyClip(lines[line-1]); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Copied %s to clipboard. Will clear in %d seconds.\n", name, clipTime())
return nil
}
// cmdFind lists entries whose path contains any of the given terms.
func cmdFind(args []string) error {
if len(args) == 0 {
return &usageError{"Usage: passv find term..."}
}
s, err := openStore()
if err != nil {
return err
}
names, err := s.list("")
if err != nil {
return err
}
fmt.Println("Search Terms: " + strings.Join(args, ", "))
for _, n := range names {
for _, t := range args {
if strings.Contains(n, t) {
fmt.Println(n)
break
}
}
}
return nil
}
// cmdGrep decrypts every entry and prints lines matching the pattern.
func cmdGrep(args []string) error {
ignoreCase, args := popBool(args, "-i", "--ignore-case")
if len(args) != 1 {
return &usageError{"Usage: passv grep [-i] search-regexp"}
}
pattern := args[0]
if ignoreCase {
pattern = "(?i)" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("invalid pattern: %w", err)
}
s, err := openStore()
if err != nil {
return err
}
v, err := newVault()
if err != nil {
return err
}
names, err := s.list("")
if err != nil {
return err
}
for _, n := range names {
plain, err := decryptEntry(s, v, n)
if err != nil {
fmt.Fprintf(os.Stderr, "passv: skipping %s: %v\n", n, err)
continue
}
var hits []string
for _, ln := range strings.Split(string(plain), "\n") {
if re.MatchString(ln) {
hits = append(hits, ln)
}
}
if len(hits) > 0 {
fmt.Printf("%s:\n", n)
for _, h := range hits {
fmt.Println(h)
}
}
}
return nil
}
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// cmdInsert adds a new entry, prompting for the secret (or reading it from stdin
// with -e/--echo, or reading multiline content with -m/--multiline).
func cmdInsert(args []string) error {
echo, args := popBool(args, "-e", "--echo")
multiline, args := popBool(args, "-m", "--multiline")
force, args := popBool(args, "-f", "--force")
if len(args) != 1 {
return &usageError{"Usage: passv insert [-e|-m] [-f] pass-name"}
}
name := strings.Trim(args[0], "/")
s, err := openStore()
if err != nil {
return err
}
if s.hasEntry(name) && !force {
if !confirm(fmt.Sprintf("An entry already exists for %s. Overwrite it?", name)) {
return nil
}
}
var content []byte
if multiline {
fmt.Fprintf(os.Stderr, "Enter contents of %s and press Ctrl+D when finished:\n\n", name)
if content, err = io.ReadAll(os.Stdin); err != nil {
return err
}
} else {
if content, err = readSecret(name, echo); err != nil {
return err
}
}
v, err := newVault()
if err != nil {
return err
}
if err := encryptEntry(s, v, name, content); err != nil {
return err
}
s.commit("Add given password for " + name + " to store.")
return nil
}
// cmdGenerate creates a random password, stores it, and prints or copies it.
func cmdGenerate(args []string) error {
noSymbols, args := popBool(args, "-n", "--no-symbols")
clip, args := popBool(args, "-c", "--clip")
inPlace, args := popBool(args, "-i", "--in-place")
force, args := popBool(args, "-f", "--force")
if len(args) < 1 || len(args) > 2 {
return &usageError{"Usage: passv generate [-n] [-c] [-i|-f] pass-name [length]"}
}
name := strings.Trim(args[0], "/")
length := defaultLength()
if len(args) == 2 {
length = atoiDefault(args[1], length)
}
s, err := openStore()
if err != nil {
return err
}
v, err := newVault()
if err != nil {
return err
}
pw, err := genPassword(length, !noSymbols)
if err != nil {
return err
}
var content []byte
switch {
case inPlace && s.hasEntry(name):
cur, err := decryptEntry(s, v, name)
if err != nil {
return err
}
lines := strings.Split(string(cur), "\n")
lines[0] = pw
content = []byte(strings.Join(lines, "\n"))
default:
if s.hasEntry(name) && !force {
if !confirm(fmt.Sprintf("An entry already exists for %s. Overwrite it?", name)) {
return nil
}
}
content = []byte(pw)
}
if err := encryptEntry(s, v, name, content); err != nil {
return err
}
s.commit("Add generated password for " + name + " to store.")
if clip {
if err := copyClip(pw); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Copied %s to clipboard. Will clear in %d seconds.\n", name, clipTime())
return nil
}
fmt.Printf("The generated password for %s is:\n%s\n", name, pw)
return nil
}
// cmdEdit decrypts an entry into a temp file, opens $EDITOR, and re-encrypts.
func cmdEdit(args []string) error {
if len(args) != 1 {
return &usageError{"Usage: passv edit pass-name"}
}
name := strings.Trim(args[0], "/")
s, err := openStore()
if err != nil {
return err
}
v, err := newVault()
if err != nil {
return err
}
var current []byte
if s.hasEntry(name) {
if current, err = decryptEntry(s, v, name); err != nil {
return err
}
}
tmp, err := secureTempFile(filepath.Base(name))
if err != nil {
return err
}
defer os.Remove(tmp)
if err := os.WriteFile(tmp, current, 0o600); err != nil {
return err
}
if err := runEditor(tmp); err != nil {
return err
}
edited, err := os.ReadFile(tmp)
if err != nil {
return err
}
if bytes.Equal(edited, current) {
fmt.Fprintf(os.Stderr, "Password unchanged for %s.\n", name)
return nil
}
if err := encryptEntry(s, v, name, edited); err != nil {
return err
}
s.commit("Edit password for " + name + ".")
return nil
}
// secureTempFile prefers /dev/shm (tmpfs, never hits disk) for the cleartext
// editor buffer, falling back to the OS temp dir.
func secureTempFile(hint string) (string, error) {
dir := os.TempDir()
if fi, err := os.Stat("/dev/shm"); err == nil && fi.IsDir() {
dir = "/dev/shm"
}
f, err := os.CreateTemp(dir, "passv-"+hint+"-*.txt")
if err != nil {
return "", err
}
name := f.Name()
f.Close()
return name, nil
}
func runEditor(path string) error {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
cmd := exec.Command("sh", "-c", editor+" \""+path+"\"")
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
+30
View File
@@ -0,0 +1,30 @@
module git.unkin.net/unkin/passv
go 1.25.0
require (
github.com/hashicorp/vault/api v1.15.0
golang.org/x/term v0.28.0
)
require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
)
+81
View File
@@ -0,0 +1,81 @@
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI=
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// decryptEntry resolves an entry's governing Vault key and decrypts its `.gpg`
// file through the engine.
func decryptEntry(s *store, v *vaultClient, name string) ([]byte, error) {
if !s.hasEntry(name) {
return nil, errf("Error: %s is not in the password store.", name)
}
ref, err := s.vaultRef(name)
if err != nil {
return nil, err
}
ct, err := s.readCipher(name)
if err != nil {
return nil, err
}
return v.decrypt(ref, ct)
}
// encryptEntry encrypts content for an entry using its governing Vault key and
// writes the `.gpg` file.
func encryptEntry(s *store, v *vaultClient, name string, content []byte) error {
ref, err := s.vaultRef(name)
if err != nil {
return err
}
ct, err := v.encrypt(ref, content)
if err != nil {
return err
}
return s.writeCipher(name, ct)
}
// confirm asks a yes/no question on stderr, defaulting to no.
func confirm(prompt string) bool {
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
+106
View File
@@ -0,0 +1,106 @@
// Command passv is a Vault-backed, drop-in replacement for the standard
// password-store (`pass`). It keeps the exact 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, so
// the private key never lives on the client. A store's `.gpg-id` holds a Vault
// key reference ("<mount>/<key>", e.g. "gpg/app") in place of a GPG fingerprint.
package main
import (
"errors"
"fmt"
"os"
)
var version = "dev"
// usageError is returned by commands for bad invocations (exit code 1 with the
// command's own usage line already included in the message).
type usageError struct{ msg string }
func (e *usageError) Error() string { return e.msg }
func main() {
if err := run(os.Args[1:]); err != nil {
var ue *usageError
if errors.As(err, &ue) {
fmt.Fprintln(os.Stderr, ue.msg)
} else {
fmt.Fprintln(os.Stderr, "passv: "+err.Error())
}
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 {
return cmdShow(nil) // default: list the whole store
}
switch args[0] {
case "init":
return cmdInit(args[1:])
case "ls", "list":
return cmdShow(args[1:]) // show handles the dir-vs-entry split
case "show", "cat":
return cmdShow(args[1:])
case "find", "search":
return cmdFind(args[1:])
case "grep":
return cmdGrep(args[1:])
case "insert", "add":
return cmdInsert(args[1:])
case "generate":
return cmdGenerate(args[1:])
case "edit":
return cmdEdit(args[1:])
case "rm", "remove", "delete":
return cmdRm(args[1:])
case "mv", "rename":
return cmdMv(args[1:])
case "cp", "copy":
return cmdCp(args[1:])
case "git":
return cmdGit(args[1:])
case "help", "-h", "--help":
usage()
return nil
case "version", "--version", "-v":
fmt.Printf("passv %s\n", version)
return nil
default:
// pass semantics: a bare name is an implicit `show`.
return cmdShow(args)
}
}
func usage() {
fmt.Print(`passv — a Vault-backed drop-in for pass
Usage:
passv [ls] [subfolder] list the store (or a subfolder)
passv [show] pass-name [-c|--clip[=N]] decrypt an entry (optionally to clipboard)
passv insert [-e|-m] [-f] pass-name add a new entry
passv generate [-n] [-c] [-i|-f] name [length]
generate and store a random password
passv edit pass-name edit an entry in $EDITOR
passv rm [-r] [-f] pass-name remove an entry or directory
passv mv|cp [-f] old new move/copy (re-encrypts across gpg-ids)
passv find term... list entries matching term
passv grep [-i] regexp search decrypted content
passv git args... run git in the store
passv init [-p subfolder] <mount>/<key>
point a (sub)store at a Vault gpg key
Environment:
PASSWORD_STORE_DIR store location (default ~/.password-store)
VAULT_ADDR, VAULT_TOKEN standard Vault connection settings
PASSWORD_STORE_CLIP_TIME seconds before the clipboard is cleared (45)
PASSWORD_STORE_GENERATED_LENGTH default generated length (25)
`)
}
// fatalf builds a plain error (no "passv:" doubling for already-formatted msgs).
func errf(format string, a ...interface{}) error {
return fmt.Errorf(format, a...)
}
+31
View File
@@ -0,0 +1,31 @@
---
# nfpm config for the passv RPM. Rendered through envsubst (see
# scripts/build-rpm.sh) then fed to `nfpm pkg`.
name: passv
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "Vault-backed drop-in replacement for pass (password-store); routes GPG crypto through vault-plugin-secrets-gpg"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
replaces:
- passv
provides:
- passv
contents:
- src: dist/passv
dst: /usr/bin/passv
file_info:
mode: 0755
owner: root
group: root
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestSplitRef(t *testing.T) {
cases := []struct {
in string
mount, key string
wantErr bool
}{
{"gpg/app", "gpg", "app", false},
{"/gpg/app/", "gpg", "app", false},
{"secret/gpg/team", "secret/gpg", "team", false},
{"app", "", "", true},
{"gpg/", "", "", true},
{"/app", "", "", true},
}
for _, c := range cases {
m, k, err := splitRef(c.in)
if c.wantErr {
if err == nil {
t.Errorf("splitRef(%q): expected error", c.in)
}
continue
}
if err != nil {
t.Errorf("splitRef(%q): %v", c.in, err)
continue
}
if m != c.mount || k != c.key {
t.Errorf("splitRef(%q) = (%q,%q), want (%q,%q)", c.in, m, k, c.mount, c.key)
}
}
}
func TestParseClip(t *testing.T) {
cases := []struct {
in []string
clip bool
line int
restName string
}{
{[]string{"foo"}, false, 1, "foo"},
{[]string{"-c", "foo"}, true, 1, "foo"},
{[]string{"foo", "--clip"}, true, 1, "foo"},
{[]string{"--clip=3", "foo"}, true, 3, "foo"},
{[]string{"-c2", "foo"}, true, 2, "foo"},
}
for _, c := range cases {
clip, line, rest := parseClip(c.in)
if clip != c.clip || line != c.line {
t.Errorf("parseClip(%v) = (%v,%d), want (%v,%d)", c.in, clip, line, c.clip, c.line)
}
if len(rest) != 1 || rest[0] != c.restName {
t.Errorf("parseClip(%v) rest = %v, want [%q]", c.in, rest, c.restName)
}
}
}
func TestGpgIDWalkUp(t *testing.T) {
dir := t.TempDir()
s := &store{dir: dir}
// Root .gpg-id, plus a subtree override.
if err := os.WriteFile(filepath.Join(dir, ".gpg-id"), []byte("gpg/personal\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "work", "team"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "work", ".gpg-id"), []byte("gpg/work\n"), 0o600); err != nil {
t.Fatal(err)
}
cases := map[string]string{
"email/gmail": "gpg/personal",
"work/deploy": "gpg/work",
"work/team/secret": "gpg/work",
"root-entry": "gpg/personal",
}
for name, want := range cases {
got, err := s.vaultRef(name)
if err != nil {
t.Errorf("vaultRef(%q): %v", name, err)
continue
}
if got != want {
t.Errorf("vaultRef(%q) = %q, want %q", name, got, want)
}
}
}
func TestVaultRefUninitialized(t *testing.T) {
s := &store{dir: t.TempDir()}
if _, err := s.vaultRef("anything"); err == nil {
t.Fatal("expected error for store with no .gpg-id / .vault-id")
}
}
// TestDualModeVaultID covers a store shared with plain gpg `pass`: `.gpg-id`
// holds GPG fingerprints, and a sibling `.vault-id` carries the Vault ref that
// passv must use.
func TestDualModeVaultID(t *testing.T) {
dir := t.TempDir()
s := &store{dir: dir}
// .gpg-id has real GPG fingerprints (as plain pass would write); passv must
// not treat those as a Vault ref.
if err := os.WriteFile(filepath.Join(dir, ".gpg-id"),
[]byte("ABCD1234ABCD1234ABCD1234ABCD1234ABCD1234\nDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF\n"), 0o600); err != nil {
t.Fatal(err)
}
// Without a .vault-id, passv cannot know which Vault key to use.
if _, err := s.vaultRef("email/gmail"); err == nil {
t.Fatal("expected error when .gpg-id holds fingerprints and no .vault-id exists")
}
// Adding .vault-id resolves it.
if err := os.WriteFile(filepath.Join(dir, ".vault-id"), []byte("gpg/app\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := s.vaultRef("email/gmail")
if err != nil {
t.Fatal(err)
}
if got != "gpg/app" {
t.Fatalf("vaultRef = %q, want gpg/app", got)
}
}
func TestGenPassword(t *testing.T) {
pw, err := genPassword(32, false)
if err != nil {
t.Fatal(err)
}
if len(pw) != 32 {
t.Fatalf("length = %d, want 32", len(pw))
}
for _, r := range pw {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
t.Fatalf("no-symbols password contains symbol %q", r)
}
}
// Two generations must differ (astronomically unlikely to collide).
a, _ := genPassword(25, true)
b, _ := genPassword(25, true)
if a == b {
t.Fatal("consecutive generated passwords collided")
}
}
func TestListSkipsHidden(t *testing.T) {
dir := t.TempDir()
s := &store{dir: dir}
os.MkdirAll(filepath.Join(dir, ".git"), 0o700)
os.WriteFile(filepath.Join(dir, ".git", "config.gpg"), []byte("x"), 0o600)
os.WriteFile(filepath.Join(dir, ".gpg-id"), []byte("gpg/app"), 0o600)
os.MkdirAll(filepath.Join(dir, "email"), 0o700)
os.WriteFile(filepath.Join(dir, "email", "gmail.gpg"), []byte("x"), 0o600)
os.WriteFile(filepath.Join(dir, "note.txt"), []byte("x"), 0o600)
names, err := s.list("")
if err != nil {
t.Fatal(err)
}
if len(names) != 1 || names[0] != "email/gmail" {
t.Fatalf("list = %v, want [email/gmail]", names)
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"bufio"
"crypto/rand"
"fmt"
"io"
"math/big"
"os"
"strings"
"golang.org/x/term"
)
const (
alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
symbols = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
)
// readSecret reads a new secret for name. With echo (or when stdin is not a
// terminal, e.g. piped input) it reads a single line; otherwise it prompts
// twice without echo and checks the two entries match.
func readSecret(name string, echo bool) ([]byte, error) {
if echo || !term.IsTerminal(int(os.Stdin.Fd())) {
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && err != io.EOF {
return nil, err
}
return []byte(strings.TrimRight(line, "\r\n")), nil
}
fmt.Fprintf(os.Stderr, "Enter password for %s: ", name)
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, err
}
fmt.Fprintf(os.Stderr, "Retype password for %s: ", name)
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, err
}
if string(p1) != string(p2) {
return nil, errf("the entered passwords do not match")
}
return p1, nil
}
// genPassword returns a cryptographically random password.
func genPassword(length int, withSymbols bool) (string, error) {
if length <= 0 {
length = 25
}
charset := alnum
if withSymbols {
charset += symbols
}
buf := make([]byte, length)
max := big.NewInt(int64(len(charset)))
for i := range buf {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
buf[i] = charset[n.Int64()]
}
return string(buf), nil
}
func defaultLength() int {
return atoiDefault(os.Getenv("PASSWORD_STORE_GENERATED_LENGTH"), 25)
}
+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"
+150
View File
@@ -0,0 +1,150 @@
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()
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// printTree renders the entry tree under sub in the same shape pass does (via
// `tree`): directories and entries sorted together, `.gpg` suffixes stripped,
// dotfiles hidden.
func printTree(s *store, sub string) error {
root := filepath.Join(s.dir, sub)
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
return errf("Error: %s is not in the password store.", sub)
}
if sub == "" {
fmt.Println("Password Store")
} else {
fmt.Println(sub)
}
return printBranch(root, "")
}
func printBranch(dir, prefix string) error {
ents, err := os.ReadDir(dir)
if err != nil {
return err
}
var items []os.DirEntry
for _, e := range ents {
if strings.HasPrefix(e.Name(), ".") {
continue
}
if !e.IsDir() && !strings.HasSuffix(e.Name(), ".gpg") {
continue
}
items = append(items, e)
}
sort.Slice(items, func(i, j int) bool { return items[i].Name() < items[j].Name() })
for i, e := range items {
last := i == len(items)-1
branch, extend := "├── ", "│ "
if last {
branch, extend = "└── ", " "
}
name := e.Name()
if !e.IsDir() {
name = strings.TrimSuffix(name, ".gpg")
}
fmt.Println(prefix + branch + name)
if e.IsDir() {
if err := printBranch(filepath.Join(dir, e.Name()), prefix+extend); err != nil {
return err
}
}
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/hashicorp/vault/api"
)
// vaultClient wraps the Vault API client and turns a password-store gpg-id (a
// "<mount>/<key>" reference into the vault-plugin-secrets-gpg engine) into
// encrypt/decrypt calls. The private key never leaves Vault; this shim only
// ever ships base64 to and from the engine.
type vaultClient struct {
api *api.Client
}
func newVault() (*vaultClient, error) {
cfg := api.DefaultConfig()
if err := cfg.ReadEnvironment(); err != nil {
return nil, fmt.Errorf("reading Vault environment: %w", err)
}
c, err := api.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("creating Vault client: %w", err)
}
if c.Token() == "" {
if tok := resolveToken(); tok != "" {
c.SetToken(tok)
}
}
if c.Token() == "" {
return nil, fmt.Errorf("no Vault token (set VAULT_TOKEN or run `vault login`)")
}
return &vaultClient{api: c}, nil
}
// resolveToken falls back to the on-disk token that `vault login` writes.
func resolveToken() string {
if t := os.Getenv("VAULT_TOKEN"); t != "" {
return t
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
b, err := os.ReadFile(filepath.Join(home, ".vault-token"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// splitRef parses "<mount>/<key>" (e.g. "gpg/app") into its parts.
func splitRef(ref string) (mount, key string, err error) {
ref = strings.Trim(strings.TrimSpace(ref), "/")
i := strings.LastIndex(ref, "/")
if i <= 0 || i == len(ref)-1 {
return "", "", fmt.Errorf("invalid key reference %q (want <mount>/<key>, e.g. gpg/app)", ref)
}
return ref[:i], ref[i+1:], nil
}
func (v *vaultClient) encrypt(ref string, plaintext []byte) ([]byte, error) {
mount, key, err := splitRef(ref)
if err != nil {
return nil, err
}
resp, err := v.api.Logical().Write(mount+"/encrypt/"+key, map[string]interface{}{
"plaintext": base64.StdEncoding.EncodeToString(plaintext),
"format": "base64",
})
if err != nil {
return nil, fmt.Errorf("vault encrypt (%s): %w", ref, err)
}
return fieldBytes(resp, "ciphertext")
}
func (v *vaultClient) decrypt(ref string, ciphertext []byte) ([]byte, error) {
mount, key, err := splitRef(ref)
if err != nil {
return nil, err
}
resp, err := v.api.Logical().Write(mount+"/decrypt/"+key, map[string]interface{}{
// The engine auto-detects armored vs raw binary; we send base64 binary.
"ciphertext": base64.StdEncoding.EncodeToString(ciphertext),
})
if err != nil {
return nil, fmt.Errorf("vault decrypt (%s): %w", ref, err)
}
return fieldBytes(resp, "plaintext")
}
// publicKey returns the armored public key for a reference, used by `init` to
// verify the key exists and is reachable before writing the store.
func (v *vaultClient) publicKey(ref string) (string, error) {
mount, key, err := splitRef(ref)
if err != nil {
return "", err
}
resp, err := v.api.Logical().Read(mount + "/keys/" + key)
if err != nil {
return "", fmt.Errorf("vault read key (%s): %w", ref, err)
}
if resp == nil || resp.Data["public_key"] == nil {
return "", fmt.Errorf("key %q not found in Vault", ref)
}
return resp.Data["public_key"].(string), nil
}
// fieldBytes pulls a base64 string field out of a Vault response and decodes it.
func fieldBytes(resp *api.Secret, field string) ([]byte, error) {
if resp == nil || resp.Data[field] == nil {
return nil, fmt.Errorf("vault response missing %q", field)
}
s, ok := resp.Data[field].(string)
if !ok {
return nil, fmt.Errorf("vault field %q is not a string", field)
}
return base64.StdEncoding.DecodeString(s)
}