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.
167 lines
5.0 KiB
Go
167 lines
5.0 KiB
Go
package gpg
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
func pathSign(b *gpgBackend) *framework.Path {
|
|
return &framework.Path{
|
|
Pattern: "sign/" + framework.GenericNameRegex("name"),
|
|
DisplayAttrs: &framework.DisplayAttributes{
|
|
OperationPrefix: "gpg",
|
|
OperationSuffix: "sign",
|
|
},
|
|
Fields: map[string]*framework.FieldSchema{
|
|
"name": {
|
|
Type: framework.TypeString,
|
|
Description: "Name of the key to sign with.",
|
|
Required: true,
|
|
},
|
|
"input": {
|
|
Type: framework.TypeString,
|
|
Description: "Base64-encoded message to sign.",
|
|
},
|
|
"key_version": {
|
|
Type: framework.TypeInt,
|
|
Description: "Key version to sign with (0 or omitted = latest).",
|
|
Default: 0,
|
|
},
|
|
"format": {
|
|
Type: framework.TypeString,
|
|
Description: "Signature encoding: ascii-armor (default) or base64 (raw binary detached signature).",
|
|
Default: "ascii-armor",
|
|
},
|
|
},
|
|
Operations: map[logical.Operation]framework.OperationHandler{
|
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathSignWrite},
|
|
},
|
|
HelpSynopsis: "Produce a detached OpenPGP signature over the input.",
|
|
HelpDescription: "Signs with the key's primary signing key. Verify elsewhere with gpg or the verify/<name> path.",
|
|
}
|
|
}
|
|
|
|
func (b *gpgBackend) pathSignWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
name := data.Get("name").(string)
|
|
|
|
message, err := base64.StdEncoding.DecodeString(data.Get("input").(string))
|
|
if err != nil {
|
|
return logical.ErrorResponse("input must be valid base64: %s", err), logical.ErrInvalidRequest
|
|
}
|
|
|
|
asciiArmor, errResp := parseFormat(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
|
|
}
|
|
|
|
sig, err := key.sign(message, data.Get("key_version").(int), asciiArmor)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
|
}
|
|
|
|
signature := string(sig)
|
|
if !asciiArmor {
|
|
signature = base64.StdEncoding.EncodeToString(sig)
|
|
}
|
|
return &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"signature": signature,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func pathVerify(b *gpgBackend) *framework.Path {
|
|
return &framework.Path{
|
|
Pattern: "verify/" + framework.GenericNameRegex("name"),
|
|
DisplayAttrs: &framework.DisplayAttributes{
|
|
OperationPrefix: "gpg",
|
|
OperationSuffix: "verify",
|
|
},
|
|
Fields: map[string]*framework.FieldSchema{
|
|
"name": {
|
|
Type: framework.TypeString,
|
|
Description: "Name of the key to verify against.",
|
|
Required: true,
|
|
},
|
|
"input": {
|
|
Type: framework.TypeString,
|
|
Description: "Base64-encoded message that was signed.",
|
|
},
|
|
"signature": {
|
|
Type: framework.TypeString,
|
|
Description: "The detached signature: ASCII-armored, or base64-encoded raw binary.",
|
|
},
|
|
},
|
|
Operations: map[logical.Operation]framework.OperationHandler{
|
|
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathVerifyWrite},
|
|
},
|
|
HelpSynopsis: "Verify a detached OpenPGP signature over the input.",
|
|
HelpDescription: "Checks the signature against every version of the key; returns valid=true/false.",
|
|
}
|
|
}
|
|
|
|
func (b *gpgBackend) pathVerifyWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
|
name := data.Get("name").(string)
|
|
|
|
message, err := base64.StdEncoding.DecodeString(data.Get("input").(string))
|
|
if err != nil {
|
|
return logical.ErrorResponse("input must be valid base64: %s", err), logical.ErrInvalidRequest
|
|
}
|
|
|
|
signature := decodeSignature(data.Get("signature").(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
|
|
}
|
|
|
|
valid, err := key.verify(message, signature)
|
|
if err != nil {
|
|
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
|
}
|
|
return &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"valid": valid,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// decodeSignature accepts an armored signature verbatim, or decodes a base64
|
|
// raw-binary one. If it is neither, the original bytes are returned and
|
|
// verification will simply fail.
|
|
func decodeSignature(sig string) []byte {
|
|
if len(sig) > len(armorPrefix) && sig[:len(armorPrefix)] == armorPrefix {
|
|
return []byte(sig)
|
|
}
|
|
if raw, err := base64.StdEncoding.DecodeString(sig); err == nil {
|
|
return raw
|
|
}
|
|
return []byte(sig)
|
|
}
|
|
|
|
// parseFormat resolves the ascii-armor / base64 format selector.
|
|
func parseFormat(format string) (asciiArmor bool, errResp *logical.Response) {
|
|
switch format {
|
|
case "", "ascii-armor":
|
|
return true, nil
|
|
case "base64":
|
|
return false, nil
|
|
default:
|
|
return false, logical.ErrorResponse("invalid format %q (must be ascii-armor or base64)", format)
|
|
}
|
|
}
|