Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

458 lines
15 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
// Package vault implements the issuer.Connector interface for HashiCorp Vault PKI
// secrets engine.
//
// Vault PKI provides a full-featured private CA with certificate signing, revocation,
// CRL, and OCSP capabilities. This connector uses the Vault HTTP API to sign CSRs
// via the /v1/{mount}/sign/{role} endpoint, authenticated with a Vault token.
//
// Vault issues certificates synchronously (like step-ca), so GetOrderStatus always
// returns "completed". CRL and OCSP are delegated to Vault's own endpoints.
//
// Authentication: Vault token via X-Vault-Token header.
//
// Vault API used:
//
// GET /v1/sys/health - Health check
// POST /v1/{mount}/sign/{role} - Sign CSR
// POST /v1/{mount}/revoke - Revoke certificate
// GET /v1/{mount}/ca/pem - Get CA certificate
package vault
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/certctl-io/certctl/internal/connector/issuer"
"github.com/certctl-io/certctl/internal/secret"
)
// Config represents the Vault PKI issuer connector configuration.
type Config struct {
// Addr is the Vault server address (e.g., "https://vault.example.com:8200").
// Required. Set via CERTCTL_VAULT_ADDR environment variable.
Addr string `json:"addr"`
// Token is the Vault token for authentication.
// Required. Set via CERTCTL_VAULT_TOKEN environment variable.
//
// Type: *secret.Ref (audit fix #6 Phase 3 — Bundle I close).
// Wrapping the token in a Ref means: it never stringifies (Config
// marshals as "[redacted]"), the bytes are zeroed after each
// Use/WriteTo invocation (defeats heap-dump extraction), and
// outbound X-Vault-Token header writes go through Ref.Use so the
// staging buffer is short-lived. JSON unmarshal of a string value
// populates the Ref via NewRefFromString — operator config files
// are unchanged.
Token *secret.Ref `json:"token"`
// Mount is the PKI secrets engine mount path.
// Default: "pki". Set via CERTCTL_VAULT_MOUNT environment variable.
Mount string `json:"mount"`
// Role is the PKI role name used for signing certificates.
// Required. Set via CERTCTL_VAULT_ROLE environment variable.
Role string `json:"role"`
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
// Default: "8760h". Set via CERTCTL_VAULT_TTL environment variable.
TTL string `json:"ttl"`
}
// Connector implements the issuer.Connector interface for Vault PKI.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
// Token-renewal loop fields. Top-10 fix #5 of the 2026-05-03
// issuer-coverage audit. Long-lived certctl-server deploys hit
// Vault token expiry; the loop calls /v1/auth/token/renew-self at
// TTL/2 cadence so the integration stays alive up to Vault's
// configured Max TTL. See vault_renew.go for Start / Stop /
// renewSelf / lookupSelf.
//
// renewMu guards startedOnce + cancel + done. The ticker runs in a
// goroutine that owns its own copy of these channels.
renewMu sync.Mutex
renewStarted bool // true after Start spawned the goroutine
renewCancel func() // cancels the goroutine's ctx
renewDone chan struct{} // closed when goroutine exits
renewRecorder RenewalRecorder // optional metric sink (defaults to no-op)
// renewTickerFactory lets tests substitute a deterministic ticker
// implementation for cadence assertions. Production callers leave
// this nil and the loop uses time.NewTicker.
renewTickerFactory func(d time.Duration) renewTicker
// renewClient is the HTTP client used for renew-self / lookup-self.
// Defaults to httpClient; a separate seam lets tests inject an
// httptest.Server-bound client without disturbing the issuance
// path's client.
renewClient *http.Client
}
// New creates a new Vault PKI connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil {
if config.Mount == "" {
config.Mount = "pki"
}
if config.TTL == "" {
config.TTL = "8760h"
}
}
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
return &Connector{
config: config,
logger: logger,
httpClient: httpClient,
renewClient: httpClient,
renewRecorder: noopRenewalRecorder{},
}
}
// SetRenewalRecorder wires a metric sink for the renew-self loop. The
// recorder's RecordRenewal(result string) is called with one of the
// enum values "success", "failure", or "not_renewable" on every tick.
// Pass nil to disable recording. Safe to call before Start; calling
// after Start has no effect on already-emitted increments.
//
// The interface lives in this package (not internal/service) to avoid
// an import cycle: vault is a connector package that the service-layer
// IssuerRegistry imports. The service-layer concrete type
// (*service.VaultRenewalMetrics) satisfies this interface and is wired
// in cmd/server/main.go.
func (c *Connector) SetRenewalRecorder(r RenewalRecorder) {
if r == nil {
r = noopRenewalRecorder{}
}
c.renewMu.Lock()
defer c.renewMu.Unlock()
c.renewRecorder = r
}
// vaultResponse is the standard Vault API response wrapper.
type vaultResponse struct {
Data json.RawMessage `json:"data"`
Errors []string `json:"errors,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// signData holds the data returned from the /sign endpoint.
type signData struct {
Certificate string `json:"certificate"`
IssuingCA string `json:"issuing_ca"`
CAChain []string `json:"ca_chain"`
SerialNumber string `json:"serial_number"`
Expiration int64 `json:"expiration"`
}
// ValidateConfig checks that the Vault configuration is valid and the server is reachable.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid Vault config: %w", err)
}
if cfg.Addr == "" {
return fmt.Errorf("Vault addr is required")
}
if cfg.Token.IsEmpty() {
return fmt.Errorf("Vault token is required")
}
if cfg.Role == "" {
return fmt.Errorf("Vault role is required")
}
if cfg.Mount == "" {
cfg.Mount = "pki"
}
if cfg.TTL == "" {
cfg.TTL = "8760h"
}
// Health check
healthURL := cfg.Addr + "/v1/sys/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return fmt.Errorf("failed to create health check request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("Vault not reachable at %s: %w", cfg.Addr, err)
}
defer resp.Body.Close()
// Vault health returns 200 for initialized+unsealed, 429 for standby, 472 for DR secondary,
// 473 for perf standby, 501 for uninitialized, 503 for sealed
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusTooManyRequests {
return fmt.Errorf("Vault health check returned status %d", resp.StatusCode)
}
c.config = &cfg
c.logger.Info("Vault PKI configuration validated",
"addr", cfg.Addr,
"mount", cfg.Mount,
"role", cfg.Role)
return nil
}
// IssueCertificate submits a CSR to Vault PKI for signing.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Vault PKI issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Determine TTL — cap to MaxTTLSeconds from profile if specified
ttl := c.config.TTL
if request.MaxTTLSeconds > 0 {
ttl = fmt.Sprintf("%ds", request.MaxTTLSeconds)
}
// Build the sign request body
signBody := map[string]interface{}{
"csr": request.CSRPEM,
"common_name": request.CommonName,
"ttl": ttl,
}
if len(request.SANs) > 0 {
signBody["alt_names"] = strings.Join(request.SANs, ",")
}
body, err := json.Marshal(signBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal sign request: %w", err)
}
// POST /v1/{mount}/sign/{role}
signURL := fmt.Sprintf("%s/v1/%s/sign/%s", c.config.Addr, c.config.Mount, c.config.Role)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create sign request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if err := c.config.Token.Use(func(buf []byte) error {
req.Header.Set("X-Vault-Token", string(buf))
return nil
}); err != nil {
return nil, fmt.Errorf("vault token use: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Vault sign request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read sign response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var vaultResp vaultResponse
if jsonErr := json.Unmarshal(respBody, &vaultResp); jsonErr == nil && len(vaultResp.Errors) > 0 {
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, strings.Join(vaultResp.Errors, "; "))
}
return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, string(respBody))
}
// Parse the Vault response
var vaultResp vaultResponse
if err := json.Unmarshal(respBody, &vaultResp); err != nil {
return nil, fmt.Errorf("failed to parse Vault response: %w", err)
}
var data signData
if err := json.Unmarshal(vaultResp.Data, &data); err != nil {
return nil, fmt.Errorf("failed to parse Vault sign data: %w", err)
}
if data.Certificate == "" {
return nil, fmt.Errorf("no certificate in Vault sign response")
}
// Parse the leaf certificate to extract metadata
certPEM := data.Certificate
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode certificate PEM from Vault")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Build chain PEM from ca_chain or issuing_ca
var chainPEM string
if len(data.CAChain) > 0 {
chainPEM = strings.Join(data.CAChain, "\n")
} else if data.IssuingCA != "" {
chainPEM = data.IssuingCA
}
// Normalize serial: Vault uses colon-separated hex (e.g., "aa:bb:cc"), convert to plain string
serial := normalizeSerial(data.SerialNumber)
orderID := fmt.Sprintf("vault-%s", serial)
c.logger.Info("Vault PKI certificate issued",
"common_name", request.CommonName,
"serial", serial,
"not_after", cert.NotAfter)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by creating a new signing request.
// For Vault PKI, renewal is functionally identical to issuance (new cert signed from CSR).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing Vault PKI renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
MaxTTLSeconds: request.MaxTTLSeconds,
})
}
// RevokeCertificate revokes a certificate at Vault PKI.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing Vault PKI revocation request", "serial", request.Serial)
revokeBody := map[string]interface{}{
"serial_number": request.Serial,
}
body, err := json.Marshal(revokeBody)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
revokeURL := fmt.Sprintf("%s/v1/%s/revoke", c.config.Addr, c.config.Mount)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if err := c.config.Token.Use(func(buf []byte) error {
req.Header.Set("X-Vault-Token", string(buf))
return nil
}); err != nil {
return fmt.Errorf("vault token use: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("Vault revoke request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Vault revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("Vault PKI certificate revoked", "serial", request.Serial)
return nil
}
// GetOrderStatus returns the status of a Vault PKI order.
// Vault signs synchronously, so orders are always "completed" immediately.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}, nil
}
// GenerateCRL is not supported because Vault serves CRL directly at /v1/{mount}/crl.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("Vault serves CRL directly at /v1/%s/crl; use Vault's endpoint", c.config.Mount)
}
// SignOCSPResponse is not supported because Vault serves OCSP directly at /v1/{mount}/ocsp.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("Vault serves OCSP directly at /v1/%s/ocsp; use Vault's endpoint", c.config.Mount)
}
// GetCACertPEM retrieves the CA certificate from Vault PKI.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
caURL := fmt.Sprintf("%s/v1/%s/ca/pem", c.config.Addr, c.config.Mount)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create CA cert request: %w", err)
}
if err := c.config.Token.Use(func(buf []byte) error {
req.Header.Set("X-Vault-Token", string(buf))
return nil
}); err != nil {
return "", fmt.Errorf("vault token use: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("Vault CA cert request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Vault CA cert returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read CA cert response: %w", err)
}
return string(body), nil
}
// GetRenewalInfo returns nil, nil as Vault does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// normalizeSerial converts Vault's colon-separated hex serial (e.g., "aa:bb:cc:dd")
// to a plain string representation suitable for storage.
func normalizeSerial(serial string) string {
return strings.ReplaceAll(serial, ":", "-")
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)