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.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package gpg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func pathEncrypt(b *gpgBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "encrypt/" + framework.GenericNameRegex("name"),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "gpg",
|
||||
OperationSuffix: "encrypt",
|
||||
},
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"name": {
|
||||
Type: framework.TypeString,
|
||||
Description: "Name of the key to encrypt to.",
|
||||
Required: true,
|
||||
},
|
||||
"plaintext": {
|
||||
Type: framework.TypeString,
|
||||
Description: "Base64-encoded plaintext to encrypt.",
|
||||
},
|
||||
"format": {
|
||||
Type: framework.TypeString,
|
||||
Description: "Ciphertext encoding: base64 (default, raw binary OpenPGP) or ascii-armor.",
|
||||
Default: "base64",
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathEncryptWrite},
|
||||
},
|
||||
HelpSynopsis: "Encrypt plaintext to the key's latest public version.",
|
||||
HelpDescription: "Produces an OpenPGP message decryptable via decrypt/<name> or with the exported private key.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *gpgBackend) pathEncryptWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
name := data.Get("name").(string)
|
||||
|
||||
plaintext, err := base64.StdEncoding.DecodeString(data.Get("plaintext").(string))
|
||||
if err != nil {
|
||||
return logical.ErrorResponse("plaintext must be valid base64: %s", err), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
asciiArmor, errResp := parseEncFormat(data.Get("format").(string))
|
||||
if errResp != nil {
|
||||
return errResp, logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
key, err := b.getKey(ctx, req.Storage, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == nil {
|
||||
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
ciphertext, err := key.encrypt(plaintext, asciiArmor)
|
||||
if err != nil {
|
||||
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
out := base64.StdEncoding.EncodeToString(ciphertext)
|
||||
if asciiArmor {
|
||||
out = string(ciphertext)
|
||||
}
|
||||
return &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"ciphertext": out,
|
||||
"key_version": key.LatestVersion,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pathDecrypt(b *gpgBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "decrypt/" + framework.GenericNameRegex("name"),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "gpg",
|
||||
OperationSuffix: "decrypt",
|
||||
},
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"name": {
|
||||
Type: framework.TypeString,
|
||||
Description: "Name of the key to decrypt with.",
|
||||
Required: true,
|
||||
},
|
||||
"ciphertext": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The OpenPGP message: ASCII-armored, or base64-encoded raw binary (as `pass`/`gpg` write by default).",
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathDecryptWrite},
|
||||
},
|
||||
HelpSynopsis: "Decrypt an OpenPGP message with the key's private material.",
|
||||
HelpDescription: "Tries every key version at or above min_decryption_version. Accepts armored or binary ciphertext.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *gpgBackend) pathDecryptWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
name := data.Get("name").(string)
|
||||
|
||||
ciphertext := decodeCiphertext(data.Get("ciphertext").(string))
|
||||
|
||||
key, err := b.getKey(ctx, req.Storage, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key == nil {
|
||||
return logical.ErrorResponse("key %q not found", name), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
plaintext, err := key.decrypt(ciphertext)
|
||||
if err != nil {
|
||||
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||
}
|
||||
return &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
"plaintext": base64.StdEncoding.EncodeToString(plaintext),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeCiphertext passes armored input through untouched and base64-decodes
|
||||
// everything else back to raw binary OpenPGP.
|
||||
func decodeCiphertext(ciphertext string) []byte {
|
||||
if len(ciphertext) > len(armorPrefix) && ciphertext[:len(armorPrefix)] == armorPrefix {
|
||||
return []byte(ciphertext)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(ciphertext); err == nil {
|
||||
return raw
|
||||
}
|
||||
return []byte(ciphertext)
|
||||
}
|
||||
|
||||
// parseEncFormat resolves the base64 / ascii-armor selector for ciphertext.
|
||||
func parseEncFormat(format string) (asciiArmor bool, errResp *logical.Response) {
|
||||
switch format {
|
||||
case "", "base64":
|
||||
return false, nil
|
||||
case "ascii-armor":
|
||||
return true, nil
|
||||
default:
|
||||
return false, logical.ErrorResponse("invalid format %q (must be base64 or ascii-armor)", format)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user