Files
vault-plugin-secrets-gpg/README.md
T
unkinben 71cc398197
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add GPG/OpenPGP secrets engine
Provide a transit-style Vault/OpenBao secrets engine whose key material is
OpenPGP, so services can sign/verify/encrypt/decrypt with GPG keys that never
leave the barrier — and so tools like pass can encrypt to an exported public
key while delegating decryption back to Vault.

- Add versioned key management (keys/<name> CRUD+list, config, rotate, import)
  with private material seal-wrapped under key/ and per-key locking.
- Add sign/verify (detached OpenPGP) and encrypt/decrypt paths; decrypt
  auto-detects armored vs raw-binary ciphertext (what pass/gpg write).
- Add export/<public-key|private-key>/<name>; public always exportable,
  private only when the key is marked exportable.
- Use ProtonMail go-crypto for OpenPGP; support rsa-2048/3072/4096 and ed25519.
- Clone the sibling plugin's build/packaging/CI: dual-target RPMs
  (vault + openbao plugin dirs), Woodpecker PR/release pipelines, and a
  Vault+OpenBao e2e harness. Unit tests include real gpg and pass interop.
2026-07-15 21:25:41 +10:00

4.3 KiB

vault-plugin-secrets-gpg

A HashiCorp Vault / OpenBao secrets engine that manages GPG / OpenPGP keys and performs signing, verification, encryption and decryption with them — the transit engine, but the key material is OpenPGP.

Keys are generated and stored inside Vault's own barrier storage (seal-wrapped under key/) and the private key never leaves Vault unless a key is explicitly marked exportable. Each named key is versioned: rotating adds a new OpenPGP entity, while older versions are retained so archived signatures still verify and old ciphertext still decrypts.

The armored public key the engine hands back is a standard, importable OpenPGP key, so gpg, pass and friends can encrypt to it while the private key stays in Vault and decryption is delegated back to decrypt/<name>.

gpg / pass  ──encrypt to public key──▶  binary .gpg
    │                                       │
    │ import public key                     │ decrypt/<name>
    ▼                                       ▼
export/public-key/<name>  ◀── Vault ──▶  private key (never leaves the barrier)

Paths

Path Purpose
keys/<name> (write/read/delete, list keys/) generate a key (rsa-2048, rsa-3072, rsa-4096, ed25519); read metadata + armored public key
keys/<name>/config set min_decryption_version, deletion_allowed, exportable
keys/<name>/rotate add a new version (older versions kept for decrypt/verify)
keys/<name>/import import an existing armored private key
export/<public-key|private-key>/<name>[/<version>] export armored key material
sign/<name> / verify/<name> detached OpenPGP signature over base64 input
encrypt/<name> / decrypt/<name> OpenPGP encrypt to / decrypt with the key

Creating a key is idempotent — writing keys/<name> again returns the existing key rather than regenerating it. Deletion is refused unless deletion_allowed=true is set via keys/<name>/config.

Usage

# register + enable (the plugin_directory must contain the binary)
sha=$(sha256sum /opt/vault-plugins/vault-plugin-secrets-gpg | cut -d' ' -f1)
vault plugin register -sha256=$sha secret vault-plugin-secrets-gpg
vault secrets enable -path=gpg vault-plugin-secrets-gpg

# generate a key
vault write gpg/keys/app algorithm=rsa-4096 identity="App <app@unkin.net>"
vault read  gpg/keys/app                       # metadata + armored public key

# sign / verify (input and output are base64 / armored)
sig=$(vault write -field=signature gpg/sign/app input=$(base64 <<<"ship it"))
vault write -field=valid gpg/verify/app input=$(base64 <<<"ship it") signature="$sig"

# encrypt / decrypt
ct=$(vault write -field=ciphertext gpg/encrypt/app plaintext=$(base64 <<<"secret"))
vault write -field=plaintext gpg/decrypt/app ciphertext="$ct" | base64 -d

# rotate; old ciphertext still decrypts
vault write gpg/keys/app/rotate

Using it with pass

The engine holds the private key; pass/gpg only need the public key to encrypt, and delegate decryption back to Vault.

# import the engine's public key into your gpg keyring
vault read -field=public_key gpg/keys/app | gpg --import
fpr=$(vault read -field=fingerprint gpg/keys/app)

# initialise a password store against it
pass init "$fpr"
pass insert email/example          # writes ~/.password-store/email/example.gpg (binary OpenPGP)

# decrypt an entry through Vault (decrypt accepts binary ciphertext directly)
vault write -field=plaintext gpg/decrypt/app \
  ciphertext=$(base64 -w0 ~/.password-store/email/example.gpg) | base64 -d

decrypt/<name> auto-detects ASCII-armored vs raw-binary input, so the binary .gpg files pass writes work without conversion. A thin gpg/pass wrapper that routes decryption to Vault automatically can be layered on top.

Build

make build      # -> dist/vault-plugin-secrets-gpg
make test lint  # go test -race / go vet
make e2e        # full lifecycle against Vault + OpenBao in Docker
make rpm        # vault + openbao RPM flavours

CI (Woodpecker) runs pre-commit/build/lint/test on PRs and builds+publishes the RPMs on a v* tag. The unit tests include real-gpg and real-pass interop checks (skipped automatically when those binaries are absent).