71cc398197
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.
153 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|