Files
vault-plugin-secrets-gpg/interop_test.go
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

153 lines
5.2 KiB
Go

package gpg
import (
"encoding/base64"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/hashicorp/vault/sdk/logical"
)
// TestGPGInterop proves the pass/gpg integration contract end to end using the
// real gpg binary: our exported public key imports into a gpg keyring, gpg
// encrypts to it producing a *binary* OpenPGP message (exactly what `pass`
// writes), and our engine's decrypt path opens it. It also checks the reverse:
// gpg verifies a detached signature the engine produced.
func TestGPGInterop(t *testing.T) {
gpgBin, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not installed")
}
b, s := newTestBackend(t)
createKey(t, b, s, "pass", "rsa-2048")
read := do(t, b, s, logical.ReadOperation, "keys/pass", nil)
pubArmored := read.Data["public_key"].(string)
fingerprint := read.Data["fingerprint"].(string)
// Isolated gpg home so we never touch the user's real keyring.
gnupg := t.TempDir()
run := func(stdin string, args ...string) (string, string) {
t.Helper()
cmd := exec.Command(gpgBin, append([]string{"--homedir", gnupg, "--batch", "--yes", "--trust-model", "always"}, args...)...)
cmd.Stdin = strings.NewReader(stdin)
var out, errb strings.Builder
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
t.Fatalf("gpg %v: %v\n%s", args, err, errb.String())
}
return out.String(), errb.String()
}
// 1. Import the engine's public key into gpg.
run(pubArmored, "--import")
// 2. Encrypt with gpg the way pass does: binary (no --armor) OpenPGP.
secret := "hunter2\n"
binCipher := filepath.Join(gnupg, "msg.gpg")
cmd := exec.Command(gpgBin, "--homedir", gnupg, "--batch", "--yes", "--trust-model", "always",
"-r", fingerprint, "--output", binCipher, "--encrypt")
cmd.Stdin = strings.NewReader(secret)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("gpg encrypt: %v\n%s", err, out)
}
binBytes, err := os.ReadFile(binCipher)
if err != nil {
t.Fatal(err)
}
// 3. The engine decrypts gpg's binary ciphertext (base64-wrapped for the API).
dec := do(t, b, s, logical.UpdateOperation, "decrypt/pass", map[string]interface{}{
"ciphertext": base64.StdEncoding.EncodeToString(binBytes),
})
got, _ := base64.StdEncoding.DecodeString(dec.Data["plaintext"].(string))
if string(got) != secret {
t.Fatalf("engine failed to decrypt gpg's binary ciphertext: %q", got)
}
// 4. Reverse direction: engine signs, gpg verifies.
sign := do(t, b, s, logical.UpdateOperation, "sign/pass", map[string]interface{}{
"input": base64.StdEncoding.EncodeToString([]byte(secret)),
})
sigFile := filepath.Join(gnupg, "msg.sig")
msgFile := filepath.Join(gnupg, "msg.txt")
if err := os.WriteFile(sigFile, []byte(sign.Data["signature"].(string)), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(msgFile, []byte(secret), 0o600); err != nil {
t.Fatal(err)
}
run("", "--verify", sigFile, msgFile)
}
// TestPassInterop drives the actual `pass` binary end to end: it initialises a
// password store against the engine's exported public key, inserts a secret
// (pass shells out to gpg to write a binary .gpg file), and the engine then
// decrypts that file — the real-world flow the engine is built for.
func TestPassInterop(t *testing.T) {
passBin, err := exec.LookPath("pass")
if err != nil {
t.Skip("pass not installed")
}
gpgBin, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not installed")
}
b, s := newTestBackend(t)
createKey(t, b, s, "store", "rsa-2048")
read := do(t, b, s, logical.ReadOperation, "keys/store", nil)
pubArmored := read.Data["public_key"].(string)
fingerprint := read.Data["fingerprint"].(string)
gnupg := t.TempDir()
storeDir := t.TempDir()
env := append(os.Environ(),
"GNUPGHOME="+gnupg,
"PASSWORD_STORE_DIR="+storeDir,
// The engine's key is imported without an ownertrust ultimatum, so tell
// pass's gpg invocations to trust it anyway (as a real deployment would
// via ownertrust or a signed key).
"PASSWORD_STORE_GPG_OPTS=--trust-model always",
)
// Import the public key so pass/gpg can encrypt to it.
imp := exec.Command(gpgBin, "--homedir", gnupg, "--batch", "--yes", "--import")
imp.Stdin = strings.NewReader(pubArmored)
if out, err := imp.CombinedOutput(); err != nil {
t.Fatalf("gpg import: %v\n%s", err, out)
}
passRun := func(stdin string, args ...string) {
t.Helper()
cmd := exec.Command(passBin, args...)
cmd.Env = env
cmd.Stdin = strings.NewReader(stdin)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("pass %v: %v\n%s", args, err, out)
}
}
passRun("", "init", fingerprint)
passRun("s3cr3t\n", "insert", "--echo", "email/test")
// pass wrote email/test.gpg as a binary OpenPGP message; decrypt it via the engine.
gpgFile := filepath.Join(storeDir, "email", "test.gpg")
binBytes, err := os.ReadFile(gpgFile)
if err != nil {
t.Fatalf("reading pass-written file: %v", err)
}
dec := do(t, b, s, logical.UpdateOperation, "decrypt/store", map[string]interface{}{
"ciphertext": base64.StdEncoding.EncodeToString(binBytes),
})
got, _ := base64.StdEncoding.DecodeString(dec.Data["plaintext"].(string))
if strings.TrimSpace(string(got)) != "s3cr3t" {
t.Fatalf("engine failed to decrypt pass entry: %q", got)
}
}