From 06da0a668a76197f3855b747d7447b5b2587c454 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Thu, 16 Jul 2026 22:23:16 +1000 Subject: [PATCH] Add passv: a Vault-backed drop-in for pass Provide a password-store CLI that keeps pass's on-disk layout (.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 `=show dispatch. - Resolve the recipient from a Vault ref: .gpg-id holds / 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. --- .gitignore | 5 + .pre-commit-config.yaml | 15 +++ .woodpecker/build.yml | 14 +++ .woodpecker/pre-commit.yaml | 14 +++ .woodpecker/release.yml | 47 ++++++++ .woodpecker/test.yml | 25 +++++ Makefile | 65 +++++++++++ README.md | 211 +++++++++++++++++++++++++++++++++++- args.go | 62 +++++++++++ clip.go | 53 +++++++++ cmd_init.go | 77 +++++++++++++ cmd_manage.go | 163 ++++++++++++++++++++++++++++ cmd_read.go | 133 +++++++++++++++++++++++ cmd_write.go | 196 +++++++++++++++++++++++++++++++++ go.mod | 30 +++++ go.sum | 81 ++++++++++++++ helpers.go | 47 ++++++++ main.go | 106 ++++++++++++++++++ packaging/nfpm.yaml | 31 ++++++ passv_test.go | 172 +++++++++++++++++++++++++++++ password.go | 73 +++++++++++++ scripts/build-rpm.sh | 32 ++++++ scripts/e2e.sh | 129 ++++++++++++++++++++++ store.go | 150 +++++++++++++++++++++++++ tree.go | 63 +++++++++++ vault.go | 124 +++++++++++++++++++++ 26 files changed, 2117 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yml create mode 100644 .woodpecker/test.yml create mode 100644 Makefile create mode 100644 args.go create mode 100644 clip.go create mode 100644 cmd_init.go create mode 100644 cmd_manage.go create mode 100644 cmd_read.go create mode 100644 cmd_write.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helpers.go create mode 100644 main.go create mode 100644 packaging/nfpm.yaml create mode 100644 passv_test.go create mode 100644 password.go create mode 100755 scripts/build-rpm.sh create mode 100755 scripts/e2e.sh create mode 100644 store.go create mode 100644 tree.go create mode 100644 vault.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b93236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/passv +*.out +*.test +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..45fb3b9 --- /dev/null +++ b/.woodpecker/build.yml @@ -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} diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..bd69300 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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} diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..f1a4b5c --- /dev/null +++ b/.woodpecker/release.yml @@ -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} diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..24304c5 --- /dev/null +++ b/.woodpecker/test.yml @@ -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} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7389efd --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index b3556b0..30381a1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,212 @@ # passv -Vault-backed drop-in replacement for pass (password-store); routes GPG crypto through vault-plugin-secrets-gpg \ No newline at end of file +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 `.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 +`"/"` (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 " + +# point a store at it — like `pass init `, 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 " + +# 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 " +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 `/` | + +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. diff --git a/args.go b/args.go new file mode 100644 index 0000000..c078cd6 --- /dev/null +++ b/args.go @@ -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 +} diff --git a/clip.go b/clip.go new file mode 100644 index 0000000..7c446d4 --- /dev/null +++ b/clip.go @@ -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() +} diff --git a/cmd_init.go b/cmd_init.go new file mode 100644 index 0000000..04e50ab --- /dev/null +++ b/cmd_init.go @@ -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 "/" 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] /"} + } + 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] /"} + } + 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 + ")" +} diff --git a/cmd_manage.go b/cmd_manage.go new file mode 100644 index 0000000..bbd17f4 --- /dev/null +++ b/cmd_manage.go @@ -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) +} diff --git a/cmd_read.go b/cmd_read.go new file mode 100644 index 0000000..853867a --- /dev/null +++ b/cmd_read.go @@ -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 +} diff --git a/cmd_write.go b/cmd_write.go new file mode 100644 index 0000000..38f7cbf --- /dev/null +++ b/cmd_write.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a9b7c6 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0b40678 --- /dev/null +++ b/go.sum @@ -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= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..83e2119 --- /dev/null +++ b/helpers.go @@ -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" +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..613b317 --- /dev/null +++ b/main.go @@ -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 +// `.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 ("/", 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] / + 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...) +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..586523e --- /dev/null +++ b/packaging/nfpm.yaml @@ -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 diff --git a/passv_test.go b/passv_test.go new file mode 100644 index 0000000..1f31797 --- /dev/null +++ b/passv_test.go @@ -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) + } +} diff --git a/password.go b/password.go new file mode 100644 index 0000000..2c585a1 --- /dev/null +++ b/password.go @@ -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) +} diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..8c60192 --- /dev/null +++ b/scripts/build-rpm.sh @@ -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 " +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 diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..8a47fe2 --- /dev/null +++ b/scripts/e2e.sh @@ -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 ' >/dev/null +vault write -field=public_key gpg/keys/work algorithm=ed25519 identity='Work ' >/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 ' 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" diff --git a/store.go b/store.go new file mode 100644 index 0000000..2f637c4 --- /dev/null +++ b/store.go @@ -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 `.gpg` binary OpenPGP files. It is layout-compatible +// with GnuPG `pass`; the only difference is that `.gpg-id` holds a Vault key +// reference ("/") 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 / to read this store with passv") + } + return "", fmt.Errorf("password store is not initialized (no .gpg-id / .vault-id); run `passv init /`") +} + +// 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 ...` 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() +} diff --git a/tree.go b/tree.go new file mode 100644 index 0000000..0f91ab1 --- /dev/null +++ b/tree.go @@ -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 +} diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..b0b5482 --- /dev/null +++ b/vault.go @@ -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 +// "/" 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 "/" (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 /, 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) +}