// Package vaultgpg is a thin client for Ben's Vault GPG secrets engine // (vault-plugin-secrets-gpg), used two ways: // // - the service reads the ARMORED PUBLIC key (GET /keys/) to // encrypt objects locally; and // - the CLI decrypts a wrapped DEK (POST /decrypt/) to retrieve // objects. The engine does whole-payload decrypt only, but logarchiver only // ever sends it the tiny wrapped-DEK blob, so that is a non-issue. // // Auth mirrors passv: ambient VAULT_* env (token or ~/.vault-token) for humans, // or the kubernetes auth method for the in-cluster service. package vaultgpg import ( "context" "encoding/base64" "fmt" "os" "path/filepath" "strings" "time" vault "github.com/hashicorp/vault/api" ) // Config configures the Vault client. type Config struct { Address string Mount string // e.g. "gpg" AuthMethod string // "token" | "kubernetes" K8sRole string K8sMount string // e.g. "k8s/au/syd1" K8sJWTPath string CAFile string } // Client wraps the Vault API client for GPG-engine operations. type Client struct { api *vault.Client mount string } // New builds a Client and authenticates per cfg.AuthMethod. func New(ctx context.Context, cfg Config) (*Client, error) { vc := vault.DefaultConfig() if err := vc.ReadEnvironment(); err != nil { return nil, fmt.Errorf("read vault env: %w", err) } if cfg.Address != "" { vc.Address = cfg.Address } if cfg.CAFile != "" { if err := vc.ConfigureTLS(&vault.TLSConfig{CACert: cfg.CAFile}); err != nil { return nil, fmt.Errorf("configure vault tls: %w", err) } } api, err := vault.NewClient(vc) if err != nil { return nil, fmt.Errorf("new vault client: %w", err) } mount := cfg.Mount if mount == "" { mount = "gpg" } c := &Client{api: api, mount: strings.Trim(mount, "/")} switch cfg.AuthMethod { case "", "token": if api.Token() == "" { tok, err := resolveToken() if err != nil { return nil, err } api.SetToken(tok) } case "kubernetes": if err := c.k8sLogin(ctx, cfg); err != nil { return nil, err } default: return nil, fmt.Errorf("unknown vault auth_method %q", cfg.AuthMethod) } return c, nil } func (c *Client) k8sLogin(ctx context.Context, cfg Config) error { jwtPath := cfg.K8sJWTPath if jwtPath == "" { jwtPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" } jwt, err := os.ReadFile(jwtPath) if err != nil { return fmt.Errorf("read service account token: %w", err) } mount := cfg.K8sMount if mount == "" { mount = "kubernetes" } path := fmt.Sprintf("auth/%s/login", strings.Trim(mount, "/")) secret, err := c.api.Logical().WriteWithContext(ctx, path, map[string]any{ "role": cfg.K8sRole, "jwt": strings.TrimSpace(string(jwt)), }) if err != nil { return fmt.Errorf("kubernetes login: %w", err) } if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" { return fmt.Errorf("kubernetes login returned no token") } c.api.SetToken(secret.Auth.ClientToken) return nil } // resolveToken mirrors passv: VAULT_TOKEN, else ~/.vault-token. func resolveToken() (string, error) { if t := os.Getenv("VAULT_TOKEN"); t != "" { return t, nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("no VAULT_TOKEN and cannot find home dir: %w", err) } data, err := os.ReadFile(filepath.Join(home, ".vault-token")) if err != nil { return "", fmt.Errorf("no VAULT_TOKEN and no ~/.vault-token: %w", err) } tok := strings.TrimSpace(string(data)) if tok == "" { return "", fmt.Errorf("~/.vault-token is empty") } return tok, nil } // PublicKey is the result of reading a GPG engine key. type PublicKey struct { Armored string Fingerprint string } // FetchPublicKey reads /keys/ and returns the latest armored public // key and its fingerprint. func (c *Client) FetchPublicKey(ctx context.Context, name string) (PublicKey, error) { path := fmt.Sprintf("%s/keys/%s", c.mount, name) secret, err := c.api.Logical().ReadWithContext(ctx, path) if err != nil { return PublicKey{}, fmt.Errorf("read %s: %w", path, err) } if secret == nil || secret.Data == nil { return PublicKey{}, fmt.Errorf("key %q not found at %s", name, path) } pub, _ := secret.Data["public_key"].(string) if pub == "" { return PublicKey{}, fmt.Errorf("key %q has no public_key field", name) } fpr, _ := secret.Data["fingerprint"].(string) return PublicKey{Armored: pub, Fingerprint: fpr}, nil } // Decrypt sends ciphertext (raw binary OpenPGP) to /decrypt/ and // returns the plaintext. The engine auto-detects binary vs armored; we send // base64 of the raw bytes, as passv does. func (c *Client) Decrypt(ctx context.Context, name string, ciphertext []byte) ([]byte, error) { path := fmt.Sprintf("%s/decrypt/%s", c.mount, name) secret, err := c.api.Logical().WriteWithContext(ctx, path, map[string]any{ "ciphertext": base64.StdEncoding.EncodeToString(ciphertext), }) if err != nil { return nil, fmt.Errorf("decrypt via %s: %w", path, err) } if secret == nil || secret.Data == nil { return nil, fmt.Errorf("decrypt returned no data") } b64, _ := secret.Data["plaintext"].(string) if b64 == "" { return nil, fmt.Errorf("decrypt returned no plaintext") } plain, err := base64.StdEncoding.DecodeString(b64) if err != nil { return nil, fmt.Errorf("decode plaintext: %w", err) } return plain, nil } // TokenTTL returns the remaining lease TTL of the current token, for diagnostics. func (c *Client) TokenTTL(ctx context.Context) (time.Duration, error) { secret, err := c.api.Auth().Token().LookupSelfWithContext(ctx) if err != nil { return 0, err } ttl, err := secret.TokenTTL() if err != nil { return 0, err } return ttl, nil }