Add passv: a Vault-backed drop-in for pass
Provide a password-store CLI that keeps pass's on-disk layout (<name>.gpg binary OpenPGP under $PASSWORD_STORE_DIR) but routes all encryption and decryption through a vault-plugin-secrets-gpg engine mount, so the GPG private key never lives on the client. - Implement init/show/ls/insert/generate/edit/rm/mv/cp/find/grep/git with pass-compatible flags and implicit `passv <name>`=show dispatch. - Resolve the recipient from a Vault ref: .gpg-id holds <mount>/<key> for passv-native stores, or a sibling .vault-id lets a store keep real GPG fingerprints in .gpg-id for dual gpg+Vault use. - mv/cp re-encrypt across differing recipients; same-recipient moves copy the ciphertext verbatim. - Ship the sibling build/packaging/CI: nfpm RPM to /usr/bin/passv (artifactapi rpm-internal on v* tag), Woodpecker PR/release pipelines, unit tests plus a real-Vault e2e that also proves dual gpg+Vault decryption. - README covers store creation, migrating a GPG pass store, and dual-mode.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
/dist/
|
||||||
|
/passv
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
Executable
+32
@@ -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
@@ -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"
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user